diff --git a/docs/changelog.md b/docs/changelog.md index eaaa9ac4..97187ea5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -18,6 +18,7 @@ date_modified: 2026-06-25 - Fixed [#2393](https://github.com/roboflow/supervision/pull/2393): `sv.HeatMapAnnotator.annotate` no longer blanks the hottest region when the per-pixel hit count exceeds 255; the heat mask is now derived from the float32 accumulator directly, avoiding uint8 wrap-around. - Fixed [#2393](https://github.com/roboflow/supervision/pull/2393): `sv.get_video_frames_generator` now releases the underlying `cv2.VideoCapture` via `try/finally`, so the decoder is freed when a consumer breaks out of iteration early rather than waiting for garbage collection. - Fixed [#2382](https://github.com/roboflow/supervision/pull/2382): `sv.Detections.get_anchors_coordinates` now uses oriented bounding box corners (`data["xyxyxyxy"]`) when OBB data is present, instead of falling back to the axis-aligned envelope. Anchors on rotated detections now lie on the oriented body rather than drifting to the envelope. Non-OBB detections and `Position.CENTER_OF_MASS` (which requires a mask) are unaffected. +- Fixed [#2396](https://github.com/roboflow/supervision/pull/2396): `sv.BackgroundOverlayAnnotator.annotate` no longer leaves detection regions tinted when bounding boxes have negative coordinates (extend outside the left or top scene boundary); boxes are now clipped to scene bounds before the detection region is restored. ### Added - `BaseAnnotator.requires_mask` — class-level `bool` flag on all annotators; `True` for `MaskAnnotator`, `PolygonAnnotator`, and `HaloAnnotator`; `False` for all others. Integrations can inspect this before materializing expensive mask payloads ([#2370](https://github.com/roboflow/supervision/pull/2370)) diff --git a/src/supervision/annotators/core.py b/src/supervision/annotators/core.py index 5fdb08d9..a5c785b7 100644 --- a/src/supervision/annotators/core.py +++ b/src/supervision/annotators/core.py @@ -3180,7 +3180,12 @@ class BackgroundOverlayAnnotator(BaseAnnotator): ) if detections.mask is None or self.force_box: - for x1, y1, x2, y2 in detections.xyxy.astype(int): + image_height, image_width = scene.shape[:2] + clipped_xyxy: npt.NDArray[np.int32] = clip_boxes( + xyxy=detections.xyxy, + resolution_wh=(image_width, image_height), + ).astype(np.int32) + for x1, y1, x2, y2 in clipped_xyxy: colored_mask[y1:y2, x1:x2] = scene[y1:y2, x1:x2] else: for mask in detections.mask: diff --git a/tests/annotators/test_core.py b/tests/annotators/test_core.py index 53dec9e6..b793a438 100644 --- a/tests/annotators/test_core.py +++ b/tests/annotators/test_core.py @@ -1461,6 +1461,66 @@ class TestBackgroundOverlayAnnotator: result = annotator.annotate(scene=image.copy(), detections=detections) assert not np.array_equal(image, result) + @pytest.mark.parametrize( + ("xyxy", "inside_xy", "outside_xy"), + [ + pytest.param([-5, 20, 40, 60], (20, 30), (60, 80), id="crosses-left-edge"), + pytest.param([20, -5, 60, 40], (30, 20), (80, 60), id="crosses-top-edge"), + pytest.param( + [-10, -10, 40, 40], (20, 20), (70, 70), id="crosses-both-edges" + ), + ], + ) + def test_annotate_preserves_detection_crossing_scene_border( + self, xyxy: list[int], inside_xy: tuple[int, int], outside_xy: tuple[int, int] + ) -> None: + """The visible part of a box crossing the border keeps original pixels""" + image = np.full((100, 100, 3), 200, dtype=np.uint8) + detections = _create_detections(xyxy=[xyxy]) + annotator = BackgroundOverlayAnnotator(color=Color.BLACK, opacity=0.5) + + result = annotator.annotate(scene=image.copy(), detections=detections) + + x_in, y_in = inside_xy + x_out, y_out = outside_xy + assert np.array_equal(result[y_in, x_in], np.array([200, 200, 200])) + assert np.array_equal(result[y_out, x_out], np.array([100, 100, 100])) + + def test_annotate_fully_out_negative_box_does_not_corrupt(self) -> None: + """Both-negative OOB box must not restore an in-bounds region via wrap-around""" + image = np.full((100, 100, 3), 200, dtype=np.uint8) + detections = _create_detections(xyxy=[[-30, -30, -5, -5]]) + annotator = BackgroundOverlayAnnotator(color=Color.BLACK, opacity=0.5) + + result = annotator.annotate(scene=image.copy(), detections=detections) + + assert np.array_equal(result[80, 80], np.array([100, 100, 100])) + + def test_annotate_force_box_preserves_detection_crossing_scene_border(self): + """force_box with a border-crossing box keeps the visible detection region""" + image = np.full((100, 100, 3), 200, dtype=np.uint8) + mask = np.zeros((100, 100), dtype=bool) + mask[20:60, 0:40] = True + detections = _create_detections(xyxy=[[-5, 20, 40, 60]], mask=[mask]) + annotator = BackgroundOverlayAnnotator( + color=Color.BLACK, opacity=0.5, force_box=True + ) + + result = annotator.annotate(scene=image.copy(), detections=detections) + + assert np.array_equal(result[30, 20], np.array([200, 200, 200])) + assert np.array_equal(result[80, 60], np.array([100, 100, 100])) + + def test_annotate_with_fully_out_of_bounds_detection(self): + """A box fully outside the scene leaves the whole scene tinted""" + image = np.full((100, 100, 3), 200, dtype=np.uint8) + detections = _create_detections(xyxy=[[150, 150, 200, 200]]) + annotator = BackgroundOverlayAnnotator(color=Color.BLACK, opacity=0.5) + + result = annotator.annotate(scene=image.copy(), detections=detections) + + assert np.all(result == 100) + def test_annotate_uint8_mask_matches_bool_mask(self): """Test that uint8 and bool masks produce identical overlays.""" image = np.ones((100, 100, 3), dtype=np.uint8) * 255