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:
parent
4ec5b166e6
commit
fd0568caa1
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Python build/install artifacts
|
||||||
|
target/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyd
|
||||||
|
*.so
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Maturin develop produces .pyd extensions in wifi_densepose/
|
||||||
|
wifi_densepose/*.pyd
|
||||||
|
wifi_densepose/*.so
|
||||||
|
wifi_densepose/_native.abi3.*
|
||||||
|
|
||||||
|
# Local build wheels
|
||||||
|
dist/
|
||||||
|
wheelhouse/
|
||||||
|
*.egg-info/
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,10 @@
|
||||||
|
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
|
mod bindings {
|
||||||
|
pub mod keypoint;
|
||||||
|
}
|
||||||
|
|
||||||
/// Version of the bound Rust core. Surfaced to Python as
|
/// Version of the bound Rust core. Surfaced to Python as
|
||||||
/// `wifi_densepose.__rust_version__` so users can correlate wheel
|
/// `wifi_densepose.__rust_version__` so users can correlate wheel
|
||||||
/// behaviour with the exact `v2/crates/` HEAD it was built from.
|
/// behaviour with the exact `v2/crates/` HEAD it was built from.
|
||||||
|
|
@ -30,8 +34,8 @@ const RUST_BUILD_TAG: &str = env!("CARGO_PKG_VERSION");
|
||||||
/// time. Helps users debug "is my wheel the slim one or the full one?".
|
/// time. Helps users debug "is my wheel the slim one or the full one?".
|
||||||
fn build_features() -> Vec<&'static str> {
|
fn build_features() -> Vec<&'static str> {
|
||||||
let mut feats: Vec<&'static str> = Vec::new();
|
let mut feats: Vec<&'static str> = Vec::new();
|
||||||
// P2 will turn this into a real cfg-driven list as features land.
|
|
||||||
feats.push("p1-scaffold");
|
feats.push("p1-scaffold");
|
||||||
|
feats.push("p2-keypoint-bindings"); // Keypoint + KeypointType
|
||||||
feats
|
feats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,5 +64,8 @@ fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||||
m.add("__rust_build_tag__", RUST_BUILD_TAG)?;
|
m.add("__rust_build_tag__", RUST_BUILD_TAG)?;
|
||||||
m.add("__build_features__", build_features())?;
|
m.add("__build_features__", build_features())?;
|
||||||
m.add_function(wrap_pyfunction!(hello, m)?)?;
|
m.add_function(wrap_pyfunction!(hello, m)?)?;
|
||||||
|
|
||||||
|
// P2 — Keypoint + KeypointType bindings.
|
||||||
|
bindings::keypoint::register(m)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
"""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__
|
||||||
|
|
@ -35,6 +35,14 @@ __version__ = "2.0.0a1"
|
||||||
# Users always import from `wifi_densepose` directly.
|
# Users always import from `wifi_densepose` directly.
|
||||||
from wifi_densepose import _native
|
from wifi_densepose import _native
|
||||||
|
|
||||||
|
# ─── P2 — Core type re-exports ───────────────────────────────────────
|
||||||
|
# Bound types land in `wifi_densepose._native` and are re-exported here
|
||||||
|
# under their stable public names. Users always `from wifi_densepose
|
||||||
|
# import Keypoint, KeypointType` — never reach into `_native`.
|
||||||
|
Keypoint = _native.Keypoint
|
||||||
|
KeypointType = _native.KeypointType
|
||||||
|
|
||||||
|
|
||||||
__rust_version__: str = _native.__rust_version__
|
__rust_version__: str = _native.__rust_version__
|
||||||
"""Version of the bound Rust core. Useful for bug reports."""
|
"""Version of the bound Rust core. Useful for bug reports."""
|
||||||
|
|
||||||
|
|
@ -63,4 +71,7 @@ __all__ = [
|
||||||
"__rust_build_tag__",
|
"__rust_build_tag__",
|
||||||
"__build_features__",
|
"__build_features__",
|
||||||
"hello",
|
"hello",
|
||||||
|
# P2 — core types
|
||||||
|
"Keypoint",
|
||||||
|
"KeypointType",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue