Skip to content

Commit b3a1b96

Browse files
aswanth-07Borda
andcommitted
fix(key_points): render three-coordinate points using x and y (#2759)
* fix(key_points): Render three-coordinate points * fix(key_points): Check x/y for edges and labels --------- Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
1 parent a1aaef1 commit b3a1b96

3 files changed

Lines changed: 92 additions & 28 deletions

File tree

‎docs/changelog.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ date_modified: 2026-10-16
77

88
### Unreleased <small>upcoming</small>
99

10+
- Keypoint annotators use x/y when drawing accepted three-coordinate keypoints. Vertex and ellipse drawing no longer raises a tuple-unpacking error, and edge and label visibility checks ignore the extra coordinate. Rendering matches equivalent two-coordinate input, including missing-point and explicit-visibility handling. ([#2759](https://github.com/roboflow/supervision/pull/2759))
11+
1012
- `sv.metrics.MeanAverageRecall` now follows COCO's last-target tie rule when multiple eligible targets have the same IoU. This preserves confidence-ordered matching and the crowd/ignore handling added in #2750. ([#2758](https://github.com/roboflow/supervision/pull/2758))
1113

1214
- `sv.MaskAnnotator` and `sv.HaloAnnotator` now annotate grayscale scenes, shaped `(H, W)` or `(H, W, 1)` or given as a Pillow `"L"` image, instead of raising `ValueError`. Masks and halos take the first (blue) channel of their color, the gray value OpenCV writes for boxes and polygons on the same scene. ([#2755](https://github.com/roboflow/supervision/pull/2755))

‎src/supervision/key_points/annotators.py‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
8282
Args:
8383
scene: The image where skeleton vertices will be drawn. `ImageType` is a
8484
flexible type, accepting either `numpy.ndarray` or `PIL.Image.Image`.
85-
key_points: A collection of key points where each key point consists of x
86-
and y coordinates.
85+
key_points: A collection of key points. Only the first two coordinate
86+
channels, x and y, determine pixel positions.
8787
8888
Returns:
8989
The annotated image, matching the type of `scene` (`numpy.ndarray`
@@ -116,7 +116,7 @@ def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
116116
return scene
117117

118118
for detection_index, xy in enumerate(key_points.xy):
119-
for point_index, (x, y) in enumerate(xy):
119+
for point_index, (x, y) in enumerate(xy[:, :2]):
120120
if np.allclose((x, y), 0) or not _has_finite_coordinates((x, y)):
121121
continue
122122
if (
@@ -169,8 +169,8 @@ def __init__(
169169
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
170170
"""Annotates the given scene by drawing lines between specified key points to
171171
form edges. Edges where either endpoint is marked as not visible via
172-
``key_points.visible``, or where either endpoint has a non-finite coordinate,
173-
are skipped.
172+
``key_points.visible``, or where either endpoint has a non-finite x/y
173+
coordinate, are skipped. An extra coordinate does not affect drawing.
174174
175175
Args:
176176
scene: The image where skeleton edges will be drawn. `ImageType` is a
@@ -265,8 +265,8 @@ def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
265265

266266
for edge in edges:
267267
idx_a, idx_b = _validate_edge_indices(edge=edge, vertex_count=len(xy))
268-
xy_a = xy[idx_a]
269-
xy_b = xy[idx_b]
268+
xy_a = xy[idx_a, :2]
269+
xy_b = xy[idx_b, :2]
270270
if np.allclose(xy_a, 0) or np.allclose(xy_b, 0):
271271
continue
272272
if not _has_finite_coordinates(xy_a) or not _has_finite_coordinates(
@@ -363,13 +363,13 @@ def _decompose_covariance(
363363
def _iter_ellipse_params(
364364
self, key_points: KeyPoints
365365
) -> list[list[tuple[tuple[int, int], tuple[int, int], float, float, Color]]]:
366-
"""Return ellipse params grouped by sigma level (outermost first)."""
366+
"""Return image-space ellipse params, using x/y, outermost level first."""
367367
covariances = self._get_covariances(key_points)
368368
levels: list[
369369
list[tuple[tuple[int, int], tuple[int, int], float, float, Color]]
370370
] = [[] for _ in self.sigma]
371371
for detection_index, xy in enumerate(key_points.xy):
372-
for point_index, (x, y) in enumerate(xy):
372+
for point_index, (x, y) in enumerate(xy[:, :2]):
373373
if np.allclose((x, y), 0) or not _has_finite_coordinates((x, y)):
374374
continue
375375
if (
@@ -886,7 +886,7 @@ def annotate(
886886
all_text_colors: list[Color] = []
887887

888888
for i in range(skeletons_count):
889-
xy = key_points.xy[i]
889+
xy = key_points.xy[i, :, :2]
890890

891891
class_id = (
892892
int(key_points.class_id[i]) if key_points.class_id is not None else None

‎tests/key_points/test_annotators.py‎

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,80 @@
66
from tests.helpers import assert_image_mostly_same
77

88

9+
class TestThreeCoordinateKeyPointAnnotators:
10+
@pytest.mark.parametrize(
11+
"annotator_name", ["EdgeAnnotator", "VertexLabelAnnotator"]
12+
)
13+
@pytest.mark.parametrize("dtype", ["float32", "float64"])
14+
@pytest.mark.parametrize("depth", [0.5, np.nan, np.inf, -np.inf])
15+
@pytest.mark.parametrize("visible", [None, True, False])
16+
def test_depth_does_not_change_xy_checks(
17+
self, annotator_name: str, dtype: str, depth: float, visible: bool | None
18+
) -> None:
19+
"""Depth preserves x/y validity, missing points and explicit visibility."""
20+
scene = np.zeros((64, 64, 3), dtype=np.uint8)
21+
xy = np.array([[[0, 0], [25, 30], [50, 45], [np.nan, 30]]], dtype=dtype)
22+
xyz = np.concatenate([xy, np.full((1, 4, 1), depth, dtype=dtype)], axis=2)
23+
visibility = None if visible is None else np.full((1, 4), visible)
24+
points = sv.KeyPoints(xy=xyz.copy(), visible=visibility)
25+
reference = sv.KeyPoints(xy=xy.copy(), visible=visibility)
26+
annotator = (
27+
sv.EdgeAnnotator(edges=[(1, 2), (2, 3), (3, 4)])
28+
if annotator_name == "EdgeAnnotator"
29+
else sv.VertexLabelAnnotator()
30+
)
31+
expected = annotator.annotate(scene.copy(), reference)
32+
33+
actual = annotator.annotate(scene.copy(), points)
34+
35+
np.testing.assert_array_equal(actual, expected)
36+
assert bool(np.any(actual)) is (visible is not False)
37+
np.testing.assert_array_equal(points.xy, xyz)
38+
if visible is None:
39+
assert points.visible is None
40+
else:
41+
np.testing.assert_array_equal(points.visible, np.full((1, 4), visible))
42+
43+
@pytest.mark.parametrize(
44+
"annotator_name",
45+
[
46+
"VertexAnnotator",
47+
"VertexEllipseAreaAnnotator",
48+
"VertexEllipseOutlineAnnotator",
49+
"VertexEllipseHaloAnnotator",
50+
],
51+
)
52+
@pytest.mark.parametrize("dtype", ["float32", "float64"])
53+
@pytest.mark.parametrize("hidden", [False, True])
54+
def test_matches_two_coordinate_rendering(
55+
self, annotator_name: str, dtype: str, hidden: bool
56+
) -> None:
57+
"""An extra coordinate leaves pixel rendering and visibility unchanged."""
58+
scene = np.zeros((64, 64, 3), dtype=np.uint8)
59+
xy = np.array([[[25.0, 30.0], [0.0, 0.0], [np.nan, np.nan]]], dtype=dtype)
60+
xyz = np.concatenate([xy, np.full((1, 3, 1), 0.2, dtype=dtype)], axis=2)
61+
visible = np.array([[not hidden, True, True]])
62+
covariance = np.tile(np.eye(2, dtype=np.float32) * 9, (1, 3, 1, 1))
63+
points = sv.KeyPoints(
64+
xy=xyz.copy(),
65+
visible=visible.copy(),
66+
data={"covariance": covariance.copy()},
67+
)
68+
reference = sv.KeyPoints(
69+
xy=xy, visible=visible, data={"covariance": covariance}
70+
)
71+
annotator = getattr(sv, annotator_name)()
72+
expected = annotator.annotate(scene.copy(), reference)
73+
74+
actual = annotator.annotate(scene.copy(), points)
75+
76+
np.testing.assert_array_equal(actual, expected)
77+
assert bool(np.any(actual)) is not hidden
78+
np.testing.assert_array_equal(points.xy, xyz)
79+
np.testing.assert_array_equal(points.visible, visible)
80+
np.testing.assert_array_equal(points.data["covariance"], covariance)
81+
82+
983
class TestVertexAnnotator:
1084
"""Verify that VertexAnnotator correctly draws keypoints on an image.
1185
@@ -245,20 +319,14 @@ def test_non_finite_endpoint_does_not_hide_finite_edges(self, scene):
245319
result = annotator.annotate(scene=scene.copy(), key_points=key_points)
246320
assert not np.array_equal(result, scene)
247321

248-
def test_non_finite_z_skips_edge_despite_finite_xy(self, scene):
249-
"""A finite (x, y) pair with a non-finite z component is skipped.
250-
251-
Pins the current full-row finiteness rule, which matches
252-
`KeyPoints.as_detections`: a non-finite z vetoes drawing even though z is never
253-
rasterized by EdgeAnnotator. A future change to only check (x, y) should be a
254-
deliberate decision, not an accidental one.
255-
"""
322+
def test_non_finite_z_does_not_hide_finite_xy(self, scene):
323+
"""Edges use finite x/y coordinates even when depth is non-finite."""
256324
key_points = sv.KeyPoints(
257325
xy=np.array([[[10.0, 10.0, np.nan], [90.0, 90.0, 1.0]]], dtype=np.float32),
258326
)
259327
annotator = sv.EdgeAnnotator(edges=[(1, 2)])
260328
result = annotator.annotate(scene=scene.copy(), key_points=key_points)
261-
assert np.array_equal(result, scene)
329+
assert not np.array_equal(result, scene)
262330

263331
def test_all_non_finite_multi_detection_draws_nothing(self, scene):
264332
"""A fully non-finite, multi-detection, multi-point array draws nothing and does
@@ -634,20 +702,14 @@ def test_non_finite_vertex_skipped_when_visible_is_set(self, scene):
634702
result = annotator.annotate(scene=scene.copy(), key_points=key_points)
635703
assert not np.array_equal(result, scene)
636704

637-
def test_non_finite_z_skips_label_despite_finite_xy(self, scene):
638-
"""A finite (x, y) pair with a non-finite z component is skipped.
639-
640-
Pins the current full-row finiteness rule, which matches
641-
`KeyPoints.as_detections`: a non-finite z vetoes drawing even though z is never
642-
rasterized by VertexLabelAnnotator. A future change to only check (x, y) should
643-
be a deliberate decision, not an accidental one.
644-
"""
705+
def test_non_finite_z_does_not_hide_finite_xy(self, scene):
706+
"""Labels use finite x/y coordinates even when depth is non-finite."""
645707
key_points = sv.KeyPoints(
646708
xy=np.array([[[50.0, 50.0, np.nan]]], dtype=np.float32),
647709
)
648710
annotator = sv.VertexLabelAnnotator()
649711
result = annotator.annotate(scene=scene.copy(), key_points=key_points)
650-
assert np.array_equal(result, scene)
712+
assert not np.array_equal(result, scene)
651713

652714
def test_all_non_finite_multi_detection_draws_nothing(self, scene):
653715
"""A fully non-finite, multi-detection, multi-point array draws nothing and does

0 commit comments

Comments
 (0)