diff --git a/docs/changelog.md b/docs/changelog.md index a8add0cc..0a0f2552 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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)) diff --git a/src/supervision/detection/core.py b/src/supervision/detection/core.py index 29eccbaf..043de2f2 100644 --- a/src/supervision/detection/core.py +++ b/src/supervision/detection/core.py @@ -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( diff --git a/src/supervision/detection/utils/boxes.py b/src/supervision/detection/utils/boxes.py index 4f18699e..21bc799b 100644 --- a/src/supervision/detection/utils/boxes.py +++ b/src/supervision/detection/utils/boxes.py @@ -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]: diff --git a/tests/detection/test_core.py b/tests/detection/test_core.py index 4d013efe..e61ee86e 100644 --- a/tests/detection/test_core.py +++ b/tests/detection/test_core.py @@ -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""" diff --git a/tests/detection/utils/test_boxes.py b/tests/detection/utils/test_boxes.py index 1192d111..0bc11c80 100644 --- a/tests/detection/utils/test_boxes.py +++ b/tests/detection/utils/test_boxes.py @@ -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