"""
Tests for supervision/annotators/core.py
"""

import warnings
from collections.abc import Iterator
from typing import Any, cast

import numpy as np
import pytest
from PIL import Image

import supervision.annotators.core as annotators_core
from supervision import _cv2 as cv2
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
