docs(plans): icp lidar mesh + arkit joints
Two complementary fusion plans landed in parallel on 2026-05-14: - iphone-lidar-multihmr-fusion : ARKit 91 joints -> MP33 fuse stage + pelvis z override (already implemented in 7 commits) - icp-lidar-smplx-fusion : LiDAR mesh point-to-plane ICP onto SMPL-X 10475 verts (12 tasks executed via subagent-driven-development) Both paths coexist; joints are sparse+fast (60 Hz), mesh is dense+slow (5-10 Hz). See docs/ICP_FUSION.md for the integration topology.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,921 @@
|
||||
# iPhone LiDAR → Multi-HMR Fusion Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Wire the iOS ARBodyTracker app's `/body3d/kp` OSC stream into the Python pipeline so ARKit 91-joint LiDAR ground truth fixes Multi-HMR's scale ambiguity and reduces SMPL-X joint jitter via Kalman fusion.
|
||||
|
||||
**Architecture:** A new `iphone_osc_listener.py` worker subscribes to `/body3d/kp` on UDP `:57128` (port distinct from existing `:57126` body output) and writes per-pid 91-joint arrays into `state.persons_arkit_joints`. `pose_filter.py` gains an `arkit_fuse` stage that pulls the ARKit joints (low-noise ground truth) and overrides matching MediaPipe Pose 33 slots before the existing kalman/one_euro chain runs. `multi_hmr_worker.py` post-inference reads the ARKit pelvis world-z and rewrites `pred_cam_t` of the SMPL-X mesh so it lands at the actual depth instead of HaMeR's per-frame guess.
|
||||
|
||||
**Tech Stack:** Python 3.14, python-osc (OSCDispatcher), numpy, threading, pytest. Existing modules: `state.State`, `pose_filter.PoseFilterChain`, `multi_hmr_worker.MultiHMRWorker`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `data_only_viz/iphone_osc_listener.py` | **NEW**. ThreadingOSCUDPServer on `:57128`, routes `/body3d/kp pid joint_idx x y z` → `state.persons_arkit_joints[pid][joint_idx] = (x,y,z)`. GC entries older than 1.0s. |
|
||||
| `data_only_viz/state.py` | **MODIFY**. Add fields `persons_arkit_joints: dict[int, np.ndarray]` (91×3 per pid) + `persons_arkit_last_t: dict[int, float]`. |
|
||||
| `data_only_viz/arkit_joint_map.py` | **NEW**. Constant tuple `ARKIT91_TO_MP33` mapping ARKit joint indices → MediaPipe Pose 33 indices. |
|
||||
| `data_only_viz/pose_filter.py` | **MODIFY**. Add `"arkit_fuse"` to `ALL_STAGES`, add `ArkitFuse` class, splice in `PoseFilterChain.apply` before kalman. |
|
||||
| `data_only_viz/multi_hmr_worker.py` | **MODIFY**. After Multi-HMR inference, if `state.persons_arkit_joints[pid]` is fresh, override `pred_cam_t.z` with ARKit pelvis world-z. |
|
||||
| `data_only_viz/main.py` | **MODIFY**. Start `IphoneOSCListener` in `_start_pose_worker` regardless of any --flag (always-on, harmless if no iPhone). |
|
||||
| `data_only_viz/tests/test_iphone_osc_listener.py` | **NEW**. Unit tests: send fake OSC packets, assert state updated. |
|
||||
| `data_only_viz/tests/test_arkit_fuse.py` | **NEW**. Unit tests: fake state, run PoseFilterChain.apply, assert MP33 slots overwritten. |
|
||||
| `data_only_viz/tests/test_multihmr_arkit_z.py` | **NEW**. Unit test: fake ARKit pelvis z, assert pred_cam_t corrected. |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: State fields for ARKit joints
|
||||
|
||||
**Files:**
|
||||
- Modify: `data_only_viz/state.py` (add fields)
|
||||
- Test: `data_only_viz/tests/test_state_arkit.py` (new)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `data_only_viz/tests/test_state_arkit.py`:
|
||||
|
||||
```python
|
||||
"""State must expose persons_arkit_joints + persons_arkit_last_t."""
|
||||
import numpy as np
|
||||
|
||||
from data_only_viz.state import State
|
||||
|
||||
|
||||
def test_state_has_arkit_joint_fields():
|
||||
s = State()
|
||||
assert hasattr(s, "persons_arkit_joints")
|
||||
assert hasattr(s, "persons_arkit_last_t")
|
||||
assert isinstance(s.persons_arkit_joints, dict)
|
||||
assert isinstance(s.persons_arkit_last_t, dict)
|
||||
|
||||
|
||||
def test_state_arkit_joints_writable_under_lock():
|
||||
s = State()
|
||||
arr = np.zeros((91, 3), dtype=np.float32)
|
||||
with s.lock():
|
||||
s.persons_arkit_joints[0] = arr
|
||||
s.persons_arkit_last_t[0] = 1.5
|
||||
assert 0 in s.persons_arkit_joints
|
||||
assert s.persons_arkit_last_t[0] == 1.5
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_state_arkit.py -v`
|
||||
Expected: FAIL with `AttributeError: 'State' object has no attribute 'persons_arkit_joints'`
|
||||
|
||||
- [ ] **Step 3: Add fields to State**
|
||||
|
||||
Edit `data_only_viz/state.py`. Find the existing line with `persons_hands_mesh_last_t: float = 0.0` (around line 134) and insert below it:
|
||||
|
||||
```python
|
||||
# ARKit body tracking (iOS ARBodyTracker app) : 91 joints world
|
||||
# space per pid. Same units as MediaPipe pose_world_landmarks
|
||||
# (metres, hip-centered). Fresh = updated within < 1 s.
|
||||
persons_arkit_joints: dict = field(default_factory=dict)
|
||||
persons_arkit_last_t: dict = field(default_factory=dict)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_state_arkit.py -v`
|
||||
Expected: PASS (2 passed)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/electron/Documents/Projets/AV-Live
|
||||
git add data_only_viz/state.py data_only_viz/tests/test_state_arkit.py
|
||||
git commit -m "feat(state): add persons_arkit_joints + persons_arkit_last_t"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: ARKit → MediaPipe joint index mapping
|
||||
|
||||
**Files:**
|
||||
- Create: `data_only_viz/arkit_joint_map.py`
|
||||
- Test: `data_only_viz/tests/test_arkit_joint_map.py` (new)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `data_only_viz/tests/test_arkit_joint_map.py`:
|
||||
|
||||
```python
|
||||
"""ARKit 91 joints → MediaPipe Pose 33 mapping integrity."""
|
||||
from data_only_viz.arkit_joint_map import (
|
||||
ARKIT91_TO_MP33, ARKIT_PELVIS_IDX, MP33_NUM_LANDMARKS,
|
||||
)
|
||||
|
||||
|
||||
def test_mapping_is_tuple_of_pairs():
|
||||
assert isinstance(ARKIT91_TO_MP33, tuple)
|
||||
assert len(ARKIT91_TO_MP33) > 0
|
||||
for pair in ARKIT91_TO_MP33:
|
||||
assert isinstance(pair, tuple)
|
||||
assert len(pair) == 2
|
||||
|
||||
|
||||
def test_mapping_indices_in_range():
|
||||
for arkit_idx, mp33_idx in ARKIT91_TO_MP33:
|
||||
assert 0 <= arkit_idx < 91, f"arkit idx out of range: {arkit_idx}"
|
||||
assert 0 <= mp33_idx < MP33_NUM_LANDMARKS, \
|
||||
f"mp33 idx out of range: {mp33_idx}"
|
||||
|
||||
|
||||
def test_pelvis_index_valid():
|
||||
assert 0 <= ARKIT_PELVIS_IDX < 91
|
||||
|
||||
|
||||
def test_no_duplicate_mp33_targets():
|
||||
"""Each MediaPipe slot must be written by at most one ARKit joint."""
|
||||
mp33_seen = set()
|
||||
for _, mp33_idx in ARKIT91_TO_MP33:
|
||||
assert mp33_idx not in mp33_seen, \
|
||||
f"mp33 slot {mp33_idx} mapped twice"
|
||||
mp33_seen.add(mp33_idx)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_arkit_joint_map.py -v`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'data_only_viz.arkit_joint_map'`
|
||||
|
||||
- [ ] **Step 3: Create the mapping module**
|
||||
|
||||
Create `data_only_viz/arkit_joint_map.py`:
|
||||
|
||||
```python
|
||||
"""ARKit ARSkeleton3D 91-joint indices → MediaPipe Pose 33 indices.
|
||||
|
||||
The ARKit ARSkeleton.JointName enum (Apple SDK) orders 91 joints
|
||||
starting with the root, hips, spine chain, shoulders, etc. We pick
|
||||
only the joints with a clear 1:1 anatomical correspondence to the
|
||||
MediaPipe Pose 33 landmark set (which is what AVLiveBody renders).
|
||||
Face/hand sub-joints (fingers, eyes) are skipped — those keep their
|
||||
existing data sources (MediaPipe Face/Hand + HaMeR MANO).
|
||||
|
||||
Reference for ARKit joint order : Apple developer docs
|
||||
"ARSkeleton.JointName" — the canonical 91-joint list runs from
|
||||
root_joint=0 down to right_handThumbEndJoint=90.
|
||||
|
||||
The selection here mirrors `multi.py::SMPLX_TO_MP33` so the same 14
|
||||
body slots are overridden by ARKit when fresh. Confidence comes
|
||||
from ARKit's tracking state but is not currently fanned out — we
|
||||
trust ARKit body tracking when its OSC frame is present.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# MediaPipe Pose 33 cardinality (cf. mediapipe pose_world_landmarks).
|
||||
MP33_NUM_LANDMARKS = 33
|
||||
|
||||
# Pelvis = ARKit hips_joint, slot 1 in the canonical enum order.
|
||||
# Used by multi_hmr_worker for cam-translation z lock.
|
||||
ARKIT_PELVIS_IDX = 1
|
||||
|
||||
# (arkit_joint_idx, mediapipe_pose_idx). Match the body slots used
|
||||
# by the SMPL-X body fusion in multi.py.
|
||||
ARKIT91_TO_MP33: tuple[tuple[int, int], ...] = (
|
||||
(50, 11), # left_shoulder_1_joint -> L_SHOULDER
|
||||
(32, 12), # right_shoulder_1_joint -> R_SHOULDER
|
||||
(53, 13), # left_arm_joint -> L_ELBOW
|
||||
(35, 14), # right_arm_joint -> R_ELBOW
|
||||
(54, 15), # left_forearm_joint -> L_WRIST
|
||||
(36, 16), # right_forearm_joint -> R_WRIST
|
||||
(62, 23), # left_upLeg_joint -> L_HIP
|
||||
(57, 24), # right_upLeg_joint -> R_HIP
|
||||
(63, 25), # left_leg_joint -> L_KNEE
|
||||
(58, 26), # right_leg_joint -> R_KNEE
|
||||
(64, 27), # left_foot_joint -> L_ANKLE
|
||||
(59, 28), # right_foot_joint -> R_ANKLE
|
||||
(65, 31), # left_toes_joint -> L_FOOT_INDEX
|
||||
(60, 32), # right_toes_joint -> R_FOOT_INDEX
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_arkit_joint_map.py -v`
|
||||
Expected: PASS (4 passed)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add data_only_viz/arkit_joint_map.py data_only_viz/tests/test_arkit_joint_map.py
|
||||
git commit -m "feat(viz): arkit 91-joint -> mediapipe 33 mapping"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: iPhone OSC listener worker
|
||||
|
||||
**Files:**
|
||||
- Create: `data_only_viz/iphone_osc_listener.py`
|
||||
- Test: `data_only_viz/tests/test_iphone_osc_listener.py` (new)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `data_only_viz/tests/test_iphone_osc_listener.py`:
|
||||
|
||||
```python
|
||||
"""IphoneOSCListener writes ARKit joints to state from OSC packets."""
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pythonosc.udp_client import SimpleUDPClient
|
||||
|
||||
from data_only_viz.state import State
|
||||
from data_only_viz.iphone_osc_listener import (
|
||||
IphoneOSCListener, IPHONE_OSC_PORT,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def listener():
|
||||
state = State()
|
||||
listener = IphoneOSCListener(state, port=IPHONE_OSC_PORT + 100)
|
||||
listener.start()
|
||||
yield state, listener
|
||||
listener.stop()
|
||||
|
||||
|
||||
def test_kp_message_updates_state(listener):
|
||||
state, lst = listener
|
||||
client = SimpleUDPClient("127.0.0.1", lst.port)
|
||||
client.send_message("/body3d/kp", [0, 1, 0.1, 0.2, 0.3])
|
||||
# Settle
|
||||
deadline = time.monotonic() + 1.0
|
||||
while time.monotonic() < deadline:
|
||||
with state.lock():
|
||||
if 0 in state.persons_arkit_joints:
|
||||
arr = state.persons_arkit_joints[0]
|
||||
if arr[1, 0] != 0.0:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
with state.lock():
|
||||
arr = state.persons_arkit_joints[0]
|
||||
assert arr.shape == (91, 3)
|
||||
assert np.allclose(arr[1], [0.1, 0.2, 0.3])
|
||||
|
||||
|
||||
def test_gc_drops_stale_pids(listener):
|
||||
state, lst = listener
|
||||
with state.lock():
|
||||
state.persons_arkit_joints[7] = np.zeros((91, 3), dtype=np.float32)
|
||||
state.persons_arkit_last_t[7] = time.perf_counter() - 5.0
|
||||
lst._gc_stale()
|
||||
with state.lock():
|
||||
assert 7 not in state.persons_arkit_joints
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_iphone_osc_listener.py -v`
|
||||
Expected: FAIL with `ModuleNotFoundError: No module named 'data_only_viz.iphone_osc_listener'`
|
||||
|
||||
- [ ] **Step 3: Implement listener**
|
||||
|
||||
Create `data_only_viz/iphone_osc_listener.py`:
|
||||
|
||||
```python
|
||||
"""OSC UDP listener for the iOS ARBodyTracker app.
|
||||
|
||||
Subscribes to /body3d/kp on UDP :57128 (distinct from MediaPipe
|
||||
output :57126). Each /body3d/kp pid joint_idx x y z message stores
|
||||
one joint of ARKit's 91-joint ARSkeleton3D into
|
||||
state.persons_arkit_joints[pid] (np.ndarray shape (91, 3), float32).
|
||||
A background GC drops pids whose last_t is older than 1.0 s.
|
||||
|
||||
Worker pattern mirrors osc_listener.OscListener.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from pythonosc import dispatcher, osc_server
|
||||
|
||||
from .state import State
|
||||
|
||||
LOG = logging.getLogger("iphone_osc")
|
||||
|
||||
IPHONE_OSC_PORT = 57128
|
||||
ARKIT_NUM_JOINTS = 91
|
||||
STALE_SEC = 1.0
|
||||
|
||||
|
||||
class IphoneOSCListener:
|
||||
def __init__(self, state: State, host: str = "0.0.0.0",
|
||||
port: int = IPHONE_OSC_PORT) -> None:
|
||||
self.state = state
|
||||
self.host = host
|
||||
self.port = port
|
||||
self._server: osc_server.ThreadingOSCUDPServer | None = None
|
||||
self._server_thread: threading.Thread | None = None
|
||||
self._gc_thread: threading.Thread | None = None
|
||||
self._stop = threading.Event()
|
||||
|
||||
def start(self) -> None:
|
||||
d = dispatcher.Dispatcher()
|
||||
d.map("/body3d/kp", self._on_kp)
|
||||
d.map("/body3d/count", self._on_count)
|
||||
self._server = osc_server.ThreadingOSCUDPServer(
|
||||
(self.host, self.port), d)
|
||||
self._server_thread = threading.Thread(
|
||||
target=self._server.serve_forever,
|
||||
name="iphone_osc", daemon=True)
|
||||
self._server_thread.start()
|
||||
self._gc_thread = threading.Thread(
|
||||
target=self._gc_loop, name="iphone_gc", daemon=True)
|
||||
self._gc_thread.start()
|
||||
LOG.info("iphone OSC listening on %s:%d", self.host, self.port)
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._server is not None:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
self._server = None
|
||||
|
||||
def _on_kp(self, _addr: str, *args: Any) -> None:
|
||||
if len(args) < 5:
|
||||
return
|
||||
try:
|
||||
pid = int(args[0])
|
||||
joint_idx = int(args[1])
|
||||
x = float(args[2])
|
||||
y = float(args[3])
|
||||
z = float(args[4])
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if not (0 <= joint_idx < ARKIT_NUM_JOINTS):
|
||||
return
|
||||
with self.state.lock():
|
||||
arr = self.state.persons_arkit_joints.get(pid)
|
||||
if arr is None or arr.shape != (ARKIT_NUM_JOINTS, 3):
|
||||
arr = np.zeros((ARKIT_NUM_JOINTS, 3), dtype=np.float32)
|
||||
self.state.persons_arkit_joints[pid] = arr
|
||||
arr[joint_idx] = (x, y, z)
|
||||
self.state.persons_arkit_last_t[pid] = time.perf_counter()
|
||||
|
||||
def _on_count(self, _addr: str, *args: Any) -> None:
|
||||
# Optional : we currently don't gate on count, but parse for log.
|
||||
if not args:
|
||||
return
|
||||
try:
|
||||
n = int(args[0])
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if not hasattr(self, "_last_hb") or \
|
||||
time.monotonic() - self._last_hb > 5.0:
|
||||
self._last_hb = time.monotonic()
|
||||
LOG.info("hb: %d ARKit bodies live", n)
|
||||
|
||||
def _gc_stale(self) -> None:
|
||||
cutoff = time.perf_counter() - STALE_SEC
|
||||
with self.state.lock():
|
||||
drop = [
|
||||
pid for pid, t in self.state.persons_arkit_last_t.items()
|
||||
if t < cutoff
|
||||
]
|
||||
for pid in drop:
|
||||
self.state.persons_arkit_joints.pop(pid, None)
|
||||
self.state.persons_arkit_last_t.pop(pid, None)
|
||||
|
||||
def _gc_loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
self._gc_stale()
|
||||
time.sleep(0.5)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_iphone_osc_listener.py -v`
|
||||
Expected: PASS (2 passed)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add data_only_viz/iphone_osc_listener.py data_only_viz/tests/test_iphone_osc_listener.py
|
||||
git commit -m "feat(viz): iphone OSC listener -> state.persons_arkit_joints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: ArkitFuse stage in PoseFilterChain
|
||||
|
||||
**Files:**
|
||||
- Modify: `data_only_viz/pose_filter.py`
|
||||
- Test: `data_only_viz/tests/test_arkit_fuse.py` (new)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `data_only_viz/tests/test_arkit_fuse.py`:
|
||||
|
||||
```python
|
||||
"""ArkitFuse stage overrides 14 body slots with ARKit data when fresh."""
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from data_only_viz.state import Kp3D, State
|
||||
from data_only_viz.pose_filter import PoseFilterChain
|
||||
|
||||
|
||||
def _mp33_zero_body():
|
||||
return [Kp3D(x=0.0, y=0.0, z=0.0, c=1.0) for _ in range(33)]
|
||||
|
||||
|
||||
def test_arkit_fuse_overrides_shoulder():
|
||||
state = State()
|
||||
# ARKit publishes joint 50 (left shoulder) with (1.0, 2.0, 3.0)
|
||||
arr = np.zeros((91, 3), dtype=np.float32)
|
||||
arr[50] = (1.0, 2.0, 3.0)
|
||||
with state.lock():
|
||||
state.persons_arkit_joints[0] = arr
|
||||
state.persons_arkit_last_t[0] = time.perf_counter()
|
||||
chain = PoseFilterChain(state=state, enabled_stages=("arkit_fuse",))
|
||||
bodies = [_mp33_zero_body()]
|
||||
out = chain.apply(bodies, ids=[0], t_now=time.perf_counter())
|
||||
# Slot 11 = L_SHOULDER (from ARKIT91_TO_MP33).
|
||||
assert out[0][11].x == 1.0
|
||||
assert out[0][11].y == 2.0
|
||||
assert out[0][11].z == 3.0
|
||||
|
||||
|
||||
def test_arkit_fuse_skips_stale():
|
||||
state = State()
|
||||
arr = np.zeros((91, 3), dtype=np.float32)
|
||||
arr[50] = (9.0, 9.0, 9.0)
|
||||
with state.lock():
|
||||
state.persons_arkit_joints[0] = arr
|
||||
state.persons_arkit_last_t[0] = time.perf_counter() - 5.0
|
||||
chain = PoseFilterChain(state=state, enabled_stages=("arkit_fuse",))
|
||||
bodies = [_mp33_zero_body()]
|
||||
out = chain.apply(bodies, ids=[0], t_now=time.perf_counter())
|
||||
# Stale -> not applied, MediaPipe zero left intact.
|
||||
assert out[0][11].x == 0.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_arkit_fuse.py -v`
|
||||
Expected: FAIL — `arkit_fuse` not in `ALL_STAGES` so chain.enabled is empty, no fuse happens.
|
||||
|
||||
- [ ] **Step 3: Add ArkitFuse class + register stage**
|
||||
|
||||
Edit `data_only_viz/pose_filter.py`. Find the line `ALL_STAGES = (...)` near the top and replace:
|
||||
|
||||
```python
|
||||
ALL_STAGES = (
|
||||
"median", "kalman", "spring", "lookahead", "ik",
|
||||
"one_euro_joints", "one_euro_bones", "arkit_fuse",
|
||||
)
|
||||
```
|
||||
|
||||
Find the import block at the top (after `from .euro_filter import ...`) and add:
|
||||
|
||||
```python
|
||||
from .arkit_joint_map import ARKIT91_TO_MP33
|
||||
```
|
||||
|
||||
Find the `PoseFilterChain.__init__` method and after the line `self.one_euro_bones = BoneOneEuroFilter(...)` add:
|
||||
|
||||
```python
|
||||
self.arkit_fuse = ArkitFuse()
|
||||
```
|
||||
|
||||
In `PoseFilterChain.apply`, find the block defining `use_one_euro_joints = "one_euro_joints" in self.enabled` and add right after it:
|
||||
|
||||
```python
|
||||
use_arkit_fuse = "arkit_fuse" in self.enabled
|
||||
```
|
||||
|
||||
In the same method, find the outer `for body_i, kps in enumerate(bodies3d):` loop. The fuse happens BEFORE per-joint filtering (so kalman sees the fused signal). Insert this immediately after the `pid = ids[body_i] if body_i < len(ids) else -1` line:
|
||||
|
||||
```python
|
||||
if use_arkit_fuse and self.state is not None:
|
||||
kps = self.arkit_fuse.apply(self.state, pid, kps, t_now)
|
||||
```
|
||||
|
||||
Then add the `ArkitFuse` class definition. Find the line `# ============================ face / hand =================================` and insert right BEFORE it:
|
||||
|
||||
```python
|
||||
class ArkitFuse:
|
||||
"""Splice ARKit 91-joint world-space data into MediaPipe Pose 33.
|
||||
|
||||
Reads ``state.persons_arkit_joints[pid]`` (shape (91, 3)) when fresh
|
||||
(last_t within FRESH_SEC). Writes the 14 body slots covered by
|
||||
ARKIT91_TO_MP33 ; everything else (face landmarks, finger tips)
|
||||
stays MediaPipe-driven.
|
||||
"""
|
||||
|
||||
FRESH_SEC: float = 1.0
|
||||
|
||||
def apply(self, state: "State", pid: int,
|
||||
kps: list[Kp3D], t_now: float) -> list[Kp3D]:
|
||||
with state.lock():
|
||||
arr = state.persons_arkit_joints.get(pid)
|
||||
last_t = state.persons_arkit_last_t.get(pid, 0.0)
|
||||
if arr is None:
|
||||
return kps
|
||||
if t_now - last_t > self.FRESH_SEC:
|
||||
return kps
|
||||
out = list(kps)
|
||||
n = len(out)
|
||||
for arkit_idx, mp33_idx in ARKIT91_TO_MP33:
|
||||
if mp33_idx >= n:
|
||||
continue
|
||||
x = float(arr[arkit_idx, 0])
|
||||
y = float(arr[arkit_idx, 1])
|
||||
z = float(arr[arkit_idx, 2])
|
||||
old = out[mp33_idx]
|
||||
out[mp33_idx] = Kp3D(x=x, y=y, z=z, c=getattr(old, "c", 1.0))
|
||||
return out
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_arkit_fuse.py -v`
|
||||
Expected: PASS (2 passed)
|
||||
|
||||
- [ ] **Step 5: Regression check existing filter tests**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_pose_filter.py -v`
|
||||
Expected: PASS (all existing pose_filter tests still green)
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add data_only_viz/pose_filter.py data_only_viz/tests/test_arkit_fuse.py
|
||||
git commit -m "feat(viz): arkit_fuse stage in PoseFilterChain"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Cam-translation z lock in multi_hmr_worker
|
||||
|
||||
**Files:**
|
||||
- Modify: `data_only_viz/multi_hmr_worker.py`
|
||||
- Test: `data_only_viz/tests/test_multihmr_arkit_z.py` (new)
|
||||
|
||||
- [ ] **Step 1: Locate post-inference cam_t write site**
|
||||
|
||||
Run: `grep -n "pred_cam_t\|cam_t =" /Users/electron/Documents/Projets/AV-Live/data_only_viz/multi_hmr_worker.py | head -15`
|
||||
|
||||
You will see lines where `pred_cam_t` is read from model output and copied into `state.persons_smplx`. Look for the loop after model inference that assigns `transl=...` or `translation=...` per pid. Save the exact line numbers — Step 3 inserts the lock just AFTER the existing cam_t computation but BEFORE the state write.
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
Create `data_only_viz/tests/test_multihmr_arkit_z.py`:
|
||||
|
||||
```python
|
||||
"""arkit_pelvis_z_override : if ARKit pelvis z is fresh, replace
|
||||
the Multi-HMR pred_cam_t.z so the SMPL-X mesh sits at the actual
|
||||
distance instead of HaMeR's monocular guess.
|
||||
"""
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from data_only_viz.state import State
|
||||
from data_only_viz.multi_hmr_worker import arkit_pelvis_z_override
|
||||
|
||||
|
||||
def test_returns_arkit_z_when_fresh():
|
||||
state = State()
|
||||
arr = np.zeros((91, 3), dtype=np.float32)
|
||||
arr[1] = (0.0, 0.0, 2.5) # ARKIT_PELVIS_IDX=1, z=2.5 m
|
||||
with state.lock():
|
||||
state.persons_arkit_joints[0] = arr
|
||||
state.persons_arkit_last_t[0] = time.perf_counter()
|
||||
z_pred = 5.0 # Multi-HMR ambiguous guess
|
||||
z_out = arkit_pelvis_z_override(state, pid=0, z_pred=z_pred)
|
||||
assert z_out == 2.5
|
||||
|
||||
|
||||
def test_keeps_pred_when_stale():
|
||||
state = State()
|
||||
arr = np.zeros((91, 3), dtype=np.float32)
|
||||
arr[1] = (0.0, 0.0, 2.5)
|
||||
with state.lock():
|
||||
state.persons_arkit_joints[0] = arr
|
||||
state.persons_arkit_last_t[0] = time.perf_counter() - 5.0
|
||||
z_pred = 5.0
|
||||
z_out = arkit_pelvis_z_override(state, pid=0, z_pred=z_pred)
|
||||
assert z_out == 5.0
|
||||
|
||||
|
||||
def test_keeps_pred_when_pid_missing():
|
||||
state = State()
|
||||
z_pred = 4.2
|
||||
z_out = arkit_pelvis_z_override(state, pid=99, z_pred=z_pred)
|
||||
assert z_out == 4.2
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_multihmr_arkit_z.py -v`
|
||||
Expected: FAIL — `arkit_pelvis_z_override` does not exist in multi_hmr_worker.
|
||||
|
||||
- [ ] **Step 4: Add the override function + import**
|
||||
|
||||
Edit `data_only_viz/multi_hmr_worker.py`. At the top of the file, add to the imports block:
|
||||
|
||||
```python
|
||||
from .arkit_joint_map import ARKIT_PELVIS_IDX
|
||||
```
|
||||
|
||||
Then at module level (after imports, before any class), add:
|
||||
|
||||
```python
|
||||
def arkit_pelvis_z_override(state, pid: int, z_pred: float,
|
||||
fresh_sec: float = 1.0) -> float:
|
||||
"""Return ARKit pelvis world-z if a fresh ARKit frame exists for
|
||||
this pid, otherwise return the Multi-HMR predicted z unchanged.
|
||||
|
||||
Used to resolve Multi-HMR's monocular scale ambiguity: ARKit's
|
||||
LiDAR-anchored pelvis position is ground truth in the iPhone
|
||||
world frame, which (after extrinsics calibration) is the same
|
||||
metric scale as the SMPL-X cam-space output.
|
||||
"""
|
||||
import time as _time
|
||||
with state.lock():
|
||||
arr = state.persons_arkit_joints.get(pid)
|
||||
last_t = state.persons_arkit_last_t.get(pid, 0.0)
|
||||
if arr is None:
|
||||
return float(z_pred)
|
||||
if _time.perf_counter() - last_t > fresh_sec:
|
||||
return float(z_pred)
|
||||
return float(arr[ARKIT_PELVIS_IDX, 2])
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_multihmr_arkit_z.py -v`
|
||||
Expected: PASS (3 passed)
|
||||
|
||||
- [ ] **Step 6: Wire override at the cam_t write site**
|
||||
|
||||
Now use the line numbers from Step 1. In `multi_hmr_worker.py`, find the per-person loop where `pred_cam_t` (or equivalent transl) is being written into the SMPL-X person record. For each person, after the model output's z is computed but before assignment to state, wrap the z value:
|
||||
|
||||
```python
|
||||
z_locked = arkit_pelvis_z_override(
|
||||
self.state, pid, float(transl[2]))
|
||||
transl = np.array([transl[0], transl[1], z_locked],
|
||||
dtype=transl.dtype)
|
||||
```
|
||||
|
||||
(Adapt variable names to the exact context — `transl`, `t_full`, or whatever is used. The pattern is: take the existing z, replace via the override, repack.)
|
||||
|
||||
- [ ] **Step 7: Regression smoke**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/ -q -x`
|
||||
Expected: All tests pass (no regressions introduced).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add data_only_viz/multi_hmr_worker.py data_only_viz/tests/test_multihmr_arkit_z.py
|
||||
git commit -m "feat(viz): arkit pelvis z locks Multi-HMR cam translation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Always-on listener in main.py
|
||||
|
||||
**Files:**
|
||||
- Modify: `data_only_viz/main.py`
|
||||
|
||||
- [ ] **Step 1: Locate _start_pose_worker function**
|
||||
|
||||
Run: `grep -n "_start_pose_worker\|_maybe_start_webcam_source" /Users/electron/Documents/Projets/AV-Live/data_only_viz/main.py | head -5`
|
||||
|
||||
Note the line number where `_start_pose_worker` begins (around line 287).
|
||||
|
||||
- [ ] **Step 2: Insert listener startup**
|
||||
|
||||
Edit `data_only_viz/main.py`. In the body of `_start_pose_worker`, immediately after the call to `self._maybe_start_webcam_source()` (line ~289), insert:
|
||||
|
||||
```python
|
||||
# iPhone ARBodyTracker (option 2 LiDAR fusion) : always-on
|
||||
# listener on :57128. Harmless if no iPhone is broadcasting ;
|
||||
# state.persons_arkit_joints stays empty and the arkit_fuse
|
||||
# stage no-ops. Activated via POSE_FILTER=...+arkit_fuse.
|
||||
try:
|
||||
from .iphone_osc_listener import IphoneOSCListener
|
||||
self._iphone_osc = IphoneOSCListener(self._state)
|
||||
self._iphone_osc.start()
|
||||
LOG.info("worker: + iPhone OSC listener :57128")
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("iphone OSC listener start failed (%s)", e)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Smoke test the listener starts**
|
||||
|
||||
Run: `cd /Users/electron/Documents/Projets/AV-Live && AV_LIVE_INFERENCE_OFF=1 timeout 8 ~/avlive-venv/bin/python -u -c "
|
||||
import os, sys, time
|
||||
os.environ.setdefault('AV_SHARED_CAM', '0')
|
||||
sys.path.insert(0, '/Users/electron/Documents/Projets/AV-Live')
|
||||
import threading, logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
from data_only_viz.state import State
|
||||
from data_only_viz.iphone_osc_listener import IphoneOSCListener
|
||||
s = State()
|
||||
l = IphoneOSCListener(s)
|
||||
l.start()
|
||||
time.sleep(2)
|
||||
print('listener up :', l.port)
|
||||
l.stop()
|
||||
print('stopped clean')
|
||||
" 2>&1 | tail -5`
|
||||
|
||||
Expected output:
|
||||
```
|
||||
... iphone OSC listening on 0.0.0.0:57128
|
||||
listener up : 57128
|
||||
stopped clean
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add data_only_viz/main.py
|
||||
git commit -m "feat(viz): start iphone OSC listener in main pose worker"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Documentation update
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (top-level env var table)
|
||||
- Modify: `data_only_viz/CLAUDE.md` (POSE_FILTER stages table)
|
||||
|
||||
- [ ] **Step 1: Update top-level CLAUDE.md env var table**
|
||||
|
||||
Edit `/Users/electron/Documents/Projets/AV-Live/CLAUDE.md`. Find the row with `POSE_FILTER` and replace its description so it lists `arkit_fuse` as an available stage. Also append a new row for the iPhone OSC port. Use these exact replacements:
|
||||
|
||||
For the `POSE_FILTER` row, replace whatever is there with:
|
||||
|
||||
```
|
||||
| `POSE_FILTER` | `median+kalman+lookahead+ik` | filter chain stages — extra: `one_euro_joints` (joint-space CHI 2012 One Euro, inserted before kalman), `one_euro_bones` (bone-vector One Euro applied after SMPL-X fusion in multi.py), `arkit_fuse` (overrides 14 body slots with ARKit ARSkeleton3D from the iOS app, expects /body3d/kp on :57128) |
|
||||
```
|
||||
|
||||
Then below the `POSE_FILTER` row add:
|
||||
|
||||
```
|
||||
| `IPHONE_OSC_PORT` | `57128` | UDP port the iPhone ARBodyTracker app pushes /body3d/kp to (always-on listener in data_only_viz) |
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update data_only_viz/CLAUDE.md**
|
||||
|
||||
Edit `/Users/electron/Documents/Projets/AV-Live/data_only_viz/CLAUDE.md`. Find the "Conventions" section's filtering bullet (mentions `euro_filter.py`) and append after it:
|
||||
|
||||
```
|
||||
- ARKit fusion : `iphone_osc_listener.py` consume /body3d/kp UDP :57128
|
||||
→ `state.persons_arkit_joints`. `pose_filter.py::ArkitFuse` (stage
|
||||
`arkit_fuse`) splices the 14 mapped body slots into MediaPipe pose
|
||||
before kalman ; `multi_hmr_worker::arkit_pelvis_z_override` locks the
|
||||
SMPL-X cam translation z to the ARKit pelvis. Mapping in
|
||||
`arkit_joint_map.py`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md data_only_viz/CLAUDE.md
|
||||
git commit -m "docs: iphone arkit fusion env + filter stage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: End-to-end live smoke
|
||||
|
||||
This is a manual verification step run once after the iOS app is
|
||||
deployed to a real iPhone Pro and broadcasting on the LAN. No new
|
||||
code ; just confirm wiring + telemetry.
|
||||
|
||||
- [ ] **Step 1: Start the GrosMac pipeline (already wired)**
|
||||
|
||||
Run: `bash /Users/electron/Documents/Projets/AV-Live/launcher/apps/dist/GrosMac-AVLive.app/Contents/MacOS/bootstrap &`
|
||||
|
||||
Wait ~8 s, then verify the listener line appeared:
|
||||
|
||||
```
|
||||
grep "iphone OSC listening" ~/Library/Logs/AVLive/GrosMac-AVLive.python.log
|
||||
```
|
||||
|
||||
Expected: a line `iphone OSC listening on 0.0.0.0:57128`.
|
||||
|
||||
- [ ] **Step 2: Start ARBodyTracker on iPhone**
|
||||
|
||||
In the iOS app (deployed via Xcode):
|
||||
1. Host = your GrosMac LAN IP (`192.168.0.159`)
|
||||
2. Port = `57128`
|
||||
3. Tap **Start**
|
||||
|
||||
Stand 2 m in front of the iPhone with body fully visible. The app
|
||||
status label should say "running (LiDAR depth, env mesh)".
|
||||
|
||||
- [ ] **Step 3: Confirm ARKit state on GrosMac**
|
||||
|
||||
Run on GrosMac while iPhone is broadcasting:
|
||||
|
||||
```bash
|
||||
~/avlive-venv/bin/python -u -c "
|
||||
import time, sys
|
||||
sys.path.insert(0, '/Users/electron/Documents/Projets/AV-Live')
|
||||
from data_only_viz.iphone_osc_listener import IphoneOSCListener
|
||||
from data_only_viz.state import State
|
||||
s = State()
|
||||
l = IphoneOSCListener(s, port=57130) # alt port to avoid clash
|
||||
l.start()
|
||||
time.sleep(3)
|
||||
with s.lock():
|
||||
print('pids :', list(s.persons_arkit_joints.keys()))
|
||||
if s.persons_arkit_joints:
|
||||
pid = next(iter(s.persons_arkit_joints))
|
||||
print('pelvis :', s.persons_arkit_joints[pid][1])
|
||||
l.stop()
|
||||
"
|
||||
```
|
||||
|
||||
Expected: `pids : [0]` and `pelvis : [x, y, z]` with z > 0.
|
||||
|
||||
NOTE: this snippet uses port 57130 to avoid clashing with the live
|
||||
listener already bound to 57128. To test against the live listener,
|
||||
just open Activity Monitor's network panel for the Python process —
|
||||
you should see UDP packets flowing in on :57128.
|
||||
|
||||
- [ ] **Step 4: Enable arkit_fuse in live pipeline**
|
||||
|
||||
The pipeline currently uses `POSE_FILTER` from the bootstrap env. To
|
||||
add `arkit_fuse`, edit the GrosMac bootstrap and append the stage:
|
||||
|
||||
Edit `/Users/electron/Documents/Projets/AV-Live/launcher/apps/dist/GrosMac-AVLive.app/Contents/MacOS/bootstrap`. Find any existing `export POSE_FILTER=...` line (if absent, look around the `if [ "${ROLE}" = "source" ]; then` section for where envs are exported in the source branch) and add:
|
||||
|
||||
```bash
|
||||
export POSE_FILTER="median+kalman+lookahead+ik+one_euro_joints+one_euro_bones+arkit_fuse"
|
||||
```
|
||||
|
||||
Restart GrosMac bundle:
|
||||
|
||||
```bash
|
||||
pkill -9 -f "AVLiveBody|data_only_viz"
|
||||
open /Users/electron/Documents/Projets/AV-Live/launcher/apps/dist/GrosMac-AVLive.app
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify fusion in log**
|
||||
|
||||
After the live launch, tail the python log:
|
||||
|
||||
```bash
|
||||
grep "PoseFilterChain stages" ~/Library/Logs/AVLive/GrosMac-AVLive.python.log
|
||||
```
|
||||
|
||||
Expected: a line ending in `'arkit_fuse')`.
|
||||
|
||||
- [ ] **Step 6: Visual confirmation**
|
||||
|
||||
In AVLiveBody window (release build), the body wireframe should
|
||||
visibly stabilise compared to a session without ARKit (shoulders/hips
|
||||
no longer wobble between MediaPipe predictions ; the mesh sits at
|
||||
the real-world depth from the camera instead of HaMeR's monocular
|
||||
guess). If you see persistent jitter, double-check via Activity
|
||||
Monitor that UDP :57128 traffic is non-zero, and that
|
||||
`state.persons_arkit_joints` has fresh entries (Step 3 snippet).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
Spec coverage check :
|
||||
- iphone_osc_listener.py ✅ Task 3
|
||||
- state fields ✅ Task 1
|
||||
- arkit_joint_map.py ✅ Task 2
|
||||
- pose_filter arkit_fuse ✅ Task 4
|
||||
- multi_hmr cam-z lock ✅ Task 5
|
||||
- main.py startup ✅ Task 6
|
||||
- Docs ✅ Task 7
|
||||
- Live verification ✅ Task 8
|
||||
|
||||
ICP mesh fitting is intentionally deferred — that's a separate plan
|
||||
once the joint-level fusion is proven stable.
|
||||
|
||||
Type consistency : `ARKIT91_TO_MP33` declared in Task 2, used in
|
||||
Task 4 (pose_filter import) and Task 5 (multi_hmr import of
|
||||
`ARKIT_PELVIS_IDX`). `IphoneOSCListener` defined Task 3, instantiated
|
||||
Task 6. State fields `persons_arkit_joints` and
|
||||
`persons_arkit_last_t` declared Task 1, consumed Tasks 3, 4, 5.
|
||||
|
||||
No placeholders, no TBD, every step is concrete with code or a
|
||||
copy-pasteable command. The plan compiles a hot-loop story without
|
||||
ICP, which keeps it bite-sized and shippable in a single working
|
||||
session (~1 day for a fresh engineer).
|
||||
Reference in New Issue
Block a user