Skip to content

Commit a8b1ef4

Browse files
MohammadHijjawi97Bordacodex
authored
fix(annotators): draw masks and halos on grayscale scenes (#2755)
`MaskAnnotator` and `HaloAnnotator` raised `ValueError` on grayscale scenes, shaped `(H, W)` or `(H, W, 1)` or given as a Pillow "L" image, because `_paint_masks_by_area` assigned a three-value BGR color to single-channel pixels. Other annotators draw on the same scenes through OpenCV, which writes the first (blue) channel of the color. Paint grayscale canvases with that first channel, restore the singleton channel axis that OpenCV drops in `addWeighted` and `blur`, and compute the halo alpha from the single channel instead of `COLOR_BGR2GRAY`. BGR scenes are unchanged. --------- Co-authored-by: jirka <6035284+borda@users.noreply.github.com> Co-authored-by: Codex <codex@openai.com>
1 parent 9399814 commit a8b1ef4

3 files changed

Lines changed: 149 additions & 19 deletions

File tree

‎docs/changelog.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ date_modified: 2026-10-16
77

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

10+
- `sv.MaskAnnotator` and `sv.HaloAnnotator` now annotate grayscale scenes, shaped `(H, W)` or `(H, W, 1)` or given as a Pillow `"L"` image, instead of raising `ValueError`. Masks and halos take the first (blue) channel of their color, the gray value OpenCV writes for boxes and polygons on the same scene. ([#2755](https://github.com/roboflow/supervision/pull/2755))
11+
1012
- `sv.Detections.with_nmm` now computes area-weighted confidence without overflowing integer, float16 or float32 box extents. ([#2753](https://github.com/roboflow/supervision/pull/2753))
1113

1214
- `sv.metrics.MeanAverageRecall`, `Precision`, `Recall` and `F1Score` with `metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES` now count a prediction whose IoU is exactly 0.55, 0.6, 0.8 or 0.85 as a match at that threshold, as they do for boxes and masks. The oriented-box IoU was float64 while the thresholds are float32, which rounds those four values up. ([#2752](https://github.com/roboflow/supervision/pull/2752))

‎src/supervision/annotators/core.py‎

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,20 @@ def _iter_mask_crops(
438438
)
439439

440440

441+
def _color_for_canvas(
442+
color_bgr: tuple[int, int, int], canvas: npt.NDArray[np.uint8]
443+
) -> tuple[int, ...]:
444+
"""Return the color as the pixel value OpenCV drawing writes to `canvas`.
445+
446+
A grayscale canvas, shaped ``(H, W)`` or ``(H, W, 1)``, takes the first
447+
(blue) channel, as ``cv2.rectangle`` and ``cv2.fillPoly`` do, so masks match
448+
the boxes and polygons other annotators draw on the same scene.
449+
"""
450+
if canvas.ndim == 2 or canvas.shape[2] == 1:
451+
return color_bgr[:1]
452+
return color_bgr
453+
454+
441455
def _paint_masks_by_area(
442456
canvas: npt.NDArray[np.uint8],
443457
detections: Detections,
@@ -454,7 +468,8 @@ def _paint_masks_by_area(
454468
allocation per mask; dense masks fall back to full-frame boolean indexing.
455469
456470
Args:
457-
canvas: BGR image array painted in place. Shape ``(H, W, 3)``.
471+
canvas: Image array painted in place: BGR of shape ``(H, W, 3)``, or
472+
grayscale of shape ``(H, W)`` or ``(H, W, 1)``.
458473
detections: Detections whose masks to paint. Returns immediately
459474
without modifying `canvas` when ``detections.mask`` is ``None``.
460475
color: Single color or palette used to resolve each detection's color.
@@ -482,12 +497,15 @@ def _paint_masks_by_area(
482497
origin_x, origin_y = canvas_origin
483498
canvas_h, canvas_w = canvas.shape[:2]
484499
for detection_idx in np.flip(np.argsort(detections.area)):
485-
color_bgr = resolve_color(
486-
color=color,
487-
detections=detections,
488-
detection_idx=detection_idx,
489-
color_lookup=color_lookup,
490-
).as_bgr()
500+
pixel_value = _color_for_canvas(
501+
resolve_color(
502+
color=color,
503+
detections=detections,
504+
detection_idx=detection_idx,
505+
color_lookup=color_lookup,
506+
).as_bgr(),
507+
canvas,
508+
)
491509
if compact_mask is not None:
492510
x1 = int(compact_mask.offsets[detection_idx, 0])
493511
y1 = int(compact_mask.offsets[detection_idx, 1])
@@ -508,7 +526,7 @@ def _paint_masks_by_area(
508526
canvas_y1 : canvas_y1 + paint_h,
509527
canvas_x1 : canvas_x1 + paint_w,
510528
]
511-
canvas_slice[crop_slice] = color_bgr
529+
canvas_slice[crop_slice] = pixel_value
512530
if union is not None:
513531
union[
514532
canvas_y1 : canvas_y1 + paint_h,
@@ -517,7 +535,7 @@ def _paint_masks_by_area(
517535
else:
518536
mask = np.asarray(masks[detection_idx], dtype=bool)
519537
mask = mask[origin_y : origin_y + canvas_h, origin_x : origin_x + canvas_w]
520-
canvas[mask] = color_bgr
538+
canvas[mask] = pixel_value
521539
if union is not None:
522540
union |= mask
523541
return union
@@ -564,7 +582,9 @@ def annotate(
564582
Args:
565583
scene: The image where masks will be drawn.
566584
`ImageType` is a flexible type, accepting either `numpy.ndarray`
567-
or `PIL.Image.Image`.
585+
or `PIL.Image.Image`. Grayscale arrays may have shape `(H, W)`
586+
or `(H, W, 1)`; masks are drawn in the first (blue) channel of
587+
their color, as OpenCV draws boxes and polygons.
568588
detections: Object detections to annotate.
569589
custom_color_lookup: Custom color lookup array.
570590
Allows to override the default color mapping strategy.
@@ -627,7 +647,8 @@ def annotate(
627647
tmp = cv2.addWeighted(
628648
colored_mask, self.opacity, scene_roi.copy(), 1 - self.opacity, 0
629649
)
630-
scene_roi[:] = tmp
650+
# OpenCV drops a singleton channel axis; restore it before assignment.
651+
scene_roi[:] = tmp.reshape(scene_roi.shape)
631652
return scene
632653

633654

@@ -866,7 +887,9 @@ def annotate(
866887
Args:
867888
scene: The image where the halo effect will be applied.
868889
`ImageType` is a flexible type, accepting either `numpy.ndarray`
869-
or `PIL.Image.Image`.
890+
or `PIL.Image.Image`. Grayscale arrays may have shape `(H, W)`
891+
or `(H, W, 1)`; halos are drawn in the first (blue) channel of
892+
their color, as OpenCV draws boxes and polygons.
870893
detections: Object detections to annotate.
871894
custom_color_lookup: Custom color lookup array.
872895
Allows to override the default color mapping strategy.
@@ -910,18 +933,21 @@ def annotate(
910933
)
911934
assert fmask is not None # collect_union=True always returns an array
912935

913-
colored_mask = cast(
914-
npt.NDArray[np.uint8],
915-
cv2.blur(colored_mask, (self.kernel_size, self.kernel_size)),
916-
)
917-
colored_mask[fmask] = [0, 0, 0]
918-
gray = cv2.cvtColor(colored_mask, cv2.COLOR_BGR2GRAY)
936+
# OpenCV drops a singleton channel axis; restore the scene's layout.
937+
colored_mask = cv2.blur(
938+
colored_mask, (self.kernel_size, self.kernel_size)
939+
).reshape(scene.shape)
940+
colored_mask[fmask] = 0
941+
if colored_mask.ndim == 2 or colored_mask.shape[2] == 1:
942+
gray = colored_mask.reshape(scene.shape[:2])
943+
else:
944+
gray = cv2.cvtColor(colored_mask, cv2.COLOR_BGR2GRAY)
919945
gray_max = gray.max()
920946
if gray_max == 0:
921947
# no halo to draw (e.g. empty masks); leave the scene untouched
922948
return scene
923949
alpha = self.opacity * gray / gray_max
924-
alpha_mask = alpha[:, :, np.newaxis]
950+
alpha_mask = alpha if scene.ndim == 2 else alpha[:, :, np.newaxis]
925951
# Blend in float space so halo opacity cannot wrap around uint8 boundaries.
926952
blended_scene = np.clip(
927953
scene.astype(np.float32) * (1 - alpha_mask)

‎tests/annotators/test_core.py‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,108 @@ def test_annotate_with_all_false_mask_preserves_scene(self):
770770
assert np.array_equal(result, scene)
771771

772772

773+
class TestSingleChannelMaskAnnotators:
774+
"""Verify mask-painting annotators on grayscale scenes."""
775+
776+
@staticmethod
777+
def _detections(compact: bool = False) -> Detections:
778+
"""Return one square mask detection inside a 30x40 scene."""
779+
mask = np.zeros((1, 30, 40), dtype=bool)
780+
mask[0, 8:20, 10:26] = True
781+
xyxy = np.array([[10, 8, 25, 19]], dtype=np.float32)
782+
masks = CompactMask.from_dense(mask, xyxy, (30, 40)) if compact else mask
783+
return Detections(xyxy=xyxy, mask=masks, class_id=np.array([0]))
784+
785+
@pytest.mark.parametrize(
786+
"annotator",
787+
[
788+
pytest.param(
789+
MaskAnnotator(color=Color(r=180, g=180, b=180), opacity=0.6),
790+
id="mask",
791+
),
792+
pytest.param(
793+
HaloAnnotator(color=Color(r=180, g=180, b=180), kernel_size=5),
794+
id="halo",
795+
),
796+
],
797+
)
798+
@pytest.mark.parametrize("channel_axis", [False, True])
799+
@pytest.mark.parametrize("compact", [False, True])
800+
def test_grayscale_matches_color_scene(
801+
self,
802+
annotator: MaskAnnotator | HaloAnnotator,
803+
channel_axis: bool,
804+
compact: bool,
805+
) -> None:
806+
"""A grayscale scene is annotated like each channel of its BGR copy."""
807+
gray = np.arange(30 * 40, dtype=np.uint8).reshape(30, 40)
808+
scene = gray[..., None].copy() if channel_axis else gray.copy()
809+
color_scene = np.repeat(gray[..., None], 3, axis=2)
810+
detections = self._detections(compact=compact)
811+
expected = annotator.annotate(scene=color_scene, detections=detections)
812+
813+
result = annotator.annotate(scene=scene, detections=detections)
814+
815+
assert result is scene
816+
assert result.shape == scene.shape
817+
np.testing.assert_array_equal(result.reshape(30, 40), expected[..., 0])
818+
assert not np.array_equal(result.reshape(30, 40), gray)
819+
820+
def test_halo_grayscale_uses_blue_color_component(self) -> None:
821+
"""A grayscale halo responds to changes in the BGR blue component."""
822+
scene = np.zeros((30, 40), dtype=np.uint8)
823+
detections = self._detections()
824+
blue_halo = HaloAnnotator(
825+
color=Color(r=20, g=40, b=220), kernel_size=5
826+
).annotate(scene=scene.copy(), detections=detections)
827+
dim_blue_halo = HaloAnnotator(
828+
color=Color(r=20, g=40, b=20), kernel_size=5
829+
).annotate(scene=scene.copy(), detections=detections)
830+
831+
assert blue_halo[7, 15] > dim_blue_halo[7, 15]
832+
833+
@pytest.mark.parametrize(
834+
"annotator_type",
835+
[
836+
pytest.param(MaskAnnotator, id="mask"),
837+
pytest.param(PolygonAnnotator, id="polygon"),
838+
pytest.param(ColorAnnotator, id="color"),
839+
],
840+
)
841+
def test_grayscale_draws_first_color_channel(
842+
self,
843+
annotator_type: type[MaskAnnotator]
844+
| type[PolygonAnnotator]
845+
| type[ColorAnnotator],
846+
) -> None:
847+
"""Masks take the same gray value as other annotators' OpenCV drawing."""
848+
color = Color(r=10, g=20, b=200)
849+
if annotator_type is PolygonAnnotator:
850+
annotator: MaskAnnotator | PolygonAnnotator | ColorAnnotator = (
851+
PolygonAnnotator(color=color)
852+
)
853+
else:
854+
annotator = annotator_type(color=color, opacity=1.0)
855+
scene = np.zeros((30, 40), dtype=np.uint8)
856+
857+
result = annotator.annotate(scene=scene, detections=self._detections())
858+
859+
assert result[8, 18] == color.b
860+
861+
def test_grayscale_pillow_image(self) -> None:
862+
"""A Pillow grayscale image is annotated and returned as one."""
863+
image = Image.new("L", (40, 30), color=50)
864+
865+
result = MaskAnnotator(color=Color.WHITE, opacity=1.0).annotate(
866+
scene=image, detections=self._detections()
867+
)
868+
869+
assert isinstance(result, Image.Image)
870+
assert result.mode == "L"
871+
assert result.getpixel((18, 12)) == 255
872+
assert result.getpixel((2, 2)) == 50
873+
874+
773875
class TestPaintMasksByArea:
774876
"""Tests for the _paint_masks_by_area helper function."""
775877

0 commit comments

Comments
 (0)