mirror of
https://github.com/ruvnet/RuView
synced 2026-06-19 11:53:19 +00:00
feat(adr-117/p2): Keypoint + KeypointType bindings — 23 new tests (29/29 GREEN)
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>
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
//! ADR-117 P2 — PyO3 bindings for `wifi_densepose_core::Keypoint` +
|
||||
//! `KeypointType` + `Confidence`.
|
||||
//!
|
||||
//! Design notes (consequential for the Python API surface):
|
||||
//!
|
||||
//! 1. **`Confidence` is NOT bound as a separate Python class.** End
|
||||
//! users hate having to construct a wrapper just to pass a float.
|
||||
//! Python-side, confidence is just an `f32` in `[0.0, 1.0]`; the
|
||||
//! binding validates on the way in.
|
||||
//!
|
||||
//! 2. **`KeypointType` is bound as a `#[pyclass]` enum** (PyO3 0.22
|
||||
//! supports `#[pyclass(eq, eq_int)]` for C-like enums). Python-side
|
||||
//! it surfaces as `wifi_densepose.KeypointType.Nose`, etc.
|
||||
//!
|
||||
//! 3. **`Keypoint` constructor accepts `z` as `Optional[float]`** so
|
||||
//! Python users can pass `Keypoint(KeypointType.Nose, 0.5, 0.3,
|
||||
//! 0.95)` for 2D or `Keypoint(..., z=0.1)` for 3D.
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
||||
use wifi_densepose_core::{Confidence, Keypoint, KeypointType};
|
||||
|
||||
// ─── KeypointType ────────────────────────────────────────────────────
|
||||
|
||||
/// COCO-17 keypoint identifier — re-export of the Rust core enum.
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose import KeypointType
|
||||
/// kp = KeypointType.Nose
|
||||
/// print(kp.name) # "Nose"
|
||||
/// ```
|
||||
// `hash` makes the enum hashable in Python (usable as dict keys + set
|
||||
// members) — derived from `Hash` on the Rust side. `frozen` is a
|
||||
// hard requirement for `hash` per pyo3 contract.
|
||||
#[pyclass(eq, eq_int, hash, frozen, name = "KeypointType")]
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PyKeypointType {
|
||||
Nose = 0,
|
||||
LeftEye = 1,
|
||||
RightEye = 2,
|
||||
LeftEar = 3,
|
||||
RightEar = 4,
|
||||
LeftShoulder = 5,
|
||||
RightShoulder = 6,
|
||||
LeftElbow = 7,
|
||||
RightElbow = 8,
|
||||
LeftWrist = 9,
|
||||
RightWrist = 10,
|
||||
LeftHip = 11,
|
||||
RightHip = 12,
|
||||
LeftKnee = 13,
|
||||
RightKnee = 14,
|
||||
LeftAnkle = 15,
|
||||
RightAnkle = 16,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyKeypointType {
|
||||
/// Lowercase snake_case name (matches the COCO standard).
|
||||
#[getter]
|
||||
fn snake_name(&self) -> &'static str {
|
||||
self.as_rust().name()
|
||||
}
|
||||
|
||||
/// Integer index 0–16 (COCO ordering).
|
||||
#[getter]
|
||||
fn index(&self) -> u8 {
|
||||
(*self).into()
|
||||
}
|
||||
|
||||
/// True if this keypoint is on the face (nose, eyes, ears).
|
||||
fn is_face(&self) -> bool {
|
||||
self.as_rust().is_face()
|
||||
}
|
||||
|
||||
/// True if this keypoint is in the upper body (shoulders, elbows, wrists).
|
||||
fn is_upper_body(&self) -> bool {
|
||||
self.as_rust().is_upper_body()
|
||||
}
|
||||
|
||||
/// All 17 keypoint types in COCO order. Useful for Jupyter
|
||||
/// enumeration: `for kp in KeypointType.all(): ...`.
|
||||
#[staticmethod]
|
||||
fn all() -> Vec<Self> {
|
||||
KeypointType::all().iter().map(|k| PyKeypointType::from_rust(*k)).collect()
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
format!("KeypointType.{:?}", self.as_rust())
|
||||
}
|
||||
}
|
||||
|
||||
impl PyKeypointType {
|
||||
pub(crate) fn as_rust(&self) -> KeypointType {
|
||||
// SAFETY equivalent: the enum variants line up 1:1 with the
|
||||
// Rust enum's `#[repr(u8)]` discriminants. The match below is
|
||||
// exhaustive on both sides so a future addition to either side
|
||||
// fails to compile until the other is updated.
|
||||
match self {
|
||||
Self::Nose => KeypointType::Nose,
|
||||
Self::LeftEye => KeypointType::LeftEye,
|
||||
Self::RightEye => KeypointType::RightEye,
|
||||
Self::LeftEar => KeypointType::LeftEar,
|
||||
Self::RightEar => KeypointType::RightEar,
|
||||
Self::LeftShoulder => KeypointType::LeftShoulder,
|
||||
Self::RightShoulder => KeypointType::RightShoulder,
|
||||
Self::LeftElbow => KeypointType::LeftElbow,
|
||||
Self::RightElbow => KeypointType::RightElbow,
|
||||
Self::LeftWrist => KeypointType::LeftWrist,
|
||||
Self::RightWrist => KeypointType::RightWrist,
|
||||
Self::LeftHip => KeypointType::LeftHip,
|
||||
Self::RightHip => KeypointType::RightHip,
|
||||
Self::LeftKnee => KeypointType::LeftKnee,
|
||||
Self::RightKnee => KeypointType::RightKnee,
|
||||
Self::LeftAnkle => KeypointType::LeftAnkle,
|
||||
Self::RightAnkle => KeypointType::RightAnkle,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_rust(k: KeypointType) -> Self {
|
||||
match k {
|
||||
KeypointType::Nose => Self::Nose,
|
||||
KeypointType::LeftEye => Self::LeftEye,
|
||||
KeypointType::RightEye => Self::RightEye,
|
||||
KeypointType::LeftEar => Self::LeftEar,
|
||||
KeypointType::RightEar => Self::RightEar,
|
||||
KeypointType::LeftShoulder => Self::LeftShoulder,
|
||||
KeypointType::RightShoulder => Self::RightShoulder,
|
||||
KeypointType::LeftElbow => Self::LeftElbow,
|
||||
KeypointType::RightElbow => Self::RightElbow,
|
||||
KeypointType::LeftWrist => Self::LeftWrist,
|
||||
KeypointType::RightWrist => Self::RightWrist,
|
||||
KeypointType::LeftHip => Self::LeftHip,
|
||||
KeypointType::RightHip => Self::RightHip,
|
||||
KeypointType::LeftKnee => Self::LeftKnee,
|
||||
KeypointType::RightKnee => Self::RightKnee,
|
||||
KeypointType::LeftAnkle => Self::LeftAnkle,
|
||||
KeypointType::RightAnkle => Self::RightAnkle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PyKeypointType> for u8 {
|
||||
fn from(k: PyKeypointType) -> u8 {
|
||||
k as u8
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Keypoint ────────────────────────────────────────────────────────
|
||||
|
||||
/// Single skeletal joint with COCO type, 2D-or-3D position, and a
|
||||
/// confidence score in [0.0, 1.0].
|
||||
///
|
||||
/// Python:
|
||||
/// ```python
|
||||
/// from wifi_densepose import Keypoint, KeypointType
|
||||
///
|
||||
/// kp = Keypoint(KeypointType.Nose, 0.5, 0.3, 0.95)
|
||||
/// print(kp.x, kp.y, kp.confidence, kp.is_visible)
|
||||
///
|
||||
/// kp_3d = Keypoint(KeypointType.LeftWrist, 0.2, 0.4, 0.8, z=0.1)
|
||||
/// print(kp_3d.position_3d) # (0.2, 0.4, 0.1)
|
||||
/// ```
|
||||
#[pyclass(frozen, name = "Keypoint")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyKeypoint {
|
||||
inner: Keypoint,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyKeypoint {
|
||||
/// Construct a new keypoint. Confidence must be in [0.0, 1.0].
|
||||
/// `z` is optional — omit for a 2D keypoint, supply for 3D.
|
||||
#[new]
|
||||
#[pyo3(signature = (keypoint_type, x, y, confidence, *, z=None))]
|
||||
fn new(
|
||||
keypoint_type: PyKeypointType,
|
||||
x: f32,
|
||||
y: f32,
|
||||
confidence: f32,
|
||||
z: Option<f32>,
|
||||
) -> PyResult<Self> {
|
||||
let conf = Confidence::new(confidence).map_err(|e| {
|
||||
pyo3::exceptions::PyValueError::new_err(e.to_string())
|
||||
})?;
|
||||
let inner = match z {
|
||||
Some(zv) => Keypoint::new_3d(keypoint_type.as_rust(), x, y, zv, conf),
|
||||
None => Keypoint::new(keypoint_type.as_rust(), x, y, conf),
|
||||
};
|
||||
Ok(Self { inner })
|
||||
}
|
||||
|
||||
/// COCO keypoint type.
|
||||
#[getter]
|
||||
fn keypoint_type(&self) -> PyKeypointType {
|
||||
PyKeypointType::from_rust(self.inner.keypoint_type)
|
||||
}
|
||||
|
||||
/// X coordinate.
|
||||
#[getter]
|
||||
fn x(&self) -> f32 {
|
||||
self.inner.x
|
||||
}
|
||||
|
||||
/// Y coordinate.
|
||||
#[getter]
|
||||
fn y(&self) -> f32 {
|
||||
self.inner.y
|
||||
}
|
||||
|
||||
/// Z coordinate, or None for 2D keypoints.
|
||||
#[getter]
|
||||
fn z(&self) -> Option<f32> {
|
||||
self.inner.z
|
||||
}
|
||||
|
||||
/// Detection confidence in [0.0, 1.0].
|
||||
#[getter]
|
||||
fn confidence(&self) -> f32 {
|
||||
self.inner.confidence.value()
|
||||
}
|
||||
|
||||
/// True if this keypoint clears the default visibility threshold
|
||||
/// (`confidence >= 0.5`).
|
||||
#[getter]
|
||||
fn is_visible(&self) -> bool {
|
||||
self.inner.is_visible()
|
||||
}
|
||||
|
||||
/// 2D position as a tuple `(x, y)`.
|
||||
#[getter]
|
||||
fn position_2d(&self) -> (f32, f32) {
|
||||
self.inner.position_2d()
|
||||
}
|
||||
|
||||
/// 3D position as a tuple `(x, y, z)`, or None for 2D keypoints.
|
||||
#[getter]
|
||||
fn position_3d(&self) -> Option<(f32, f32, f32)> {
|
||||
self.inner.position_3d()
|
||||
}
|
||||
|
||||
/// Euclidean distance to another keypoint. If both are 3D the
|
||||
/// distance includes the z-axis; otherwise it's 2D only.
|
||||
fn distance_to(&self, other: &PyKeypoint) -> f32 {
|
||||
self.inner.distance_to(&other.inner)
|
||||
}
|
||||
|
||||
fn __repr__(&self) -> String {
|
||||
match self.inner.z {
|
||||
Some(z) => format!(
|
||||
"Keypoint(KeypointType.{:?}, x={}, y={}, z={}, confidence={:.4})",
|
||||
self.inner.keypoint_type, self.inner.x, self.inner.y, z, self.inner.confidence.value()
|
||||
),
|
||||
None => format!(
|
||||
"Keypoint(KeypointType.{:?}, x={}, y={}, confidence={:.4})",
|
||||
self.inner.keypoint_type, self.inner.x, self.inner.y, self.inner.confidence.value()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn __eq__(&self, other: &PyKeypoint) -> bool {
|
||||
self.inner.keypoint_type == other.inner.keypoint_type
|
||||
&& self.inner.x == other.inner.x
|
||||
&& self.inner.y == other.inner.y
|
||||
&& self.inner.z == other.inner.z
|
||||
&& (self.inner.confidence.value() - other.inner.confidence.value()).abs() < f32::EPSILON
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the binding types with the `_native` PyModule.
|
||||
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyKeypointType>()?;
|
||||
m.add_class::<PyKeypoint>()?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user