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:
parent
9faa4f6133
commit
ace3ebd03e
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue