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

988 lines
32 KiB
Python

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)