from contextlib import ExitStack as DoesNotRaise import numpy as np import pytest from supervision.detection.utils.polygons import ( approximate_polygon, filter_polygons_by_area, ) @pytest.mark.parametrize( ("polygons", "min_area", "max_area", "expected_result", "exception"), [ ( [np.array([[0, 0], [0, 10], [10, 10], [10, 0]])], None, None, [np.array([[0, 0], [0, 10], [10, 10], [10, 0]])], DoesNotRaise(), ), # single polygon without area constraints ( [np.array([[0, 0], [0, 10], [10, 10], [10, 0]])], 50, None, [np.array([[0, 0], [0, 10], [10, 10], [10, 0]])], DoesNotRaise(), ), # single polygon with min_area constraint ( [np.array([[0, 0], [0, 10], [10, 10], [10, 0]])], None, 50, [], DoesNotRaise(), ), # single polygon with max_area constraint ( [ np.array([[0, 0], [0, 10], [10, 10], [10, 0]]), np.array([[0, 0], [0, 20], [20, 20], [20, 0]]), ], 200, None, [np.array([[0, 0], [0, 20], [20, 20], [20, 0]])], DoesNotRaise(), ), # two polygons with min_area constraint ( [ np.array([[0, 0], [0, 10], [10, 10], [10, 0]]), np.array([[0, 0], [0, 20], [20, 20], [20, 0]]), ], None, 200, [np.array([[0, 0], [0, 10], [10, 10], [10, 0]])], DoesNotRaise(), ), # two polygons with max_area constraint ( [ np.array([[0, 0], [0, 10], [10, 10], [10, 0]]), np.array([[0, 0], [0, 20], [20, 20], [20, 0]]), ], 200, 200, [], DoesNotRaise(), ), # two polygons with both area constraints ( [ np.array([[0, 0], [0, 10], [10, 10], [10, 0]]), np.array([[0, 0], [0, 20], [20, 20], [20, 0]]), ], 100, 100, [np.array([[0, 0], [0, 10], [10, 10], [10, 0]])], DoesNotRaise(), ), # two polygons with min_area and # max_area equal to the area of the first polygon ( [ np.array([[0, 0], [0, 10], [10, 10], [10, 0]]), np.array([[0, 0], [0, 20], [20, 20], [20, 0]]), ], 400, 400, [np.array([[0, 0], [0, 20], [20, 20], [20, 0]])], DoesNotRaise(), ), # two polygons with min_area and # max_area equal to the area of the second polygon ], ) def test_filter_polygons_by_area( polygons: list[np.ndarray], min_area: float | None, max_area: float | None, expected_result: list[np.ndarray], exception: Exception, ) -> None: with exception: result = filter_polygons_by_area( polygons=polygons, min_area=min_area, max_area=max_area ) assert len(result) == len(expected_result) for result_polygon, expected_result_polygon in zip(result, expected_result): assert np.array_equal(result_polygon, expected_result_polygon) def _regular_polygon(num_points: int, radius: float = 40.0) -> np.ndarray: angles = np.linspace(0, 2 * np.pi, num_points, endpoint=False) return np.stack( [50 + radius * np.cos(angles), 50 + radius * np.sin(angles)], axis=1 ).astype(np.float32) class TestApproximatePolygon: @pytest.mark.parametrize("num_points", [20, 50, 100, 200]) @pytest.mark.parametrize("percentage", [0.1, 0.5, 0.75, 0.9]) def test_within_budget_and_valid(self, num_points: int, percentage: float) -> None: """The result stays a valid polygon and respects the point budget. The exception is the 3-point floor: keeping a valid polygon (at least 3 points) can leave more points than the budget by an arbitrary margin, since a single epsilon step may jump straight from above budget to below 3 points. """ polygon = _regular_polygon(num_points) target_points = max(int(num_points * (1 - percentage)), 3) result = approximate_polygon(polygon, percentage=percentage) assert result.ndim == 2 assert result.shape[1] == 2 assert 3 <= len(result) <= num_points if target_points > 3: assert len(result) <= target_points def test_zero_percentage_keeps_polygon(self) -> None: """A percentage of 0 removes no points.""" polygon = _regular_polygon(40) result = approximate_polygon(polygon, percentage=0.0) assert len(result) == len(polygon) @pytest.mark.parametrize( "percentage", [ pytest.param(-0.001, id="just-below-zero"), pytest.param(-1.0, id="negative-one"), pytest.param(1.0, id="exactly-one"), pytest.param(1.5, id="above-one"), ], ) def test_raises_on_out_of_range_percentage(self, percentage: float) -> None: """Percentage outside [0, 1) must raise ValueError.""" polygon = _regular_polygon(20) with pytest.raises(ValueError, match="Percentage must be in the range"): approximate_polygon(polygon, percentage=percentage) @pytest.mark.parametrize( "epsilon_step", [ pytest.param(0.0, id="zero"), pytest.param(-0.05, id="negative"), ], ) def test_raises_on_non_positive_epsilon_step(self, epsilon_step: float) -> None: """Non-positive epsilon_step must raise ValueError.""" polygon = _regular_polygon(20) with pytest.raises(ValueError, match="epsilon_step must be positive"): approximate_polygon(polygon, percentage=0.5, epsilon_step=epsilon_step)