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