mirror of
https://github.com/ruvnet/RuView
synced 2026-06-14 11:03:18 +00:00
fd0568caa1
Lands the first chunk of P2: PyO3 bindings for `Keypoint` and
`KeypointType` from `wifi_densepose_core`. Bound types surface to
Python as `wifi_densepose.Keypoint` / `wifi_densepose.KeypointType`.
## Design choices that affect the API surface
1. **`Confidence` is NOT bound as a separate class.** Users hate
wrapping a float in a constructor. Python-side, confidence is just
a `float in [0.0, 1.0]`; the binding validates on construction
(`ValueError` for out-of-range, matching the Rust core error).
2. **`KeypointType` is a `#[pyclass(eq, eq_int, hash, frozen)]` enum**
— hashable so users can drop it into dicts/sets (the most common
pattern in pose-analysis notebooks: `keypoints_by_type[k.type] = k`).
3. **`Keypoint.__init__` keyword-only `z`** so 2D users don't have to
write `None` and 3D users get a clear named arg:
`Keypoint(KeypointType.LeftWrist, 0.2, 0.4, 0.8, z=0.1)`.
4. **`Keypoint` is `#[pyclass(frozen)]`** — no in-place mutation. The
Rust core type is immutable through Copy + Hash + Eq, and exposing
setters from Python would create a copy-vs-reference inconsistency
between languages.
## Files
- `python/src/bindings/keypoint.rs` — 220 lines of `#[pymethods]`
wrappers + Rust↔Python enum round-trip
- `python/src/lib.rs` — `mod bindings { pub mod keypoint; }` +
`bindings::keypoint::register(m)?` call from `#[pymodule]`
- `python/wifi_densepose/__init__.py` — re-exports `Keypoint` and
`KeypointType` at the package root
- `python/tests/test_keypoint.py` — 23 tests covering:
- 17-element COCO ordering of `KeypointType.all()`
- index→type mapping for every variant
- snake_name matches COCO spec
- `is_face()` / `is_upper_body()` predicates
- hashability (the bug I caught when I added the set-based face
test — fixed by adding `hash` to the `#[pyclass]` attribute)
- 2D + 3D constructor variants
- position_2d / position_3d tuples
- is_visible threshold
- confidence validation (Err on out-of-range)
- distance_to (2D Euclidean, 3D Euclidean, fallback when one is 2D
and the other is 3D)
- __repr__ + __eq__
- the new `p2-keypoint-bindings` feature marker landed
## Local validation
\`\`\`
$ cd python && .venv/Scripts/python -m pytest tests/ -v
tests/test_smoke.py::test_package_imports PASSED
tests/test_smoke.py::test_version_string_well_formed PASSED
tests/test_smoke.py::test_rust_version_surfaced PASSED
tests/test_smoke.py::test_build_features_listed PASSED
tests/test_smoke.py::test_hello_returns_ok PASSED
tests/test_smoke.py::test_native_module_private PASSED
tests/test_keypoint.py::test_keypoint_type_all_returns_17 PASSED
…
======================== 29 passed in 0.06s =========================
\`\`\`
Wheel size after both bindings: still well under the 5 MB ADR §5.4
budget (release build with --strip on Windows: ~340 KB).
Also adds `python/.gitignore` to prevent the `.venv/` + `target/` +
`_native.abi3.pyd` artifacts from getting committed.
## What's left in P2
CsiFrame + PoseEstimate bindings land in the next iteration. They're
larger (CsiFrame has the subcarrier buffer; PoseEstimate has
17×Keypoint + BoundingBox + track_id + score). Pattern is now proven
so they go faster.
Refs #785, ADR-117 §6.
Co-Authored-By: claude-flow <ruv@ruv.net>
201 lines
6.6 KiB
Python
201 lines
6.6 KiB
Python
"""ADR-117 P2 tests — Keypoint + KeypointType binding round-trips.
|
|
|
|
Run with: cd python && .venv/Scripts/python -m pytest tests/test_keypoint.py -v
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from wifi_densepose import Keypoint, KeypointType
|
|
|
|
|
|
# ─── KeypointType ────────────────────────────────────────────────────
|
|
|
|
|
|
def test_keypoint_type_all_returns_17() -> None:
|
|
"""COCO standard defines exactly 17 keypoints."""
|
|
assert len(KeypointType.all()) == 17
|
|
|
|
|
|
def test_keypoint_type_index_matches_coco_ordering() -> None:
|
|
"""Indexes 0..16 match the COCO canonical ordering."""
|
|
expected = [
|
|
(KeypointType.Nose, 0),
|
|
(KeypointType.LeftEye, 1),
|
|
(KeypointType.RightEye, 2),
|
|
(KeypointType.LeftEar, 3),
|
|
(KeypointType.RightEar, 4),
|
|
(KeypointType.LeftShoulder, 5),
|
|
(KeypointType.RightShoulder, 6),
|
|
(KeypointType.LeftElbow, 7),
|
|
(KeypointType.RightElbow, 8),
|
|
(KeypointType.LeftWrist, 9),
|
|
(KeypointType.RightWrist, 10),
|
|
(KeypointType.LeftHip, 11),
|
|
(KeypointType.RightHip, 12),
|
|
(KeypointType.LeftKnee, 13),
|
|
(KeypointType.RightKnee, 14),
|
|
(KeypointType.LeftAnkle, 15),
|
|
(KeypointType.RightAnkle, 16),
|
|
]
|
|
for kp, idx in expected:
|
|
assert kp.index == idx, f"{kp} expected index {idx} got {kp.index}"
|
|
|
|
|
|
def test_keypoint_type_snake_name() -> None:
|
|
"""snake_name follows COCO convention."""
|
|
assert KeypointType.Nose.snake_name == "nose"
|
|
assert KeypointType.LeftShoulder.snake_name == "left_shoulder"
|
|
assert KeypointType.RightAnkle.snake_name == "right_ankle"
|
|
|
|
|
|
def test_keypoint_type_is_face() -> None:
|
|
"""is_face() matches the 5 facial keypoints."""
|
|
face = {
|
|
KeypointType.Nose,
|
|
KeypointType.LeftEye,
|
|
KeypointType.RightEye,
|
|
KeypointType.LeftEar,
|
|
KeypointType.RightEar,
|
|
}
|
|
for kp in KeypointType.all():
|
|
assert kp.is_face() == (kp in face)
|
|
|
|
|
|
def test_keypoint_type_is_upper_body() -> None:
|
|
"""is_upper_body() catches shoulders, elbows, wrists."""
|
|
assert KeypointType.LeftShoulder.is_upper_body()
|
|
assert KeypointType.RightShoulder.is_upper_body()
|
|
assert KeypointType.LeftElbow.is_upper_body()
|
|
assert KeypointType.LeftWrist.is_upper_body()
|
|
assert not KeypointType.LeftHip.is_upper_body()
|
|
|
|
|
|
def test_keypoint_type_eq() -> None:
|
|
"""Equality + identity work across calls."""
|
|
assert KeypointType.Nose == KeypointType.Nose
|
|
assert KeypointType.Nose != KeypointType.LeftEye
|
|
|
|
|
|
def test_keypoint_type_repr() -> None:
|
|
"""repr is a useful Python expression."""
|
|
assert repr(KeypointType.Nose) == "KeypointType.Nose"
|
|
assert repr(KeypointType.LeftWrist) == "KeypointType.LeftWrist"
|
|
|
|
|
|
# ─── Keypoint ────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_keypoint_2d_construct() -> None:
|
|
"""Default 2D keypoint."""
|
|
kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
|
assert kp.x == pytest.approx(0.5)
|
|
assert kp.y == pytest.approx(0.3)
|
|
assert kp.z is None
|
|
assert kp.confidence == pytest.approx(0.95)
|
|
assert kp.keypoint_type == KeypointType.Nose
|
|
assert kp.is_visible
|
|
|
|
|
|
def test_keypoint_3d_construct() -> None:
|
|
"""3D keypoint with kwarg z."""
|
|
kp = Keypoint(KeypointType.LeftWrist, 0.2, 0.4, 0.8, z=0.1)
|
|
assert kp.position_3d == pytest.approx((0.2, 0.4, 0.1))
|
|
assert kp.z == pytest.approx(0.1)
|
|
|
|
|
|
def test_keypoint_position_2d_tuple() -> None:
|
|
kp = Keypoint(KeypointType.RightHip, 0.6, 0.7, 0.99)
|
|
assert kp.position_2d == pytest.approx((0.6, 0.7))
|
|
|
|
|
|
def test_keypoint_position_3d_none_for_2d() -> None:
|
|
"""2D keypoints return None for position_3d, not a default z."""
|
|
kp = Keypoint(KeypointType.Nose, 0.5, 0.5, 0.99)
|
|
assert kp.position_3d is None
|
|
|
|
|
|
def test_keypoint_is_visible_below_threshold() -> None:
|
|
"""Confidence under 0.5 is NOT visible (default threshold)."""
|
|
kp_low = Keypoint(KeypointType.Nose, 0.0, 0.0, 0.3)
|
|
kp_high = Keypoint(KeypointType.Nose, 0.0, 0.0, 0.7)
|
|
assert not kp_low.is_visible
|
|
assert kp_high.is_visible
|
|
|
|
|
|
def test_keypoint_confidence_validation_too_high() -> None:
|
|
"""Confidence > 1.0 rejected."""
|
|
with pytest.raises(ValueError, match="Confidence must be in"):
|
|
Keypoint(KeypointType.Nose, 0.0, 0.0, 1.5)
|
|
|
|
|
|
def test_keypoint_confidence_validation_negative() -> None:
|
|
"""Negative confidence rejected."""
|
|
with pytest.raises(ValueError, match="Confidence must be in"):
|
|
Keypoint(KeypointType.Nose, 0.0, 0.0, -0.1)
|
|
|
|
|
|
def test_keypoint_distance_2d() -> None:
|
|
"""Euclidean distance in 2D."""
|
|
a = Keypoint(KeypointType.Nose, 0.0, 0.0, 1.0)
|
|
b = Keypoint(KeypointType.LeftEye, 3.0, 4.0, 1.0)
|
|
assert a.distance_to(b) == pytest.approx(5.0)
|
|
|
|
|
|
def test_keypoint_distance_3d() -> None:
|
|
"""Euclidean distance in 3D when both have z."""
|
|
a = Keypoint(KeypointType.Nose, 0.0, 0.0, 1.0, z=0.0)
|
|
b = Keypoint(KeypointType.LeftEye, 1.0, 2.0, 1.0, z=2.0)
|
|
# sqrt(1 + 4 + 4) = 3.0
|
|
assert a.distance_to(b) == pytest.approx(3.0)
|
|
|
|
|
|
def test_keypoint_distance_falls_back_to_2d_if_mixed() -> None:
|
|
"""Mixing 2D and 3D keypoints uses 2D distance only."""
|
|
a = Keypoint(KeypointType.Nose, 0.0, 0.0, 1.0) # 2D
|
|
b = Keypoint(KeypointType.LeftEye, 3.0, 4.0, 1.0, z=99.0) # 3D
|
|
# Should be 5.0 (2D distance), not include the z=99 term
|
|
assert a.distance_to(b) == pytest.approx(5.0)
|
|
|
|
|
|
def test_keypoint_repr_2d() -> None:
|
|
kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
|
r = repr(kp)
|
|
assert "KeypointType.Nose" in r
|
|
assert "x=0.5" in r
|
|
assert "y=0.3" in r
|
|
assert "z" not in r # no z field for 2D
|
|
|
|
|
|
def test_keypoint_repr_3d() -> None:
|
|
kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95, z=0.1)
|
|
r = repr(kp)
|
|
assert "z=0.1" in r
|
|
|
|
|
|
def test_keypoint_eq() -> None:
|
|
"""Two keypoints with same fields compare equal."""
|
|
a = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
|
b = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
|
assert a == b
|
|
|
|
|
|
def test_keypoint_neq_different_type() -> None:
|
|
a = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
|
b = Keypoint(KeypointType.LeftEye, 0.5, 0.3, 0.95)
|
|
assert a != b
|
|
|
|
|
|
def test_keypoint_neq_different_position() -> None:
|
|
a = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
|
b = Keypoint(KeypointType.Nose, 0.6, 0.3, 0.95)
|
|
assert a != b
|
|
|
|
|
|
def test_build_features_marks_p2() -> None:
|
|
"""The P2 marker is now in the wheel's feature list."""
|
|
import wifi_densepose
|
|
|
|
assert "p2-keypoint-bindings" in wifi_densepose.__build_features__
|