222 lines
7.3 KiB
Python
222 lines
7.3 KiB
Python
"""Tests for private contour fallbacks."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from supervision import _cv2
|
|
from supervision._cv2._components import (
|
|
_connected_components,
|
|
_connected_components_with_stats,
|
|
)
|
|
from supervision._cv2._contours import _find_contours
|
|
from supervision._cv2._drawing import _fill_poly
|
|
from supervision._cv2._geometry import _intersect_convex_convex
|
|
from supervision._cv2.constants import _CHAIN_APPROX_SIMPLE, _RETR_TREE
|
|
from supervision.detection.utils.masks import _chamfer_distances
|
|
|
|
try:
|
|
cv2 = importlib.import_module("cv2")
|
|
except (ImportError, OSError):
|
|
pytest.skip(
|
|
"OpenCV is required as the reference implementation for this test module",
|
|
allow_module_level=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source", "expected_count"),
|
|
[
|
|
pytest.param(
|
|
np.pad(np.ones((4, 4), dtype=np.uint8), 2),
|
|
1,
|
|
id="rectangle",
|
|
),
|
|
pytest.param(
|
|
np.pad(
|
|
np.array(
|
|
[[1, 1, 1, 1], [1, 0, 0, 1], [1, 0, 0, 1], [1, 1, 1, 1]],
|
|
dtype=np.uint8,
|
|
),
|
|
2,
|
|
),
|
|
2,
|
|
id="nested-hole",
|
|
),
|
|
pytest.param(
|
|
np.indices((4, 4)).sum(axis=0).astype(np.uint8) % 2,
|
|
3,
|
|
id="checkerboard",
|
|
),
|
|
pytest.param(np.zeros((4, 4), dtype=np.uint8), 0, id="empty"),
|
|
],
|
|
)
|
|
def test_find_contours_matches_opencv(source: np.ndarray, expected_count: int) -> None:
|
|
"""Match required contour vertices without constructing unused hierarchy."""
|
|
actual_contours, actual_hierarchy = _find_contours(
|
|
source, _RETR_TREE, _CHAIN_APPROX_SIMPLE
|
|
)
|
|
expected_contours, expected_hierarchy = cv2.findContours(
|
|
source.copy(), cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE
|
|
)
|
|
|
|
assert len(actual_contours) == expected_count
|
|
assert len(actual_contours) == len(expected_contours)
|
|
actual_geometry = sorted(
|
|
tuple(map(tuple, contour.reshape(-1, 2))) for contour in actual_contours
|
|
)
|
|
expected_geometry = sorted(
|
|
tuple(map(tuple, contour.reshape(-1, 2))) for contour in expected_contours
|
|
)
|
|
assert actual_geometry == expected_geometry
|
|
assert actual_hierarchy is None
|
|
assert expected_hierarchy is None or len(expected_hierarchy) == 1
|
|
|
|
|
|
def test_facade_find_contours_returns_geometry_list() -> None:
|
|
"""Expose the same geometry-only list contract on the native backend."""
|
|
source = np.pad(np.ones((4, 4), dtype=np.uint8), 2)
|
|
|
|
contours = _cv2.find_contours(source)
|
|
|
|
assert isinstance(contours, list)
|
|
assert len(contours) == 1
|
|
|
|
|
|
def test_randomized_contours_preserve_opencv_geometry() -> None:
|
|
"""Preserve the OpenCV contour geometry set on seeded binary masks."""
|
|
rng = np.random.default_rng(2026)
|
|
|
|
for _ in range(100):
|
|
source = (rng.random((16, 19)) < rng.uniform(0.1, 0.8)).astype(np.uint8)
|
|
actual, _ = _find_contours(source, _RETR_TREE, _CHAIN_APPROX_SIMPLE)
|
|
expected, _ = cv2.findContours(
|
|
source.copy(), cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE
|
|
)
|
|
|
|
actual_geometry = sorted(
|
|
tuple(map(tuple, contour.reshape(-1, 2))) for contour in actual
|
|
)
|
|
expected_geometry = sorted(
|
|
tuple(map(tuple, contour.reshape(-1, 2))) for contour in expected
|
|
)
|
|
assert actual_geometry == expected_geometry
|
|
|
|
|
|
def test_chamfer_distances_match_opencv_on_seeded_masks() -> None:
|
|
"""Match OpenCV's platform-dependent 3x3 L2 coefficients within 30 µpx."""
|
|
rng = np.random.default_rng(20260717)
|
|
|
|
for _ in range(100):
|
|
shape = (int(rng.integers(2, 40)), int(rng.integers(2, 40)))
|
|
main_mask = rng.random(shape) < 0.15
|
|
if not np.any(main_mask):
|
|
main_mask[0, 0] = True
|
|
expected = cv2.distanceTransform((~main_mask).astype(np.uint8), cv2.DIST_L2, 3)
|
|
actual = _chamfer_distances(main_mask).astype(np.float32) / 65536
|
|
|
|
np.testing.assert_allclose(actual, expected, atol=3e-5, rtol=0)
|
|
|
|
|
|
def test_geometry_consumers_use_fallback_bindings(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Exercise production geometry consumers with private fallback bindings."""
|
|
from supervision.detection.utils.converters import mask_to_polygons, polygon_to_mask
|
|
from supervision.detection.utils.iou_and_nms import oriented_box_iou_batch
|
|
from supervision.detection.utils.masks import (
|
|
contains_multiple_segments,
|
|
filter_segments_by_distance,
|
|
)
|
|
|
|
monkeypatch.setattr(_cv2, "connectedComponents", _connected_components)
|
|
monkeypatch.setattr(
|
|
_cv2, "connectedComponentsWithStats", _connected_components_with_stats
|
|
)
|
|
monkeypatch.setattr(_cv2, "fillPoly", _fill_poly)
|
|
monkeypatch.setattr(
|
|
_cv2,
|
|
"find_contours",
|
|
lambda image: _find_contours(image, _RETR_TREE, _CHAIN_APPROX_SIMPLE)[0],
|
|
)
|
|
assert isinstance(_cv2.find_contours(np.ones((2, 2), dtype=np.uint8)), list)
|
|
monkeypatch.setattr(_cv2, "intersectConvexConvex", _intersect_convex_convex)
|
|
|
|
mask = np.zeros((10, 10), dtype=bool)
|
|
mask[2:7, 2:7] = True
|
|
mask[3:5, 3:5] = False
|
|
assert len(mask_to_polygons(mask)) == 2
|
|
assert not contains_multiple_segments(mask)
|
|
equal_area = np.zeros((6, 10), dtype=bool)
|
|
equal_area[1:3, 1:3] = True
|
|
equal_area[1:3, 7:9] = True
|
|
expected_equal_area = np.zeros_like(equal_area)
|
|
expected_equal_area[1:3, 1:3] = True
|
|
np.testing.assert_array_equal(
|
|
filter_segments_by_distance(
|
|
equal_area,
|
|
absolute_distance=0,
|
|
mode="centroid",
|
|
),
|
|
expected_equal_area,
|
|
)
|
|
assert (
|
|
polygon_to_mask(
|
|
np.array([[2, 2], [6, 2], [6, 6], [2, 6]], dtype=np.int32),
|
|
(10, 10),
|
|
).sum()
|
|
== 25
|
|
)
|
|
|
|
boxes = np.array(
|
|
[
|
|
[[0, 0], [4, 0], [4, 4], [0, 4]],
|
|
[[2, 0], [6, 0], [6, 4], [2, 4]],
|
|
],
|
|
dtype=np.float32,
|
|
)
|
|
assert oriented_box_iou_batch(boxes, boxes)[0, 1] == 1 / 3
|
|
|
|
|
|
def test_edge_distance_uses_chamfer_threshold_without_distance_image() -> None:
|
|
"""Preserve OpenCV's diagonal threshold while avoiding distanceTransform."""
|
|
from supervision.detection.utils.masks import filter_segments_by_distance
|
|
|
|
assert not hasattr(_cv2, "distanceTransform")
|
|
mask = np.zeros((7, 7), dtype=bool)
|
|
mask[1:3, 1:3] = True
|
|
mask[4, 4] = True
|
|
|
|
actual = filter_segments_by_distance(mask, absolute_distance=2.8, mode="edge")
|
|
|
|
np.testing.assert_array_equal(actual, mask)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("threshold", "keep_all"),
|
|
[
|
|
pytest.param(float("inf"), True, id="positive-infinity"),
|
|
pytest.param(float("nan"), False, id="nan"),
|
|
pytest.param(float("-inf"), False, id="negative-infinity"),
|
|
],
|
|
)
|
|
def test_edge_distance_handles_non_finite_thresholds(
|
|
threshold: float, keep_all: bool
|
|
) -> None:
|
|
"""Handle non-finite edge thresholds without unsafe allocations."""
|
|
from supervision.detection.utils.masks import filter_segments_by_distance
|
|
|
|
mask = np.zeros((7, 7), dtype=bool)
|
|
mask[1:3, 1:3] = True
|
|
mask[5, 5] = True
|
|
expected = mask.copy()
|
|
if not keep_all:
|
|
expected[5, 5] = False
|
|
|
|
actual = filter_segments_by_distance(mask, absolute_distance=threshold, mode="edge")
|
|
|
|
np.testing.assert_array_equal(actual, expected)
|