diff --git a/docs/changelog.md b/docs/changelog.md index 33451be3..717e1db8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,12 +1,14 @@ --- description: "Full version history of the supervision Python library — release notes, breaking changes, new features, and deprecations for every version." -date_modified: 2026-06-08 +date_modified: 2026-06-09 --- # Changelog ### UnReleased +- Fixed [#2306](https://github.com/roboflow/supervision/pull/2306): [`sv.Detections.area`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.area) now returns the rotated body's area for detections carrying `data["xyxyxyxy"]` (oriented box corners) instead of the area of the derived axis-aligned bounding box, which overestimates by up to ~2x at 45° rotation. Affects annotator z-ordering inside [`MaskAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.MaskAnnotator) and [`HaloAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.HaloAnnotator), and any user code that filters or sorts OBB detections by area. The mask path and the non-OBB AABB fallback are unchanged. + - Fixed [#2289](https://github.com/roboflow/supervision/pull/2289): [`DetectionDataset.as_yolo`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.as_yolo) now accepts `is_obb=True` to round-trip oriented bounding box datasets without losing the rotation. Previously the save path had no OBB option and silently wrote 5-token axis-aligned bbox lines for datasets loaded with `from_yolo(..., is_obb=True)`, dropping the four corners stored in `detections.data["xyxyxyxy"]`. Re-loading those saved files with `is_obb=True` then crashed the validator. Mirrors the existing `from_yolo(..., is_obb=True)` load path. - Deprecated: public `validate_*` helper functions now delegate to private `_validate_*` implementations via `pydeprecate` shims. Existing imports continue to work with a `FutureWarning`; internal Supervision code uses the private helpers directly. diff --git a/src/supervision/detection/core.py b/src/supervision/detection/core.py index c482e21d..aac4d7df 100644 --- a/src/supervision/detection/core.py +++ b/src/supervision/detection/core.py @@ -19,6 +19,7 @@ from supervision.detection.tools.transformers import ( process_transformers_v4_segmentation_result, process_transformers_v5_segmentation_result, ) +from supervision.detection.utils.boxes import obb_polygon_area from supervision.detection.utils.converters import ( mask_to_xyxy, polygon_to_mask, @@ -2366,20 +2367,49 @@ class Detections: def area(self) -> npt.NDArray[np.generic]: """ Calculate the area of each detection in the set of object detections. - If masks field is defined property returns are of each mask. - If only box is given property return area of each box. + + 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 + the rotated body (shoelace formula on the four corners). + 3. Otherwise, return the axis-aligned box area (``box_area``). + + **OBB dispatch contract**: presence of ``data[ORIENTED_BOX_COORDINATES]`` + with shape ``(N, 4, 2)`` is the canonical signal that a detection carries + oriented bounding box geometry. The same presence-of-key check governs + ``with_nms``, ``with_nmm``, and this property — always store OBB corners + under ``config.ORIENTED_BOX_COORDINATES`` with that shape. + + **Return dtype**: ``float64`` (OBB branch), input dtype (AABB fallback), + ``int64`` (mask branch). Returns: - An array of floats containing the area of each detection + An array containing the area of each detection in the format of `(area_1, area_2, ..., area_n)`, where n is the number of detections. + + Example: + >>> import numpy as np + >>> import supervision as sv + >>> corners = np.array( + ... [[[0, 5], [5, 10], [10, 5], [5, 0]]], dtype=np.float32 + ... ) + >>> detections = sv.Detections( + ... xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32), + ... class_id=np.array([0]), + ... data={"xyxyxyxy": corners}, + ... ) + >>> 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]) - else: - return self.box_area + if ORIENTED_BOX_COORDINATES in self.data: + return obb_polygon_area(self.data[ORIENTED_BOX_COORDINATES]) + return self.box_area @property def box_area(self) -> npt.NDArray[np.generic]: diff --git a/src/supervision/detection/utils/boxes.py b/src/supervision/detection/utils/boxes.py index a19b05d4..e4b00d6c 100644 --- a/src/supervision/detection/utils/boxes.py +++ b/src/supervision/detection/utils/boxes.py @@ -241,6 +241,34 @@ def move_oriented_boxes( return xyxyxyxy + offset +def obb_polygon_area(corners: npt.NDArray) -> npt.NDArray[np.float64]: + """Compute the area of N oriented bounding boxes using the shoelace formula. + + Args: + corners: OBB corner coordinates with shape `(N, 4, 2)`. + + Returns: + Area of each box as a 1-D float64 array of shape `(N,)`. + + Raises: + ValueError: If `corners` does not have shape `(N, 4, 2)`. + + Examples: + >>> import numpy as np + >>> from supervision.detection.utils.boxes import obb_polygon_area + >>> corners = np.array([[[0, 5], [5, 10], [10, 5], [5, 0]]], dtype=np.float32) + >>> obb_polygon_area(corners) + array([50.]) + """ + corners = np.asarray(corners) + if corners.ndim != 3 or corners.shape[-2:] != (4, 2): + raise ValueError(f"corners must have shape (N, 4, 2); got {corners.shape}") + x = corners[..., 0].astype(np.float64, copy=False) + y = corners[..., 1].astype(np.float64, copy=False) + cross = x * np.roll(y, -1, axis=-1) - y * np.roll(x, -1, axis=-1) + return 0.5 * np.abs(np.sum(cross, axis=-1)) + + def scale_boxes( xyxy: npt.NDArray[np.float64], factor: float ) -> npt.NDArray[np.float64]: diff --git a/tests/detection/test_core.py b/tests/detection/test_core.py index 5f529f3b..bdaa9503 100644 --- a/tests/detection/test_core.py +++ b/tests/detection/test_core.py @@ -1081,3 +1081,132 @@ class TestDetectionsWithNmm: assert len(result) == 1 expected_xyxy = np.array([[10.0, 10.0, 50.0, 30.0]], dtype=np.float32) assert np.allclose(result.xyxy, expected_xyxy, atol=0.5) + + +class TestDetectionsArea: + """Selection order for the `area` property: mask → OBB → AABB.""" + + @pytest.mark.parametrize( + ("width", "height", "angle_deg", "expected_area"), + [ + pytest.param(20, 10, 0, 200.0, id="axis-aligned"), + pytest.param(20, 10, 45, 200.0, id="45-deg rotation"), + pytest.param(20, 10, 30, 200.0, id="30-deg rotation"), + pytest.param(20, 10, -60, 200.0, id="negative rotation"), + ], + ) + def test_uses_oriented_box_corners_when_present( + self, width: float, height: float, angle_deg: float, expected_area: float + ) -> None: + """Area equals the rotated body's area regardless of rotation, not the AABB.""" + quad = _rotated_rect(50, 50, width, height, angle_deg) + detections = _make_obb_detections([quad], [0.9], [0]) + + assert np.allclose(detections.area, [expected_area]) + + def test_falls_back_to_box_area_without_obb_data(self) -> None: + """Without ORIENTED_BOX_COORDINATES, area mirrors box_area (AABB).""" + detections = Detections( + xyxy=np.array([[0, 0, 20, 10]], dtype=np.float32), + class_id=np.array([0], dtype=int), + ) + + assert np.allclose(detections.area, [200.0]) + assert np.allclose(detections.area, detections.box_area) + + def test_mask_takes_precedence_over_oriented_box(self) -> None: + """When both `mask` and `ORIENTED_BOX_COORDINATES` are present, area is + computed from the mask.""" + mask = np.zeros((40, 40), dtype=bool) + mask[10:30, 10:25] = True # 20 rows x 15 cols = 300 pixels + quad = _rotated_rect(20, 20, 20, 10, 0) # OBB area = 200 + detections = Detections( + xyxy=np.array([[10, 10, 25, 30]], dtype=np.float32), + class_id=np.array([0], dtype=int), + mask=mask[None, ...], + data={ORIENTED_BOX_COORDINATES: quad[None, ...]}, + ) + + assert np.allclose(detections.area, [300.0]) + + def test_empty_detections_with_obb_data_returns_empty_array(self) -> None: + """Boundary case: empty Detections carrying an OBB data field must + return an empty area array (matches the mask / box_area branches).""" + detections = Detections( + xyxy=np.empty((0, 4), dtype=np.float32), + class_id=np.array([], dtype=int), + data={ORIENTED_BOX_COORDINATES: np.empty((0, 4, 2), dtype=np.float32)}, + ) + + assert detections.area.shape == (0,) + + def test_degenerate_oriented_box_has_zero_area(self) -> None: + """An OBB whose four corners coincide has zero area — the shoelace + formula must not produce NaN or a negative value.""" + quad = np.full((4, 2), 5.0, dtype=np.float32) + detections = _make_obb_detections([quad], [0.9], [0]) + + assert np.allclose(detections.area, [0.0]) + + def test_handles_batched_oriented_boxes(self) -> None: + """Multiple OBBs in one `Detections` each get their own correct area. + Guards against the shoelace reduction collapsing across boxes instead + of along the per-box corner axis.""" + quads = [ + _rotated_rect(50, 50, 20, 10, 0), # 200 + _rotated_rect(100, 100, 20, 10, 45), # 200 (rotation must not change it) + _rotated_rect(150, 150, 30, 5, 30), # 150 + ] + detections = _make_obb_detections(quads, [0.9, 0.9, 0.9], [0, 0, 0]) + + assert np.allclose(detections.area, [200.0, 200.0, 150.0]) + + @pytest.mark.parametrize( + "bad_shape", + [ + pytest.param((1, 8), id="flat-N8"), + pytest.param((1, 3, 2), id="triangle"), + ], + ) + def test_raises_on_malformed_obb_coordinates_shape(self, bad_shape: tuple) -> None: + """ValueError when OBB data shape is wrong for area computation.""" + bad_corners = np.zeros(bad_shape, dtype=np.float32) + detections = Detections( + xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32), + class_id=np.array([0]), + data={ORIENTED_BOX_COORDINATES: bad_corners}, + ) + + with pytest.raises(ValueError, match="must have shape"): + _ = detections.area + + @pytest.mark.parametrize( + ("branch", "expected_dtype"), + [ + pytest.param("obb", np.float64, id="obb-branch-float64"), + pytest.param("aabb", np.float32, id="aabb-branch-preserves-input-dtype"), + pytest.param("mask", np.int64, id="mask-branch-int64"), + ], + ) + def test_area_return_dtype_per_branch( + self, branch: str, expected_dtype: type + ) -> None: + """Area dtype matches the documented per-branch contract.""" + if branch == "obb": + quad = _rotated_rect(50, 50, 20, 10, 0) + detections = _make_obb_detections([quad], [0.9], [0]) + elif branch == "aabb": + detections = Detections( + xyxy=np.array([[0, 0, 20, 10]], dtype=np.float32), + class_id=np.array([0], dtype=int), + ) + else: + mask = np.zeros((1, 40, 40), dtype=bool) + mask[0, 10:30, 10:30] = True + detections = Detections( + xyxy=np.array([[10, 10, 30, 30]], dtype=np.float32), + class_id=np.array([0], dtype=int), + mask=mask, + ) + + assert detections.area.dtype == expected_dtype