feat: store keypoints on detections (#2290)

* docs: add API design principles to contribution guidelines
* feat: store keypoints on detections
* test: add "keypoints" to internal test cases
* docs: document keypoints field semantics and add docstring + dtype guard
* refactor: deduplicate keypoints shape check and add K-mismatch guard
* docs: clarify Detections.keypoints vs sv.KeyPoints decision rule and add KeyPoints filter example
* feat(key_points): add KeyPoints.from_detections() cross-container adapter
* test: extend keypoints test coverage — dtype guard, __eq__, dynamic field sets
* test: expand keypoints test coverage (M7/M8)
* fix: correct validate_xy expected_shape and dimensionality message
* fix: add ndim guard in KeyPoints.from_detections
* test: add unit tests for KeyPoints.from_detections adapter
* refactor: fix class_id cast and import formatting in key_points

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Jirka Borovec 2026-06-04 09:09:37 -06:00 committed by GitHub
parent 0c67942d32
commit e03111e67c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 531 additions and 447 deletions

View File

@ -12,6 +12,7 @@ Please read and adhere to our [Code of Conduct](https://supervision.roboflow.com
- [Contribution Guidelines](#contribution-guidelines)
- [Contributing Features](#contributing-features)
- [API Design Principles](#api-design-principles)
- [How to Contribute Changes](#how-to-contribute-changes)
- [Installation for Contributors](#installation-for-contributors)
- [Code Style and Quality](#code-style-and-quality)
@ -41,6 +42,42 @@ For example, counting objects that cross a line anywhere on an image is a common
Before you contribute a new feature, consider submitting an Issue to discuss the feature so the community can weigh in and assist.
### API Design Principles
Supervision APIs should remain generic, composable, and predictable across model
families. Before adding a new integration, annotator option, or data conversion
method, check the existing `sv.Detections`, `sv.KeyPoints`, and annotator
patterns and follow these principles:
1. **Model integrations normalize raw external outputs into existing Supervision
containers.** Use `sv.Detections` for detection, segmentation, and other
instance-level predictions that include boxes, masks, class ids, confidence
scores, or extra per-instance fields. Use `sv.KeyPoints` for standalone
keypoint or pose predictions when keypoints exist independently of detection
boxes (e.g. pure pose estimation, landmark detection on pre-cropped images).
Use `Detections.keypoints` when keypoints are always co-incident with boxes
from the same model — the field stores an `(n, K, 2)` or `(n, K, 3)` array
where the optional third channel is per-point confidence in `[0, 1]`.
2. **Do not add a `from_<model>` method when the model already returns a
Supervision object.** `from_*` methods are for converting raw outputs from
external packages such as Ultralytics, Transformers, Inference, or MediaPipe.
If a model's `predict()` method already returns `sv.Detections`, keep that
result type and store additional structured payloads in `detections.data` or
`detections.metadata` using documented keys.
3. **Annotators render data; filtering and visibility are container state.**
Filtering by confidence, class id, tracker id, geometry, or custom data should
happen before annotation through the container slicing APIs, for example
`detections[detections.confidence > 0.7]` or `key_points[key_points.confidence > 0.5]`.
Per-point presentation state, such as a `KeyPoints.visible` mask, may
live on the container and be honored consistently by annotators.
4. **Annotator constructor arguments should describe visual presentation, not
model-quality gates.** Use constructor arguments for color, thickness,
opacity, text, position, style, and generic visualization parameters such as
sigma levels. Annotators may skip invalid geometry defensively, including
missing points, zero-area boxes, non-finite coordinates, or points marked
invisible on the container. They should not introduce confidence thresholds or
model-specific quality gates as rendering options.
## How to Contribute Changes
First, fork this repository to your own GitHub account. Click "fork" in the top corner of the `supervision` repository to get started:

View File

@ -46,6 +46,18 @@ All work must follow the conventions of the `supervision` library
- Follow existing naming patterns.
- Maintain backward compatibility unless explicitly allowed.
- Prefer functional utilities over complex classes unless justified.
- Treat [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md#api-design-principles)
as the canonical API design reference.
- Keep model integrations aligned with existing containers: models that already
return `sv.Detections` should continue to do so, with extra payloads stored
in `data` or `metadata` under documented keys.
- Reserve `from_*` methods for converting raw outputs from external packages
into Supervision containers; do not add one-off adapters for outputs that are
already `sv.Detections` or `sv.KeyPoints`.
- Annotators render already-selected data. Do filtering by confidence, class id,
tracker id, geometry, or custom fields before annotation with container
slicing APIs, not annotator constructor arguments. Container-level visibility
masks may be honored by annotators when documented consistently.
### Performance

View File

@ -135,6 +135,13 @@ class Detections:
mask: An array of shape `(n, H, W)` containing the segmentation masks
(`bool` data type), or `None` when masks are not available, or as
:class:`~supervision.detection.compact_mask.CompactMask`.
keypoints: An array of shape `(n, K, 2)` or `(n, K, 3)` containing
keypoint coordinates for each detection, or `None` when keypoints
are not available. `K` is the number of keypoints per detection (e.g.
17 for COCO pose). The optional third channel is a per-point confidence
score in `[0, 1]` (float32). Use `sv.KeyPoints` for standalone pose
predictions without associated detection boxes; use this field when
keypoints are always co-incident with boxes from the same model.
confidence: An array of shape `(n,)` containing the confidence scores
of the detections, or `None` when confidence values are not available.
class_id: An array of shape `(n,)` containing the class ids of the
@ -156,6 +163,7 @@ class Detections:
tracker_id: npt.NDArray[np.generic] | None = None
data: dict[str, npt.NDArray[np.generic] | list[Any]] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
keypoints: npt.NDArray[np.generic] | None = None
def __post_init__(self) -> None:
validate_detections_fields(
@ -165,6 +173,7 @@ class Detections:
class_id=self.class_id,
tracker_id=self.tracker_id,
data=self.data,
keypoints=self.keypoints,
)
def __len__(self) -> int:
@ -188,6 +197,11 @@ class Detections:
"""
Iterates over the Detections object and yield a tuple of
`(xyxy, mask, confidence, class_id, tracker_id, data)` for each detection.
Note:
The `keypoints` field is intentionally excluded from iteration to preserve
the stable 6-tuple shape that downstream code depends on. Access keypoints
directly via `detections.keypoints`.
"""
for i in range(len(self.xyxy)):
yield (
@ -206,6 +220,7 @@ class Detections:
[
np.array_equal(self.xyxy, other.xyxy),
np.array_equal(self.mask, other.mask),
np.array_equal(self.keypoints, other.keypoints),
np.array_equal(self.class_id, other.class_id),
np.array_equal(self.confidence, other.confidence),
np.array_equal(self.tracker_id, other.tracker_id),
@ -2109,8 +2124,8 @@ class Detections:
Merge a list of Detections objects into a single Detections object.
This method takes a list of Detections objects and combines their
respective fields (`xyxy`, `mask`, `confidence`, `class_id`, and `tracker_id`)
into a single Detections object.
respective fields (`xyxy`, `mask`, `keypoints`, `confidence`, `class_id`, and
`tracker_id`) into a single Detections object.
For example, if merging Detections with 3 and 4 detected objects, this method
will return a Detections with 7 objects (7 entries in `xyxy`, `mask`, etc).
@ -2171,6 +2186,7 @@ class Detections:
class_id=detections.class_id,
tracker_id=detections.tracker_id,
data=detections.data,
keypoints=detections.keypoints,
)
xyxy = np.vstack([d.xyxy for d in detections_list])
@ -2188,9 +2204,19 @@ class Detections:
return CompactMask.merge(masks)
# Mixed or all-ndarray: __array__ auto-converts any CompactMask.
return np.vstack([np.asarray(m) for m in masks])
if name == "keypoints":
kp_arrays = [d.__getattribute__(name) for d in detections_list]
shapes = [a.shape[1:] for a in kp_arrays]
if len(set(shapes)) > 1:
raise ValueError(
f"All 'keypoints' arrays must share the same (K, channels); "
f"got shapes: {[a.shape for a in kp_arrays]}"
)
return np.vstack(kp_arrays)
return np.hstack([d.__getattribute__(name) for d in detections_list])
mask = stack_or_none("mask")
keypoints = stack_or_none("keypoints")
confidence = stack_or_none("confidence")
class_id = stack_or_none("class_id")
tracker_id = stack_or_none("tracker_id")
@ -2208,6 +2234,7 @@ class Detections:
tracker_id=tracker_id,
data=data,
metadata=metadata,
keypoints=keypoints,
)
def get_anchors_coordinates(self, anchor: Position) -> npt.NDArray[np.generic]:
@ -2322,6 +2349,7 @@ class Detections:
tracker_id=self.tracker_id[index] if self.tracker_id is not None else None,
data=get_data_item(self.data, index),
metadata=self.metadata,
keypoints=self.keypoints[index] if self.keypoints is not None else None,
)
def __setitem__(self, key: str, value: npt.NDArray[np.generic] | list[Any]) -> None:
@ -2582,7 +2610,8 @@ def merge_inner_detection_object_pair(
The resulting `confidence` of the merged object is calculated by the weighted
contribution of each detection to the merged object.
The bounding boxes and masks of the two input detections are merged into a
single bounding box and mask, respectively.
single bounding box and mask, respectively. If keypoints are present, keypoints
from the winning detection are preserved.
Args:
detections_1: The first Detections object.
@ -2657,6 +2686,7 @@ def merge_inner_detection_object_pair(
tracker_id=winning_detection.tracker_id,
data=winning_detection.data,
metadata=metadata,
keypoints=winning_detection.keypoints,
)

View File

@ -208,7 +208,6 @@ class VertexEllipseAnnotator(BaseKeyPointAnnotator):
thickness: int = 2,
sigma: float = 2.0,
covariance_data_key: str = "covariance",
confidence_threshold: float = 0.0,
max_axis_length: float | None = None,
line_style: Literal["solid", "dashed"] = "solid",
dash_length: int = 16,
@ -220,8 +219,6 @@ class VertexEllipseAnnotator(BaseKeyPointAnnotator):
sigma: Number of standard deviations represented by the ellipse axes.
covariance_data_key: Key in ``key_points.data`` containing covariance
matrices with shape ``(N, K, 2, 2)``.
confidence_threshold: Minimum keypoint confidence required for drawing.
Ignored when ``key_points.confidence`` is ``None``.
max_axis_length: Optional cap for ellipse semi-axis lengths in pixels.
When ``None`` (default), near-singular precision matrices can produce
extremely large eigenvalues and frame-spanning ellipses. Set this to
@ -247,7 +244,6 @@ class VertexEllipseAnnotator(BaseKeyPointAnnotator):
self.thickness = thickness
self.sigma = sigma
self.covariance_data_key = covariance_data_key
self.confidence_threshold = confidence_threshold
self.max_axis_length = max_axis_length
self.line_style = line_style
self.dash_length = dash_length
@ -300,8 +296,6 @@ class VertexEllipseAnnotator(BaseKeyPointAnnotator):
confidence = key_points.confidence[detection_index, point_index]
if not np.isfinite(confidence):
continue
if confidence < self.confidence_threshold:
continue
ellipse = self._covariance_to_ellipse(
covariance=covariances[detection_index, point_index]
)

View File

@ -1,6 +1,5 @@
from __future__ import annotations
import logging
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field
from typing import Any, Union, cast
@ -11,9 +10,10 @@ import numpy.typing as npt
from supervision.config import CLASS_NAME_DATA_FIELD
from supervision.detection.core import Detections
from supervision.detection.utils.internal import get_data_item, is_data_equal
from supervision.validators import validate_key_points_fields
logger = logging.getLogger(__name__)
from supervision.validators import (
validate_detection_keypoints,
validate_key_points_fields,
)
Index1D = Union[
int,
@ -26,94 +26,6 @@ Index1D = Union[
Index2D = tuple[Index1D, Index1D]
def _rfdetr_source_shape(
rfdetr_detections: Detections,
detections_count: int,
) -> npt.NDArray[np.float32]:
source_shape = rfdetr_detections.data.get("source_shape")
if source_shape is None:
raise ValueError(
"RF-DETR detections with keypoint precision data must contain "
"data['source_shape'] with shape (N, 2) where each row is "
"(height, width) in pixels."
)
source_shape_array = np.asarray(source_shape, dtype=np.float32)
expected_shape = (detections_count, 2)
if source_shape_array.shape != expected_shape:
raise ValueError(
"Expected RF-DETR source_shape shape "
f"{expected_shape}, got {source_shape_array.shape}."
)
return source_shape_array
def _rfdetr_precision_cholesky_to_pixel_covariance(
precision_cholesky: npt.NDArray[np.float32],
source_shape: npt.NDArray[np.float32],
) -> npt.NDArray[np.float32]:
if precision_cholesky.ndim != 3 or precision_cholesky.shape[2] != 3:
raise ValueError(
"Expected RF-DETR keypoint precision shape (N, K, 3), "
f"got {precision_cholesky.shape}."
)
if precision_cholesky.shape[0] != source_shape.shape[0]:
raise ValueError(
"RF-DETR keypoint precision and source_shape must contain the same "
"number of detections, got "
f"{precision_cholesky.shape[0]} and {source_shape.shape[0]}."
)
n_total = precision_cholesky.shape[0] * precision_cholesky.shape[1]
n_non_finite = 0
n_singular = 0
n_overflow = 0
covariances = np.full(
(*precision_cholesky.shape[:2], 2, 2), np.nan, dtype=np.float32
)
for detection_index, detection_precision in enumerate(precision_cholesky):
height, width = source_shape[detection_index]
scale = np.diag([width, height]).astype(np.float64)
for keypoint_index, params in enumerate(detection_precision):
if not np.isfinite(params).all():
n_non_finite += 1
continue
log_l11 = float(np.clip(params[0], -20.0, 20.0))
l21 = float(np.clip(params[1], -1.0e4, 1.0e4))
log_l22 = float(np.clip(params[2], -20.0, 20.0))
l11 = float(np.exp(log_l11))
l22 = float(np.exp(log_l22))
precision = np.array(
[[l11 * l11, l11 * l21], [l11 * l21, l21 * l21 + l22 * l22]],
dtype=np.float64,
)
try:
covariance = np.linalg.inv(precision)
except np.linalg.LinAlgError:
n_singular += 1
continue
pixel_covariance = scale @ covariance @ scale
if np.isfinite(pixel_covariance).all():
covariances[detection_index, keypoint_index] = pixel_covariance
else:
n_overflow += 1
n_failed = n_non_finite + n_singular + n_overflow
if n_failed > 0:
logger.warning(
"%d of %d precision matrices failed: "
"non_finite=%d, singular=%d, overflow=%d",
n_failed,
n_total,
n_non_finite,
n_singular,
n_overflow,
)
return covariances
def _optional_array_equal(
first: npt.NDArray[np.generic] | None,
second: npt.NDArray[np.generic] | None,
@ -250,13 +162,6 @@ class KeyPoints:
key_point = sv.KeyPoints.from_transformers(results[0])
```
Note:
[`sv.KeyPoints.from_rfdetr`][supervision.key_points.core.KeyPoints.from_rfdetr]
accepts ``sv.Detections`` (not native RF-DETR output) because RF-DETR keypoints
are attached as extra fields inside a ``sv.Detections`` object returned by
``model.predict()``. Run that conversion first, then pass the result to
``from_rfdetr``.
Attributes:
xy: An array of shape `(n, m, 2)` containing
`n` detected objects, each composed of `m` equally-sized
@ -338,111 +243,6 @@ class KeyPoints:
]
)
@classmethod
def from_rfdetr(cls, rfdetr_detections: Detections) -> KeyPoints:
"""
Create a `sv.KeyPoints` object from RF-DETR `sv.Detections` output.
RF-DETR attaches keypoint coordinates to ``detections.data["keypoints"]``
with shape ``(N, K, 3)`` where the last dimension stores ``[x, y,
confidence]`` in pixel coordinates. When RF-DETR also provides
``detections.data["keypoint_precision_cholesky"]``, this method converts
those per-keypoint precision parameters into pixel-space covariance matrices
and stores them in ``key_points.data["covariance"]`` for use with
`sv.VertexEllipseAnnotator`.
Note:
``detections.data["source_shape"]`` must have shape ``(N, 2)`` where each
row is ``(height, width)`` in pixels note this is HW order, not the WH
order used by ``resolution_wh`` elsewhere in supervision.
Keypoint confidence values are stored as-is from RF-DETR output and are
expected to be probabilities in the range ``[0, 1]``. If RF-DETR returns
logits instead, user-supplied ``confidence_threshold`` values in
`sv.VertexEllipseAnnotator` should be adjusted accordingly.
Args:
rfdetr_detections: RF-DETR prediction returned by ``model.predict()``.
Returns:
A `sv.KeyPoints` object containing RF-DETR keypoints and optional
covariance matrices.
Raises:
ValueError: If the RF-DETR detections do not contain valid keypoints,
or if precision parameters are present without source shape data.
Examples:
Basic usage keypoints only:
>>> import numpy as np
>>> import supervision as sv
>>> kp_arr = np.array([[[50, 80, 0.9], [60, 90, 0.8]]], dtype=np.float32)
>>> detections = sv.Detections(
... xyxy=np.array([[10, 20, 100, 200]], dtype=np.float32),
... data={"keypoints": kp_arr},
... )
>>> key_points = sv.KeyPoints.from_rfdetr(detections)
>>> key_points.xy.shape
(1, 2, 2)
With precision Cholesky parameters (produces covariance data):
>>> kp_arr2 = np.array([[[50, 80, 0.9], [60, 90, 0.8]]], dtype=np.float32)
>>> chol = np.zeros((1, 2, 3), dtype=np.float32)
>>> src = np.array([[480, 640]], dtype=np.float32)
>>> detections_with_cov = sv.Detections(
... xyxy=np.array([[10, 20, 100, 200]], dtype=np.float32),
... data={
... "keypoints": kp_arr2,
... "keypoint_precision_cholesky": chol,
... "source_shape": src,
... },
... )
>>> kp = sv.KeyPoints.from_rfdetr(detections_with_cov)
>>> "covariance" in kp.data
True
"""
rfdetr_keypoints = rfdetr_detections.data.get("keypoints")
if rfdetr_keypoints is None:
raise ValueError("RF-DETR detections must contain data['keypoints'].")
keypoints = np.asarray(rfdetr_keypoints, dtype=np.float32)
if keypoints.ndim != 3 or keypoints.shape[2] != 3:
raise ValueError(
f"Expected RF-DETR keypoints shape (N, K, 3), got {keypoints.shape}."
)
if keypoints.shape[0] == 0:
return cls.empty()
data: dict[str, npt.NDArray[np.generic] | list[Any]] = {}
precision_cholesky = rfdetr_detections.data.get("keypoint_precision_cholesky")
if precision_cholesky is not None:
precision_cholesky_array = np.asarray(precision_cholesky, dtype=np.float32)
if precision_cholesky_array.shape[:2] != keypoints.shape[:2]:
raise ValueError(
"keypoint_precision_cholesky shape "
f"{precision_cholesky_array.shape[:2]} does not match "
f"keypoints shape {keypoints.shape[:2]}."
)
source_shape = _rfdetr_source_shape(
rfdetr_detections, detections_count=keypoints.shape[0]
)
data["covariance"] = _rfdetr_precision_cholesky_to_pixel_covariance(
precision_cholesky=precision_cholesky_array,
source_shape=source_shape,
)
class_id: npt.NDArray[np.int_] | None = None
if rfdetr_detections.class_id is not None:
class_id = rfdetr_detections.class_id.astype(np.int_)
return cls(
xy=keypoints[:, :, :2].astype(np.float32),
confidence=keypoints[:, :, 2].astype(np.float32),
class_id=class_id,
data=data,
)
@classmethod
def from_inference(cls, inference_result: Any) -> KeyPoints:
"""
@ -1065,6 +865,60 @@ class KeyPoints:
self.data[key] = value
@classmethod
def from_detections(cls, detections: Detections) -> KeyPoints:
"""Convert a `sv.Detections` object to `sv.KeyPoints` using its keypoints field.
Use this adapter when passing `Detections.keypoints` to keypoint annotators
such as `sv.VertexAnnotator`, `sv.EdgeAnnotator`, or `sv.VertexEllipseAnnotator`
which accept `sv.KeyPoints` rather than raw NumPy arrays.
Args:
detections: A `sv.Detections` object with a non-``None`` ``keypoints``
field of shape ``(n, K, 2)`` or ``(n, K, 3)``.
Returns:
A `sv.KeyPoints` instance. When the third channel is present it is
interpreted as per-point confidence and stored in ``confidence``.
Raises:
ValueError: If ``detections.keypoints`` is ``None``.
Examples:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> detections = sv.Detections(
... xyxy=np.array([[10, 20, 30, 40]], dtype=np.float32),
... keypoints=np.zeros((1, 17, 2), dtype=np.float32),
... )
>>> key_points = sv.KeyPoints.from_detections(detections)
>>> key_points.xy.shape
(1, 17, 2)
```
"""
if detections.keypoints is None:
raise ValueError(
"detections.keypoints is None; cannot convert to KeyPoints"
)
kp = detections.keypoints
validate_detection_keypoints(kp, len(detections))
if kp.shape[2] == 3:
xy = kp[..., :2].astype(np.float32)
confidence = kp[..., 2].astype(np.float32)
else:
xy = kp.astype(np.float32)
confidence = None
class_id: npt.NDArray[np.int_] | None = None
if detections.class_id is not None:
class_id = detections.class_id.astype(np.int_)
return cls(
xy=xy,
confidence=confidence,
class_id=class_id,
)
@classmethod
def empty(cls) -> KeyPoints:
"""

View File

@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Optional
import numpy as np
from deprecate import deprecated, void
@ -63,6 +63,51 @@ def validate_mask(mask: Any, n: int) -> None:
)
def validate_detection_keypoints(keypoints: Any, n: int) -> None:
"""Validate that keypoints is a numeric 3D array with shape (n, K, 2) or (n, K, 3).
The optional third channel encodes per-point confidence scores in ``[0, 1]``.
Pass ``None`` when keypoints are absent; any other value must be a numeric
``np.ndarray``.
Args:
keypoints: The keypoints array to validate, or ``None``.
n: Expected number of detections (first dimension of the array).
Raises:
ValueError: If ``keypoints`` is not ``None`` and does not satisfy the shape
or dtype constraints described above.
Examples:
```pycon
>>> import numpy as np
>>> validate_detection_keypoints(None, 3)
>>> validate_detection_keypoints(np.zeros((3, 17, 2), dtype=np.float32), 3)
```
"""
if keypoints is None:
return
expected_shape = f"({n}, K, 2) or ({n}, K, 3)"
if not isinstance(keypoints, np.ndarray):
raise ValueError(
"keypoints must be a 3D np.ndarray with shape "
+ f"{expected_shape}, but got {type(keypoints).__name__}"
)
if not np.issubdtype(keypoints.dtype, np.number):
raise ValueError(
f"keypoints must have a numeric dtype, but got dtype {keypoints.dtype}"
)
try:
validate_xy(keypoints, n)
except ValueError:
actual_shape = str(keypoints.shape)
raise ValueError(
"keypoints must be a 3D np.ndarray with shape "
+ f"{expected_shape}, but got shape {actual_shape}"
)
def validate_class_id(class_id: Any, n: int) -> None:
expected_shape = f"({n},)"
actual_shape = str(getattr(class_id, "shape", None))
@ -140,16 +185,26 @@ def validate_data(data: dict[str, Any], n: int) -> None:
raise ValueError(f"Value for key '{key}' must be a list or np.ndarray")
def validate_xy(xy: Any, n: int, m: int) -> None:
expected_shape = f"({n, m},)"
def validate_xy(xy: Any, n: int, m: Optional[int] = None) -> None:
actual_shape = str(getattr(xy, "shape", None))
is_valid = isinstance(xy, np.ndarray) and (
xy.shape == (n, m, 2) or xy.shape == (n, m, 3)
)
if m is None:
is_valid = (
isinstance(xy, np.ndarray)
and xy.ndim == 3
and xy.shape[0] == n
and xy.shape[2] in (2, 3)
)
expected_shape = f"({n}, K, 2) or ({n}, K, 3)"
else:
is_valid = isinstance(xy, np.ndarray) and (
xy.shape == (n, m, 2) or xy.shape == (n, m, 3)
)
expected_shape = f"({n}, {m}, 2) or ({n}, {m}, 3)"
if not is_valid:
raise ValueError(
f"xy must be a 2D np.ndarray with shape {expected_shape}, but got shape "
f"xy must be a 3D np.ndarray with shape {expected_shape}, but got shape "
f"{actual_shape}"
)
@ -161,10 +216,12 @@ def validate_detections_fields(
confidence: Any,
tracker_id: Any,
data: dict[str, Any],
keypoints: Any = None,
) -> None:
validate_xyxy(xyxy)
n = len(xyxy)
validate_mask(mask, n)
validate_detection_keypoints(keypoints, n)
validate_class_id(class_id, n)
validate_confidence(confidence, n)
validate_tracker_id(tracker_id, n)

View File

@ -155,6 +155,46 @@ def test_detections_non_bool_mask_warns_with_migration_path() -> None:
)
@pytest.mark.parametrize(
("keypoints", "exception"),
[
(np.array([[[1, 2], [3, 4]]], dtype=np.float32), DoesNotRaise()),
(np.array([[[1, 2, 0.9], [3, 4, 0.8]]], dtype=np.float32), DoesNotRaise()),
(
np.array([[1, 2, 0.9], [3, 4, 0.8]], dtype=np.float32),
pytest.raises(ValueError, match=r"keypoints must be a 3D np.ndarray"),
),
(
np.array([[[1, 2, 0.9, 1], [3, 4, 0.8, 1]]], dtype=np.float32),
pytest.raises(ValueError, match=r"keypoints must be a 3D np.ndarray"),
),
(
np.array(
[
[[1, 2, 0.9], [3, 4, 0.8]],
[[5, 6, 0.7], [7, 8, 0.6]],
],
dtype=np.float32,
),
pytest.raises(ValueError, match=r"keypoints must be a 3D np.ndarray"),
),
(
np.array([[["a", "b"]]], dtype=object),
pytest.raises(ValueError, match=r"keypoints must have a numeric dtype"),
),
],
)
def test_detections_keypoints_validation(
keypoints: np.ndarray, exception: Exception
) -> None:
"""Validate that Detections rejects invalid keypoints arrays."""
with exception:
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
keypoints=keypoints,
)
@pytest.mark.parametrize(
("detections", "index", "expected_result", "exception"),
[
@ -304,6 +344,164 @@ def test_getitem(
assert result == expected_result
def test_getitem_preserves_keypoints() -> None:
keypoints = np.array(
[
[[1, 2, 0.9], [3, 4, 0.8]],
[[5, 6, 0.7], [7, 8, 0.6]],
],
dtype=np.float32,
)
detections = Detections(
xyxy=np.array([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32),
confidence=np.array([0.9, 0.8], dtype=np.float32),
keypoints=keypoints,
)
result = detections[[1]]
assert isinstance(result, Detections)
np.testing.assert_array_equal(result.keypoints, keypoints[[1]])
def test_merge_preserves_keypoints() -> None:
detections_1 = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
keypoints=np.array([[[1, 2, 0.9], [3, 4, 0.8]]], dtype=np.float32),
)
detections_2 = Detections(
xyxy=np.array([[20, 20, 30, 30]], dtype=np.float32),
keypoints=np.array([[[5, 6, 0.7], [7, 8, 0.6]]], dtype=np.float32),
)
result = Detections.merge([detections_1, detections_2])
np.testing.assert_array_equal(
result.keypoints,
np.array(
[
[[1, 2, 0.9], [3, 4, 0.8]],
[[5, 6, 0.7], [7, 8, 0.6]],
],
dtype=np.float32,
),
)
def test_merge_rejects_mixed_keypoints_availability() -> None:
"""Merging detections where only some have keypoints raises ValueError."""
detections_1 = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
keypoints=np.array([[[1, 2, 0.9], [3, 4, 0.8]]], dtype=np.float32),
)
detections_2 = Detections(
xyxy=np.array([[20, 20, 30, 30]], dtype=np.float32),
)
with pytest.raises(ValueError, match="All or none of the 'keypoints'"):
Detections.merge([detections_1, detections_2])
def test_getitem_with_none_keypoints() -> None:
"""Integer-index slicing when keypoints=None returns None keypoints."""
detections = Detections(
xyxy=np.array([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32),
)
result = detections[[0]]
assert result.keypoints is None
def test_getitem_preserves_keypoints_boolean_mask() -> None:
"""Boolean-mask indexing propagates the selected keypoints rows."""
keypoints = np.array(
[[[1, 2, 0.9], [3, 4, 0.8]], [[5, 6, 0.7], [7, 8, 0.6]]],
dtype=np.float32,
)
detections = Detections(
xyxy=np.array([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32),
keypoints=keypoints,
)
result = detections[np.array([True, False])]
np.testing.assert_array_equal(result.keypoints, keypoints[[0]])
def test_getitem_preserves_keypoints_slice() -> None:
"""Slice indexing propagates the selected keypoints rows."""
keypoints = np.array(
[[[1, 2, 0.9], [3, 4, 0.8]], [[5, 6, 0.7], [7, 8, 0.6]]],
dtype=np.float32,
)
detections = Detections(
xyxy=np.array([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32),
keypoints=keypoints,
)
result = detections[1:]
np.testing.assert_array_equal(result.keypoints, keypoints[1:])
def test_merge_preserves_keypoints_no_confidence() -> None:
"""Merging (N, K, 2) keypoints (no confidence channel) concatenates correctly."""
detections_1 = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
keypoints=np.array([[[1, 2], [3, 4]]], dtype=np.float32),
)
detections_2 = Detections(
xyxy=np.array([[20, 20, 30, 30]], dtype=np.float32),
keypoints=np.array([[[5, 6], [7, 8]]], dtype=np.float32),
)
result = Detections.merge([detections_1, detections_2])
np.testing.assert_array_equal(
result.keypoints,
np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]], dtype=np.float32),
)
def test_merge_all_none_keypoints() -> None:
"""Merging detections where all keypoints are None yields None keypoints."""
detections_1 = Detections(xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32))
detections_2 = Detections(xyxy=np.array([[20, 20, 30, 30]], dtype=np.float32))
result = Detections.merge([detections_1, detections_2])
assert result.keypoints is None
def test_merge_three_way_preserves_keypoints() -> None:
"""Three-way merge concatenates keypoints from all detections in order."""
kp1 = np.array([[[1, 2, 0.9]]], dtype=np.float32)
kp2 = np.array([[[3, 4, 0.8]]], dtype=np.float32)
kp3 = np.array([[[5, 6, 0.7]]], dtype=np.float32)
detections_1 = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32), keypoints=kp1
)
detections_2 = Detections(
xyxy=np.array([[20, 20, 30, 30]], dtype=np.float32), keypoints=kp2
)
detections_3 = Detections(
xyxy=np.array([[40, 40, 50, 50]], dtype=np.float32), keypoints=kp3
)
result = Detections.merge([detections_1, detections_2, detections_3])
np.testing.assert_array_equal(
result.keypoints,
np.array([[[1, 2, 0.9]], [[3, 4, 0.8]], [[5, 6, 0.7]]], dtype=np.float32),
)
def test_empty_detections_keypoints_is_none() -> None:
"""Detections.empty() must have keypoints=None."""
assert Detections.empty().keypoints is None
@pytest.mark.parametrize(
("detections_list", "expected_result", "exception"),
[
@ -703,11 +901,42 @@ def test_get_anchor_coordinates(
_create_detections(xyxy=[[10, 10, 20, 20]], data={"test_1": [3]}),
False,
), # detections with xyxy, and different data field values
(
Detections(
xyxy=np.array([[0, 0, 1, 1]], dtype=np.float32),
keypoints=np.array([[[1.0, 2.0]]], dtype=np.float32),
),
Detections(
xyxy=np.array([[0, 0, 1, 1]], dtype=np.float32),
keypoints=np.array([[[1.0, 2.0]]], dtype=np.float32),
),
True,
), # equal non-None keypoints
(
Detections(
xyxy=np.array([[0, 0, 1, 1]], dtype=np.float32),
keypoints=np.array([[[1.0, 2.0]]], dtype=np.float32),
),
Detections(xyxy=np.array([[0, 0, 1, 1]], dtype=np.float32)),
False,
), # one has keypoints, other is None
(
Detections(
xyxy=np.array([[0, 0, 1, 1]], dtype=np.float32),
keypoints=np.array([[[1.0, 2.0]]], dtype=np.float32),
),
Detections(
xyxy=np.array([[0, 0, 1, 1]], dtype=np.float32),
keypoints=np.array([[[9.0, 9.0]]], dtype=np.float32),
),
False,
), # same shape, different keypoint values
],
)
def test_equal(
detections_a: Detections, detections_b: Detections, expected_result: bool
) -> None:
"""Verify Detections equality covers all fields including keypoints."""
assert (detections_a == detections_b) == expected_result
@ -880,6 +1109,25 @@ def test_merge_inner_detection_object_pair(
assert result == expected_result
def test_merge_inner_detection_object_pair_preserves_winning_keypoints() -> None:
losing_keypoints = np.array([[[1, 2, 0.9], [3, 4, 0.8]]], dtype=np.float32)
winning_keypoints = np.array([[[5, 6, 0.7], [7, 8, 0.6]]], dtype=np.float32)
detection_1 = Detections(
xyxy=np.array([[0, 0, 20, 20]], dtype=np.float32),
confidence=np.array([0.1], dtype=np.float32),
keypoints=losing_keypoints,
)
detection_2 = Detections(
xyxy=np.array([[10, 10, 30, 30]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
keypoints=winning_keypoints,
)
result = merge_inner_detection_object_pair(detection_1, detection_2)
np.testing.assert_array_equal(result.keypoints, winning_keypoints)
@pytest.mark.parametrize(
("detections", "expected"),
[

View File

@ -214,7 +214,7 @@ class TestVertexEllipseAnnotator:
"covariance": np.array([[[[25.0, 0.0], [0.0, 9.0]]]], dtype=np.float32)
},
)
annotator = sv.VertexEllipseAnnotator(confidence_threshold=0.0)
annotator = sv.VertexEllipseAnnotator()
result = annotator.annotate(scene=scene.copy(), key_points=key_points)
@ -241,33 +241,32 @@ class TestVertexEllipseAnnotator:
with pytest.raises(ValueError, match="Expected covariance shape"):
annotator.annotate(scene=scene.copy(), key_points=sample_key_points)
def test_confidence_threshold_filters_low_confidence_keypoints(self, scene):
def test_pre_masked_keypoints_are_annotated(self, scene):
"""
Scenario: Two keypoints with confidences 0.3 and 0.7; threshold=0.5.
Expected: Only the high-confidence keypoint is drawn.
Scenario: Caller masks low-confidence keypoints before annotation.
Expected: Only the already-selected keypoint is drawn.
"""
cov = np.array([[[[25.0, 0.0], [0.0, 9.0]]]], dtype=np.float32)
key_points_low = sv.KeyPoints(
xy=np.array([[[20.0, 20.0]]], dtype=np.float32),
confidence=np.array([[0.3]], dtype=np.float32),
data={"covariance": cov},
key_points = sv.KeyPoints(
xy=np.array([[[20.0, 20.0], [40.0, 40.0]]], dtype=np.float32),
confidence=np.array([[0.3, 0.7]], dtype=np.float32),
data={
"covariance": np.tile(
np.array([[[[25.0, 0.0], [0.0, 9.0]]]], dtype=np.float32),
(1, 2, 1, 1),
)
},
)
key_points_high = sv.KeyPoints(
xy=np.array([[[20.0, 20.0]]], dtype=np.float32),
confidence=np.array([[0.7]], dtype=np.float32),
data={"covariance": cov},
)
annotator = sv.VertexEllipseAnnotator(confidence_threshold=0.5)
key_points.xy[key_points.confidence < 0.5] = 0.0
annotator = sv.VertexEllipseAnnotator()
result_low = annotator.annotate(scene=scene.copy(), key_points=key_points_low)
result_high = annotator.annotate(scene=scene.copy(), key_points=key_points_high)
result = annotator.annotate(scene=scene.copy(), key_points=key_points)
assert np.array_equal(result_low, scene), (
"low-confidence keypoint must be skipped"
)
assert not np.array_equal(result_high, scene), (
"high-confidence keypoint must be drawn"
np.testing.assert_array_equal(
key_points.xy[0, 0], np.array([0.0, 0.0], dtype=np.float32)
)
assert not np.array_equal(result, scene)
# The masked keypoint was moved to (0,0) but must not be drawn there.
np.testing.assert_array_equal(result[:10, :10], scene[:10, :10])
def test_max_axis_length_caps_large_eigenvalue(self, scene):
"""

View File

@ -3,7 +3,6 @@ from contextlib import nullcontext as DoesNotRaise
import numpy as np
import pytest
from supervision.detection.core import Detections
from supervision.key_points.core import KeyPoints
from tests.helpers import (
_create_key_points,
@ -14,31 +13,6 @@ from tests.helpers import (
_FakeYoloNasKeyPointResults,
)
@pytest.fixture
def rfdetr_detections() -> Detections:
keypoints = np.array(
[
[[10.0, 20.0, 0.9], [30.0, 40.0, 0.8]],
[[50.0, 60.0, 0.7], [70.0, 80.0, 0.6]],
],
dtype=np.float32,
)
precision_cholesky = np.zeros((2, 2, 3), dtype=np.float32)
return Detections(
xyxy=np.array(
[[0.0, 0.0, 40.0, 50.0], [10.0, 20.0, 90.0, 100.0]], dtype=np.float32
),
confidence=np.array([0.95, 0.85], dtype=np.float32),
class_id=np.array([1, 1]),
data={
"keypoints": keypoints,
"keypoint_precision_cholesky": precision_cholesky,
"source_shape": np.array([[100, 200], [50, 100]], dtype=np.int64),
},
)
KEY_POINTS = _create_key_points(
xy=[
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
@ -54,73 +28,6 @@ KEY_POINTS = _create_key_points(
)
def test_key_points_from_rfdetr_loads_keypoints_and_covariance(
rfdetr_detections: Detections,
) -> None:
key_points = KeyPoints.from_rfdetr(rfdetr_detections)
assert key_points.xy.shape == (2, 2, 2)
np.testing.assert_allclose(
key_points.xy, rfdetr_detections.data["keypoints"][:, :, :2]
)
np.testing.assert_allclose(
key_points.confidence, rfdetr_detections.data["keypoints"][:, :, 2]
)
np.testing.assert_array_equal(key_points.class_id, rfdetr_detections.class_id)
assert "covariance" in key_points.data
covariance = key_points.data["covariance"]
assert covariance.shape == (2, 2, 2, 2)
np.testing.assert_allclose(
covariance[0, 0], np.diag([200.0**2, 100.0**2]), rtol=1e-4, atol=1e-6
)
np.testing.assert_allclose(
covariance[1, 0], np.diag([100.0**2, 50.0**2]), rtol=1e-4, atol=1e-6
)
def test_key_points_from_rfdetr_without_precision_omits_covariance(
rfdetr_detections: Detections,
) -> None:
del rfdetr_detections.data["keypoint_precision_cholesky"]
key_points = KeyPoints.from_rfdetr(rfdetr_detections)
assert key_points.xy.shape == (2, 2, 2)
assert "covariance" not in key_points.data
def test_key_points_from_rfdetr_missing_keypoints_raises(
rfdetr_detections: Detections,
) -> None:
del rfdetr_detections.data["keypoints"]
with pytest.raises(ValueError, match=r"data\['keypoints'\]"):
KeyPoints.from_rfdetr(rfdetr_detections)
def test_key_points_from_rfdetr_precision_requires_source_shape(
rfdetr_detections: Detections,
) -> None:
del rfdetr_detections.data["source_shape"]
with pytest.raises(ValueError, match="source_shape"):
KeyPoints.from_rfdetr(rfdetr_detections)
def test_key_points_from_rfdetr_empty_keypoints_returns_empty(
rfdetr_detections: Detections,
) -> None:
rfdetr_detections.xyxy = np.empty((0, 4), dtype=np.float32)
rfdetr_detections.confidence = np.empty((0,), dtype=np.float32)
rfdetr_detections.class_id = np.empty((0,), dtype=int)
rfdetr_detections.data["keypoints"] = np.empty((0, 2, 3), dtype=np.float32)
del rfdetr_detections.data["source_shape"]
key_points = KeyPoints.from_rfdetr(rfdetr_detections)
assert key_points == KeyPoints.empty()
@pytest.mark.parametrize(
("key_points", "index", "expected_result", "exception"),
[
@ -766,3 +673,56 @@ def test_from_mediapipe_input(mediapipe_results, resolution_wh, expected_key_poi
mediapipe_results, resolution_wh=resolution_wh
)
assert key_points == expected_key_points
class TestFromDetections:
"""Verify KeyPoints.from_detections adapter behavior."""
def test_xy_only_input_no_confidence(self) -> None:
"""(n, K, 2) keypoints: xy extracted, confidence is None."""
from supervision.detection.core import Detections
kp = np.array([[[1.0, 2.0], [3.0, 4.0]]], dtype=np.float32)
detections = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
keypoints=kp,
)
result = KeyPoints.from_detections(detections)
np.testing.assert_array_equal(result.xy, kp)
assert result.confidence is None
def test_xy_with_confidence_channel(self) -> None:
"""(n, K, 3) keypoints: xy from first 2 channels, confidence from third."""
from supervision.detection.core import Detections
kp = np.array([[[1.0, 2.0, 0.9], [3.0, 4.0, 0.7]]], dtype=np.float32)
detections = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
keypoints=kp,
)
result = KeyPoints.from_detections(detections)
np.testing.assert_array_equal(result.xy, kp[..., :2])
np.testing.assert_array_equal(result.confidence, kp[..., 2])
def test_none_keypoints_raises_value_error(self) -> None:
"""keypoints=None raises ValueError with a descriptive message."""
from supervision.detection.core import Detections
detections = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
)
with pytest.raises(ValueError, match="keypoints is None"):
KeyPoints.from_detections(detections)
def test_class_id_propagated(self) -> None:
"""class_id from Detections is forwarded to the KeyPoints object."""
from supervision.detection.core import Detections
kp = np.array([[[1.0, 2.0], [3.0, 4.0]]], dtype=np.float32)
detections = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
keypoints=kp,
class_id=np.array([42], dtype=int),
)
result = KeyPoints.from_detections(detections)
np.testing.assert_array_equal(result.class_id, np.array([42]))

View File

@ -1,73 +0,0 @@
import numpy as np
import pytest
import supervision as sv
def test_keypoints_from_rfdetr_detections() -> None:
"""Converts RF-DETR detections.data['keypoints'] into a KeyPoints object."""
detections = sv.Detections(
xyxy=np.array([[0, 0, 10, 10], [10, 10, 20, 20]], dtype=np.float32),
class_id=np.array([1, 3], dtype=int),
data={
"keypoints": np.array(
[
[[1.0, 2.0, 0.9], [3.0, 4.0, 0.8]],
[[5.0, 6.0, 0.7], [7.0, 8.0, 0.6]],
],
dtype=np.float32,
)
},
)
key_points = sv.KeyPoints.from_rfdetr(detections)
assert key_points.xy.shape == (2, 2, 2)
assert key_points.confidence is not None
assert key_points.confidence.shape == (2, 2)
assert key_points.class_id is not None
assert np.array_equal(key_points.class_id, np.array([1, 3], dtype=int))
def test_keypoints_from_rfdetr_missing_keypoints_raises_clear_error() -> None:
"""Missing detections.data['keypoints'] raises a clear conversion error."""
detections = sv.Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0], dtype=int),
)
with pytest.raises(ValueError, match=r"data\['keypoints'\]"):
sv.KeyPoints.from_rfdetr(detections)
def test_keypoints_from_rfdetr_malformed_shape_raises_clear_error() -> None:
"""Malformed keypoints shape raises a clear conversion error."""
detections = sv.Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0], dtype=int),
data={"keypoints": np.array([[[1.0, 2.0]]], dtype=np.float32)},
)
with pytest.raises(ValueError, match="shape \\(N, K, 3\\)"):
sv.KeyPoints.from_rfdetr(detections)
def test_keypoint_annotator_uses_vertex_and_edge_rendering() -> None:
"""Converted RF-DETR keypoints are consumable by vertex and edge annotators."""
scene = np.zeros((32, 32, 3), dtype=np.uint8)
detections = sv.Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
data={
"keypoints": np.array(
[[[10.0, 10.0, 0.9], [20.0, 20.0, 0.8]]], dtype=np.float32
)
},
)
key_points = sv.KeyPoints.from_rfdetr(detections)
scene = sv.VertexAnnotator().annotate(scene=scene, key_points=key_points)
scene = sv.EdgeAnnotator(edges=[(1, 2)]).annotate(
scene=scene, key_points=key_points
)
assert np.any(scene != 0)

View File

@ -121,46 +121,20 @@ class MockDataclass:
(
Detections.empty(),
False,
{
"xyxy",
"class_id",
"confidence",
"mask",
"tracker_id",
"data",
"metadata",
},
set(Detections.__dataclass_fields__),
DoesNotRaise(),
),
(
Detections.empty(),
True,
{
"xyxy",
"class_id",
"confidence",
"mask",
"tracker_id",
"data",
"metadata",
"area",
"box_area",
"box_aspect_ratio",
},
set(Detections.__dataclass_fields__)
| {"area", "box_area", "box_aspect_ratio"},
DoesNotRaise(),
),
(
Detections(xyxy=np.array([[1, 2, 3, 4]])),
False,
{
"xyxy",
"class_id",
"confidence",
"mask",
"tracker_id",
"data",
"metadata",
},
set(Detections.__dataclass_fields__),
DoesNotRaise(),
),
(
@ -173,15 +147,7 @@ class MockDataclass:
data={"key_1": [1, 2], "key_2": [3, 4]},
),
False,
{
"xyxy",
"class_id",
"confidence",
"mask",
"tracker_id",
"data",
"metadata",
},
set(Detections.__dataclass_fields__),
DoesNotRaise(),
),
],