diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e4e66699..78a7e0e8 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -12,7 +12,6 @@ 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) @@ -42,42 +41,6 @@ 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_` 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: diff --git a/AGENTS.md b/AGENTS.md index 643ec255..91aa72c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,18 +46,6 @@ 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 diff --git a/src/supervision/detection/core.py b/src/supervision/detection/core.py index 2a4e0b6c..c5118004 100644 --- a/src/supervision/detection/core.py +++ b/src/supervision/detection/core.py @@ -135,13 +135,6 @@ 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 @@ -163,7 +156,6 @@ 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( @@ -173,7 +165,6 @@ class Detections: class_id=self.class_id, tracker_id=self.tracker_id, data=self.data, - keypoints=self.keypoints, ) def __len__(self) -> int: @@ -197,11 +188,6 @@ 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 ( @@ -220,7 +206,6 @@ 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), @@ -2124,8 +2109,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`, `keypoints`, `confidence`, `class_id`, and - `tracker_id`) into a single Detections object. + respective fields (`xyxy`, `mask`, `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). @@ -2186,7 +2171,6 @@ 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]) @@ -2204,19 +2188,9 @@ 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") @@ -2234,7 +2208,6 @@ class Detections: tracker_id=tracker_id, data=data, metadata=metadata, - keypoints=keypoints, ) def get_anchors_coordinates(self, anchor: Position) -> npt.NDArray[np.generic]: @@ -2349,7 +2322,6 @@ 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: @@ -2610,8 +2582,7 @@ 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. If keypoints are present, keypoints - from the winning detection are preserved. + single bounding box and mask, respectively. Args: detections_1: The first Detections object. @@ -2686,7 +2657,6 @@ def merge_inner_detection_object_pair( tracker_id=winning_detection.tracker_id, data=winning_detection.data, metadata=metadata, - keypoints=winning_detection.keypoints, ) diff --git a/src/supervision/key_points/annotators.py b/src/supervision/key_points/annotators.py index a389c5bf..00e427fa 100644 --- a/src/supervision/key_points/annotators.py +++ b/src/supervision/key_points/annotators.py @@ -208,6 +208,7 @@ 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, @@ -219,6 +220,8 @@ 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 @@ -244,6 +247,7 @@ 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 @@ -296,6 +300,8 @@ 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] ) diff --git a/src/supervision/key_points/core.py b/src/supervision/key_points/core.py index 9694844a..82317335 100644 --- a/src/supervision/key_points/core.py +++ b/src/supervision/key_points/core.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from collections.abc import Iterable, Iterator from dataclasses import dataclass, field from typing import Any, Union, cast @@ -10,10 +11,9 @@ 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_detection_keypoints, - validate_key_points_fields, -) +from supervision.validators import validate_key_points_fields + +logger = logging.getLogger(__name__) Index1D = Union[ int, @@ -26,6 +26,94 @@ 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, @@ -162,6 +250,13 @@ 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 @@ -243,6 +338,111 @@ 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: """ @@ -865,60 +1065,6 @@ 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: """ diff --git a/src/supervision/validators/__init__.py b/src/supervision/validators/__init__.py index 37631278..a5ca7236 100644 --- a/src/supervision/validators/__init__.py +++ b/src/supervision/validators/__init__.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any import numpy as np from deprecate import deprecated, void @@ -63,51 +63,6 @@ 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)) @@ -185,26 +140,16 @@ 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: Optional[int] = None) -> None: +def validate_xy(xy: Any, n: int, m: int) -> None: + expected_shape = f"({n, m},)" actual_shape = str(getattr(xy, "shape", None)) - 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)" - + is_valid = isinstance(xy, np.ndarray) and ( + xy.shape == (n, m, 2) or xy.shape == (n, m, 3) + ) if not is_valid: raise ValueError( - f"xy must be a 3D np.ndarray with shape {expected_shape}, but got shape " + f"xy must be a 2D np.ndarray with shape {expected_shape}, but got shape " f"{actual_shape}" ) @@ -216,12 +161,10 @@ 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) diff --git a/tests/detection/test_core.py b/tests/detection/test_core.py index 378102f3..d4e80506 100644 --- a/tests/detection/test_core.py +++ b/tests/detection/test_core.py @@ -155,46 +155,6 @@ 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"), [ @@ -344,164 +304,6 @@ 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"), [ @@ -901,42 +703,11 @@ 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 @@ -1109,25 +880,6 @@ 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"), [ diff --git a/tests/key_points/test_annotators.py b/tests/key_points/test_annotators.py index 803d3008..d12657be 100644 --- a/tests/key_points/test_annotators.py +++ b/tests/key_points/test_annotators.py @@ -214,7 +214,7 @@ class TestVertexEllipseAnnotator: "covariance": np.array([[[[25.0, 0.0], [0.0, 9.0]]]], dtype=np.float32) }, ) - annotator = sv.VertexEllipseAnnotator() + annotator = sv.VertexEllipseAnnotator(confidence_threshold=0.0) result = annotator.annotate(scene=scene.copy(), key_points=key_points) @@ -241,32 +241,33 @@ class TestVertexEllipseAnnotator: with pytest.raises(ValueError, match="Expected covariance shape"): annotator.annotate(scene=scene.copy(), key_points=sample_key_points) - def test_pre_masked_keypoints_are_annotated(self, scene): + def test_confidence_threshold_filters_low_confidence_keypoints(self, scene): """ - Scenario: Caller masks low-confidence keypoints before annotation. - Expected: Only the already-selected keypoint is drawn. + Scenario: Two keypoints with confidences 0.3 and 0.7; threshold=0.5. + Expected: Only the high-confidence keypoint is drawn. """ - 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), - ) - }, + 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.xy[key_points.confidence < 0.5] = 0.0 - annotator = sv.VertexEllipseAnnotator() - - result = annotator.annotate(scene=scene.copy(), key_points=key_points) - - np.testing.assert_array_equal( - key_points.xy[0, 0], np.array([0.0, 0.0], dtype=np.float32) + 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) + + result_low = annotator.annotate(scene=scene.copy(), key_points=key_points_low) + result_high = annotator.annotate(scene=scene.copy(), key_points=key_points_high) + + 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" ) - 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): """ diff --git a/tests/key_points/test_core.py b/tests/key_points/test_core.py index 62ba88d7..e0f559f8 100644 --- a/tests/key_points/test_core.py +++ b/tests/key_points/test_core.py @@ -3,6 +3,7 @@ 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, @@ -13,6 +14,31 @@ 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]], @@ -28,6 +54,73 @@ 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"), [ @@ -673,56 +766,3 @@ 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])) diff --git a/tests/key_points/test_from_rfdetr.py b/tests/key_points/test_from_rfdetr.py new file mode 100644 index 00000000..7a422adc --- /dev/null +++ b/tests/key_points/test_from_rfdetr.py @@ -0,0 +1,73 @@ +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) diff --git a/tests/utils/test_internal.py b/tests/utils/test_internal.py index ebc72ca0..eb4cab41 100644 --- a/tests/utils/test_internal.py +++ b/tests/utils/test_internal.py @@ -121,20 +121,46 @@ class MockDataclass: ( Detections.empty(), False, - set(Detections.__dataclass_fields__), + { + "xyxy", + "class_id", + "confidence", + "mask", + "tracker_id", + "data", + "metadata", + }, DoesNotRaise(), ), ( Detections.empty(), True, - set(Detections.__dataclass_fields__) - | {"area", "box_area", "box_aspect_ratio"}, + { + "xyxy", + "class_id", + "confidence", + "mask", + "tracker_id", + "data", + "metadata", + "area", + "box_area", + "box_aspect_ratio", + }, DoesNotRaise(), ), ( Detections(xyxy=np.array([[1, 2, 3, 4]])), False, - set(Detections.__dataclass_fields__), + { + "xyxy", + "class_id", + "confidence", + "mask", + "tracker_id", + "data", + "metadata", + }, DoesNotRaise(), ), ( @@ -147,7 +173,15 @@ class MockDataclass: data={"key_1": [1, 2], "key_2": [3, 4]}, ), False, - set(Detections.__dataclass_fields__), + { + "xyxy", + "class_id", + "confidence", + "mask", + "tracker_id", + "data", + "metadata", + }, DoesNotRaise(), ), ],