Files
wehub-resource-sync 9194ef5abd
Docs/Test Workflow / Test docs build (push) Failing after 0s
Check links & references / links-check (push) Failing after 1s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.10) (push) Failing after 0s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.11) (push) Failing after 0s
PR Conflict Labeler / main (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.12) (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.13) (push) Failing after 0s
Pytest/Test Workflow / Build this Package (push) Failing after 5s
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / testing-guardian (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:06:10 +08:00

1960 lines
84 KiB
Python

"""
Tests for supervision/annotators/core.py
"""
import warnings
from collections.abc import Iterator
from typing import Any, cast
import cv2
import numpy as np
import pytest
from PIL import Image
import supervision.annotators.core as annotators_core
from supervision.annotators.base import BaseAnnotator
from supervision.annotators.core import (
BackgroundOverlayAnnotator,
BlurAnnotator,
BoxAnnotator,
BoxCornerAnnotator,
CircleAnnotator,
ColorAnnotator,
ComparisonAnnotator,
CropAnnotator,
DotAnnotator,
EllipseAnnotator,
HaloAnnotator,
HeatMapAnnotator,
IconAnnotator,
LabelAnnotator,
MaskAnnotator,
OrientedBoxAnnotator,
PercentageBarAnnotator,
PixelateAnnotator,
PolygonAnnotator,
RichLabelAnnotator,
RoundBoxAnnotator,
TraceAnnotator,
TriangleAnnotator,
_paint_masks_by_area,
)
from supervision.annotators.utils import ColorLookup
from supervision.detection.compact_mask import CompactMask
from supervision.detection.core import Detections
from supervision.draw.color import Color
from supervision.geometry.core import Position
from tests.helpers import _create_detections, assert_image_mostly_same
def _get_concrete_annotator_subclasses(
cls: type[BaseAnnotator],
) -> Iterator[type[BaseAnnotator]]:
"""Recursively yield non-abstract BaseAnnotator subclasses."""
for sub in cls.__subclasses__():
if not getattr(sub, "__abstractmethods__", None):
yield sub
yield from _get_concrete_annotator_subclasses(sub)
class TestAnnotatorMaskPolicy:
"""Tests for annotator mask materialization policy metadata."""
@pytest.mark.parametrize(
"annotator",
[
pytest.param(MaskAnnotator(), id="mask"),
pytest.param(PolygonAnnotator(), id="polygon"),
pytest.param(HaloAnnotator(), id="halo"),
],
)
def test_mask_required_annotators_declare_mask_requirement(self, annotator):
"""Annotators that require detections.mask expose the requirement."""
assert type(annotator).requires_mask is True
@pytest.mark.parametrize(
"annotator",
[
pytest.param(BoxAnnotator(), id="box"),
pytest.param(LabelAnnotator(), id="label"),
pytest.param(CircleAnnotator(), id="circle"),
pytest.param(EllipseAnnotator(), id="ellipse"),
pytest.param(IconAnnotator(), id="icon"),
pytest.param(TraceAnnotator(), id="trace"),
pytest.param(BackgroundOverlayAnnotator(), id="overlay"),
pytest.param(BackgroundOverlayAnnotator(force_box=True), id="overlay-box"),
pytest.param(ComparisonAnnotator(), id="comparison"),
],
)
def test_mask_optional_annotators_declare_no_mask_requirement(self, annotator):
"""Mask-optional annotators do not require mask materialization."""
assert type(annotator).requires_mask is False
@pytest.mark.parametrize(
"annotator_class",
[
pytest.param(cls, id=cls.__name__)
for cls in _get_concrete_annotator_subclasses(BaseAnnotator)
],
)
def test_all_subclasses_have_bool_requires_mask(self, annotator_class):
"""Every concrete BaseAnnotator subclass declares requires_mask as a bool."""
assert isinstance(annotator_class.requires_mask, bool)
def test_exact_mask_requiring_annotator_set(self):
"""Only MaskAnnotator, PolygonAnnotator, HaloAnnotator require masks."""
mask_true = {
cls
for cls in _get_concrete_annotator_subclasses(BaseAnnotator)
if cls.requires_mask is True
}
assert mask_true == {MaskAnnotator, PolygonAnnotator, HaloAnnotator}
@pytest.fixture
def test_image() -> np.ndarray:
"""Create a simple blank test image fixture"""
return np.zeros((100, 100, 3), dtype=np.uint8)
@pytest.fixture
def test_mask() -> np.ndarray:
"""Create a simple rectangular mask fixture"""
mask = np.zeros((100, 100), dtype=bool)
mask[20:80, 20:80] = True
return mask
@pytest.fixture
def gradient_image() -> np.ndarray:
"""Create a gradient test image fixture"""
image = np.zeros((100, 100, 3), dtype=np.uint8)
for i in range(100):
for j in range(100):
image[i, j] = [i, j, (i + j) // 2]
return image
@pytest.mark.parametrize(
("factory", "expected_colors"),
[
(lambda: BoxAnnotator(color="#010203"), {"color": (1, 2, 3)}),
(lambda: OrientedBoxAnnotator(color="#010203"), {"color": (1, 2, 3)}),
(lambda: MaskAnnotator(color="#010203"), {"color": (1, 2, 3)}),
(lambda: PolygonAnnotator(color="#010203"), {"color": (1, 2, 3)}),
(lambda: ColorAnnotator(color="#010203"), {"color": (1, 2, 3)}),
(lambda: HaloAnnotator(color="#010203"), {"color": (1, 2, 3)}),
(lambda: EllipseAnnotator(color="#010203"), {"color": (1, 2, 3)}),
(lambda: BoxCornerAnnotator(color="#010203"), {"color": (1, 2, 3)}),
(lambda: CircleAnnotator(color="#010203"), {"color": (1, 2, 3)}),
(
lambda: DotAnnotator(color="#010203", outline_color="#040506"),
{"color": (1, 2, 3), "outline_color": (4, 5, 6)},
),
(
lambda: LabelAnnotator(color="#010203", text_color="#040506"),
{"color": (1, 2, 3), "text_color": (4, 5, 6)},
),
(
lambda: RichLabelAnnotator(color="#010203", text_color="#040506"),
{"color": (1, 2, 3), "text_color": (4, 5, 6)},
),
(lambda: TraceAnnotator(color="#010203"), {"color": (1, 2, 3)}),
(
lambda: TriangleAnnotator(color="#010203", outline_color="#040506"),
{"color": (1, 2, 3), "outline_color": (4, 5, 6)},
),
(lambda: RoundBoxAnnotator(color="#010203"), {"color": (1, 2, 3)}),
(
lambda: PercentageBarAnnotator(color="#010203", border_color="#040506"),
{"color": (1, 2, 3), "border_color": (4, 5, 6)},
),
(lambda: CropAnnotator(border_color="#010203"), {"border_color": (1, 2, 3)}),
],
)
def test_hex_color_support_across_annotators(
factory, expected_colors: dict[str, tuple[int, int, int]]
) -> None:
annotator = factory()
for attribute_name, expected_rgb in expected_colors.items():
color = getattr(annotator, attribute_name)
assert isinstance(color, Color)
assert color.as_rgb() == expected_rgb
class TestBoxAnnotator:
"""
Verify that BoxAnnotator correctly draws bounding boxes on an image.
Ensures that `BoxAnnotator` correctly draws bounding boxes on an image, which is
essential for users to visualize detection results.
"""
def test_annotate_with_no_detections(self, test_image: np.ndarray) -> None:
"""
Verify that annotation with no detections does not change the image.
Scenario: Annotating an image with an empty set of detections.
Expected: The scene remains unchanged, ensuring no ghost boxes are drawn.
"""
detections = Detections.empty()
annotator = BoxAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image: np.ndarray) -> None:
"""
Verify that annotation with a single detection draws a bounding box.
Scenario: Annotating an image with a single bounding box.
Expected: The scene is modified by drawing a box, allowing users to identify
a single detected object.
"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = BoxAnnotator(
color=Color.WHITE, thickness=2, color_lookup=ColorLookup.INDEX
)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.85)
def test_annotate_with_multiple_detections(self, test_image: np.ndarray) -> None:
"""
Verify that annotation with multiple detections draws all bounding boxes.
Scenario: Annotating an image with multiple bounding boxes of different classes.
Expected: All boxes are drawn, enabling visualization of complex scenes with
multiple objects.
"""
detections = _create_detections(
xyxy=[[10, 10, 40, 40], [60, 60, 90, 90], [10, 60, 40, 90]],
class_id=[0, 1, 2],
)
annotator = BoxAnnotator(
color=Color.WHITE, thickness=2, color_lookup=ColorLookup.INDEX
)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.85)
def test_annotate_with_numpy_color_lookup(self, test_image: np.ndarray) -> None:
"""
Verify that annotation respects custom NumPy color lookup array.
Scenario: Providing a custom NumPy array for color lookup instead of class IDs.
Expected: Annotator respects the custom mapping, giving users flexible control
over box colors (e.g., coloring by tracking ID or custom criteria).
"""
detections = Detections(
xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]], dtype=np.float32),
confidence=np.array([0.38, 0.21], dtype=np.float32),
class_id=np.array([0, 0], dtype=np.int64),
tracker_id=None,
)
lookup = np.array([1, 0], dtype=np.int16)
annotator = BoxAnnotator(
color=Color.WHITE, thickness=2, color_lookup=ColorLookup.INDEX
)
result = annotator.annotate(
scene=test_image.copy(),
detections=detections,
custom_color_lookup=lookup,
)
assert_image_mostly_same(test_image, result, similarity_threshold=0.85)
class TestOrientedBoxAnnotator:
"""Tests for OrientedBoxAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = OrientedBoxAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_without_oriented_boxes(self, test_image):
"""Test that annotate method returns unmodified image when no OBB data"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]])
annotator = OrientedBoxAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
class TestMaskAnnotator:
"""Tests for MaskAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = MaskAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_without_masks(self, test_image):
"""Test that annotate method returns unmodified image when no masks"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = MaskAnnotator(color_lookup=ColorLookup.INDEX)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_mask(self, test_image, test_mask):
"""Test that annotate method correctly draws a single mask"""
detections = _create_detections(
xyxy=[[10, 10, 90, 90]], mask=[test_mask], class_id=[0]
)
annotator = MaskAnnotator(
color=Color.RED, opacity=1.0, color_lookup=ColorLookup.INDEX
)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.6)
def test_annotate_uint8_mask_matches_bool_mask(self, test_image, test_mask):
"""Test that uint8 and bool masks produce identical overlays."""
detections_bool = _create_detections(
xyxy=[[10, 10, 90, 90]], mask=[test_mask], class_id=[0]
)
detections_uint8 = _create_detections(
xyxy=[[10, 10, 90, 90]], mask=[test_mask], class_id=[0]
)
detections_uint8.mask = detections_uint8.mask.astype(np.uint8)
annotator = MaskAnnotator(
color=Color.RED, opacity=1.0, color_lookup=ColorLookup.INDEX
)
result_bool = annotator.annotate(
scene=test_image.copy(), detections=detections_bool
)
result_uint8 = annotator.annotate(
scene=test_image.copy(), detections=detections_uint8
)
assert np.array_equal(result_bool, result_uint8)
def test_annotate_blends_only_dense_mask_roi(self, monkeypatch):
"""Dense mask annotation should blend only the touched ROI."""
height, width = 80, 90
scene = np.random.default_rng(1).integers(
0, 256, (height, width, 3), dtype=np.uint8
)
masks = np.zeros((2, height, width), dtype=bool)
masks[0, 10:20, 15:25] = True
masks[1, 45:55, 60:75] = True
# inclusive xyxy; floor(x2)+1 gives union ROI (15,10,75,55) → shape (45,60,3)
detections = _create_detections(
xyxy=[[15.0, 10.0, 24.0, 19.0], [60.0, 45.0, 74.0, 54.0]],
mask=masks,
class_id=[0, 1],
)
original_add_weighted = cv2.addWeighted
blended_shapes = []
def add_weighted_spy(src1, alpha, src2, beta, gamma, dst=None, dtype=None):
blended_shapes.append(src1.shape)
return original_add_weighted(src1, alpha, src2, beta, gamma, dst, dtype)
monkeypatch.setattr(cv2, "addWeighted", add_weighted_spy)
result = MaskAnnotator(
opacity=0.5, color=Color.RED, color_lookup=ColorLookup.INDEX
).annotate(scene=scene.copy(), detections=detections)
assert not np.array_equal(result, scene)
assert blended_shapes == [(45, 60, 3)]
def test_annotate_blends_only_compact_mask_roi(self, monkeypatch):
"""CompactMask annotation should blend only the touched ROI."""
height, width = 80, 90
scene = np.random.default_rng(2).integers(
0, 256, (height, width, 3), dtype=np.uint8
)
masks = np.zeros((2, height, width), dtype=bool)
masks[0, 10:20, 15:25] = True
masks[1, 45:55, 60:75] = True
xyxy = np.array([[15.0, 10.0, 24.0, 19.0], [60.0, 45.0, 74.0, 54.0]])
detections = _create_detections(xyxy=xyxy.tolist(), mask=masks, class_id=[0, 1])
detections.mask = CompactMask.from_dense(
masks, detections.xyxy, (height, width)
)
original_add_weighted = cv2.addWeighted
blended_shapes = []
def add_weighted_spy(src1, alpha, src2, beta, gamma, dst=None, dtype=None):
blended_shapes.append(src1.shape)
return original_add_weighted(src1, alpha, src2, beta, gamma, dst, dtype)
monkeypatch.setattr(cv2, "addWeighted", add_weighted_spy)
result = MaskAnnotator(opacity=0.5, color_lookup=ColorLookup.INDEX).annotate(
scene=scene.copy(), detections=detections
)
assert not np.array_equal(result, scene)
assert blended_shapes == [(45, 60, 3)]
def test_annotate_skips_all_false_mask_blend(self, monkeypatch):
"""All-false masks must skip blending — addWeighted must not be called."""
height, width = 30, 40
scene = np.random.default_rng(3).integers(
0, 256, (height, width, 3), dtype=np.uint8
)
mask = np.zeros((height, width), dtype=bool)
detections = _create_detections(
xyxy=[[5.0, 5.0, 20.0, 20.0]], mask=[mask], class_id=[0]
)
call_count = []
def add_weighted_spy(src1, alpha, src2, beta, gamma, dst=None, dtype=None):
call_count.append(1)
return src2
monkeypatch.setattr(cv2, "addWeighted", add_weighted_spy)
result = MaskAnnotator(
opacity=0.5, color=Color.RED, color_lookup=ColorLookup.INDEX
).annotate(scene=scene.copy(), detections=detections)
assert np.array_equal(result, scene)
assert len(call_count) == 0, "addWeighted called for all-false masks"
def test_annotate_pixels_outside_roi_unchanged(self):
"""Pixels outside the blended ROI must be unchanged from the original scene."""
height, width = 80, 90
rng = np.random.default_rng(42)
scene = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
# Two masks in the top-left region — ROI should be [10:55, 15:75]
masks = np.zeros((2, height, width), dtype=bool)
masks[0, 10:20, 15:25] = True
masks[1, 45:55, 60:75] = True
# inclusive xyxy; floor(x2)+1 gives ROI (15,10,75,55)
xyxy = np.array([[15.0, 10.0, 24.0, 19.0], [60.0, 45.0, 74.0, 54.0]])
detections = _create_detections(xyxy=xyxy.tolist(), mask=masks, class_id=[0, 1])
result = MaskAnnotator(opacity=0.5, color_lookup=ColorLookup.INDEX).annotate(
scene=scene.copy(), detections=detections
)
# Pixels strictly below ROI row-bound (y2=55) must be unchanged
assert np.array_equal(result[55:, :], scene[55:, :])
# Pixels strictly right of ROI col-bound (x2=75) must be unchanged
assert np.array_equal(result[:, 75:], scene[:, 75:])
def test_annotate_roi_parity_with_full_frame_blend(self):
"""ROI blend must match reference blend within ±1 (uint8 rounding)."""
height, width = 60, 80
rng = np.random.default_rng(99)
scene = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
opacity = 0.5
# Single mask in bottom-right quadrant
masks = np.zeros((1, height, width), dtype=bool)
masks[0, 40:55, 50:70] = True
# inclusive xyxy; floor(x2)+1 gives ROI scene[40:55, 50:70]
xyxy = np.array([[50.0, 40.0, 69.0, 54.0]])
detections = _create_detections(xyxy=xyxy.tolist(), mask=masks, class_id=[0])
# ROI-only result (new optimized behavior)
result_roi = MaskAnnotator(
opacity=opacity, color=Color.RED, color_lookup=ColorLookup.INDEX
).annotate(scene=scene.copy(), detections=detections)
# Reference: manually compute expected blend for masked pixels only
# RED in BGR = (0, 0, 255); blend = opacity*color + (1-opacity)*scene
roi_mask = masks[0]
colored = scene.copy()
colored[roi_mask] = (0, 0, 255) # BGR RED
ref_blend = np.clip(
opacity * colored.astype(np.float32)
+ (1 - opacity) * scene.astype(np.float32),
0,
255,
).astype(np.uint8)
# Masked pixels must match reference within ±1 (uint8 rounding)
diff = np.abs(
result_roi[roi_mask].astype(np.int16) - ref_blend[roi_mask].astype(np.int16)
)
assert np.all(diff <= 1), f"Max pixel diff: {diff.max()}"
class TestPolygonAnnotator:
"""Tests for PolygonAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = PolygonAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_without_masks(self, test_image):
"""Test that annotate method returns unmodified image when no masks"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = PolygonAnnotator(color_lookup=ColorLookup.INDEX)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_mask(self, test_image, test_mask):
"""Test that annotate method correctly draws a single polygon from mask"""
detections = _create_detections(
xyxy=[[10, 10, 90, 90]], mask=[test_mask], class_id=[0]
)
annotator = PolygonAnnotator(
color=Color.WHITE, thickness=2, color_lookup=ColorLookup.INDEX
)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.85)
def test_compact_mask_uses_crops_and_matches_dense_mask(self, monkeypatch):
"""CompactMask polygons use crops without full-frame mask indexing."""
height, width = 80, 90
scene = np.zeros((height, width, 3), dtype=np.uint8)
masks = np.zeros((3, height, width), dtype=bool)
masks[0, 10:20, 15:30] = True
masks[0, 32:45, 40:55] = True
masks[1] = False
masks[2, 50:70, 5:25] = True
xyxy = np.array(
[[15, 10, 54, 44], [60, 5, 70, 15], [5, 50, 24, 69]],
dtype=np.float32,
)
dense = Detections(xyxy=xyxy, mask=masks, class_id=np.array([0, 1, 2]))
compact = Detections(
xyxy=xyxy,
mask=CompactMask.from_dense(masks, xyxy, (height, width)),
class_id=np.array([0, 1, 2]),
)
annotator = PolygonAnnotator(
color=Color.WHITE, thickness=1, color_lookup=ColorLookup.INDEX
)
expected = annotator.annotate(scene=scene.copy(), detections=dense)
original_getitem = CompactMask.__getitem__
def fail_int_index(self, index):
if isinstance(index, (int, np.integer)):
raise AssertionError("PolygonAnnotator must use CompactMask.crop")
return original_getitem(self, index)
def fail_to_dense(self):
raise AssertionError("PolygonAnnotator must not call CompactMask.to_dense")
monkeypatch.setattr(CompactMask, "__getitem__", fail_int_index)
monkeypatch.setattr(CompactMask, "to_dense", fail_to_dense)
result = annotator.annotate(scene=scene.copy(), detections=compact)
assert not np.array_equal(result, scene), "annotator painted nothing"
np.testing.assert_array_equal(result, expected)
def test_annotate_with_empty_compact_mask(self):
"""N=0 CompactMask returns scene unchanged without error."""
height, width = 60, 80
scene = np.zeros((height, width, 3), dtype=np.uint8)
empty_masks = np.zeros((0, height, width), dtype=bool)
xyxy = np.zeros((0, 4), dtype=np.float32)
detections = Detections(
xyxy=xyxy,
mask=CompactMask.from_dense(empty_masks, xyxy, (height, width)),
class_id=np.array([], dtype=int),
)
annotator = PolygonAnnotator(color=Color.WHITE, color_lookup=ColorLookup.INDEX)
result = annotator.annotate(scene=scene.copy(), detections=detections)
np.testing.assert_array_equal(result, scene)
def test_annotate_with_all_false_compact_mask_unchanged(self):
"""All-False CompactMask crop (no contours) leaves scene pixels unchanged.
A detection whose mask is entirely False produces no polygons; the
annotator must not paint any pixels for that detection.
"""
height, width = 60, 80
scene = np.zeros((height, width, 3), dtype=np.uint8)
masks = np.zeros((1, height, width), dtype=bool)
xyxy = np.array([[10.0, 10.0, 50.0, 50.0]], dtype=np.float32)
detections = Detections(
xyxy=xyxy,
mask=CompactMask.from_dense(masks, xyxy, (height, width)),
class_id=np.array([0]),
)
annotator = PolygonAnnotator(color=Color.WHITE, color_lookup=ColorLookup.INDEX)
result = annotator.annotate(scene=scene.copy(), detections=detections)
np.testing.assert_array_equal(result, scene)
def test_annotate_with_single_compact_mask_detection(self):
"""N=1 CompactMask detection annotates the same as dense mask."""
height, width = 60, 80
scene = np.zeros((height, width, 3), dtype=np.uint8)
mask = np.zeros((height, width), dtype=bool)
mask[20:40, 15:55] = True
xyxy = np.array([[15.0, 20.0, 54.0, 39.0]], dtype=np.float32)
dense = Detections(xyxy=xyxy, mask=np.array([mask]), class_id=np.array([0]))
compact = Detections(
xyxy=xyxy,
mask=CompactMask.from_dense(np.array([mask]), xyxy, (height, width)),
class_id=np.array([0]),
)
annotator = PolygonAnnotator(
color=Color.WHITE, thickness=1, color_lookup=ColorLookup.INDEX
)
expected = annotator.annotate(scene=scene.copy(), detections=dense)
result = annotator.annotate(scene=scene.copy(), detections=compact)
np.testing.assert_array_equal(result, expected)
def test_annotate_compact_mask_float_xyxy_truncation(self):
"""Float xyxy with sub-pixel values are truncated to int before crop decode.
CompactMask stores bbox origins as int32 (via truncation); PolygonAnnotator
must produce the same output whether xyxy is integral or has fractional parts.
"""
height, width = 60, 80
scene = np.zeros((height, width, 3), dtype=np.uint8)
mask = np.zeros((height, width), dtype=bool)
mask[10:30, 5:45] = True
xyxy_int = np.array([[5.0, 10.0, 44.0, 29.0]], dtype=np.float32)
xyxy_float = np.array([[5.7, 10.9, 44.3, 29.1]], dtype=np.float32)
compact_int = Detections(
xyxy=xyxy_int,
mask=CompactMask.from_dense(np.array([mask]), xyxy_int, (height, width)),
class_id=np.array([0]),
)
compact_float = Detections(
xyxy=xyxy_float,
mask=CompactMask.from_dense(np.array([mask]), xyxy_float, (height, width)),
class_id=np.array([0]),
)
annotator = PolygonAnnotator(
color=Color.WHITE, thickness=1, color_lookup=ColorLookup.INDEX
)
result_int = annotator.annotate(scene=scene.copy(), detections=compact_int)
result_float = annotator.annotate(scene=scene.copy(), detections=compact_float)
np.testing.assert_array_equal(result_int, result_float)
def test_compact_mask_disjoint_contours_offset_correct(self):
"""Disjoint-contour mask: both polygon blobs translate to correct image coords.
Verifies coordinate-level correctness of crop→image offset for a mask
with two separate blobs. After annotation, boundary pixels of each blob
must be painted; pixels between the blobs must remain unpainted.
"""
height, width = 80, 90
scene = np.zeros((height, width, 3), dtype=np.uint8)
mask = np.zeros((height, width), dtype=bool)
mask[10:20, 15:30] = True
mask[32:45, 40:55] = True
xyxy = np.array([[15.0, 10.0, 54.0, 44.0]], dtype=np.float32)
detections = Detections(
xyxy=xyxy,
mask=CompactMask.from_dense(np.array([mask]), xyxy, (height, width)),
class_id=np.array([0]),
)
annotator = PolygonAnnotator(
color=Color.WHITE, thickness=1, color_lookup=ColorLookup.INDEX
)
result = annotator.annotate(scene=scene.copy(), detections=detections)
# Boundary of blob 1 in image space — must be painted
assert np.any(result[10:20, 15:30] != 0), "blob 1 boundary not painted"
# Boundary of blob 2 in image space — must be painted
assert np.any(result[32:45, 40:55] != 0), "blob 2 boundary not painted"
# Gap between blobs — no polygon pixels expected here
assert np.all(result[21:31, :] == 0), "gap between blobs has stray pixels"
class TestColorAnnotator:
"""Tests for ColorAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = ColorAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image):
"""Test that annotate method correctly draws a single color box"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = ColorAnnotator(
color=Color.RED, opacity=1.0, color_lookup=ColorLookup.INDEX
)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.3)
class TestHaloAnnotator:
"""Tests for HaloAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = HaloAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_without_masks(self, test_image):
"""Test that annotate method returns unmodified image when no masks"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = HaloAnnotator(color_lookup=ColorLookup.INDEX)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_mask(self, test_image, test_mask):
"""Test that annotate method correctly draws a single halo"""
detections = _create_detections(
xyxy=[[10, 10, 90, 90]], mask=[test_mask], class_id=[0]
)
annotator = HaloAnnotator(
color=Color.BLUE,
opacity=0.8,
kernel_size=10,
color_lookup=ColorLookup.INDEX,
)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.85)
def test_annotate_uint8_mask_matches_bool_mask(self, test_image, test_mask):
"""Test that uint8 and bool masks produce identical halos."""
detections_bool = _create_detections(
xyxy=[[10, 10, 90, 90]], mask=[test_mask], class_id=[0]
)
detections_uint8 = _create_detections(
xyxy=[[10, 10, 90, 90]], mask=[test_mask], class_id=[0]
)
detections_uint8.mask = detections_uint8.mask.astype(np.uint8)
annotator = HaloAnnotator(
color=Color.BLUE,
opacity=0.8,
kernel_size=10,
color_lookup=ColorLookup.INDEX,
)
result_bool = annotator.annotate(
scene=test_image.copy(), detections=detections_bool
)
result_uint8 = annotator.annotate(
scene=test_image.copy(), detections=detections_uint8
)
assert np.array_equal(result_bool, result_uint8)
def test_annotate_with_all_false_mask_preserves_scene(self):
"""Test that an all-False mask leaves the scene unchanged, not corrupted."""
scene = np.full((100, 100, 3), 127, dtype=np.uint8)
masks = [np.zeros((100, 100), dtype=bool)]
detections = _create_detections(
xyxy=[[10, 10, 90, 90]], mask=masks, class_id=[0]
)
result = HaloAnnotator().annotate(scene=scene.copy(), detections=detections)
assert np.array_equal(result, scene)
class TestPaintMasksByArea:
"""Tests for the _paint_masks_by_area helper function."""
def test_paint_masks_by_area_is_noop_without_masks(self):
"""_paint_masks_by_area is a no-op when detections carry no mask."""
canvas = np.full((10, 10, 3), 7, dtype=np.uint8)
detections = _create_detections(xyxy=[[1, 1, 8, 8]], class_id=[0])
_paint_masks_by_area(canvas, detections, Color.RED, ColorLookup.INDEX)
assert np.array_equal(canvas, np.full((10, 10, 3), 7, dtype=np.uint8))
def test_union_accumulation_dense(self):
"""Dense path: collect_union=True returns array covering all painted pixels."""
height, width = 50, 60
canvas = np.zeros((height, width, 3), dtype=np.uint8)
masks = [np.zeros((height, width), dtype=bool)]
masks[0][5:20, 10:40] = True
detections = _create_detections(
xyxy=[[10.0, 5.0, 40.0, 20.0]], mask=masks, class_id=[0]
)
result_union = _paint_masks_by_area(
canvas, detections, Color.RED, ColorLookup.INDEX, collect_union=True
)
assert result_union is not None
# every painted pixel must be in the union (RED is BGR (0, 0, 255),
# so detect painted pixels via any non-zero channel)
painted = canvas.any(axis=-1)
assert np.array_equal(painted, result_union)
def test_union_accumulation_compact(self):
"""CompactMask path: collect_union=True returns array matching dense."""
height, width = 50, 60
mask = np.zeros((height, width), dtype=bool)
mask[5:20, 10:40] = True
xyxy = np.array([[10.0, 5.0, 40.0, 20.0]])
canvas_dense = np.zeros((height, width, 3), dtype=np.uint8)
dense = _create_detections(xyxy=xyxy.tolist(), mask=[mask], class_id=[0])
union_dense = _paint_masks_by_area(
canvas_dense, dense, Color.RED, ColorLookup.INDEX, collect_union=True
)
canvas_compact = np.zeros((height, width, 3), dtype=np.uint8)
compact = _create_detections(xyxy=xyxy.tolist(), mask=[mask], class_id=[0])
compact.mask = CompactMask.from_dense(
np.array([mask]), compact.xyxy, (height, width)
)
union_compact = _paint_masks_by_area(
canvas_compact, compact, Color.RED, ColorLookup.INDEX, collect_union=True
)
assert union_dense is not None
assert union_compact is not None
# compact union must cover exactly the same pixels as dense union
assert np.array_equal(union_dense, union_compact)
def test_compact_mask_drops_pixels_outside_bbox(self):
"""CompactMask is lossy: True pixels outside xyxy bbox are silently dropped.
This test documents that compact and dense paths diverge when a mask has
True pixels outside its bounding box — the 'bit-identical' claim holds
only for bbox-contained masks.
"""
height, width = 50, 60
mask = np.zeros((height, width), dtype=bool)
mask[5:25, 10:40] = True # mask extends 5 rows beyond bbox bottom
bbox = [[10.0, 5.0, 40.0, 20.0]] # y2=20 clips the mask at row 20
canvas_dense = np.zeros((height, width, 3), dtype=np.uint8)
dense = _create_detections(xyxy=bbox, mask=[mask], class_id=[0])
_paint_masks_by_area(canvas_dense, dense, Color.RED, ColorLookup.INDEX)
canvas_compact = np.zeros((height, width, 3), dtype=np.uint8)
compact = _create_detections(xyxy=bbox, mask=[mask], class_id=[0])
compact.mask = CompactMask.from_dense(
np.array([mask]), compact.xyxy, (height, width)
)
_paint_masks_by_area(canvas_compact, compact, Color.RED, ColorLookup.INDEX)
# Dense paints all True pixels incl. rows 21-24; compact only within bbox.
assert not np.array_equal(canvas_dense, canvas_compact), (
"Expected divergence: compact mask drops True pixels outside bbox"
)
# Compact subset: every pixel painted by compact is also painted by dense.
compact_painted = canvas_compact.any(axis=-1)
dense_painted = canvas_dense.any(axis=-1)
assert np.all(dense_painted[compact_painted])
def test_canvas_origin_nonzero_paints_at_correct_position(self):
"""Non-zero canvas_origin should offset mask coordinates into canvas."""
height, width = 30, 40
# Subcanvas covering region [10:25, 15:30] of the full image (15x15 px).
canvas = np.zeros((15, 15, 3), dtype=np.uint8)
# Mask in full-image coords: True at rows 12:18, cols 17:22.
# In canvas coords (origin=(15, 10)): rows 2:8, cols 2:7.
full_mask = np.zeros((height, width), dtype=bool)
full_mask[12:18, 17:22] = True
detections = Detections(
xyxy=np.array([[17.0, 12.0, 21.0, 17.0]]),
mask=full_mask[np.newaxis],
class_id=np.array([0]),
)
_paint_masks_by_area(
canvas,
detections,
Color.RED,
ColorLookup.INDEX,
canvas_origin=(15, 10),
)
# Pixels inside the mapped region should be BGR red (0, 0, 255)
assert np.all(canvas[2:8, 2:7] == (0, 0, 255))
# Pixels outside the painted region must remain zero
assert canvas[0, 0].tolist() == [0, 0, 0]
class TestCompactMaskParity:
"""Tests that CompactMask and dense mask produce identical annotator output."""
@pytest.mark.parametrize(
"annotator_factory",
[
pytest.param(
lambda: MaskAnnotator(opacity=1.0, color_lookup=ColorLookup.INDEX),
id="mask",
),
pytest.param(
lambda: PolygonAnnotator(
color=Color.WHITE, thickness=1, color_lookup=ColorLookup.INDEX
),
id="polygon",
),
pytest.param(
lambda: HaloAnnotator(kernel_size=15, color_lookup=ColorLookup.INDEX),
id="halo",
),
],
)
def test_annotator_compact_mask_matches_dense_mask(self, annotator_factory):
"""CompactMask detections annotate identically to dense bool masks."""
height, width = 120, 160
rng = np.random.default_rng(0)
scene = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
boxes = [[10, 10, 70, 60], [40, 30, 150, 110], [90, 70, 140, 115]]
masks = []
for x1, y1, x2, y2 in boxes:
mask = np.zeros((height, width), dtype=bool)
mask[y1 : y2 + 1, x1 : x2 + 1] = True
masks.append(mask)
class_id = [0, 1, 2]
xyxy = [[float(value) for value in box] for box in boxes]
dense = _create_detections(xyxy=xyxy, mask=masks, class_id=class_id)
compact = _create_detections(xyxy=xyxy, mask=masks, class_id=class_id)
compact.mask = CompactMask.from_dense(
np.array(masks), compact.xyxy, (height, width)
)
result_dense = annotator_factory().annotate(
scene=scene.copy(), detections=dense
)
result_compact = annotator_factory().annotate(
scene=scene.copy(), detections=compact
)
assert not np.array_equal(result_dense, scene), "annotator painted nothing"
assert np.array_equal(result_dense, result_compact)
def test_annotator_compact_mask_handles_edge_clipping(self):
"""CompactMask detection straddling image edge paints via NumPy clip."""
height, width = 50, 60
rng = np.random.default_rng(42)
scene = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
# Box extends 10 pixels beyond right/bottom edges
mask = np.zeros((height, width), dtype=bool)
mask[40:height, 50:width] = True
bbox = [[50.0, 40.0, width + 10.0, height + 10.0]]
detections = _create_detections(xyxy=bbox, mask=[mask], class_id=[0])
detections.mask = CompactMask.from_dense(
np.array([mask]), detections.xyxy, (height, width)
)
annotator = MaskAnnotator(opacity=1.0, color_lookup=ColorLookup.INDEX)
result = annotator.annotate(scene=scene.copy(), detections=detections)
# Result must differ from scene (something was painted) and must not raise
assert not np.array_equal(result, scene), "Expected pixels to be painted"
class TestHeatMapAnnotator:
"""Tests for HeatMapAnnotator class"""
def test_annotate_with_no_detections_does_not_warn(
self, test_image: np.ndarray
) -> None:
"""Empty detections must not trigger a divide-by-zero RuntimeWarning."""
detections = Detections.empty()
annotator = HeatMapAnnotator()
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image: np.ndarray) -> None:
"""Single detection must produce visible heat — result differs from input."""
annotator = HeatMapAnnotator()
detections = _create_detections(xyxy=[[20, 20, 60, 60]])
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert not np.array_equal(test_image, result)
def test_annotate_state_preserved_after_empty_call(
self, test_image: np.ndarray
) -> None:
"""Empty call must not poison accumulated heat."""
annotator = HeatMapAnnotator()
detections = _create_detections(xyxy=[[20, 20, 60, 60]])
annotator.annotate(scene=test_image.copy(), detections=Detections.empty())
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert not np.array_equal(test_image, result)
def test_annotate_empty_after_real_does_not_warn(
self, test_image: np.ndarray
) -> None:
"""Empty call after heat accumulated must not trigger RuntimeWarning."""
annotator = HeatMapAnnotator()
detections = _create_detections(xyxy=[[20, 20, 60, 60]])
annotator.annotate(scene=test_image.copy(), detections=detections)
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
annotator.annotate(scene=test_image.copy(), detections=Detections.empty())
def test_annotate_resets_when_resolution_changes(self) -> None:
"""Changing frame resolution must reset heat state instead of crashing."""
annotator = HeatMapAnnotator()
detections = _create_detections(xyxy=[[20, 20, 60, 60]])
first_scene = np.zeros((100, 100, 3), dtype=np.uint8)
second_scene = np.zeros((120, 80, 3), dtype=np.uint8)
annotator.annotate(scene=first_scene.copy(), detections=detections)
result = annotator.annotate(scene=second_scene.copy(), detections=detections)
assert result.shape == second_scene.shape
assert annotator.heat_mask is not None
assert annotator.heat_mask.shape == second_scene.shape[:2]
def test_annotate_hottest_region_survives_uint8_wrap(
self, test_image: np.ndarray
) -> None:
"""Heat count at 2^8=256 must not wrap uint8 to zero and blank the region."""
annotator = HeatMapAnnotator()
detections = _create_detections(xyxy=[[20, 20, 60, 60]])
for _ in range(256):
result = annotator.annotate(scene=test_image.copy(), detections=detections)
region_painted = np.count_nonzero(
np.any(result[20:60, 20:60] != test_image[20:60, 20:60], axis=2)
)
assert region_painted > 100
def test_reset_clears_accumulated_heat(self, test_image: np.ndarray) -> None:
"""reset() must zero accumulation so a reused annotator matches a fresh one.
The heatmap colours each pixel by its heat *relative to the current
maximum*, so a single uniformly-painted region always renders identically
regardless of its absolute count. To make the assertion actually depend on
reset having zeroed the buffer, heat is first built up on region A alone,
then after reset both region A and a fresh region B are annotated together.
If reset truly zeroed the buffer, A and B carry equal heat and the frame
matches a never-used annotator; if reset were a no-op, A's carried-over
count would dominate the max-normalisation and B would render a different
hue — so byte-equality with the fresh annotator can only hold when the
accumulation was genuinely discarded.
"""
region_a = _create_detections(xyxy=[[10, 10, 30, 30]])
region_a_and_b = _create_detections(xyxy=[[10, 10, 30, 30], [60, 60, 90, 90]])
reused = HeatMapAnnotator()
for _ in range(5):
reused.annotate(scene=test_image.copy(), detections=region_a)
reused.reset()
reused_result = reused.annotate(
scene=test_image.copy(), detections=region_a_and_b
)
fresh = HeatMapAnnotator()
fresh_result = fresh.annotate(
scene=test_image.copy(), detections=region_a_and_b
)
assert np.array_equal(reused_result, fresh_result)
class TestEllipseAnnotator:
"""Tests for EllipseAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = EllipseAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image):
"""Test that annotate method correctly draws a single ellipse"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = EllipseAnnotator(
color=Color.YELLOW, thickness=2, color_lookup=ColorLookup.INDEX
)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.95)
class TestBoxCornerAnnotator:
"""Tests for BoxCornerAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = BoxCornerAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image):
"""Test that annotate method correctly draws box corners"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = BoxCornerAnnotator(
color=Color.WHITE,
thickness=3,
corner_length=10,
color_lookup=ColorLookup.INDEX,
)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.95)
class TestCircleAnnotator:
"""Tests for CircleAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = CircleAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image):
"""Test that annotate method correctly draws a circle"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = CircleAnnotator(
color=Color.GREEN, thickness=2, color_lookup=ColorLookup.INDEX
)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.95)
class TestDotAnnotator:
"""Tests for DotAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = DotAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image):
"""Test that annotate method correctly draws a dot"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = DotAnnotator(
color=Color.RED,
radius=5,
position=Position.CENTER,
color_lookup=ColorLookup.INDEX,
)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.95)
class TestLabelAnnotator:
"""Tests for LabelAnnotator class"""
@pytest.mark.parametrize(
"border_radius",
[
pytest.param(0, id="radius-zero"),
pytest.param(-3, id="radius-negative"),
],
)
def test_draw_rounded_rectangle_square_matches_plain_rectangle(
self, border_radius: int
) -> None:
"""Non-positive radius fills the same pixels as a plain rectangle.
For border_radius < 0: previously raised cv2.error: radius >= 0 in
function 'circle'; fast path now silently draws square corners instead.
"""
scene = np.full((100, 120, 3), 9, dtype=np.uint8)
result = LabelAnnotator.draw_rounded_rectangle(
scene=scene.copy(),
xyxy=(10, 20, 90, 70),
color=(0, 0, 255),
border_radius=border_radius,
)
expected = scene.copy()
expected[20:71, 10:91] = (0, 0, 255)
assert np.array_equal(result, expected)
def test_draw_rounded_rectangle_clamped_to_zero_acts_as_square(self) -> None:
"""Positive border_radius clamped to 0 by a degenerate box draws square corners.
1px-wide box: min(10, 1 // 2) = min(10, 0) = 0 → fast path fires even
though the caller passed a positive radius.
"""
scene = np.full((100, 120, 3), 9, dtype=np.uint8)
result = LabelAnnotator.draw_rounded_rectangle(
scene=scene.copy(),
xyxy=(10, 20, 11, 70),
color=(0, 0, 255),
border_radius=10,
)
expected = scene.copy()
expected[20:71, 10:12] = (0, 0, 255)
assert np.array_equal(result, expected)
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = LabelAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image):
"""Test that annotate method correctly draws a label"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = LabelAnnotator(color_lookup=ColorLookup.INDEX)
result = annotator.annotate(
scene=test_image.copy(), detections=detections, labels=["test"]
)
assert_image_mostly_same(test_image, result, similarity_threshold=0.93)
def test_smart_position_spreads_boxes_once(
self, monkeypatch: pytest.MonkeyPatch, test_image: np.ndarray
) -> None:
"""smart_position should spread labels once per annotate call."""
calls = 0
original_spread_out_boxes = annotators_core.spread_out_boxes
def counting_spread_out_boxes(
boxes: np.ndarray, *args: object, **kwargs: object
) -> np.ndarray:
nonlocal calls
calls += 1
return original_spread_out_boxes(boxes, *args, **kwargs)
monkeypatch.setattr(
annotators_core, "spread_out_boxes", counting_spread_out_boxes
)
detections = _create_detections(
xyxy=[[10, 10, 90, 90], [15, 15, 85, 85]], class_id=[0, 1]
)
annotator = LabelAnnotator(color_lookup=ColorLookup.INDEX, smart_position=True)
annotator.annotate(
scene=test_image.copy(), detections=detections, labels=["one", "two"]
)
assert calls == 1
class TestRichLabelAnnotator:
"""Tests for RichLabelAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = RichLabelAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image):
"""Test that annotate method correctly draws a rich label"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = RichLabelAnnotator(color_lookup=ColorLookup.INDEX)
result = annotator.annotate(
scene=test_image.copy(), detections=detections, labels=["test"]
)
assert_image_mostly_same(test_image, result, similarity_threshold=0.95)
def test_smart_position_spreads_boxes_once(
self, monkeypatch: pytest.MonkeyPatch, test_image: np.ndarray
) -> None:
"""smart_position should spread rich labels once per annotate call."""
calls = 0
original_spread_out_boxes = annotators_core.spread_out_boxes
def counting_spread_out_boxes(
boxes: np.ndarray, *args: object, **kwargs: object
) -> np.ndarray:
nonlocal calls
calls += 1
return original_spread_out_boxes(boxes, *args, **kwargs)
monkeypatch.setattr(
annotators_core, "spread_out_boxes", counting_spread_out_boxes
)
detections = _create_detections(
xyxy=[[10, 10, 90, 90], [15, 15, 85, 85]], class_id=[0, 1]
)
annotator = RichLabelAnnotator(
color_lookup=ColorLookup.INDEX, smart_position=True
)
annotator.annotate(
scene=Image.fromarray(test_image.copy()),
detections=detections,
labels=["one", "two"],
)
assert calls == 1
class TestBlurAnnotator:
"""Tests for BlurAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = BlurAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, gradient_image):
"""Test that annotate method correctly blurs a region"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = BlurAnnotator(kernel_size=15)
result = annotator.annotate(scene=gradient_image.copy(), detections=detections)
assert not np.array_equal(gradient_image, result)
@pytest.mark.parametrize("bad_size", [0, -1, -10])
def test_invalid_kernel_size_raises(self, bad_size):
"""BlurAnnotator must reject kernel_size < 1 at construction time."""
with pytest.raises(ValueError, match="kernel_size must be >= 1"):
BlurAnnotator(kernel_size=bad_size)
def test_annotate_zero_area_bbox_is_skipped(self, test_image):
"""Zero-area bounding boxes must be silently skipped, not crash."""
detections = _create_detections(xyxy=[[10, 10, 10, 50]], class_id=[0])
annotator = BlurAnnotator(kernel_size=5)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
class TestPixelateAnnotator:
"""Tests for PixelateAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = PixelateAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, gradient_image):
"""Test that annotate method correctly pixelates a region"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = PixelateAnnotator(pixel_size=10)
result = annotator.annotate(scene=gradient_image.copy(), detections=detections)
assert not np.array_equal(gradient_image, result)
def test_annotate_bbox_smaller_than_pixel_size_does_not_raise(self):
"""PixelateAnnotator must not crash when the bbox is smaller than pixel_size.
Regression test for https://github.com/roboflow/supervision/issues/703:
a fixed pixel_size larger than the detection dimensions previously caused
an OpenCV assertion error in cv2.resize.
"""
image = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
# bbox is 5x5; pixel_size=50 is much larger, triggers the avg-fill fallback
detections = _create_detections(xyxy=[[10, 10, 15, 15]], class_id=[0])
annotator = PixelateAnnotator(pixel_size=50)
result = annotator.annotate(scene=image.copy(), detections=detections)
assert result.shape == image.shape
def test_annotate_grayscale_image_does_not_raise(self):
"""PixelateAnnotator must work on single-channel (grayscale) images.
The small-ROI avg-fill branch previously sliced cv2.mean()[:3] into a
2-D array, causing a NumPy broadcast error on grayscale frames.
"""
gray = np.random.randint(0, 255, (100, 100), dtype=np.uint8)
# Normal-size detection — exercises the resize path on a grayscale frame
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = PixelateAnnotator(pixel_size=10)
result = annotator.annotate(scene=gray.copy(), detections=detections)
assert result.shape == gray.shape
def test_annotate_grayscale_image_small_roi_does_not_raise(self):
"""Grayscale image with bbox smaller than pixel_size uses scalar avg fill.
Exercises the ndim-aware branch added to the small-ROI fallback.
"""
gray = np.random.randint(0, 255, (100, 100), dtype=np.uint8)
detections = _create_detections(xyxy=[[10, 10, 15, 15]], class_id=[0])
annotator = PixelateAnnotator(pixel_size=50)
result = annotator.annotate(scene=gray.copy(), detections=detections)
assert result.shape == gray.shape
@pytest.mark.parametrize("bad_size", [0, -1, -10])
def test_invalid_pixel_size_raises(self, bad_size):
"""PixelateAnnotator must reject pixel_size < 1 at construction time."""
with pytest.raises(ValueError, match="pixel_size must be >= 1"):
PixelateAnnotator(pixel_size=bad_size)
def test_annotate_zero_area_bbox_is_skipped(self, test_image):
"""Zero-area bounding boxes must be silently skipped, not crash."""
detections = _create_detections(xyxy=[[10, 10, 10, 50]], class_id=[0])
annotator = PixelateAnnotator(pixel_size=5)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
class TestTriangleAnnotator:
"""Tests for TriangleAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = TriangleAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image):
"""Test that annotate method correctly draws a triangle"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = TriangleAnnotator(
color=Color.RED, base=20, height=20, color_lookup=ColorLookup.INDEX
)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.95)
class TestRoundBoxAnnotator:
"""Tests for RoundBoxAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = RoundBoxAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image):
"""Test that annotate method correctly draws a round box"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = RoundBoxAnnotator(
color=Color.BLUE, thickness=2, roundness=0.5, color_lookup=ColorLookup.INDEX
)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.9)
class TestPercentageBarAnnotator:
"""Tests for PercentageBarAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = PercentageBarAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image):
"""Test that annotate method correctly draws a percentage bar"""
detections = _create_detections(
xyxy=[[10, 10, 90, 90]], confidence=[0.75], class_id=[0]
)
annotator = PercentageBarAnnotator(color_lookup=ColorLookup.INDEX)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert_image_mostly_same(test_image, result, similarity_threshold=0.93)
class TestPositionHelpers:
"""Tests for helper methods that map `Position` to coordinates."""
@pytest.mark.parametrize(
("helper", "args"),
[
pytest.param(
PercentageBarAnnotator.calculate_border_coordinates,
((10, 10), (4, 4), cast(Position, "invalid")),
id="percentage-bar",
),
pytest.param(
CropAnnotator.calculate_crop_coordinates,
((10, 10), (4, 4), cast(Position, "invalid")),
id="crop",
),
],
)
def test_unknown_position_raises(
self, helper: Any, args: tuple[Any, Any, Any]
) -> None:
"""Unsupported positions must raise instead of returning None."""
with pytest.raises(ValueError, match="Unsupported position"):
helper(*args)
class TestCropAnnotator:
"""Tests for CropAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = CropAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, gradient_image):
"""Test that annotate method correctly draws a crop"""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = CropAnnotator(border_color_lookup=ColorLookup.INDEX)
result = annotator.annotate(scene=gradient_image.copy(), detections=detections)
assert not np.array_equal(gradient_image, result)
def test_annotate_emits_no_deprecation_warning(self, gradient_image):
"""Internal overlay must not surface the deprecated `overlay_image` warning."""
detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0])
annotator = CropAnnotator(border_color_lookup=ColorLookup.INDEX)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
annotator.annotate(scene=gradient_image.copy(), detections=detections)
deprecations = [
w
for w in caught
if issubclass(w.category, (DeprecationWarning, FutureWarning))
]
assert deprecations == []
def test_annotate_with_partially_out_of_bounds_detection(self, gradient_image):
"""Partially-OOB box is clipped and rendered; scene must change."""
detections = _create_detections(xyxy=[[-10, -10, 30, 30]], class_id=[0])
annotator = CropAnnotator(
position=Position.CENTER, border_color_lookup=ColorLookup.INDEX
)
result = annotator.annotate(scene=gradient_image.copy(), detections=detections)
assert result.shape == gradient_image.shape
assert not np.array_equal(gradient_image, result)
def test_annotate_with_fully_out_of_bounds_detection(self, gradient_image):
"""A box fully outside the scene collapses to zero area and is skipped."""
detections = _create_detections(xyxy=[[-50, -50, -10, -10]], class_id=[0])
annotator = CropAnnotator(border_color_lookup=ColorLookup.INDEX)
result = annotator.annotate(scene=gradient_image.copy(), detections=detections)
assert np.array_equal(gradient_image, result)
@pytest.mark.parametrize(
"xyxy",
[
pytest.param([-5, 20, 40, 60], id="negative-x-min"),
pytest.param([20, -5, 60, 40], id="negative-y-min"),
pytest.param([-10, -10, 30, 30], id="negative-x-and-y-min"),
pytest.param([60, 20, 140, 60], id="past-right-edge"),
pytest.param([-20, -20, 140, 140], id="larger-than-scene"),
],
)
def test_annotate_with_box_crossing_scene_border(
self, gradient_image, xyxy: list[int]
) -> None:
"""Boxes extending past the scene border are clipped instead of raising"""
detections = _create_detections(xyxy=[xyxy], class_id=[0])
annotator = CropAnnotator()
result = annotator.annotate(scene=gradient_image.copy(), detections=detections)
assert result.shape == gradient_image.shape
@pytest.mark.parametrize(
"xyxy",
[
pytest.param([150, 150, 200, 200], id="fully-outside"),
pytest.param([-50, -50, -10, -10], id="fully-negative"),
pytest.param([30, 20, 30, 60], id="zero-width"),
pytest.param([30, 30, 30, 30], id="zero-area"),
],
)
def test_annotate_skips_boxes_empty_after_clipping(
self, gradient_image, xyxy: list[int]
) -> None:
"""Boxes with no visible area are skipped instead of raising cv2.error"""
detections = _create_detections(xyxy=[xyxy], class_id=[0])
annotator = CropAnnotator()
result = annotator.annotate(scene=gradient_image.copy(), detections=detections)
assert np.array_equal(gradient_image, result)
def test_annotate_mixed_valid_and_degenerate_boxes(self, gradient_image) -> None:
"""A degenerate box does not prevent valid boxes from being drawn"""
detections = _create_detections(
xyxy=[[150, 150, 200, 200], [10, 10, 90, 90]], class_id=[0, 1]
)
annotator = CropAnnotator()
result = annotator.annotate(scene=gradient_image.copy(), detections=detections)
assert not np.array_equal(gradient_image, result)
def test_annotate_overlapping_crops_sample_from_original_scene(self) -> None:
"""Later crops must sample the original un-annotated scene.
box1 is pasted into the region that box2 crops from. Without the
source_scene = scene.copy() fix, box2 reads box1's paste value
instead of the original pixel — the aliasing regression.
"""
# Arrange: two distinct pixel bands; box1's paste region overlaps box2's crop
scene = np.full((80, 80, 3), 50, dtype=np.uint8)
scene[0:20, 0:20] = 10 # band A — box1 crops here (value 10)
scene[20:40, 20:40] = 200 # band B — box2 crops here; box1 pastes here
detections = _create_detections(
xyxy=[[0, 0, 20, 20], [20, 20, 40, 40]], class_id=[0, 1]
)
# BOTTOM_RIGHT: each crop is pasted at its (x2, y2) corner.
# box1 pastes band A (value 10) at rows 20-39, cols 20-39 — exactly
# where box2 will crop.
annotator = CropAnnotator(
position=Position.BOTTOM_RIGHT,
scale_factor=1.0,
)
# Act
result = annotator.annotate(scene=scene.copy(), detections=detections)
# Assert: box2's crop is pasted at rows 40-59, cols 40-59.
# Interior (rows 42-57, cols 42-57) avoids the 2-pixel default border
# and must equal 200 — the original band B value. Without the fix,
# box2 samples 10 from the painted scene instead of 200 from original.
interior = result[42:58, 42:58]
assert np.all(interior == 200), (
f"box2 crop paste region should contain original pixel 200, "
f"got {np.unique(interior).tolist()!r}. "
"Regression: source_scene must be scene.copy(), not an alias."
)
class TestIconAnnotator:
"""Tests for IconAnnotator class"""
def test_annotate_emits_no_deprecation_warning(self, test_image, tmp_path):
"""Internal overlay must not surface the deprecated `overlay_image` warning."""
icon_path = str(tmp_path / "icon.png")
icon = np.full((20, 20, 4), (0, 255, 0, 255), dtype=np.uint8)
cv2.imwrite(icon_path, icon)
detections = _create_detections(xyxy=[[20, 20, 60, 60]], class_id=[0])
annotator = IconAnnotator()
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
annotator.annotate(
scene=test_image.copy(), detections=detections, icon_path=icon_path
)
deprecations = [
w
for w in caught
if issubclass(w.category, (DeprecationWarning, FutureWarning))
]
assert deprecations == []
def test_icon_cache_is_shared_by_path_and_resolution(
self, monkeypatch, test_image, tmp_path
):
"""Equal path/resolution icon loads are cached across annotator instances."""
icon_path = str(tmp_path / "icon.png")
icon = np.full((20, 20, 4), (0, 255, 0, 255), dtype=np.uint8)
cv2.imwrite(icon_path, icon)
detections = _create_detections(xyxy=[[20, 20, 60, 60]], class_id=[0])
imread_calls = 0
original_imread = cv2.imread
def count_imread(path, flags):
nonlocal imread_calls
imread_calls += 1
return original_imread(path, flags)
monkeypatch.setattr(cv2, "imread", count_imread)
for _ in range(2):
IconAnnotator(icon_resolution_wh=(16, 16)).annotate(
scene=test_image.copy(), detections=detections, icon_path=icon_path
)
assert imread_calls == 1
class TestBackgroundOverlayAnnotator:
"""Tests for BackgroundOverlayAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections = Detections.empty()
annotator = BackgroundOverlayAnnotator()
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self):
"""Test that annotate method correctly draws background overlay"""
image = np.ones((100, 100, 3), dtype=np.uint8) * 255
detections = _create_detections(xyxy=[[10, 10, 90, 90]])
annotator = BackgroundOverlayAnnotator(color=Color.BLACK, opacity=0.5)
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
mask = np.zeros((100, 100), dtype=bool)
mask[10:90, 10:90] = True
detections_bool = _create_detections(xyxy=[[10, 10, 90, 90]], mask=[mask])
detections_uint8 = _create_detections(xyxy=[[10, 10, 90, 90]], mask=[mask])
detections_uint8.mask = detections_uint8.mask.astype(np.uint8)
annotator = BackgroundOverlayAnnotator(color=Color.BLACK, opacity=0.5)
result_bool = annotator.annotate(scene=image.copy(), detections=detections_bool)
result_uint8 = annotator.annotate(
scene=image.copy(), detections=detections_uint8
)
assert np.array_equal(result_bool, result_uint8)
class TestComparisonAnnotator:
"""Tests for ComparisonAnnotator class"""
def test_annotate_with_no_detections(self, test_image):
"""Test that annotate method returns unmodified image when no detections"""
detections1 = Detections.empty()
detections2 = Detections.empty()
annotator = ComparisonAnnotator()
result = annotator.annotate(
scene=test_image.copy(), detections_1=detections1, detections_2=detections2
)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection_each(self):
"""Test that annotate method correctly compares two detections"""
image = np.ones((100, 100, 3), dtype=np.uint8) * 255
detections1 = _create_detections(xyxy=[[10, 10, 50, 50]])
detections2 = _create_detections(xyxy=[[30, 30, 70, 70]])
annotator = ComparisonAnnotator()
result = annotator.annotate(
scene=image.copy(), detections_1=detections1, detections_2=detections2
)
assert not np.array_equal(image, result)
class TestTraceAnnotatorReset:
"""Tests for TraceAnnotator.reset() clearing accumulated trace history."""
def test_reset_empties_trace_buffers(self, test_image: np.ndarray) -> None:
"""reset() must clear the underlying Trace buffers to their empty state."""
annotator = TraceAnnotator(trace_length=10)
detections = _create_detections(
xyxy=[[10, 10, 30, 30]], class_id=[1], tracker_id=[7]
)
annotator.annotate(scene=test_image.copy(), detections=detections)
annotator.reset()
assert annotator.trace.frame_id.shape == (0,)
assert annotator.trace.xy.shape == (0, 2)
assert annotator.trace.tracker_id.shape == (0,)
assert annotator.trace.current_frame_id == 0
def test_reset_matches_fresh_annotator(self, test_image: np.ndarray) -> None:
"""After reset() a reused annotator must render identically to a fresh one.
The two streams reuse the same ``tracker_id`` but follow spatially
distinct paths. If reset were a no-op, ``Trace.get`` would return the
first stream's points concatenated with the second's and draw a spurious
polyline bridging the two paths; only a genuine reset leaves solely the
second stream's points, so byte-equality with a never-used annotator can
hold only when the prior history was actually discarded. ``trace_length``
is large enough that no windowing prunes away the stale points that a
broken reset would leave behind.
"""
first_stream = [
_create_detections(
xyxy=[[10 + step * 6, 10 + step * 6, 20 + step * 6, 20 + step * 6]],
class_id=[1],
tracker_id=[7],
)
for step in range(5)
]
second_stream = [
_create_detections(
xyxy=[[80 - step * 6, 10 + step * 6, 90 - step * 6, 20 + step * 6]],
class_id=[1],
tracker_id=[7],
)
for step in range(5)
]
reused = TraceAnnotator(trace_length=30)
reused_scene = test_image.copy()
for detections in first_stream:
reused_scene = reused.annotate(scene=reused_scene, detections=detections)
reused.reset()
reused_scene = test_image.copy()
for detections in second_stream:
reused_scene = reused.annotate(scene=reused_scene, detections=detections)
fresh = TraceAnnotator(trace_length=30)
fresh_scene = test_image.copy()
for detections in second_stream:
fresh_scene = fresh.annotate(scene=fresh_scene, detections=detections)
assert np.array_equal(reused_scene, fresh_scene)
class TestTraceAnnotatorSmoothStationary:
"""Regression tests for TraceAnnotator(smooth=True) on stationary tracker ids."""
def test_stationary_tracker_does_not_crash_spline_fit(self, test_image):
"""
When the same tracker stays at an identical anchor point for several
frames the trace buffer accumulates duplicate points. `scipy.splprep`
rejects a zero-length input curve with `ValueError: Invalid inputs.`,
so the annotator must survive this input without raising.
"""
detections = _create_detections(
xyxy=[[100, 100, 120, 120]],
class_id=[1],
tracker_id=[42],
)
annotator = TraceAnnotator(smooth=True, trace_length=10)
scene = test_image.copy()
for _ in range(6):
scene = annotator.annotate(scene=scene, detections=detections)
assert scene.shape == test_image.shape
def test_smooth_trace_still_renders_for_moving_tracker(self, test_image):
"""Moving tracker must produce a spline trace distinct from the raw polyline.
Compares smooth=True output against smooth=False for the same movement
path to confirm the smoothing path is actually exercised (not just that
some pixels changed).
"""
smooth_annotator = TraceAnnotator(smooth=True, trace_length=10, thickness=2)
raw_annotator = TraceAnnotator(smooth=False, trace_length=10, thickness=2)
scene_smooth = test_image.copy()
scene_raw = test_image.copy()
for offset in range(6):
detections = _create_detections(
xyxy=[
[10 + offset * 5, 10 + offset * 5, 30 + offset * 5, 30 + offset * 5]
],
class_id=[1],
tracker_id=[7],
)
scene_smooth = smooth_annotator.annotate(
scene=scene_smooth, detections=detections
)
scene_raw = raw_annotator.annotate(scene=scene_raw, detections=detections)
# After 4+ unique anchor positions the spline path fires and diverges from the
# raw polyline — the two output images must differ.
assert not np.array_equal(scene_smooth, scene_raw)
@pytest.mark.parametrize(
"unique_positions",
[1, 2, 3, 4],
ids=["1_unique", "2_unique", "3_unique", "4_unique"],
)
def test_smooth_does_not_crash_for_unique_point_counts(
self, test_image, unique_positions
):
"""smooth=True must not crash for any unique-position count from 1 to 4.
Each position is repeated twice to simulate brief holds between moves.
Covers the boundary at len(unique_xy) == 4 where splprep first fires.
"""
annotator = TraceAnnotator(smooth=True, trace_length=10, thickness=2)
scene = test_image.copy()
for pos_idx in range(unique_positions):
for _ in range(2):
x = 10 + pos_idx * 15
detections = _create_detections(
xyxy=[[x, x, x + 15, x + 15]],
class_id=[1],
tracker_id=[99],
)
scene = annotator.annotate(scene=scene, detections=detections)
assert scene.shape == test_image.shape
def test_smooth_fallback_matches_raw_when_fewer_than_four_unique_points(
self, test_image
):
"""With <4 unique positions smooth=True output must match smooth=False.
Verifies the dedup-then-fallback path: when unique_xy has ≤3 points,
both branches use the same raw-polyline draw.
"""
annotator_smooth = TraceAnnotator(smooth=True, trace_length=10, thickness=2)
annotator_raw = TraceAnnotator(smooth=False, trace_length=10, thickness=2)
scene_smooth = test_image.copy()
scene_raw = test_image.copy()
for pos_idx in range(3):
for _ in range(2):
x = 10 + pos_idx * 15
detections = _create_detections(
xyxy=[[x, x, x + 15, x + 15]],
class_id=[1],
tracker_id=[99],
)
scene_smooth = annotator_smooth.annotate(
scene=scene_smooth, detections=detections
)
scene_raw = annotator_raw.annotate(
scene=scene_raw, detections=detections
)
assert np.array_equal(scene_smooth, scene_raw)
def test_smooth_true_single_frame_does_not_crash(self, test_image):
"""A single annotate() call with smooth=True must not crash.
When len(xy) == 1 the drawing guard skips cv2.polylines entirely;
the dedup path runs safely on an empty np.diff result.
"""
detections = _create_detections(
xyxy=[[50, 50, 70, 70]],
class_id=[1],
tracker_id=[1],
)
annotator = TraceAnnotator(smooth=True, trace_length=10)
scene = annotator.annotate(scene=test_image.copy(), detections=detections)
assert scene.shape == test_image.shape
def test_smooth_false_stationary_tracker_does_not_crash(self, test_image):
"""smooth=False with a stationary tracker must not crash (regression guard).
Ensures the refactor did not accidentally alter the smooth=False code path.
"""
detections = _create_detections(
xyxy=[[100, 100, 120, 120]],
class_id=[1],
tracker_id=[42],
)
annotator = TraceAnnotator(smooth=False, trace_length=10)
scene = test_image.copy()
for _ in range(6):
scene = annotator.annotate(scene=scene, detections=detections)
assert scene.shape == test_image.shape