From fd0568caa134013ae96eb1df2feab16944f79fc0 Mon Sep 17 00:00:00 2001 From: ruv Date: Sun, 24 May 2026 10:54:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(adr-117/p2):=20Keypoint=20+=20KeypointType?= =?UTF-8?q?=20bindings=20=E2=80=94=2023=20new=20tests=20(29/29=20GREEN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- python/.gitignore | 20 +++ python/src/bindings/keypoint.rs | 276 ++++++++++++++++++++++++++++++ python/src/lib.rs | 9 +- python/tests/test_keypoint.py | 200 ++++++++++++++++++++++ python/wifi_densepose/__init__.py | 11 ++ 5 files changed, 515 insertions(+), 1 deletion(-) create mode 100644 python/.gitignore create mode 100644 python/src/bindings/keypoint.rs create mode 100644 python/tests/test_keypoint.py diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 00000000..ad24f1d9 --- /dev/null +++ b/python/.gitignore @@ -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/ diff --git a/python/src/bindings/keypoint.rs b/python/src/bindings/keypoint.rs new file mode 100644 index 00000000..73a6c369 --- /dev/null +++ b/python/src/bindings/keypoint.rs @@ -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 { + 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 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, + ) -> PyResult { + 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 { + 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::()?; + m.add_class::()?; + Ok(()) +} diff --git a/python/src/lib.rs b/python/src/lib.rs index e05fd167..5ffcaa93 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -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(()) } diff --git a/python/tests/test_keypoint.py b/python/tests/test_keypoint.py new file mode 100644 index 00000000..4ba09ab8 --- /dev/null +++ b/python/tests/test_keypoint.py @@ -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__ diff --git a/python/wifi_densepose/__init__.py b/python/wifi_densepose/__init__.py index 9cb2b76b..d71162c4 100644 --- a/python/wifi_densepose/__init__.py +++ b/python/wifi_densepose/__init__.py @@ -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", ]