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
277 lines
9.9 KiB
Python
277 lines
9.9 KiB
Python
"""Integration tests: CompactMask <-> Detections, annotators, merge."""
|
|
|
|
from contextlib import ExitStack as DoesNotRaise
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
import supervision as sv
|
|
from supervision.detection.compact_mask import CompactMask
|
|
from supervision.detection.core import Detections
|
|
|
|
|
|
def _full_xyxy(n: int, h: int, w: int) -> np.ndarray:
|
|
"""N boxes covering the whole image (ensures crop == full mask)."""
|
|
return np.tile(np.array([0, 0, w, h], dtype=np.float32), (n, 1))
|
|
|
|
|
|
def _make_compact_detections(
|
|
n: int, h: int = 40, w: int = 40
|
|
) -> tuple[Detections, np.ndarray]:
|
|
"""Detections with a CompactMask backed by full-image bounding boxes.
|
|
|
|
Using full-image xyxy means all True pixels are within the crop region,
|
|
so from_dense -> to_dense is lossless.
|
|
"""
|
|
rng = np.random.default_rng(42)
|
|
masks = rng.integers(0, 2, size=(n, h, w)).astype(bool)
|
|
xyxy = _full_xyxy(n, h, w)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(h, w))
|
|
det = Detections(
|
|
xyxy=xyxy,
|
|
mask=cm,
|
|
confidence=np.ones(n, dtype=np.float32) * 0.9,
|
|
class_id=np.arange(n),
|
|
)
|
|
return det, masks
|
|
|
|
|
|
class TestConstruction:
|
|
"""Tests for building Detections with a CompactMask.
|
|
|
|
Verifies that a CompactMask is accepted as a valid mask argument and that
|
|
the validator raises ValueError when the mask length does not match the
|
|
number of bounding boxes.
|
|
"""
|
|
|
|
def test_detections_construction_with_compact_mask(self) -> None:
|
|
with DoesNotRaise():
|
|
det, _ = _make_compact_detections(3)
|
|
assert isinstance(det.mask, CompactMask)
|
|
assert len(det) == 3
|
|
|
|
def test_detections_compact_mask_validation_mismatch(self) -> None:
|
|
n, h, w = 3, 20, 20
|
|
xyxy = _full_xyxy(n, h, w)
|
|
masks_wrong_n = np.zeros((n + 1, h, w), dtype=bool)
|
|
cm = CompactMask.from_dense(masks_wrong_n, _full_xyxy(n + 1, h, w), (h, w))
|
|
with pytest.raises(ValueError, match="mask must contain"):
|
|
Detections(xyxy=xyxy, mask=cm)
|
|
|
|
|
|
class TestFiltering:
|
|
"""Tests for Detections.__getitem__ with a CompactMask.
|
|
|
|
Verifies that integer, slice, and boolean-array indexing all preserve the
|
|
CompactMask type and return the correct subset of masks.
|
|
"""
|
|
|
|
def test_int_wraps_to_compact_mask(self) -> None:
|
|
det, _ = _make_compact_detections(3)
|
|
# Detections converts int to [int] internally -> subset has 1 element
|
|
subset = det[1]
|
|
assert isinstance(subset.mask, CompactMask)
|
|
assert len(subset) == 1
|
|
|
|
def test_slice_preserves_compact_mask(self) -> None:
|
|
det, masks = _make_compact_detections(4)
|
|
subset = det[1:3]
|
|
assert isinstance(subset.mask, CompactMask)
|
|
assert len(subset) == 2
|
|
np.testing.assert_array_equal(subset.mask.to_dense(), masks[1:3])
|
|
|
|
def test_bool_array_preserves_compact_mask(self) -> None:
|
|
det, masks = _make_compact_detections(4)
|
|
selector = np.array([True, False, True, False])
|
|
subset = det[selector]
|
|
assert isinstance(subset.mask, CompactMask)
|
|
assert len(subset) == 2
|
|
np.testing.assert_array_equal(subset.mask.to_dense(), masks[[0, 2]])
|
|
|
|
|
|
class TestIteration:
|
|
"""Tests for iterating over Detections with a CompactMask.
|
|
|
|
Verifies that each iteration step yields a 2-D boolean (H, W) array
|
|
identical to the corresponding dense mask, so downstream code that
|
|
iterates over detections needs no changes.
|
|
"""
|
|
|
|
def test_iter_yields_2d_dense(self) -> None:
|
|
h, w = 20, 20
|
|
det, masks = _make_compact_detections(3, h, w)
|
|
for i, (_, mask_2d, *_) in enumerate(det):
|
|
assert mask_2d is not None
|
|
assert isinstance(mask_2d, np.ndarray)
|
|
assert mask_2d.shape == (h, w)
|
|
assert mask_2d.dtype == bool
|
|
np.testing.assert_array_equal(mask_2d, masks[i])
|
|
|
|
|
|
class TestEquality:
|
|
"""Tests for Detections.__eq__ mixing CompactMask and dense arrays.
|
|
|
|
Verifies that a Detections object backed by a CompactMask compares equal
|
|
to an otherwise identical Detections object backed by a dense ndarray.
|
|
"""
|
|
|
|
def test_compact_vs_dense(self) -> None:
|
|
h, w = 20, 20
|
|
det_compact, masks = _make_compact_detections(2, h, w)
|
|
xyxy = det_compact.xyxy.copy()
|
|
det_dense = Detections(
|
|
xyxy=xyxy,
|
|
mask=masks,
|
|
confidence=np.ones(2, dtype=np.float32) * 0.9,
|
|
class_id=np.arange(2),
|
|
)
|
|
assert det_compact == det_dense
|
|
|
|
|
|
class TestArea:
|
|
"""Tests for the Detections.area property with a CompactMask.
|
|
|
|
Verifies that the fast CompactMask path in Detections.area returns the
|
|
same per-detection pixel counts as summing the equivalent dense array.
|
|
"""
|
|
|
|
def test_compact_matches_dense(self) -> None:
|
|
det_compact, masks = _make_compact_detections(3)
|
|
expected_area = np.array([m.sum() for m in masks])
|
|
np.testing.assert_array_equal(det_compact.area, expected_area)
|
|
|
|
|
|
class TestMerge:
|
|
"""Tests for merging Detections objects that contain CompactMask instances.
|
|
|
|
Covers three scenarios:
|
|
- All-compact merge: result is a CompactMask.
|
|
- Mixed compact + dense: dense inputs are converted; result is a CompactMask.
|
|
- Inner pair merge (merge_inner_detection_object_pair): used during NMS-like
|
|
operations, each input must contain exactly one detection.
|
|
"""
|
|
|
|
def test_all_compact(self) -> None:
|
|
h, w = 30, 30
|
|
det1, masks1 = _make_compact_detections(2, h, w)
|
|
|
|
rng = np.random.default_rng(7)
|
|
masks2 = rng.integers(0, 2, size=(3, h, w)).astype(bool)
|
|
xyxy2 = _full_xyxy(3, h, w)
|
|
cm2 = CompactMask.from_dense(masks2, xyxy2, (h, w))
|
|
det2 = Detections(
|
|
xyxy=xyxy2,
|
|
mask=cm2,
|
|
confidence=np.ones(3, dtype=np.float32) * 0.8,
|
|
class_id=np.arange(3),
|
|
)
|
|
|
|
merged = Detections.merge([det1, det2])
|
|
assert isinstance(merged.mask, CompactMask)
|
|
assert len(merged) == 5
|
|
expected = np.concatenate([masks1, masks2], axis=0)
|
|
np.testing.assert_array_equal(merged.mask.to_dense(), expected)
|
|
|
|
def test_mixed_compact_and_dense(self) -> None:
|
|
"""Merging a CompactMask with a dense ndarray returns a CompactMask."""
|
|
h, w = 20, 20
|
|
det_compact, masks_compact = _make_compact_detections(2, h, w)
|
|
masks_dense = np.zeros((1, h, w), dtype=bool)
|
|
masks_dense[0, 3:8, 3:8] = True
|
|
xyxy_dense = _full_xyxy(1, h, w)
|
|
det_dense = Detections(
|
|
xyxy=xyxy_dense,
|
|
mask=masks_dense,
|
|
confidence=np.array([0.5], dtype=np.float32),
|
|
class_id=np.array([0]),
|
|
)
|
|
|
|
merged = Detections.merge([det_compact, det_dense])
|
|
assert isinstance(merged.mask, CompactMask)
|
|
assert len(merged) == 3
|
|
expected = np.concatenate([masks_compact, masks_dense], axis=0)
|
|
np.testing.assert_array_equal(merged.mask.to_dense(), expected)
|
|
assert merged.mask.image_shape == (h, w)
|
|
|
|
def test_inner_pair_with_compact(self) -> None:
|
|
from supervision.detection.core import merge_inner_detection_object_pair
|
|
|
|
h, w = 20, 20
|
|
masks_a = np.zeros((1, h, w), dtype=bool)
|
|
masks_a[0, 0:5, 0:5] = True
|
|
xyxy_a = _full_xyxy(1, h, w)
|
|
cm_a = CompactMask.from_dense(masks_a, xyxy_a, (h, w))
|
|
det_a = Detections(
|
|
xyxy=xyxy_a,
|
|
mask=cm_a,
|
|
confidence=np.array([0.9], dtype=np.float32),
|
|
class_id=np.array([1]),
|
|
)
|
|
|
|
masks_b = np.zeros((1, h, w), dtype=bool)
|
|
masks_b[0, 5:10, 5:10] = True
|
|
xyxy_b = _full_xyxy(1, h, w)
|
|
cm_b = CompactMask.from_dense(masks_b, xyxy_b, (h, w))
|
|
det_b = Detections(
|
|
xyxy=xyxy_b,
|
|
mask=cm_b,
|
|
confidence=np.array([0.7], dtype=np.float32),
|
|
class_id=np.array([1]),
|
|
)
|
|
|
|
with DoesNotRaise():
|
|
result = merge_inner_detection_object_pair(det_a, det_b)
|
|
assert len(result) == 1
|
|
|
|
|
|
class TestAnnotators:
|
|
"""Tests for annotators that consume CompactMask via Detections.
|
|
|
|
Verifies that MaskAnnotator and PolygonAnnotator produce pixel-identical
|
|
output when given Detections backed by a CompactMask versus the equivalent
|
|
dense ndarray, confirming that the annotators are transparent to the mask
|
|
representation.
|
|
"""
|
|
|
|
def test_mask_annotator(self) -> None:
|
|
h, w = 40, 40
|
|
det_compact, masks = _make_compact_detections(2, h, w)
|
|
det_dense = Detections(
|
|
xyxy=det_compact.xyxy.copy(),
|
|
mask=masks,
|
|
confidence=det_compact.confidence.copy(),
|
|
class_id=det_compact.class_id.copy(),
|
|
)
|
|
|
|
image = np.zeros((h, w, 3), dtype=np.uint8)
|
|
annotator = sv.MaskAnnotator(color_lookup=sv.ColorLookup.INDEX)
|
|
|
|
annotated_compact = annotator.annotate(image.copy(), det_compact)
|
|
annotated_dense = annotator.annotate(image.copy(), det_dense)
|
|
|
|
np.testing.assert_array_equal(
|
|
annotated_compact,
|
|
annotated_dense,
|
|
err_msg="MaskAnnotator output differs between CompactMask and dense mask",
|
|
)
|
|
|
|
def test_polygon_annotator(self) -> None:
|
|
h, w = 40, 40
|
|
# Use solid rectangular masks for stable polygon results.
|
|
masks = np.zeros((2, h, w), dtype=bool)
|
|
masks[0, 5:15, 5:15] = True
|
|
masks[1, 20:30, 20:30] = True
|
|
xyxy = _full_xyxy(2, h, w)
|
|
cm = CompactMask.from_dense(masks, xyxy, (h, w))
|
|
|
|
det_compact = Detections(xyxy=xyxy, mask=cm, class_id=np.array([0, 1]))
|
|
det_dense = Detections(xyxy=xyxy, mask=masks, class_id=np.array([0, 1]))
|
|
|
|
image = np.zeros((h, w, 3), dtype=np.uint8)
|
|
annotator = sv.PolygonAnnotator(color_lookup=sv.ColorLookup.INDEX)
|
|
|
|
annotated_compact = annotator.annotate(image.copy(), det_compact)
|
|
annotated_dense = annotator.annotate(image.copy(), det_dense)
|
|
|
|
np.testing.assert_array_equal(annotated_compact, annotated_dense)
|