fix(detection): make `Detections.area` OBB-aware (#2306)

* fix(detection): make Detections.area OBB-aware

When detections carry ORIENTED_BOX_COORDINATES (the four xyxyxyxy corners),
the area property returned the area of the derived axis-aligned bounding
box instead of the rotated body. The AABB overestimates by up to ~2x for a
45-degree rotation, which silently miscomputes downstream values — most
visibly the area-sorted z-ordering inside MaskAnnotator / HaloAnnotator,
and any user code that filters detections by area.

* docs(detection): use string literal in Detections.area doctest
* test(detection): single-line docstring on test_uses_oriented_box_corners_when_present
* fix(detection): validate (N,4,2) shape of OBB data field in Detections.area
* perf(detection): replace np.roll pair with cross-diagonal shoelace in Detections.area
* perf(detection): cast x/y slices to float64 instead of full corners array
* refactor(detection): extract obb_polygon_area to detection/utils/boxes.py
* test(detection): add test_raises_on_malformed_obb_coordinates_shape
* test(detection): assert per-branch dtype contract for Detections.area
* docs(detection): document OBB dispatch contract and dtype in Detections.area docstring

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
Agis Kounelis 2026-06-10 04:20:34 +08:00 committed by GitHub
parent 9faa4f6133
commit ace3ebd03e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 195 additions and 6 deletions

View File

@ -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.

View File

@ -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]:

View File

@ -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]:

View File

@ -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