fix(detection): make `get_anchors_coordinates` OBB-aware (#2382)
- Fixed `get_anchors_coordinates` to compute anchor positions from oriented bounding boxes when OBB geometry is available, ensuring anchor-based operations (such as zone counting and annotators) align with the rotated object instead of its axis-aligned bounding box. - Preserved existing behavior for axis-aligned boxes, while continuing to use mask centroids for `CENTER_OF_MASS` anchors when masks are available. - Improved the `get_anchors_coordinates` documentation with the updated anchor selection order, OBB usage examples, and notes describing OBB winding-order requirements and anchor tie-breaking behavior. --------- Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
parent
058d8fd990
commit
8692148c67
|
|
@ -13,6 +13,9 @@ date_modified: 2026-06-25
|
|||
|
||||
Users on Python 3.9 should upgrade their environment before updating supervision.
|
||||
|
||||
### Fixed
|
||||
- Fixed [#2382](https://github.com/roboflow/supervision/pull/2382): `sv.Detections.get_anchors_coordinates` now uses oriented bounding box corners (`data["xyxyxyxy"]`) when OBB data is present, instead of falling back to the axis-aligned envelope. Anchors on rotated detections now lie on the oriented body rather than drifting to the envelope. Non-OBB detections and `Position.CENTER_OF_MASS` (which requires a mask) are unaffected.
|
||||
|
||||
### Added
|
||||
- `BaseAnnotator.requires_mask` — class-level `bool` flag on all annotators; `True` for `MaskAnnotator`, `PolygonAnnotator`, and `HaloAnnotator`; `False` for all others. Integrations can inspect this before materializing expensive mask payloads ([#2370](https://github.com/roboflow/supervision/pull/2370))
|
||||
- `CompactMask.from_coco_rle` — efficient COCO RLE ingestion into crop-scoped compact mask format without materializing dense `(N, H, W)` arrays ([#2367](https://github.com/roboflow/supervision/pull/2367))
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ from supervision.detection.utils._typing import (
|
|||
_DetectionDataValueType,
|
||||
_MetadataType,
|
||||
)
|
||||
from supervision.detection.utils.boxes import obb_polygon_area, xyxyxyxy_to_xyxy
|
||||
from supervision.detection.utils.boxes import (
|
||||
_oriented_box_anchors,
|
||||
obb_polygon_area,
|
||||
xyxyxyxy_to_xyxy,
|
||||
)
|
||||
from supervision.detection.utils.converters import (
|
||||
mask_to_xyxy,
|
||||
polygon_to_mask,
|
||||
|
|
@ -2401,24 +2405,73 @@ class Detections:
|
|||
)
|
||||
|
||||
def get_anchors_coordinates(self, anchor: Position) -> npt.NDArray[np.generic]:
|
||||
"""
|
||||
Calculates and returns the coordinates of a specific anchor point
|
||||
within the bounding boxes defined by the `xyxy` attribute. The anchor
|
||||
point can be any of the predefined positions in the `Position` enum,
|
||||
such as `CENTER`, `CENTER_LEFT`, `BOTTOM_RIGHT`, etc.
|
||||
"""Compute anchor-point coordinates for each detection.
|
||||
|
||||
The anchor can be any position in the `Position` enum, such as
|
||||
`CENTER`, `CENTER_LEFT`, `BOTTOM_RIGHT`, etc.
|
||||
|
||||
Selection order:
|
||||
|
||||
1. If ``data[ORIENTED_BOX_COORDINATES]`` is set and ``anchor`` is not
|
||||
``Position.CENTER_OF_MASS``, coordinates are computed from the
|
||||
oriented bounding box corners (result lies on the actual rotated
|
||||
body).
|
||||
2. If ``anchor`` is ``Position.CENTER_OF_MASS``, the detection mask
|
||||
centroid is returned regardless of OBB data presence.
|
||||
3. Otherwise, the anchor is derived from the axis-aligned envelope
|
||||
(``xyxy``).
|
||||
|
||||
Args:
|
||||
anchor: An enum specifying the position of the anchor point within the
|
||||
bounding box. Supported positions are defined in the `Position` enum.
|
||||
anchor: Anchor position to compute. Supported positions are
|
||||
defined in the `Position` enum.
|
||||
|
||||
Returns:
|
||||
An array of shape `(n, 2)`, where `n` is the number of bounding
|
||||
boxes. Each row contains the `[x, y]` coordinates of the specified
|
||||
anchor point for the corresponding bounding box.
|
||||
Array of shape `(n, 2)` where each row is the `[x, y]` anchor
|
||||
coordinate for the corresponding detection.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provided `anchor` is not supported.
|
||||
|
||||
Examples:
|
||||
Axis-aligned detection:
|
||||
|
||||
```pycon
|
||||
>>> import numpy as np
|
||||
>>> import supervision as sv
|
||||
>>> detections = sv.Detections(
|
||||
... xyxy=np.array([[0.0, 0.0, 10.0, 4.0]])
|
||||
... )
|
||||
>>> detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
|
||||
array([[5., 4.]])
|
||||
|
||||
```
|
||||
|
||||
Oriented (rotated) detection — anchor lies on the rotated body,
|
||||
not the axis-aligned envelope:
|
||||
|
||||
```pycon
|
||||
>>> import numpy as np
|
||||
>>> import supervision as sv
|
||||
>>> corners = np.array(
|
||||
... [[[0.0, 0.0], [10.0, 0.0], [10.0, 4.0], [0.0, 4.0]]]
|
||||
... )
|
||||
>>> detections = sv.Detections(
|
||||
... xyxy=np.array([[0.0, 0.0, 10.0, 4.0]]),
|
||||
... data={"xyxyxyxy": corners},
|
||||
... )
|
||||
>>> detections.get_anchors_coordinates(sv.Position.BOTTOM_CENTER)
|
||||
array([[5., 4.]])
|
||||
|
||||
```
|
||||
"""
|
||||
if ORIENTED_BOX_COORDINATES in self.data and anchor != Position.CENTER_OF_MASS:
|
||||
return cast(
|
||||
npt.NDArray[np.generic],
|
||||
_oriented_box_anchors(
|
||||
np.asarray(self.data[ORIENTED_BOX_COORDINATES]), anchor
|
||||
),
|
||||
)
|
||||
|
||||
xyxy = self.xyxy
|
||||
|
||||
def coordinates(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from deprecate import ( # type: ignore[import-untyped,unused-ignore]
|
|||
)
|
||||
|
||||
from supervision.detection.utils.iou_and_nms import box_iou_batch
|
||||
from supervision.geometry.core import Position
|
||||
|
||||
|
||||
def clip_boxes(
|
||||
|
|
@ -312,6 +313,95 @@ def xyxyxyxy_to_xyxy(
|
|||
return cast(npt.NDArray[np.number], np.stack([x_min, y_min, x_max, y_max], axis=-1))
|
||||
|
||||
|
||||
# Anchor position -> (sx, sy) offset from the box center, in units of the box
|
||||
# half-width and half-height. Image coordinates, so +y points down.
|
||||
_ANCHOR_OFFSETS: dict[Position, tuple[float, float]] = {
|
||||
Position.CENTER: (0.0, 0.0),
|
||||
Position.CENTER_LEFT: (-1.0, 0.0),
|
||||
Position.CENTER_RIGHT: (1.0, 0.0),
|
||||
Position.TOP_CENTER: (0.0, -1.0),
|
||||
Position.BOTTOM_CENTER: (0.0, 1.0),
|
||||
Position.TOP_LEFT: (-1.0, -1.0),
|
||||
Position.TOP_RIGHT: (1.0, -1.0),
|
||||
Position.BOTTOM_LEFT: (-1.0, 1.0),
|
||||
Position.BOTTOM_RIGHT: (1.0, 1.0),
|
||||
}
|
||||
|
||||
|
||||
def _oriented_box_anchors(
|
||||
xyxyxyxy: npt.NDArray[np.number], anchor: Position
|
||||
) -> npt.NDArray[np.float64]:
|
||||
"""Locate an anchor point on each oriented bounding box.
|
||||
|
||||
The returned point always lies on the oriented rectangle itself: corners map
|
||||
to corners, side anchors to side midpoints, and `CENTER` to the box center.
|
||||
For an axis-aligned box the result matches the anchor derived from the
|
||||
envelope, so this is a drop-in replacement that stops the anchor from drifting
|
||||
off a rotated body.
|
||||
|
||||
Args:
|
||||
xyxyxyxy: OBB corner coordinates with shape `(N, 4, 2)` in winding order,
|
||||
each box as `[[x1, y1], [x2, y2], [x3, y3], [x4, y4]]`.
|
||||
anchor: The anchor position to locate. `Position.CENTER_OF_MASS` is not
|
||||
supported here, as it is defined on a mask rather than a box.
|
||||
|
||||
Returns:
|
||||
Anchor coordinates as an array of shape `(N, 2)`.
|
||||
|
||||
Raises:
|
||||
ValueError: If `xyxyxyxy` does not have shape `(N, 4, 2)`, or the anchor
|
||||
is not supported.
|
||||
|
||||
Note:
|
||||
Corners must be in consecutive winding order (clockwise or
|
||||
counter-clockwise). Non-sequential ordering (e.g. diagonal pairs)
|
||||
silently produces incorrect results.
|
||||
|
||||
Width and height are determined by x-axis projection of each half-side
|
||||
vector. When a box rotates past ``arctan(w/h)`` (approx. 68 deg for a
|
||||
10 x 4 box) the assigned *width* side flips discontinuously, producing
|
||||
a jump in anchor position (~``|w - h|`` pixels for ``BOTTOM_CENTER``).
|
||||
The anchor always lies on the box; the effect is cosmetic for static
|
||||
images but visible on rotating objects in video.
|
||||
|
||||
Examples:
|
||||
```pycon
|
||||
>>> import numpy as np
|
||||
>>> from supervision.detection.utils.boxes import _oriented_box_anchors
|
||||
>>> from supervision.geometry.core import Position
|
||||
>>> corners = np.array(
|
||||
... [[[0, 0], [10, 0], [10, 4], [0, 4]]], dtype=np.float32
|
||||
... )
|
||||
>>> _oriented_box_anchors(corners, Position.BOTTOM_CENTER)
|
||||
array([[5., 4.]])
|
||||
|
||||
```
|
||||
"""
|
||||
corners = np.asarray(xyxyxyxy, dtype=np.float64)
|
||||
if corners.ndim != 3 or corners.shape[-2:] != (4, 2):
|
||||
raise ValueError(f"xyxyxyxy must have shape (N, 4, 2); got {corners.shape}")
|
||||
if anchor not in _ANCHOR_OFFSETS:
|
||||
raise ValueError(f"{anchor} is not supported.")
|
||||
sx, sy = _ANCHOR_OFFSETS[anchor]
|
||||
|
||||
center = corners.mean(axis=1)
|
||||
# Two perpendicular half-side vectors per box.
|
||||
half_side_a = (corners[:, 1] - corners[:, 0]) / 2
|
||||
half_side_b = (corners[:, 2] - corners[:, 1]) / 2
|
||||
|
||||
# Map each box's own sides onto the image axes: the side more aligned with
|
||||
# the x-axis plays the role of width, the other of height. This makes the
|
||||
# offsets collapse to the axis-aligned frame when the box is not rotated.
|
||||
is_width = np.abs(half_side_a[:, 0]) >= np.abs(half_side_b[:, 0])
|
||||
width = np.where(is_width[:, None], half_side_a, half_side_b)
|
||||
height = np.where(is_width[:, None], half_side_b, half_side_a)
|
||||
# Point width toward +x and height toward +y so the offset signs are stable.
|
||||
width = np.where((width[:, 0] < 0)[:, None], -width, width)
|
||||
height = np.where((height[:, 1] < 0)[:, None], -height, height)
|
||||
|
||||
return cast(npt.NDArray[np.float64], center + sx * width + sy * height)
|
||||
|
||||
|
||||
def scale_boxes(
|
||||
xyxy: npt.NDArray[np.float64], factor: float
|
||||
) -> npt.NDArray[np.float64]:
|
||||
|
|
|
|||
|
|
@ -1555,6 +1555,53 @@ class TestDetectionsObbDispatch:
|
|||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestGetAnchorsObbDispatch:
|
||||
"""`get_anchors_coordinates` reads oriented corners when OBB data is present."""
|
||||
|
||||
def test_anchor_lies_on_rotated_body(self) -> None:
|
||||
"""BOTTOM_CENTER of a rotated OBB is a side midpoint, not an envelope point."""
|
||||
quad = _rotated_rect(100, 100, 120, 36, 35)
|
||||
detections = _make_obb_detections([quad], [0.9], [0])
|
||||
|
||||
anchor = detections.get_anchors_coordinates(Position.BOTTOM_CENTER)[0]
|
||||
|
||||
side_midpoints = (quad + np.roll(quad, -1, axis=0)) / 2
|
||||
assert np.min(np.linalg.norm(side_midpoints - anchor, axis=1)) < 1e-4
|
||||
|
||||
def test_identical_envelope_different_rotation_differ(self) -> None:
|
||||
"""Same envelope, mirrored rotation: the oriented anchor tells them apart."""
|
||||
quad_a = _rotated_rect(50, 50, 80, 20, 30)
|
||||
quad_b = _rotated_rect(50, 50, 80, 20, -30)
|
||||
det_a = _make_obb_detections([quad_a], [0.9], [0])
|
||||
det_b = _make_obb_detections([quad_b], [0.9], [0])
|
||||
|
||||
assert np.allclose(det_a.xyxy, det_b.xyxy)
|
||||
anchor_a = det_a.get_anchors_coordinates(Position.BOTTOM_CENTER)
|
||||
anchor_b = det_b.get_anchors_coordinates(Position.BOTTOM_CENTER)
|
||||
assert not np.allclose(anchor_a, anchor_b)
|
||||
|
||||
def test_center_of_mass_still_requires_mask(self) -> None:
|
||||
"""OBB data must not divert `CENTER_OF_MASS` away from the mask path."""
|
||||
detections = _make_obb_detections(
|
||||
[_rotated_rect(100, 100, 120, 36, 35)], [0.9], [0]
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="without a detection mask"):
|
||||
detections.get_anchors_coordinates(Position.CENTER_OF_MASS)
|
||||
|
||||
def test_center_of_mass_with_obb_and_mask_uses_mask(self) -> None:
|
||||
"""OBB data + mask present: CENTER_OF_MASS returns mask centroid, no raise."""
|
||||
quad = _rotated_rect(50, 50, 40, 20, 0)
|
||||
detections = _make_obb_detections([quad], [0.9], [0])
|
||||
mask = np.zeros((1, 100, 100), dtype=bool)
|
||||
mask[0, 40:60, 30:70] = True
|
||||
detections.mask = mask
|
||||
|
||||
result = detections.get_anchors_coordinates(Position.CENTER_OF_MASS)
|
||||
|
||||
assert result.shape == (1, 2)
|
||||
|
||||
|
||||
class TestMergeObbCorners:
|
||||
"""_merge_obb_corners"""
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ from contextlib import ExitStack as DoesNotRaise
|
|||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.detection.utils.boxes import (
|
||||
_oriented_box_anchors as oriented_box_anchors,
|
||||
)
|
||||
from supervision.detection.utils.boxes import (
|
||||
clip_boxes,
|
||||
denormalize_boxes,
|
||||
|
|
@ -10,6 +13,25 @@ from supervision.detection.utils.boxes import (
|
|||
scale_boxes,
|
||||
xyxyxyxy_to_xyxy,
|
||||
)
|
||||
from supervision.geometry.core import Position
|
||||
|
||||
_ALL_ANCHORS = [
|
||||
Position.CENTER,
|
||||
Position.CENTER_LEFT,
|
||||
Position.CENTER_RIGHT,
|
||||
Position.TOP_CENTER,
|
||||
Position.BOTTOM_CENTER,
|
||||
Position.TOP_LEFT,
|
||||
Position.TOP_RIGHT,
|
||||
Position.BOTTOM_LEFT,
|
||||
Position.BOTTOM_RIGHT,
|
||||
]
|
||||
|
||||
|
||||
def _rotate(corners: np.ndarray, angle_deg: float, about: np.ndarray) -> np.ndarray:
|
||||
angle = np.deg2rad(angle_deg)
|
||||
rot = np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]])
|
||||
return (corners - about) @ rot.T + about
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -268,3 +290,97 @@ def test_xyxyxyxy_to_xyxy(corners: np.ndarray, expected: np.ndarray) -> None:
|
|||
"""Converts OBB corners to axis-aligned bounding boxes."""
|
||||
result = xyxyxyxy_to_xyxy(corners)
|
||||
assert np.allclose(result, expected, atol=1e-5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("anchor", "expected"),
|
||||
[
|
||||
pytest.param(Position.CENTER, [5.0, 2.0], id="center"),
|
||||
pytest.param(Position.CENTER_LEFT, [0.0, 2.0], id="center-left"),
|
||||
pytest.param(Position.CENTER_RIGHT, [10.0, 2.0], id="center-right"),
|
||||
pytest.param(Position.TOP_CENTER, [5.0, 0.0], id="top-center"),
|
||||
pytest.param(Position.BOTTOM_CENTER, [5.0, 4.0], id="bottom-center"),
|
||||
pytest.param(Position.TOP_LEFT, [0.0, 0.0], id="top-left"),
|
||||
pytest.param(Position.TOP_RIGHT, [10.0, 0.0], id="top-right"),
|
||||
pytest.param(Position.BOTTOM_LEFT, [0.0, 4.0], id="bottom-left"),
|
||||
pytest.param(Position.BOTTOM_RIGHT, [10.0, 4.0], id="bottom-right"),
|
||||
],
|
||||
)
|
||||
def test_oriented_box_anchors_axis_aligned_matches_envelope(
|
||||
anchor: Position, expected: list[float]
|
||||
) -> None:
|
||||
"""On an axis-aligned box the anchor equals the plain envelope anchor."""
|
||||
corners = np.array([[[0, 0], [10, 0], [10, 4], [0, 4]]], dtype=np.float32)
|
||||
result = oriented_box_anchors(corners, anchor)
|
||||
assert np.allclose(result, [expected])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("anchor", _ALL_ANCHORS, ids=lambda a: a.value.lower())
|
||||
def test_oriented_box_anchors_are_rotation_covariant(anchor: Position) -> None:
|
||||
"""Rotating the box rotates each anchor by the same angle about the center."""
|
||||
base = np.array([[[0, 0], [10, 0], [10, 4], [0, 4]]], dtype=np.float64)
|
||||
center = np.array([5.0, 2.0])
|
||||
rotated = _rotate(base[0], 30, center)[np.newaxis]
|
||||
|
||||
expected = _rotate(oriented_box_anchors(base, anchor)[0], 30, center)
|
||||
result = oriented_box_anchors(rotated, anchor)[0]
|
||||
|
||||
assert np.allclose(result, expected)
|
||||
|
||||
|
||||
def test_oriented_box_anchors_are_points_of_the_rotated_rectangle() -> None:
|
||||
"""Each anchor of a rotated box is one of its corners, side midpoints or center."""
|
||||
base = np.array([[0, 0], [10, 0], [10, 4], [0, 4]], dtype=np.float64)
|
||||
corners = _rotate(base, 30, np.array([5.0, 2.0]))
|
||||
rectangle_points = np.vstack(
|
||||
[corners, (corners + np.roll(corners, -1, axis=0)) / 2, corners.mean(axis=0)]
|
||||
)
|
||||
|
||||
anchors = np.array(
|
||||
[oriented_box_anchors(corners[np.newaxis], a)[0] for a in _ALL_ANCHORS]
|
||||
)
|
||||
|
||||
distances = np.linalg.norm(rectangle_points[None] - anchors[:, None], axis=2)
|
||||
assert np.all(distances.min(axis=1) < 1e-6)
|
||||
|
||||
|
||||
def test_oriented_box_anchors_empty_returns_expected_shape() -> None:
|
||||
"""An empty batch yields an empty `(0, 2)` array."""
|
||||
result = oriented_box_anchors(np.empty((0, 4, 2)), Position.BOTTOM_CENTER)
|
||||
assert result.shape == (0, 2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"corners",
|
||||
[
|
||||
pytest.param(np.zeros((4, 2)), id="missing-batch-axis"),
|
||||
pytest.param(np.zeros((1, 4)), id="not-corner-pairs"),
|
||||
pytest.param(np.zeros((1, 3, 2)), id="wrong-corner-count"),
|
||||
],
|
||||
)
|
||||
def test_oriented_box_anchors_bad_shape_raises(corners: np.ndarray) -> None:
|
||||
"""A batch that is not `(N, 4, 2)` is rejected."""
|
||||
with pytest.raises(ValueError, match="must have shape"):
|
||||
oriented_box_anchors(corners, Position.CENTER)
|
||||
|
||||
|
||||
def test_oriented_box_anchors_center_of_mass_unsupported() -> None:
|
||||
"""`CENTER_OF_MASS` is a mask anchor and has no box definition."""
|
||||
with pytest.raises(ValueError, match="not supported"):
|
||||
oriented_box_anchors(np.zeros((1, 4, 2)), Position.CENTER_OF_MASS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("anchor", _ALL_ANCHORS, ids=lambda a: a.value.lower())
|
||||
def test_oriented_box_anchors_at_90_degrees_on_box(anchor: Position) -> None:
|
||||
"""All anchors of a 90-deg-rotated box lie on the box (exercises is_width=False)."""
|
||||
base = np.array([[0, 0], [10, 0], [10, 4], [0, 4]], dtype=np.float64)
|
||||
center = np.array([5.0, 2.0])
|
||||
corners = _rotate(base, 90, center)[np.newaxis]
|
||||
|
||||
result = oriented_box_anchors(corners, anchor)[0]
|
||||
|
||||
rectangle_points = np.vstack(
|
||||
[corners[0], (corners[0] + np.roll(corners[0], -1, axis=0)) / 2, center]
|
||||
)
|
||||
distances = np.linalg.norm(rectangle_points - result, axis=1)
|
||||
assert distances.min() < 1e-6
|
||||
|
|
|
|||
Loading…
Reference in New Issue