diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 78a7e0e8..e4e66699 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -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_` 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 91aa72c0..643ec255 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/src/supervision/detection/core.py b/src/supervision/detection/core.py index c5118004..2a4e0b6c 100644 --- a/src/supervision/detection/core.py +++ b/src/supervision/detection/core.py @@ -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, ) diff --git a/src/supervision/key_points/annotators.py b/src/supervision/key_points/annotators.py index 00e427fa..a389c5bf 100644 --- a/src/supervision/key_points/annotators.py +++ b/src/supervision/key_points/annotators.py @@ -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] ) diff --git a/src/supervision/key_points/core.py b/src/supervision/key_points/core.py index 82317335..9694844a 100644 --- a/src/supervision/key_points/core.py +++ b/src/supervision/key_points/core.py @@ -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: """ diff --git a/src/supervision/validators/__init__.py b/src/supervision/validators/__init__.py index a5ca7236..37631278 100644 --- a/src/supervision/validators/__init__.py +++ b/src/supervision/validators/__init__.py @@ -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) diff --git a/tests/detection/test_core.py b/tests/detection/test_core.py index d4e80506..378102f3 100644 --- a/tests/detection/test_core.py +++ b/tests/detection/test_core.py @@ -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"), [ diff --git a/tests/key_points/test_annotators.py b/tests/key_points/test_annotators.py index d12657be..803d3008 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(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): """ diff --git a/tests/key_points/test_core.py b/tests/key_points/test_core.py index e0f559f8..62ba88d7 100644 --- a/tests/key_points/test_core.py +++ b/tests/key_points/test_core.py @@ -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])) diff --git a/tests/key_points/test_from_rfdetr.py b/tests/key_points/test_from_rfdetr.py deleted file mode 100644 index 7a422adc..00000000 --- a/tests/key_points/test_from_rfdetr.py +++ /dev/null @@ -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) diff --git a/tests/utils/test_internal.py b/tests/utils/test_internal.py index eb4cab41..ebc72ca0 100644 --- a/tests/utils/test_internal.py +++ b/tests/utils/test_internal.py @@ -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(), ), ],