fix(key_points): handle empty and numpy index input, keep degenerate skeletons (#2402)

* handle empty and numpy index input, keep degenerate skeletons

- Filter non-finite keypoint coordinates when converting to detections while preserving finite zero-area skeletons.
- Treat zero-length KeyPoints selections as empty and add regression coverage for metadata alignment and selected-index equivalence.

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Abhijith Neil Abraham 2026-07-06 15:02:38 -07:00 committed by GitHub
parent c3413a8f10
commit 5b4c8b6d0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 122 additions and 10 deletions

View File

@ -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.

View File

@ -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

View File

@ -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(