Files
WeHub Mirror b18e8dce68
Pytest/Test Workflow / Build this Package (push) Failing after 0s
Docs/Test Workflow / Test docs build (push) Has been cancelled
Check links & references / links-check (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (none, macos-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (none, macos-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (none, macos-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (none, macos-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (none, ubuntu-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (none, ubuntu-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (none, ubuntu-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (none, ubuntu-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (none, windows-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (none, windows-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (none, windows-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (none, windows-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (opencv-python, macos-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (opencv-python, ubuntu-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (opencv-python, windows-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (opencv-python-headless, macos-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (opencv-python-headless, ubuntu-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Pytest Run (opencv-python-headless, windows-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Clean Wheel on macos-latest / Python 3.10 (push) Has been cancelled
Pytest/Test Workflow / Clean Wheel on macos-latest / Python 3.13 (push) Has been cancelled
Pytest/Test Workflow / Clean Wheel on ubuntu-latest / Python 3.10 (push) Has been cancelled
Pytest/Test Workflow / Clean Wheel on ubuntu-latest / Python 3.13 (push) Has been cancelled
Pytest/Test Workflow / Clean Wheel on windows-latest / Python 3.10 (push) Has been cancelled
Pytest/Test Workflow / Clean Wheel on windows-latest / Python 3.13 (push) Has been cancelled
PR Conflict Labeler / main (push) Has been cancelled
Docs/Build and Publish / Publish Docs (push) Has been cancelled
Pytest/Test Workflow / testing-guardian (push) Has been cancelled
WeHub snapshot of fb22686a5eb245d7e2594020003919ec2ff5510f
2026-08-07 16:05:38 +08:00

416 lines
14 KiB
Python

from contextlib import ExitStack as DoesNotRaise
from typing import cast
import numpy as np
import pytest
from supervision.annotators.utils import (
ColorLookup,
Trace,
get_labels_text,
hex_to_rgba,
is_valid_hex,
resolve_color,
resolve_color_idx,
resolve_text_background_xyxy,
rgba_to_hex,
wrap_text,
)
from supervision.detection.core import Detections
from supervision.draw.color import Color, ColorPalette
from supervision.geometry.core import Position
from tests.helpers import _create_detections
@pytest.mark.parametrize(
("detections", "detection_idx", "color_lookup", "expected_result", "exception"),
[
(
_create_detections(
xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]],
class_id=[5, 3],
tracker_id=[2, 6],
),
0,
ColorLookup.INDEX,
0,
DoesNotRaise(),
), # multiple detections; index lookup
(
_create_detections(
xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]],
class_id=[5, 3],
tracker_id=[2, 6],
),
0,
ColorLookup.CLASS,
5,
DoesNotRaise(),
), # multiple detections; class lookup
(
_create_detections(
xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]],
class_id=[5, 3],
tracker_id=[2, 6],
),
0,
ColorLookup.TRACK,
2,
DoesNotRaise(),
), # multiple detections; track lookup
(
Detections.empty(),
0,
ColorLookup.INDEX,
None,
pytest.raises(ValueError, match="out of bounds for detections of length 0"),
), # no detections; index lookup; out of bounds
(
_create_detections(
xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]],
class_id=[5, 3],
tracker_id=[2, 6],
),
2,
ColorLookup.INDEX,
None,
pytest.raises(ValueError, match="Detection index 2"),
), # multiple detections; index lookup; out of bounds
(
_create_detections(xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]]),
0,
ColorLookup.CLASS,
None,
pytest.raises(ValueError, match="resolve color by class"),
), # multiple detections; class lookup; no class_id
(
_create_detections(xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]]),
0,
ColorLookup.TRACK,
None,
pytest.raises(ValueError, match="resolve color by track"),
), # multiple detections; class lookup; no track_id
(
_create_detections(xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]]),
0,
np.array([1, 0]),
1,
DoesNotRaise(),
), # multiple detections; custom lookup; correct length
(
_create_detections(xyxy=[[10, 10, 20, 20], [20, 20, 30, 30]]),
0,
np.array([1]),
None,
pytest.raises(ValueError, match="Length of color lookup 1"),
), # multiple detections; custom lookup; wrong length
],
)
def test_resolve_color_idx(
detections: Detections,
detection_idx: int,
color_lookup: ColorLookup | np.ndarray,
expected_result: int | None,
exception: Exception,
) -> None:
with exception:
result = resolve_color_idx(
detections=detections,
detection_idx=detection_idx,
color_lookup=color_lookup,
)
assert result == expected_result
def test_resolve_color_accepts_negative_class_id() -> None:
"""Negative class ids map deterministically instead of aborting annotation."""
detections = _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[-1])
palette = ColorPalette.from_hex(["#ff0000", "#00ff00", "#0000ff"])
result = resolve_color(
color=palette,
detections=detections,
detection_idx=0,
color_lookup=ColorLookup.CLASS,
)
assert result == Color.from_hex("#0000ff")
def test_resolve_color_accepts_palette_from_src_namespace() -> None:
"""Palette instances imported through src.supervision still resolve by index."""
from src.supervision.draw.color import ColorPalette as SrcColorPalette
detections = _create_detections(xyxy=[[0, 0, 10, 10]], class_id=[0])
result = resolve_color(
color=SrcColorPalette.DEFAULT,
detections=detections,
detection_idx=0,
color_lookup=ColorLookup.CLASS,
)
assert result.as_bgr() == (251, 81, 163)
@pytest.mark.parametrize(
("text", "max_line_length", "expected_result", "exception"),
[
(None, None, [""], DoesNotRaise()), # text is None
("", None, [""], DoesNotRaise()), # empty string
(" \t ", 3, [""], DoesNotRaise()), # whitespace-only (spaces + tab)
(12345, None, ["12345"], DoesNotRaise()), # plain integer
(-6789, None, ["-6789"], DoesNotRaise()), # negative integer
(np.int64(1000), None, ["1000"], DoesNotRaise()), # NumPy int64
([1, 2, 3], None, ["[1, 2, 3]"], DoesNotRaise()), # list to string
(
"When you play the game of thrones, you win or you die.\nFear cuts deeper than swords.\nA mind needs books as a sword needs a whetstone.", # noqa: E501
None,
[
"When you play the game of thrones, you win or you die.",
"Fear cuts deeper than swords.",
"A mind needs books as a sword needs a whetstone.",
],
DoesNotRaise(),
), # Game-of-Thrones quotes, multiline
("\n", None, [""], DoesNotRaise()), # single newline
(
"valarmorghulisvalardoharis",
6,
["valarm", "orghul", "isvala", "rdohar", "is"],
DoesNotRaise(),
), # long Valyrian phrase, wrapped
(
"Winter is coming\nFire and blood",
10,
[
"Winter is",
"coming",
"Fire and",
"blood",
],
DoesNotRaise(),
), # mix of short/long with newline
(
"What is dead may never die",
0,
None,
pytest.raises(ValueError, match="max_line_length must be"),
), # width 0 - invalid
(
"A Lannister always pays his debts",
-1,
None,
pytest.raises(ValueError, match="positive integer"),
), # width -1 - invalid
(None, 10, [""], DoesNotRaise()), # text None, width set
],
)
def test_wrap_text(
text: object,
max_line_length: int | None,
expected_result: list[str],
exception: Exception,
) -> None:
with exception:
result = wrap_text(text=text, max_line_length=max_line_length)
assert result == expected_result
def test_resolve_text_background_xyxy_rejects_unknown_position() -> None:
"""Unsupported positions must raise instead of returning an implicit None."""
with pytest.raises(ValueError, match="Unsupported position"):
resolve_text_background_xyxy(
center_coordinates=(10, 10),
text_wh=(20, 10),
position=cast(Position, "invalid"),
)
def test_trace_put_requires_tracker_id_before_mutation() -> None:
"""Trace.put must not mutate internal history before validating tracker ids."""
trace = Trace()
detections = _create_detections(xyxy=[[0, 0, 1, 1]], class_id=[0])
before_frame_id = trace.current_frame_id
before_history = trace.frame_id.copy()
before_xy = trace.xy.copy()
before_tracker_id = trace.tracker_id.copy()
with pytest.raises(ValueError, match="tracker_id"):
trace.put(detections)
assert trace.current_frame_id == before_frame_id
assert np.array_equal(trace.frame_id, before_history)
assert np.array_equal(trace.xy, before_xy)
assert np.array_equal(trace.tracker_id, before_tracker_id)
@pytest.mark.parametrize(
("hex_color", "expected_rgba"),
[
("#FF00FF", (255, 0, 255, 255)),
("FF00FF", (255, 0, 255, 255)),
("#FF00FF80", (255, 0, 255, 128)),
("00FF0080", (0, 255, 0, 128)),
(" #ff00ff80 ", (255, 0, 255, 128)),
("abcdef", (171, 205, 239, 255)),
],
)
def test_hex_to_rgba_valid(
hex_color: str, expected_rgba: tuple[int, int, int, int]
) -> None:
assert hex_to_rgba(hex_color) == expected_rgba
@pytest.mark.parametrize(
"hex_color",
[
pytest.param("#FF00F", id="five-digits"),
pytest.param("#GGHHII", id="non-hex-digits"),
pytest.param("#FFF", id="short-form"),
pytest.param("1234567", id="seven-digits"),
pytest.param("##000000", id="multiple-prefixes"),
],
)
def test_hex_to_rgba_invalid(hex_color: str) -> None:
"""Invalid formats raise instead of being normalized into valid colors."""
with pytest.raises(ValueError, match="Invalid hex"):
hex_to_rgba(hex_color)
@pytest.mark.parametrize(
("rgba", "expected_hex"),
[
((0, 0, 0, 0), "#00000000"),
((255, 0, 255, 255), "#FF00FFFF"),
((0, 255, 0, 128), "#00FF0080"),
((255, 255, 255, 255), "#FFFFFFFF"),
],
)
def test_rgba_to_hex(rgba: tuple[int, int, int, int], expected_hex: str) -> None:
assert rgba_to_hex(rgba) == expected_hex
@pytest.mark.parametrize(
"rgba",
[
(255, 0, 0),
(256, 0, 0, 255),
(-1, 0, 0, 255),
(255, 0, 0, -1),
],
)
def test_rgba_to_hex_invalid(rgba: tuple[int, ...]) -> None:
with pytest.raises(ValueError, match="RGBA must be a 4-tuple"):
rgba_to_hex(rgba) # type: ignore[arg-type]
@pytest.mark.parametrize(
("hex_color", "expected_result"),
[
("#FF00FF", True),
("ff00ff", True),
("00FF0080", True),
(" 00ff0080 ", True),
("#XYZ123", False),
("FF00F", False),
("#FFF", False),
("#1234567", False),
],
)
def test_is_valid_hex(hex_color: str, expected_result: bool) -> None:
assert is_valid_hex(hex_color) is expected_result
class TestGetLabelsText:
"""Tests for the get_labels_text helper."""
def test_returns_custom_labels_when_provided(self) -> None:
"""Custom labels are returned as-is without inspecting detections."""
detections = _create_detections(xyxy=[[0, 0, 1, 1]], class_id=[0])
custom = ["cat"]
assert get_labels_text(detections, custom) is custom
def test_uses_class_name_data_field(self) -> None:
"""Labels come from CLASS_NAME_DATA_FIELD when present in data."""
detections = _create_detections(
xyxy=[[0, 0, 1, 1], [1, 1, 2, 2]],
class_id=[0, 1],
data={"class_name": np.array(["cat", "dog"])},
)
assert get_labels_text(detections, None) == ["cat", "dog"]
def test_falls_back_to_class_id(self) -> None:
"""Labels fall back to class_id strings when no class_name data."""
detections = _create_detections(
xyxy=[[0, 0, 1, 1], [1, 1, 2, 2]],
class_id=[5, 3],
)
assert get_labels_text(detections, None) == ["5", "3"]
def test_falls_back_to_class_id_single_detection(self) -> None:
"""class_id fallback holds for a single detection (N=1 edge case)."""
detections = _create_detections(xyxy=[[0, 0, 1, 1]], class_id=[7])
assert get_labels_text(detections, None) == ["7"]
def test_falls_back_to_index(self) -> None:
"""Labels fall back to detection indices when no class_id."""
detections = _create_detections(
xyxy=[[0, 0, 1, 1], [1, 1, 2, 2], [2, 2, 3, 3]],
)
assert get_labels_text(detections, None) == ["0", "1", "2"]
def test_falls_back_to_index_single_detection(self) -> None:
"""Index fallback holds for a single detection (N=1 edge case)."""
detections = _create_detections(xyxy=[[0, 0, 1, 1]])
assert get_labels_text(detections, None) == ["0"]
def test_custom_labels_take_precedence_over_class_name(self) -> None:
"""Custom labels win over the class_name branch specifically."""
detections = _create_detections(
xyxy=[[0, 0, 1, 1], [1, 1, 2, 2]],
class_id=[0, 1],
data={"class_name": np.array(["cat", "dog"])},
)
custom = ["a", "b"]
assert get_labels_text(detections, custom) is custom
def test_uses_class_name_data_field_with_non_str_dtype(self) -> None:
"""str() coercion is load-bearing when class_name holds a non-str dtype."""
detections = _create_detections(
xyxy=[[0, 0, 1, 1], [1, 1, 2, 2]],
class_id=[0, 1],
data={"class_name": np.array([1, 2])},
)
assert get_labels_text(detections, None) == ["1", "2"]
def test_empty_detections(self) -> None:
"""Empty detections produce an empty label list, via the class_id branch."""
detections = Detections.empty()
assert detections.class_id is not None
assert get_labels_text(detections, None) == []
def test_raises_on_class_name_length_mismatch(self) -> None:
"""A class_name array shorter than detections raises instead of truncating."""
detections = _create_detections(
xyxy=[[0, 0, 1, 1], [1, 1, 2, 2]],
class_id=[0, 1],
data={"class_name": np.array(["cat", "dog"])},
)
detections.data["class_name"] = np.array(["cat"]) # bypass alignment checks
with pytest.raises(ValueError, match="class_name"):
get_labels_text(detections, None)
def test_raises_on_class_id_length_mismatch(self) -> None:
"""A class_id array shorter than detections raises instead of truncating."""
detections = _create_detections(
xyxy=[[0, 0, 1, 1], [1, 1, 2, 2]],
class_id=[0, 1],
)
detections.class_id = np.array([0]) # bypass alignment checks
with pytest.raises(ValueError, match="class_id"):
get_labels_text(detections, None)