supervision/tests/annotators/test_core.py

907 lines
39 KiB
Python

"""
Tests for supervision/annotators/core.py
"""
import warnings
import numpy as np
import pytest
from supervision.annotators.core import (
BackgroundOverlayAnnotator,
BlurAnnotator,
BoxAnnotator,
BoxCornerAnnotator,
CircleAnnotator,
ColorAnnotator,
ComparisonAnnotator,
CropAnnotator,
DotAnnotator,
EllipseAnnotator,
HaloAnnotator,
HeatMapAnnotator,
LabelAnnotator,
MaskAnnotator,
OrientedBoxAnnotator,
PercentageBarAnnotator,
PixelateAnnotator,
PolygonAnnotator,
RichLabelAnnotator,
RoundBoxAnnotator,
TraceAnnotator,
TriangleAnnotator,
)
from supervision.annotators.utils import ColorLookup
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
@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)
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)
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)
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())
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"""
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)
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)
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 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)
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)
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 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