fix(annotators): clip BackgroundOverlayAnnotator boxes to the scene… (#2396)

* fix(annotators): clip BackgroundOverlayAnnotator boxes to the scene before   restoring detection regions
* fix(annotators): use explicit np.int32 cast in BackgroundOverlayAnnotator
* test(annotators): strengthen BackgroundOverlayAnnotator test coverage
* docs(changelog): add Unreleased entry for BackgroundOverlayAnnotator fix

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
Abhijith Neil Abraham 2026-07-03 09:36:25 -07:00 committed by GitHub
parent 0e1056df19
commit afcf13a6f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 67 additions and 1 deletions

View File

@ -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))

View File

@ -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:

View File

@ -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