from contextlib import ExitStack as DoesNotRaise import numpy as np import pytest from supervision import Detections, LineZone, LineZoneAnnotatorMulticlass from supervision.geometry.core import Point, Position, Vector from tests.helpers import _create_detections @pytest.mark.parametrize( ("vector", "expected_result", "exception"), [ ( Vector(start=Point(x=0.0, y=0.0), end=Point(x=0.0, y=0.0)), None, pytest.raises(ValueError, match="magnitude of the vector"), ), ( Vector(start=Point(x=1.0, y=1.0), end=Point(x=1.0, y=1.0)), None, pytest.raises(ValueError, match="cannot be zero"), ), ( Vector(start=Point(x=0.0, y=0.0), end=Point(x=0.0, y=4.0)), ( Vector(start=Point(x=0.0, y=0.0), end=Point(x=-1.0, y=0.0)), Vector(start=Point(x=0.0, y=4.0), end=Point(x=1.0, y=4.0)), ), DoesNotRaise(), ), ( Vector(Point(0.0, 0.0), Point(4.0, 0.0)), ( Vector(start=Point(x=0.0, y=0.0), end=Point(x=0.0, y=1.0)), Vector(start=Point(x=4.0, y=0.0), end=Point(x=4.0, y=-1.0)), ), DoesNotRaise(), ), ( Vector(Point(0.0, 0.0), Point(3.0, 4.0)), ( Vector(start=Point(x=0, y=0), end=Point(x=-0.8, y=0.6)), Vector(start=Point(x=3, y=4), end=Point(x=3.8, y=3.4)), ), DoesNotRaise(), ), ( Vector(Point(0.0, 0.0), Point(4.0, 3.0)), ( Vector(start=Point(x=0, y=0), end=Point(x=-0.6, y=0.8)), Vector(start=Point(x=4, y=3), end=Point(x=4.6, y=2.2)), ), DoesNotRaise(), ), ( Vector(Point(0.0, 0.0), Point(3.0, -4.0)), ( Vector(start=Point(x=0, y=0), end=Point(x=0.8, y=0.6)), Vector(start=Point(x=3, y=-4), end=Point(x=2.2, y=-4.6)), ), DoesNotRaise(), ), ], ) def test_calculate_region_of_interest_limits( vector: Vector, expected_result: tuple[Vector, Vector] | None, exception: Exception, ) -> None: with exception: result = LineZone._calculate_region_of_interest_limits(vector=vector) assert result == expected_result @pytest.mark.parametrize( ("vector", "xyxy_sequence", "expected_crossed_in", "expected_crossed_out"), [ ( # Vertical line, simple crossing Vector(Point(0, 0), Point(0, 10)), [ [4, 4, 6, 6], [4 - 10, 4, 6 - 10, 6], [4, 4, 6, 6], [4 - 10, 4, 6 - 10, 6], ], [False, False, True, False], [False, True, False, True], ), ( # Vertical line reversed, simple crossing Vector(Point(0, 10), Point(0, 0)), [ [4, 4, 6, 6], [4 - 10, 4, 6 - 10, 6], [4, 4, 6, 6], [4 - 10, 4, 6 - 10, 6], ], [False, True, False, True], [False, False, True, False], ), ( # Horizontal line, simple crossing Vector(Point(0, 0), Point(10, 0)), [ [4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], [4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], ], [False, True, False, True], [False, False, True, False], ), ( # Horizontal line reversed, simple crossing Vector(Point(10, 0), Point(0, 0)), [ [4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], [4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], ], [False, False, True, False], [False, True, False, True], ), ( # Diagonal line, simple crossing Vector(Point(5, 0), Point(0, 5)), [ [0, 0, 2, 2], [0 + 10, 0 + 10, 2 + 10, 2 + 10], [0, 0, 2, 2], [0 + 10, 0 + 10, 2 + 10, 2 + 10], ], [False, True, False, True], [False, False, True, False], ), ( # Crossing beside - right side Vector(Point(0, 0), Point(10, 0)), [ [20, 4, 24, 6], [20, 4 - 10, 24, 6 - 10], [20, 4, 24, 6], [20, 4 - 10, 24, 6 - 10], ], [False, False, False, False], [False, False, False, False], ), ( # Horizontal line, simple crossing, far away Vector(Point(0, 0), Point(10, 0)), [ [4, 1e32, 6, 1e32 + 2], [4, -1e32, 6, -1e32 + 2], [4, 1e32, 6, 1e32 + 2], [4, -1e32, 6, -1e32 + 2], ], [False, True, False, True], [False, False, True, False], ), ( # Crossing beside - left side Vector(Point(0, 0), Point(10, 0)), [ [-20, 4, -24, 6], [-20, 4 - 10, -24, 6 - 10], [-20, 4, -24, 6], [-20, 4 - 10, -24, 6 - 10], ], [False, False, False, False], [False, False, False, False], ), ( # Move above Vector(Point(0, 0), Point(10, 0)), [ [-4, 4, -2, 6], [-4 + 20, 4, -2 + 20, 6], [-4, 4, -2, 6], [-4 + 20, 4, -2 + 20, 6], ], [False, False, False, False], [False, False, False, False], ), ( # Move below Vector(Point(0, 0), Point(10, 0)), [ [-4, -6, -2, -4], [-4 + 20, -6, -2 + 20, -4], [-4, -6, -2, -4], [-4 + 20, -6, -2 + 20, -4], ], [False, False, False, False], [False, False, False, False], ), ( # Move into line partway Vector(Point(0, 0), Point(10, 0)), [ [4, 4, 6, 6], [4 + 5, 4, 6 + 5, 6], [4, 4, 6, 6], [4 + 5, 4, 6 + 5, 6], ], [False, False, False, False], [False, False, False, False], ), ( # V-shaped crossing from outside limits - not supported. Vector(Point(0, 0), Point(10, 0)), [[-3, 6, -1, 8], [4, -6, 6, -4], [11, 6, 13, 8]], [False, False, False], [False, False, False], ), ( # Diagonal movement, from within limits to outside - not supported Vector(Point(0, 0), Point(10, 0)), [[4, 1, 6, 3], [11, 1 - 20, 13, 3 - 20]], [False, False], [False, False], ), ( # Diagonal movement, from outside limits to within - not supported Vector(Point(0, 0), Point(10, 0)), [ [11, 21, 13, 23], [4, -3, 6, -1], ], [False, False], [False, False], ), ( # Diagonal crossing, from outside to outside limits - not supported. Vector(Point(0, 0), Point(10, 0)), [ [-4, 4, -2, 8], [-4 + 16, -4, -2 + 16, -6], [-4, 4, -2, 8], [-4 + 16, -4, -2 + 16, -6], ], [False, False, False, False], [False, False, False, False], ), ], ) def test_line_zone_one_detection_default_anchors( vector: Vector, xyxy_sequence: list[list[float]], expected_crossed_in: list[bool], expected_crossed_out: list[bool], ) -> None: line_zone = LineZone(start=vector.start, end=vector.end) crossed_in_list = [] crossed_out_list = [] for i, bbox in enumerate(xyxy_sequence): detections = _create_detections( xyxy=[bbox], tracker_id=[0], ) crossed_in, crossed_out = line_zone.trigger(detections) crossed_in_list.append(crossed_in[0]) crossed_out_list.append(crossed_out[0]) assert crossed_in_list == expected_crossed_in, ( f"expected {expected_crossed_in}, got {crossed_in_list}" ) assert crossed_out_list == expected_crossed_out, ( f"expected {expected_crossed_out}, got {crossed_out_list}" ) @pytest.mark.parametrize( ( "vector", "xyxy_sequence", "triggering_anchors", "expected_crossed_in", "expected_crossed_out", ), [ ( # Scrape line, left side, corner anchors Vector(Point(0, 0), Point(10, 0)), [ [-2, 4, 2, 6], [-2, 4 - 10, 2, 6 - 10], [-2, 4, 2, 6], [-2, 4 - 10, 2, 6 - 10], ], [ Position.TOP_LEFT, Position.BOTTOM_LEFT, Position.TOP_RIGHT, Position.BOTTOM_RIGHT, ], [False, False, False, False], [False, False, False, False], ), ( # Scrape line, left side, right anchors Vector(Point(0, 0), Point(10, 0)), [ [-2, 4, 2, 6], [-2, 4 - 10, 2, 6 - 10], [-2, 4, 2, 6], [-2, 4 - 10, 2, 6 - 10], ], [Position.TOP_RIGHT, Position.BOTTOM_RIGHT], [False, True, False, True], [False, False, True, False], ), ( # Scrape line, left side, center anchor (along line point) Vector(Point(0, 0), Point(10, 0)), [ [-2, 4, 2, 6], [-2, 4 - 10, 2, 6 - 10], [-2, 4, 2, 6], [-2, 4 - 10, 2, 6 - 10], ], [Position.CENTER], [False, True, False, True], [False, False, True, False], ), ( # Scrape line, left side, center anchor (along line point) Vector(Point(0, 0), Point(10, 0)), [ [-2, 4, 2, 6], [-2, 4 - 10, 2, 6 - 10], [-2, 4, 2, 6], [-2, 4 - 10, 2, 6 - 10], [-2, 4 - 10, 2, 6 - 10], ], [Position.CENTER], [False, True, False, True, False], [False, False, True, False, False], ), ( # Scrape line, right side, corner anchors Vector(Point(0, 0), Point(10, 0)), [ [8, 4, 12, 6], [8, 4 - 10, 12, 6 - 10], [8, 4, 12, 6], [8, 4 - 10, 12, 6 - 10], ], [ Position.TOP_LEFT, Position.BOTTOM_LEFT, Position.TOP_RIGHT, Position.BOTTOM_RIGHT, ], [False, False, False, False], [False, False, False, False], ), ( # Scrape line, right side, left anchors Vector(Point(0, 0), Point(10, 0)), [ [8, 4, 12, 6], [8, 4 - 10, 12, 6 - 10], [8, 4, 12, 6], [8, 4 - 10, 12, 6 - 10], ], [Position.TOP_LEFT, Position.BOTTOM_LEFT], [False, True, False, True], [False, False, True, False], ), ( # Scrape line, right side, center anchor (along line point) Vector(Point(0, 0), Point(10, 0)), [ [8, 4, 12, 6], [8, 4 - 10, 12, 6 - 10], [8, 4, 12, 6], [8, 4 - 10, 12, 6 - 10], ], [Position.CENTER], [False, True, False, True], [False, False, True, False], ), ( # Simple crossing, one anchor Vector(Point(0, 0), Point(10, 0)), [ [4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], [4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], ], [Position.CENTER], [False, True, False, True], [False, False, True, False], ), ( # Simple crossing, all box anchors Vector(Point(0, 0), Point(10, 0)), [ [4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], [4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], ], [ Position.CENTER, Position.CENTER_LEFT, Position.CENTER_RIGHT, Position.TOP_CENTER, Position.TOP_LEFT, Position.TOP_RIGHT, Position.BOTTOM_LEFT, Position.BOTTOM_CENTER, Position.BOTTOM_RIGHT, ], [False, True, False, True], [False, False, True, False], ), ], ) def test_line_zone_one_detection( vector: Vector, xyxy_sequence: list[list[float]], triggering_anchors: list[Position], expected_crossed_in: list[bool], expected_crossed_out: list[bool], ) -> None: line_zone = LineZone( start=vector.start, end=vector.end, triggering_anchors=triggering_anchors ) crossed_in_list = [] crossed_out_list = [] for i, bbox in enumerate(xyxy_sequence): detections = _create_detections( xyxy=[bbox], tracker_id=[0], ) crossed_in, crossed_out = line_zone.trigger(detections) crossed_in_list.append(crossed_in[0]) crossed_out_list.append(crossed_out[0]) assert crossed_in_list == expected_crossed_in, ( f"expected {expected_crossed_in}, got {crossed_in_list}" ) assert crossed_out_list == expected_crossed_out, ( f"expected {expected_crossed_out}, got {crossed_out_list}" ) @pytest.mark.parametrize( ( "vector", "xyxy_sequence", "anchors", "expected_crossed_in", "expected_crossed_out", "exception", ), [ ( # One stays, one crosses Vector(Point(0, 0), Point(10, 0)), [ [[4, 4, 6, 6], [4, 4, 6, 6]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], [[4, 4, 6, 6], [4, 4, 6, 6]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], ], [ Position.TOP_LEFT, Position.TOP_RIGHT, Position.BOTTOM_LEFT, Position.BOTTOM_RIGHT, ], [[False, False], [False, True], [False, False], [False, True]], [[False, False], [False, False], [False, True], [False, False]], DoesNotRaise(), ), ( # Both cross at the same time Vector(Point(0, 0), Point(10, 0)), [ [[4, 4, 6, 6], [4, 4, 6, 6]], [[4, 4 - 10, 6, 6 - 10], [4, 4 - 10, 6, 6 - 10]], [[4, 4, 6, 6], [4, 4, 6, 6]], [[4, 4 - 10, 6, 6 - 10], [4, 4 - 10, 6, 6 - 10]], ], [ Position.TOP_LEFT, Position.TOP_RIGHT, Position.BOTTOM_LEFT, Position.BOTTOM_RIGHT, ], [[False, False], [True, True], [False, False], [True, True]], [[False, False], [False, False], [True, True], [False, False]], DoesNotRaise(), ), ], ) def test_line_zone_multiple_detections( vector: Vector, xyxy_sequence: list[list[list[float]]], anchors: list[Position], expected_crossed_in: list[list[bool]], expected_crossed_out: list[list[bool]], exception: Exception, ) -> None: with exception: line_zone = LineZone( start=vector.start, end=vector.end, triggering_anchors=anchors ) crossed_in_list = [] crossed_out_list = [] for bboxes in xyxy_sequence: detections = _create_detections( xyxy=bboxes, tracker_id=[i for i in range(0, len(bboxes))], ) crossed_in, crossed_out = line_zone.trigger(detections) crossed_in_list.append(list(crossed_in)) crossed_out_list.append(list(crossed_out)) assert crossed_in_list == expected_crossed_in assert crossed_out_list == expected_crossed_out @pytest.mark.parametrize( ( "vector", "xyxy_sequence", "triggering_anchors", "minimum_crossing_threshold", "expected_crossed_in", "expected_crossed_out", ), [ ( # Detection lingers around line, all crosses counted Vector(Point(0, 0), Point(10, 0)), [ [2, 4, 3, 6], [2, 4 - 10, 3, 6], [2, 4, 3, 6], [2, 4 - 10, 3, 6], [2, 4 - 10, 3, 6], ], [ Position.TOP_LEFT, ], 1, [False, True, False, True, False], [False, False, True, False, False], ), ( # Detection lingers around line, only final cross counted Vector(Point(0, 0), Point(10, 0)), [ [2, 4, 3, 6], [2, 4 - 10, 3, 6], [2, 4, 3, 6], [2, 4 - 10, 3, 6], [2, 4 - 10, 3, 6], ], [ Position.TOP_LEFT, ], 2, [False, False, False, False, True], [False, False, False, False, False], ), ( # Detection lingers around line for a long time Vector(Point(0, 0), Point(10, 0)), [ [2, 4, 3, 6], [2, 4 - 10, 3, 6], [2, 4, 3, 6], [2, 4 - 10, 3, 6], [2, 4, 3, 6], [2, 4 - 10, 3, 6], [2, 4, 3, 6], [2, 4 - 10, 3, 6], [2, 4, 3, 6], [2, 4 - 10, 3, 6], [2, 4, 3, 6], [2, 4 - 10, 3, 6], [2, 4 - 10, 3, 6], ], [ Position.TOP_LEFT, ], 2, [False] * 12 + [True], [False] * 13, ), ( # Detection lingers around line, longer cycle Vector(Point(0, 0), Point(10, 0)), [ [2, 4, 3, 6], [2, 4 - 10, 3, 6], [2, 4, 3, 6], [2, 4, 3, 6], [2, 4, 3, 6], [2, 4 - 10, 3, 6], [2, 4 - 10, 3, 6], [2, 4 - 10, 3, 6], [2, 4 - 10, 3, 6], ], [ Position.TOP_LEFT, ], 4, [False] * 8 + [True], [False] * 9, ), ], ) def test_line_zone_one_detection_long_horizon( vector: Vector, xyxy_sequence: list[list[float]], triggering_anchors: list[Position], minimum_crossing_threshold: int, expected_crossed_in: list[bool], expected_crossed_out: list[bool], ) -> None: line_zone = LineZone( start=vector.start, end=vector.end, triggering_anchors=triggering_anchors, minimum_crossing_threshold=minimum_crossing_threshold, ) crossed_in_list = [] crossed_out_list = [] for i, bbox in enumerate(xyxy_sequence): detections = _create_detections( xyxy=[bbox], tracker_id=[0], ) crossed_in, crossed_out = line_zone.trigger(detections) crossed_in_list.append(crossed_in[0]) crossed_out_list.append(crossed_out[0]) assert crossed_in_list == expected_crossed_in, ( f"expected {expected_crossed_in}, got {crossed_in_list}" ) assert crossed_out_list == expected_crossed_out, ( f"expected {expected_crossed_out}, got {crossed_out_list}" ) @pytest.mark.parametrize( ( "vector", "xyxy_sequence", "anchors", "minimum_crossing_threshold", "expected_crossed_in", "expected_crossed_out", "expected_count_in", "expected_count_out", "exception", ), [ ( # One stays, one crosses, one disappears before crossing Vector(Point(0, 0), Point(10, 0)), [ [[4, 4, 6, 6], [4, 4, 6, 6], [4, 4, 6, 6]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], [4, 4, 6, 6]], [[4, 4, 6, 6], [4, 4, 6, 6]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], ], [ Position.TOP_LEFT, ], 1, [ [False, False, False], [False, True, False], [False, False], [False, True], [False, False], ], [ [False, False, False], [False, False, False], [False, True], [False, False], [False, False], ], [0, 1, 1, 2, 2], [0, 0, 1, 1, 1], DoesNotRaise(), ), ( # One stays, one crosses, one disappears immediately after crossing Vector(Point(0, 0), Point(10, 0)), [ [[4, 4, 6, 6], [4, 4, 6, 6], [4, 4, 6, 6]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], [4, 4, 6, 6]], [[4, 4, 6, 6], [4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], ], [ Position.TOP_LEFT, ], 1, [ [False, False, False], [False, True, False], [False, False, True], [False, True], [False, False], ], [ [False, False, False], [False, False, False], [False, True, False], [False, False], [False, False], ], [0, 1, 2, 3, 3], [0, 0, 1, 1, 1], DoesNotRaise(), ), ( # One stays, one crosses, one disappears before crossing Vector(Point(0, 0), Point(10, 0)), [ [[4, 4, 6, 6], [4, 4, 6, 6], [4, 4, 6, 6]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], [4, 4, 6, 6]], [[4, 4, 6, 6], [4, 4, 6, 6]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], ], [ Position.TOP_LEFT, ], 2, [ [False, False, False], [False, False, False], [False, False], [False, False], [False, True], ], [ [False, False, False], [False, False, False], [False, False], [False, False], [False, False], ], [0, 0, 0, 0, 1], [0, 0, 0, 0, 0], DoesNotRaise(), ), ( # One stays, one crosses, one disappears immediately after crossing Vector(Point(0, 0), Point(10, 0)), [ [[4, 4, 6, 6], [4, 4, 6, 6], [4, 4, 6, 6]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], [4, 4, 6, 6]], [[4, 4, 6, 6], [4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]], ], [ Position.TOP_LEFT, ], 2, [ [False, False, False], [False, False, False], [False, False, False], [False, False], [False, True], ], [ [False, False, False], [False, False, False], [False, False, False], [False, False], [False, False], ], [0, 0, 0, 0, 1], [0, 0, 0, 0, 0], DoesNotRaise(), ), ], ) def test_line_zone_long_horizon_disappearing_detections( vector: Vector, xyxy_sequence: list[list[list[float] | None]], anchors: list[Position], minimum_crossing_threshold: int, expected_crossed_in: list[list[bool]], expected_crossed_out: list[list[bool]], expected_count_in: list[int], expected_count_out: list[int], exception: Exception, ) -> None: with exception: line_zone = LineZone( start=vector.start, end=vector.end, triggering_anchors=anchors, minimum_crossing_threshold=minimum_crossing_threshold, ) crossed_in_list = [] crossed_out_list = [] count_in_list = [] count_out_list = [] for bboxes in xyxy_sequence: detections = _create_detections( xyxy=bboxes, tracker_id=[i for i in range(0, len(bboxes))], ) crossed_in, crossed_out = line_zone.trigger(detections) crossed_in_list.append(list(crossed_in)) crossed_out_list.append(list(crossed_out)) count_in_list.append(line_zone.in_count) count_out_list.append(line_zone.out_count) assert crossed_in_list == expected_crossed_in assert crossed_out_list == expected_crossed_out assert count_in_list == expected_count_in assert count_out_list == expected_count_out @pytest.mark.parametrize( ( "xyxy_sequence", "tracker_id_sequence", "class_id_sequence", "expected_in_count_per_class", "expected_out_count_per_class", ), [ pytest.param( [ [[4, 4, 6, 6]], # frame 0: object 0, class 0, position 4,4,6,6 [[4, -6, 6, -4]], # frame 1: object 0, class 0, position 4,-6,6,-4 [[4, 4, 6, 6]], # frame 2: object 0, class 0, position 4,4,6,6 [[4, 4, 6, 6]], # frame 3: object 0, class 1, position 4,4,6,6 [[4, -6, 6, -4]], # frame 4: object 0, class 1, position 4,-6,6,-4 [[4, 4, 6, 6]], # frame 5: object 0, class 1, position 4,4,6,6 ], # tracker_id_sequence [[0], [0], [0], [0], [0], [0]], # class_id_sequence [[0], [0], [0], [1], [1], [1]], # expected_in_count_per_class {0: 1, 1: 1}, # expected_out_count_per_class {0: 1, 1: 1}, id="single_object_tracker_id_reuse_with_different_classes", ), pytest.param( [ # frame 0: objects 0&1 cross IN [[4, 4, 6, 6], [4, 4, 6, 6]], # frame 1 [[4, -6, 6, -4], [4, -6, 6, -4]], # frame 2: objects 0&1 cross OUT [[4, 4, 6, 6], [4, 4, 6, 6]], # frame 3: objects 2&3 cross IN [[4, 4, 6, 6], [4, 4, 6, 6]], # frame 4 [[4, -6, 6, -4], [4, -6, 6, -4]], # frame 5: objects 2&3 cross OUT [[4, 4, 6, 6], [4, 4, 6, 6]], ], # tracker_id_sequence [[0, 1], [0, 1], [0, 1], [2, 3], [2, 3], [2, 3]], # class_id_sequence [[0, 1], [0, 1], [0, 1], [4, 5], [4, 5], [4, 5]], # expected_in_count_per_class {0: 1, 1: 1, 4: 1, 5: 1}, # expected_out_count_per_class {0: 1, 1: 1, 4: 1, 5: 1}, id="multiple_objects_tracker_id_reuse_with_different_classes", ), ], ) def test_line_zone_tracker_id_reuse_with_different_classes( xyxy_sequence: list[list[list[float]]], tracker_id_sequence: list[list[int]], class_id_sequence: list[list[int]], expected_in_count_per_class: dict[int, int], expected_out_count_per_class: dict[int, int], ) -> None: line_zone = LineZone(start=Point(0, 0), end=Point(10, 0)) for xyxy, tracker_id, class_id in zip( xyxy_sequence, tracker_id_sequence, class_id_sequence ): detections = _create_detections( xyxy=xyxy, tracker_id=tracker_id, class_id=class_id ) line_zone.trigger(detections) assert line_zone.in_count_per_class == expected_in_count_per_class assert line_zone.out_count_per_class == expected_out_count_per_class def test_line_zone_trigger_does_not_call_np_cross( monkeypatch: pytest.MonkeyPatch, ) -> None: """Guard against reintroducing np.cross, deprecated for 2-D input in NumPy 2.0.""" def _raise(*args, **kwargs): raise AssertionError("np.cross must not be called on 2-D vectors") monkeypatch.setattr(np, "cross", _raise) line_zone = LineZone(start=Point(0, 0), end=Point(0, 10)) for xyxy in [[4, 4, 6, 6], [-6, 4, -4, 6]]: detections = _create_detections(xyxy=[xyxy], tracker_id=[0]) crossed_in, crossed_out = line_zone.trigger(detections) assert not crossed_in[0] assert crossed_out[0] assert line_zone.out_count == 1 def test_line_zone_trigger_evicts_stale_crossing_history() -> None: """History for tracker IDs absent from the current frame is evicted.""" line_zone = LineZone(start=Point(0, 0), end=Point(10, 0)) first_detections = _create_detections( xyxy=[[4, 4, 6, 6]], tracker_id=[0], class_id=[1] ) second_detections = _create_detections( xyxy=[[4, 4, 6, 6]], tracker_id=[1], class_id=[2] ) line_zone.trigger(first_detections) # Trigger twice with second_detections so tracker_id=0 accumulates # crossing_history_length absent frames (default=2) and is evicted. line_zone.trigger(second_detections) line_zone.trigger(second_detections) assert set(line_zone.crossing_state_history) == {1} def test_line_zone_trigger_evicts_stale_crossing_history_on_empty_frames() -> None: """Empty frames age out tracker crossing history.""" line_zone = LineZone(start=Point(0, 0), end=Point(10, 0)) detections = _create_detections(xyxy=[[4, 4, 6, 6]], tracker_id=[0], class_id=[1]) line_zone.trigger(detections) for _ in range(line_zone.crossing_history_length): line_zone.trigger(Detections.empty()) assert not line_zone.crossing_state_history def test_line_zone_trigger_evicts_stale_crossing_history_on_class_change() -> None: """Class changes must not split a tracker crossing history.""" line_zone = LineZone(start=Point(0, 0), end=Point(10, 0)) first_detections = _create_detections( xyxy=[[4, 4, 6, 6]], tracker_id=[0], class_id=[1] ) second_detections = _create_detections( xyxy=[[4, 4, 6, 6]], tracker_id=[0], class_id=[2] ) line_zone.trigger(first_detections) for _ in range(line_zone.crossing_history_length): line_zone.trigger(second_detections) assert set(line_zone.crossing_state_history) == {0} def test_line_zone_class_flicker_keeps_crossing_counts_continuous() -> None: """A tracker's class change must not suppress a real crossing.""" line_zone = LineZone(start=Point(0, 0), end=Point(10, 0)) detections_sequence = [ _create_detections(xyxy=[[4, 4, 6, 6]], tracker_id=[0], class_id=[0]), _create_detections(xyxy=[[4, -6, 6, -4]], tracker_id=[0], class_id=[1]), _create_detections(xyxy=[[4, -6, 6, -4]], tracker_id=[0], class_id=[1]), _create_detections(xyxy=[[4, 4, 6, 6]], tracker_id=[0], class_id=[1]), ] crossed_in = [] crossed_out = [] for detections in detections_sequence: crossed_in_frame, crossed_out_frame = line_zone.trigger(detections) crossed_in.append(bool(crossed_in_frame[0])) crossed_out.append(bool(crossed_out_frame[0])) assert crossed_in == [False, True, False, False] assert crossed_out == [False, False, False, True] assert line_zone.in_count_per_class == {1: 1} assert line_zone.out_count_per_class == {1: 1} assert set(line_zone.crossing_state_history) == {0} def test_line_zone_annotator_multiclass_supports_none_class_id() -> None: line_zone = LineZone(start=Point(0, 0), end=Point(0, 10)) for xyxy in [[4, 4, 6, 6], [-6, 4, -4, 6]]: detections = _create_detections(xyxy=[xyxy], tracker_id=[0]) line_zone.trigger(detections) assert line_zone.out_count_per_class == {None: 1} frame = np.zeros((100, 100, 3), dtype=np.uint8) annotator = LineZoneAnnotatorMulticlass(force_draw_class_ids=False) annotated_frame = annotator.annotate(frame=frame.copy(), line_zones=[line_zone]) assert annotated_frame.shape == frame.shape assert not np.array_equal(annotated_frame, frame)