From 967bf34404c1ff14be066ef90f14cd64f307fc3d Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Mon, 29 Jun 2026 07:31:40 -0700 Subject: [PATCH 1/2] refactor: centralize geometry-aware dispatch for detection_area and detection_iou Fixes #2318 --- src/supervision/detection/core.py | 13 +- src/supervision/detection/utils/geometry.py | 101 +++++++ tests/detection/utils/test_geometry.py | 277 ++++++++++++++++++++ 3 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 src/supervision/detection/utils/geometry.py create mode 100644 tests/detection/utils/test_geometry.py diff --git a/src/supervision/detection/core.py b/src/supervision/detection/core.py index a131b6c911..337d0200a6 100644 --- a/src/supervision/detection/core.py +++ b/src/supervision/detection/core.py @@ -24,12 +24,13 @@ _DetectionDataValueType, _MetadataType, ) -from supervision.detection.utils.boxes import obb_polygon_area, xyxyxyxy_to_xyxy +from supervision.detection.utils.boxes import xyxyxyxy_to_xyxy from supervision.detection.utils.converters import ( mask_to_xyxy, polygon_to_mask, xywh_to_xyxy, ) +from supervision.detection.utils.geometry import detection_area from supervision.detection.utils.internal import ( extract_ultralytics_masks, get_data_item, @@ -2481,15 +2482,7 @@ def area(self) -> npt.NDArray[np.generic]: >>> detections.area array([50.]) """ - if self.mask is not None: - if isinstance(self.mask, CompactMask): - return self.mask.area - return np.array([np.sum(mask) for mask in self.mask]) - if ORIENTED_BOX_COORDINATES in self.data: - return obb_polygon_area( - cast(npt.NDArray[np.number], self.data[ORIENTED_BOX_COORDINATES]) - ) - return self.box_area + return detection_area(self) @property def box_area(self) -> npt.NDArray[np.generic]: diff --git a/src/supervision/detection/utils/geometry.py b/src/supervision/detection/utils/geometry.py new file mode 100644 index 0000000000..37859f680f --- /dev/null +++ b/src/supervision/detection/utils/geometry.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import numpy as np +import numpy.typing as npt + +from supervision.config import ORIENTED_BOX_COORDINATES +from supervision.detection.compact_mask import CompactMask +from supervision.detection.utils.boxes import obb_polygon_area +from supervision.detection.utils.iou_and_nms import ( + OverlapMetric, + box_iou_batch, + mask_iou_batch, + oriented_box_iou_batch, +) + +if TYPE_CHECKING: + from supervision.detection.core import Detections + + +def detection_area(detections: Detections) -> npt.NDArray[np.generic]: + """ + Calculate detection areas using the richest geometry present. + + Selection order: + + 1. If ``mask`` is set, return the area of each mask. + 2. Else, if ``data[ORIENTED_BOX_COORDINATES]`` is set, return the area of + each oriented box. + 3. Otherwise, return the axis-aligned box area. + + Args: + detections: Detections whose geometry should be measured. + + Returns: + A 1-D array containing the area of each detection. + """ + if detections.mask is not None: + if isinstance(detections.mask, CompactMask): + return detections.mask.area + return np.array([np.sum(mask) for mask in detections.mask]) + if ORIENTED_BOX_COORDINATES in detections.data: + return obb_polygon_area( + cast(npt.NDArray[np.number], detections.data[ORIENTED_BOX_COORDINATES]) + ) + return detections.box_area + + +def detection_iou( + detections_true: Detections, + detections_detection: Detections, + overlap_metric: OverlapMetric | str = OverlapMetric.IOU, +) -> npt.NDArray[np.floating]: + """ + Calculate pairwise IoU using the richest geometry both operands share. + + Selection order: + + 1. If both operands have masks, use mask IoU. + 2. Else, if both operands have oriented-box coordinates, use OBB IoU. + 3. Otherwise, use axis-aligned box IoU. + + Args: + detections_true: Reference detections. + detections_detection: Candidate detections. + overlap_metric: Metric used to compute the degree of overlap. + + Returns: + A matrix of pairwise overlaps with shape + ``(len(detections_true), len(detections_detection))``. + """ + overlap_metric = OverlapMetric.from_value(overlap_metric) + if detections_true.mask is not None and detections_detection.mask is not None: + return mask_iou_batch( + detections_true.mask, + detections_detection.mask, + overlap_metric=overlap_metric, + ) + + if ( + ORIENTED_BOX_COORDINATES in detections_true.data + and ORIENTED_BOX_COORDINATES in detections_detection.data + ): + return oriented_box_iou_batch( + cast( + npt.NDArray[np.number], + detections_true.data[ORIENTED_BOX_COORDINATES], + ), + cast( + npt.NDArray[np.number], + detections_detection.data[ORIENTED_BOX_COORDINATES], + ), + overlap_metric=overlap_metric, + ) + + return box_iou_batch( + detections_true.xyxy, + detections_detection.xyxy, + overlap_metric=overlap_metric, + ) diff --git a/tests/detection/utils/test_geometry.py b/tests/detection/utils/test_geometry.py new file mode 100644 index 0000000000..0ae3fd7cbe --- /dev/null +++ b/tests/detection/utils/test_geometry.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import cv2 +import numpy as np +import numpy.typing as npt +import pytest + +from supervision.config import ORIENTED_BOX_COORDINATES +from supervision.detection.compact_mask import CompactMask +from supervision.detection.core import Detections +from supervision.detection.utils.boxes import xyxyxyxy_to_xyxy +from supervision.detection.utils.geometry import detection_area, detection_iou +from supervision.detection.utils.iou_and_nms import ( + OverlapMetric, + box_iou_batch, + mask_iou_batch, + oriented_box_iou_batch, +) + + +def _rotated_rect( + center_x: float, center_y: float, width: float, height: float, angle_deg: float +) -> npt.NDArray[np.float32]: + """Return four corners of a rotated rectangle in clockwise order.""" + half_w = width / 2.0 + half_h = height / 2.0 + corners = np.array( + [ + [-half_w, -half_h], + [half_w, -half_h], + [half_w, half_h], + [-half_w, half_h], + ], + dtype=np.float32, + ) + angle = np.deg2rad(angle_deg) + rotation = np.array( + [[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]], + dtype=np.float32, + ) + center = np.array([center_x, center_y], dtype=np.float32) + return (corners @ rotation.T + center).astype(np.float32) + + +def _detections_from_quads( + quads: list[npt.NDArray[np.float32]], + xyxy: npt.NDArray[np.float32] | None = None, +) -> Detections: + """Build Detections carrying OBB coordinates.""" + corners = np.stack(quads).astype(np.float32) + if xyxy is None: + xyxy = xyxyxyxy_to_xyxy(corners).astype(np.float32) + return Detections( + xyxy=xyxy, + data={ORIENTED_BOX_COORDINATES: corners}, + ) + + +def _full_image_xyxy( + count: int, image_shape: tuple[int, int] +) -> npt.NDArray[np.float32]: + """Return full-image xyxy boxes for CompactMask construction.""" + image_height, image_width = image_shape + xyxy = np.array([0, 0, image_width - 1, image_height - 1], dtype=np.float32) + return np.tile(xyxy, (count, 1)) + + +class TestDetectionArea: + """Tests for geometry-aware area dispatch.""" + + def test_returns_mask_pixel_area(self) -> None: + """Masks take precedence over OBBs and AABB envelopes.""" + mask = np.zeros((1, 20, 20), dtype=bool) + mask[0, 2:5, 3:8] = True + quad = _rotated_rect(10, 10, 12, 6, 30) + detections = Detections( + xyxy=np.array([[0, 0, 20, 20]], dtype=np.float32), + mask=mask, + data={ORIENTED_BOX_COORDINATES: quad[np.newaxis]}, + ) + + area = detection_area(detections) + + np.testing.assert_array_equal(area, np.array([15], dtype=np.int64)) + + def test_returns_compact_mask_area(self) -> None: + """CompactMask inputs use CompactMask.area without dense materialisation.""" + masks = np.zeros((2, 12, 12), dtype=bool) + masks[0, 1:4, 2:7] = True + masks[1, 4:9, 4:10] = True + compact_mask = CompactMask.from_dense( + masks=masks, + xyxy=_full_image_xyxy(len(masks), masks.shape[1:]), + image_shape=masks.shape[1:], + ) + detections = Detections( + xyxy=_full_image_xyxy(len(masks), masks.shape[1:]), + mask=compact_mask, + ) + + area = detection_area(detections) + + np.testing.assert_array_equal(area, compact_mask.area) + + def test_returns_oriented_box_area_when_present(self) -> None: + """OBB area is used instead of the larger rotated AABB envelope.""" + quad = _rotated_rect(50, 50, 20, 10, 45) + detections = _detections_from_quads([quad]) + + area = detection_area(detections) + + np.testing.assert_allclose(area, np.array([200.0])) + assert detections.box_area[0] > area[0] + + def test_returns_box_area_when_no_richer_geometry_is_present(self) -> None: + """AABB area is the fallback when masks and OBB corners are absent.""" + detections = Detections( + xyxy=np.array([[0, 0, 20, 10], [3, 4, 8, 12]], dtype=np.float32) + ) + + area = detection_area(detections) + + np.testing.assert_array_equal(area, detections.box_area) + + @pytest.mark.parametrize( + "detections", + [ + pytest.param( + Detections(xyxy=np.empty((0, 4), dtype=np.float32)), + id="empty-aabb", + ), + pytest.param( + Detections( + xyxy=np.empty((0, 4), dtype=np.float32), + mask=np.empty((0, 8, 8), dtype=bool), + ), + id="empty-mask", + ), + pytest.param( + Detections( + xyxy=np.empty((0, 4), dtype=np.float32), + data={ + ORIENTED_BOX_COORDINATES: np.empty( + (0, 4, 2), dtype=np.float32 + ) + }, + ), + id="empty-obb", + ), + ], + ) + def test_returns_empty_array_for_empty_detections( + self, detections: Detections + ) -> None: + """Empty Detections return an empty area array for every geometry branch.""" + area = detection_area(detections) + + assert area.shape == (0,) + + @pytest.mark.parametrize( + "detections", + [ + pytest.param( + Detections( + xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32), + mask=np.ones((1, 3, 4), dtype=bool), + ), + id="mask", + ), + pytest.param( + _detections_from_quads([_rotated_rect(50, 50, 20, 10, 30)]), + id="obb", + ), + pytest.param( + Detections(xyxy=np.array([[0, 0, 10, 5]], dtype=np.float32)), + id="aabb", + ), + ], + ) + def test_matches_detections_area_property(self, detections: Detections) -> None: + """Detections.area delegates to the shared geometry dispatch helper.""" + area = detection_area(detections) + + np.testing.assert_array_equal(area, detections.area) + + def test_keeps_oriented_area_invariant_when_envelope_changes(self) -> None: + """Rotating an OBB preserves exact OBB area while changing envelope area.""" + axis_aligned = _detections_from_quads([_rotated_rect(30, 30, 20, 10, 0)]) + rotated = _detections_from_quads([_rotated_rect(30, 30, 20, 10, 45)]) + + areas = np.concatenate( + [detection_area(axis_aligned), detection_area(rotated)] + ) + + np.testing.assert_allclose(areas, np.array([200.0, 200.0])) + assert axis_aligned.box_area[0] != pytest.approx(rotated.box_area[0]) + + def test_mask_area_is_stable_under_nearest_rotation(self) -> None: + """Mask area stays close after raster rotation despite resampling.""" + mask = np.zeros((80, 80), dtype=np.uint8) + mask[30:50, 25:55] = 1 + matrix = cv2.getRotationMatrix2D((40, 40), 25, 1.0) + rotated_mask = cv2.warpAffine( + mask, matrix, (80, 80), flags=cv2.INTER_NEAREST + ).astype(bool) + detections = Detections( + xyxy=np.array([[0, 0, 79, 79], [0, 0, 79, 79]], dtype=np.float32), + mask=np.stack([mask.astype(bool), rotated_mask]), + ) + + area = detection_area(detections) + + np.testing.assert_allclose(area[0], area[1], atol=8) + + +class TestDetectionIou: + """Tests for geometry-aware IoU dispatch.""" + + def test_returns_mask_iou_when_both_operands_have_masks(self) -> None: + """Mask IoU is used when both operands carry masks.""" + masks_a = np.zeros((1, 16, 16), dtype=bool) + masks_b = np.zeros((1, 16, 16), dtype=bool) + masks_a[0, 2:10, 2:10] = True + masks_b[0, 6:14, 6:14] = True + detections_a = Detections( + xyxy=np.array([[0, 0, 16, 16]], dtype=np.float32), + mask=masks_a, + ) + detections_b = Detections( + xyxy=np.array([[0, 0, 16, 16]], dtype=np.float32), + mask=masks_b, + ) + + iou = detection_iou(detections_a, detections_b) + + np.testing.assert_allclose(iou, mask_iou_batch(masks_a, masks_b)) + + def test_returns_oriented_box_iou_when_both_operands_have_obbs(self) -> None: + """OBB IoU is used even when AABB envelopes are identical.""" + square = np.array([[0, 0], [10, 0], [10, 10], [0, 10]], dtype=np.float32) + diamond = np.array([[5, 0], [10, 5], [5, 10], [0, 5]], dtype=np.float32) + shared_xyxy = np.array([[0, 0, 10, 10]], dtype=np.float32) + detections_a = _detections_from_quads([square], xyxy=shared_xyxy) + detections_b = _detections_from_quads([diamond], xyxy=shared_xyxy) + + iou = detection_iou(detections_a, detections_b) + + np.testing.assert_allclose( + iou, + oriented_box_iou_batch(square[np.newaxis], diamond[np.newaxis]), + ) + assert iou[0, 0] < box_iou_batch(shared_xyxy, shared_xyxy)[0, 0] + + def test_returns_box_iou_when_no_richer_geometry_is_present(self) -> None: + """AABB IoU is the fallback when neither operand carries richer geometry.""" + detections_a = Detections( + xyxy=np.array([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32) + ) + detections_b = Detections( + xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32) + ) + + iou = detection_iou(detections_a, detections_b, OverlapMetric.IOS) + + np.testing.assert_allclose( + iou, + box_iou_batch(detections_a.xyxy, detections_b.xyxy, OverlapMetric.IOS), + ) + + def test_returns_empty_matrix_for_empty_detections(self) -> None: + """Empty Detections return an empty pairwise IoU matrix.""" + empty = Detections(xyxy=np.empty((0, 4), dtype=np.float32)) + non_empty = Detections(xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32)) + + iou = detection_iou(empty, non_empty) + + assert iou.shape == (0, 1) From 5878f05dfaf69323031eb47a116c75f9243051b8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:36:37 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto=20?= =?UTF-8?q?format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/detection/utils/test_geometry.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/detection/utils/test_geometry.py b/tests/detection/utils/test_geometry.py index 0ae3fd7cbe..e52329a030 100644 --- a/tests/detection/utils/test_geometry.py +++ b/tests/detection/utils/test_geometry.py @@ -140,9 +140,7 @@ def test_returns_box_area_when_no_richer_geometry_is_present(self) -> None: Detections( xyxy=np.empty((0, 4), dtype=np.float32), data={ - ORIENTED_BOX_COORDINATES: np.empty( - (0, 4, 2), dtype=np.float32 - ) + ORIENTED_BOX_COORDINATES: np.empty((0, 4, 2), dtype=np.float32) }, ), id="empty-obb", @@ -188,9 +186,7 @@ def test_keeps_oriented_area_invariant_when_envelope_changes(self) -> None: axis_aligned = _detections_from_quads([_rotated_rect(30, 30, 20, 10, 0)]) rotated = _detections_from_quads([_rotated_rect(30, 30, 20, 10, 45)]) - areas = np.concatenate( - [detection_area(axis_aligned), detection_area(rotated)] - ) + areas = np.concatenate([detection_area(axis_aligned), detection_area(rotated)]) np.testing.assert_allclose(areas, np.array([200.0, 200.0])) assert axis_aligned.box_area[0] != pytest.approx(rotated.box_area[0]) @@ -256,9 +252,7 @@ def test_returns_box_iou_when_no_richer_geometry_is_present(self) -> None: detections_a = Detections( xyxy=np.array([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32) ) - detections_b = Detections( - xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32) - ) + detections_b = Detections(xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32)) iou = detection_iou(detections_a, detections_b, OverlapMetric.IOS)