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::*;
|
||||
|
||||
mod bindings {
|
||||
pub mod keypoint;
|
||||
}
|
||||
|
||||
/// Version of the bound Rust core. Surfaced to Python as
|
||||
/// `wifi_densepose.__rust_version__` so users can correlate wheel
|
||||
/// 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?".
|
||||
fn build_features() -> Vec<&'static str> {
|
||||
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("p2-keypoint-bindings"); // Keypoint + KeypointType
|
||||
feats
|
||||
}
|
||||
|
||||
|
|
@ -60,5 +64,8 @@ fn wifi_densepose_native(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|||
m.add("__rust_build_tag__", RUST_BUILD_TAG)?;
|
||||
m.add("__build_features__", build_features())?;
|
||||
m.add_function(wrap_pyfunction!(hello, m)?)?;
|
||||
|
||||
// P2 — Keypoint + KeypointType bindings.
|
||||
bindings::keypoint::register(m)?;
|
||||
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.
|
||||
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__
|
||||
"""Version of the bound Rust core. Useful for bug reports."""
|
||||
|
||||
|
|
@ -63,4 +71,7 @@ __all__ = [
|
|||
"__rust_build_tag__",
|
||||
"__build_features__",
|
||||
"hello",
|
||||
# P2 — core types
|
||||
"Keypoint",
|
||||
"KeypointType",
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in New Issue