Skip to content

Commit 3d06d6e

Browse files
jorge-asenjocodexclaudeBorda
authored
fix(annotators): exclude empty compact masks from the mask blend region (#2741)
All-False CompactMask crops contributed their full bounding boxes to the ROI that MaskAnnotator copies and blends. Filter crop bounds by RLE foreground count so empty crops no longer widen the region, and return None when no foreground remains so the existing early return skips the blend entirely. * docs(changelog): link empty compact mask ROI fix * fix(annotators): ignore empty dense mask boxes in the blend region --------- Co-authored-by: Codex <codex@openai.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: jirka <6035284+borda@users.noreply.github.com>
1 parent 74c9574 commit 3d06d6e

4 files changed

Lines changed: 87 additions & 8 deletions

File tree

‎docs/changelog.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ date_modified: 2026-10-16
77

88
### Unreleased <small>upcoming</small>
99

10-
-
10+
- `sv.MaskAnnotator` no longer widens its blend region to the boxes of all-False masks, for dense masks and `CompactMask` alike, and skips blending when every `CompactMask` is all-False. Empty masks previously contributed their full bounding boxes, causing unnecessary image copies and blending outside the foreground region. `CompactMask` foreground counts are read from RLE without decoding; nonempty mask bounds and painted pixels are unchanged. ([#2741](https://github.com/roboflow/supervision/pull/2741))
1111

1212
### 0.31.0 <small>Oct 16, 2026</small>
1313

‎src/supervision/detection/utils/masks.py‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -551,19 +551,20 @@ def _compact_masks_to_roi(
551551
) -> tuple[int, int, int, int] | None:
552552
"""Return exclusive bounding-box union for compact masks.
553553
554-
Uses crop metadata (offsets + shapes) — no RLE decode required.
554+
Uses crop metadata (offsets + shapes) and RLE foreground counts to exclude
555+
all-False masks without decoding crops.
555556
556557
Args:
557558
masks: Compact mask collection.
558559
image_shape: Image dimensions as ``(height, width)``.
559560
560561
Returns:
561-
Exclusive ``(x1, y1, x2, y2)`` bounds, or ``None`` when empty.
562+
Exclusive ``(x1, y1, x2, y2)`` bounds, or ``None`` when no true pixels exist.
562563
"""
563-
if len(masks) == 0:
564+
bboxes = masks.bbox_xyxy[masks.area > 0]
565+
if len(bboxes) == 0:
564566
return None
565567
image_h, image_w = image_shape
566-
bboxes = masks.bbox_xyxy # shape (N, 4), inclusive int32
567568
x_min = int(bboxes[:, 0].min())
568569
y_min = int(bboxes[:, 1].min())
569570
x_max = int(bboxes[:, 2].max()) + 1 # convert inclusive to exclusive
@@ -589,8 +590,9 @@ def _masks_to_roi(
589590
image_shape: Image dimensions as ``(height, width)``.
590591
xyxy: Optional detection boxes of shape ``(N, 4)`` in
591592
``[x1, y1, x2, y2]`` format. When provided, the dense path
592-
first checks whether all true pixels fall within the box union;
593-
if so the box bounds are returned without a full pixel scan.
593+
first checks whether all true pixels fall within the union of
594+
boxes whose masks have true pixels; if so the box bounds are
595+
returned without a full pixel scan.
594596
595597
Returns:
596598
Exclusive ``(x1, y1, x2, y2)`` bounds, or ``None`` when no true
@@ -605,6 +607,10 @@ def _masks_to_roi(
605607
# when boxes are mask-derived. Guard it with an `any()` check over the ROI so
606608
# loose boxes cannot clip true pixels that lie outside the box union.
607609
if xyxy is not None and len(xyxy) > 0:
610+
if mask_array.ndim == 3 and len(xyxy) == len(mask_array):
611+
# All-False masks cover no pixels, so their boxes must not widen
612+
# the union; at least one box remains because some pixel is true.
613+
xyxy = xyxy[mask_array.any(axis=(1, 2))]
608614
image_h, image_w = image_shape
609615
box_roi = (
610616
max(0, int(np.floor(xyxy[:, 0].min()))),

‎tests/annotators/test_core.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections.abc import Iterator
55
from pathlib import Path
66
from typing import Any, cast
7+
from unittest.mock import patch
78

89
import numpy as np
910
import numpy.typing as npt
@@ -387,7 +388,10 @@ def add_weighted_spy(src1, alpha, src2, beta, gamma, dst=None, dtype=None):
387388
assert not np.array_equal(result, scene)
388389
assert blended_shapes == [(45, 60, 3)]
389390

390-
def test_annotate_skips_all_false_mask_blend(self, monkeypatch):
391+
@pytest.mark.parametrize("compact_masks", [False, True])
392+
def test_annotate_skips_all_false_mask_blend(
393+
self, monkeypatch: pytest.MonkeyPatch, compact_masks: bool
394+
) -> None:
391395
"""All-false masks must skip blending — addWeighted must not be called."""
392396
height, width = 30, 40
393397
scene = np.random.default_rng(3).integers(
@@ -397,6 +401,10 @@ def test_annotate_skips_all_false_mask_blend(self, monkeypatch):
397401
detections = _create_detections(
398402
xyxy=[[5.0, 5.0, 20.0, 20.0]], mask=[mask], class_id=[0]
399403
)
404+
if compact_masks:
405+
detections.mask = CompactMask.from_dense(
406+
np.array([mask]), detections.xyxy, (height, width)
407+
)
400408

401409
call_count = []
402410

@@ -413,6 +421,29 @@ def add_weighted_spy(src1, alpha, src2, beta, gamma, dst=None, dtype=None):
413421
assert np.array_equal(result, scene)
414422
assert len(call_count) == 0, "addWeighted called for all-false masks"
415423

424+
@pytest.mark.parametrize("compact_masks", [False, True])
425+
def test_empty_mask_does_not_expand_blend(self, compact_masks: bool) -> None:
426+
"""An empty full-frame mask does not enlarge a sparse mask's blend region."""
427+
scene = np.zeros((60, 80, 3), dtype=np.uint8)
428+
masks = np.zeros((2, 60, 80), dtype=bool)
429+
masks[1, 40:45, 60:65] = True
430+
xyxy = np.array([[0, 0, 79, 59], [60, 40, 64, 44]], dtype=np.float32)
431+
mask = (
432+
CompactMask.from_dense(masks, xyxy, image_shape=(60, 80))
433+
if compact_masks
434+
else masks
435+
)
436+
detections = Detections(xyxy=xyxy, mask=mask)
437+
annotator = MaskAnnotator(color=Color.RED, color_lookup=ColorLookup.INDEX)
438+
439+
with patch.object(cv2, "addWeighted", wraps=cv2.addWeighted) as blend:
440+
result = annotator.annotate(scene=scene, detections=detections)
441+
442+
blend.assert_called_once()
443+
assert blend.call_args.args[0].shape == (5, 5, 3)
444+
np.testing.assert_array_equal(result.any(axis=2), masks[1])
445+
assert result is scene
446+
416447
def test_annotate_pixels_outside_roi_unchanged(self):
417448
"""Pixels outside the blended ROI must be unchanged from the original scene."""
418449
height, width = 80, 90

‎tests/detection/utils/test_masks.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from contextlib import ExitStack as DoesNotRaise
2+
from unittest.mock import patch
23

34
import numpy as np
45
import numpy.typing as npt
@@ -78,6 +79,33 @@ def test_compact_masks_to_roi_single_detection_coordinates(self):
7879
assert x2 >= 8
7980
assert y2 >= 10
8081

82+
def test_all_false_compact_masks_have_no_roi(self) -> None:
83+
"""A nonempty compact collection without foreground has no ROI."""
84+
masks = np.zeros((1, 60, 80), dtype=bool)
85+
xyxy = np.array([[0, 0, 79, 59]], dtype=np.float32)
86+
compact = CompactMask.from_dense(masks, xyxy, image_shape=(60, 80))
87+
88+
result = _compact_masks_to_roi(compact, (60, 80))
89+
90+
assert result is None
91+
92+
@pytest.mark.parametrize("empty_index", [0, 1])
93+
def test_empty_compact_crops_do_not_expand_roi(self, empty_index: int) -> None:
94+
"""An empty full-frame crop leaves the foreground crop's bounds unchanged."""
95+
masks = np.zeros((2, 60, 80), dtype=bool)
96+
masks[1 - empty_index, 40:45, 60:65] = True
97+
xyxy = np.array([[0, 0, 79, 59], [0, 0, 79, 59]], dtype=np.float32)
98+
xyxy[1 - empty_index] = [60, 40, 64, 44]
99+
compact = CompactMask.from_dense(masks, xyxy, image_shape=(60, 80))
100+
101+
with (
102+
patch.object(CompactMask, "to_dense", side_effect=AssertionError),
103+
patch.object(CompactMask, "crop", side_effect=AssertionError),
104+
):
105+
result = _compact_masks_to_roi(compact, (60, 80))
106+
107+
assert result == (60, 40, 65, 45)
108+
81109
def test_compact_masks_to_roi_two_detections_union(self):
82110
"""Two disjoint compact masks union should span both regions."""
83111
masks = np.zeros((2, 30, 40), dtype=bool)
@@ -153,6 +181,20 @@ def test_masks_to_roi_dense_with_xyxy_all_false_returns_none(self):
153181
result = _masks_to_roi(masks, (30, 40), xyxy)
154182
assert result is None
155183

184+
@pytest.mark.parametrize("empty_index", [0, 1])
185+
def test_masks_to_roi_dense_with_xyxy_ignores_empty_mask_boxes(
186+
self, empty_index: int
187+
) -> None:
188+
"""An all-False mask's full-frame box does not widen the dense box union."""
189+
masks = np.zeros((2, 60, 80), dtype=bool)
190+
masks[1 - empty_index, 40:45, 60:65] = True
191+
xyxy = np.array([[0, 0, 79, 59], [0, 0, 79, 59]], dtype=np.float32)
192+
xyxy[1 - empty_index] = [60, 40, 64, 44]
193+
194+
result = _masks_to_roi(masks, (60, 80), xyxy)
195+
196+
assert result == (60, 40, 65, 45)
197+
156198

157199
def test_resize_masks_does_not_upscale_small_masks() -> None:
158200
"""resize_masks returns small masks unchanged instead of upscaling."""

0 commit comments

Comments
 (0)