diff --git a/docs/changelog.md b/docs/changelog.md index 38aa2cb4..fb3ddb3b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -18,6 +18,7 @@ date_modified: 2026-07-06 - `sv.mask_non_max_merge` now computes exact mask overlap at the original mask resolution and ignores the deprecated `mask_dimension` parameter. Code that relied on downscaled mask overlap should recalibrate thresholds; passing `mask_dimension` positionally now emits a deprecation warning, and the parameter is scheduled for removal in `0.33.0` ([#2400](https://github.com/roboflow/supervision/pull/2400)). ### Fixed +- Fixed [#2402](https://github.com/roboflow/supervision/pull/2402): `sv.KeyPoints.as_detections` now accepts NumPy arrays, tuples, and generators in `selected_keypoint_indices` without ambiguous truth-value errors; empty index iterables select all keypoints. Valid zero-area skeletons are preserved, while all-zero and non-finite-only skeletons are filtered out. - Fixed [#2407](https://github.com/roboflow/supervision/pull/2407): `sv.ColorPalette.by_idx()` now raises a clear `ValueError` when called on an empty palette instead of leaking a `ZeroDivisionError`. Non-empty palettes keep the existing index-wrapping behavior. - Fixed [#2393](https://github.com/roboflow/supervision/pull/2393): `sv.CropAnnotator.annotate` no longer raises `cv2.error` when detections extend outside the scene; out-of-bounds boxes are clipped to scene bounds and zero-area results are skipped silently. - Fixed [#2393](https://github.com/roboflow/supervision/pull/2393): `sv.HeatMapAnnotator.annotate` no longer blanks the hottest region when the per-pixel hit count exceeds 255; the heat mask is now derived from the float32 accumulator directly, avoiding uint8 wrap-around. diff --git a/src/supervision/key_points/core.py b/src/supervision/key_points/core.py index 8c7cca73..36d1f964 100644 --- a/src/supervision/key_points/core.py +++ b/src/supervision/key_points/core.py @@ -971,9 +971,9 @@ class KeyPoints: if isinstance(i, int): i = [i] - if isinstance(i, list) and all(isinstance(x, bool) for x in i): + if isinstance(i, list) and i and all(isinstance(x, bool) for x in i): i = np.array(i) - if isinstance(j, list) and all(isinstance(x, bool) for x in j): + if isinstance(j, list) and j and all(isinstance(x, bool) for x in j): j = np.array(j) if isinstance(i, np.ndarray) and i.dtype == bool: @@ -1294,13 +1294,17 @@ class KeyPoints: return Detections.empty() xy = self.xy - if selected_keypoint_indices: - indices = np.asarray(list(selected_keypoint_indices), dtype=np.intp) + indices: npt.NDArray[np.intp] | None = None + if selected_keypoint_indices is not None: + candidate = np.asarray(list(selected_keypoint_indices), dtype=np.intp) + if candidate.size > 0: + indices = candidate + if indices is not None: xy = xy[:, indices, :] - # [0, 0] is used by some frameworks to indicate a missing keypoint; those - # points are excluded from each skeleton's bounding box. - valid = ~np.all(xy == 0, axis=2) # (N, M) + # [0, 0] is used by some frameworks to indicate a missing keypoint. Non-finite + # coordinates cannot form a valid detection box, so both cases are excluded. + valid = ~np.all(xy == 0, axis=2) & np.isfinite(xy).all(axis=2) # (N, M) has_valid = valid.any(axis=1) # (N,) x, y = xy[:, :, 0], xy[:, :, 1] @@ -1317,7 +1321,7 @@ class KeyPoints: confidence = self.detection_confidence.astype(np.float32) elif self.keypoint_confidence is not None: keypoint_confidence = self.keypoint_confidence - if selected_keypoint_indices: + if indices is not None: keypoint_confidence = keypoint_confidence[:, indices] confidence = keypoint_confidence.mean(axis=1).astype(np.float32) else: @@ -1326,6 +1330,6 @@ class KeyPoints: detections = Detections(xyxy=xyxy, confidence=confidence) detections.class_id = self.class_id detections.data = self.data - detections = detections.select(cast(Any, detections.area) > 0) + detections = detections.select(has_valid) return detections diff --git a/tests/key_points/test_core.py b/tests/key_points/test_core.py index f0b811e0..7fe544f2 100644 --- a/tests/key_points/test_core.py +++ b/tests/key_points/test_core.py @@ -811,11 +811,118 @@ def test_key_points_as_detections_mixed_valid_invalid_batch(): detections = key_points.as_detections() - # Only the valid skeleton survives the area>0 filter + # Only the skeleton with at least one valid keypoint survives assert len(detections) == 1 assert np.array_equal(detections.xyxy, np.array([[10, 20, 30, 40]])) +def test_key_points_getitem_empty_list(): + """Selecting with an empty list returns an empty KeyPoints, like Detections.""" + key_points = _create_key_points( + xy=[[[1, 2], [3, 4]], [[5, 6], [7, 8]]], + class_id=[0, 1], + ) + + result = key_points[[]] + + assert len(result) == 0 + assert result.is_empty() + + +@pytest.mark.parametrize( + "selected_keypoint_indices", + [ + pytest.param(np.array([0, 1]), id="numpy-array"), + pytest.param(iter([0, 1]), id="generator"), + pytest.param((0, 1), id="tuple"), + ], +) +def test_key_points_as_detections_index_container_types(selected_keypoint_indices): + """selected_keypoint_indices accepts any Iterable[int] container type.""" + key_points = _create_key_points( + xy=[[[10, 10], [20, 20], [30, 15]]], + class_id=[0], + ) + + detections = key_points.as_detections( + selected_keypoint_indices=selected_keypoint_indices, + ) + + assert np.array_equal(detections.xyxy, np.array([[10, 10, 20, 20]])) + + +def test_key_points_as_detections_empty_indices_selects_all(): + """An empty selected_keypoint_indices behaves like None (selects all).""" + key_points = _create_key_points( + xy=[[[10, 10], [20, 20], [30, 15]]], + confidence=[[0.1, 0.3, 0.5]], + class_id=[0], + detection_confidence=[0.9], + ) + key_points["custom_data"] = ["person"] + + all_selected = key_points.as_detections() + empty_list_selected = key_points.as_detections(selected_keypoint_indices=[]) + + assert np.array_equal(all_selected.xyxy, empty_list_selected.xyxy) + assert np.array_equal(all_selected.confidence, empty_list_selected.confidence) + assert np.array_equal(all_selected.class_id, empty_list_selected.class_id) + assert np.array_equal( + all_selected.data["custom_data"], empty_list_selected.data["custom_data"] + ) + + +@pytest.mark.parametrize( + ("xy", "expected_xyxy"), + [ + pytest.param( + [[[10, 20], [30, 20]]], + np.array([[10, 20, 30, 20]]), + id="collinear-keypoints", + ), + pytest.param( + [[[15, 25]]], + np.array([[15, 25, 15, 25]]), + id="single-keypoint", + ), + ], +) +def test_key_points_as_detections_keeps_degenerate_skeletons(xy, expected_xyxy): + """A skeleton with valid keypoints keeps its box even when the area is zero.""" + key_points = _create_key_points(xy=xy, class_id=[0]) + + detections = key_points.as_detections() + + assert len(detections) == 1 + assert np.array_equal(detections.xyxy, expected_xyxy) + + +def test_key_points_as_detections_filters_invalid_and_aligns_degenerate_metadata(): + """Invalid skeletons are removed while valid degenerate metadata stays aligned.""" + key_points = _create_key_points( + xy=[ + [[0, 0], [0, 0]], + [[np.nan, 5], [0, 0]], + [[np.inf, 5], [0, 0]], + [[15, 25], [np.nan, 50]], + [[10, 20], [30, 20]], + ], + class_id=[0, 1, 2, 3, 4], + detection_confidence=[0.1, 0.2, 0.3, 0.4, 0.5], + ) + key_points["custom_data"] = ["zero", "nan", "inf", "point", "line"] + + detections = key_points.as_detections() + + assert np.array_equal( + detections.xyxy, + np.array([[15, 25, 15, 25], [10, 20, 30, 20]], dtype=np.float32), + ) + assert np.array_equal(detections.class_id, np.array([3, 4])) + assert np.array_equal(detections.confidence, np.array([0.4, 0.5], dtype=np.float32)) + assert np.array_equal(detections.data["custom_data"], np.array(["point", "line"])) + + def test_key_points_as_detections_with_data(): """Test the as_detections method preserves data.""" key_points = _create_key_points(