docs: arkit-only body position plan

This commit is contained in:
L'électron rare
2026-06-30 18:47:10 +02:00
parent 7037ebcd82
commit bd9f9fca29
@@ -0,0 +1,337 @@
# ARKit-only body position 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:** Drive the matrix's body position/modulation 100% from the iPhone ARKit skeleton (2D + 3D) and cut the Mac MediaPipe pose/face inference under `--iphone-usb`.
**Architecture:** The iPhone already computes a 2D-projected skeleton for its overlay; we send it over USB as a new `skeleton2D` AVLiveWire frame. The Mac decodes it into `persons_arkit_2d`, builds the MP33 body (2D image-normalized) + `bodies3d` (3D world, from the existing 3D `skeleton`) from the ARKit joints, skips MediaPipe pose/face/hand inference, and emits `/pose/*` from the ARKit body. `arkit_fuse` is dropped (ARKit is the source).
**Tech Stack:** Swift (AVLiveWire package + the iPhone Swift app), Python 3 (data_only_viz), `swift test` + `pytest`.
## Global Constraints
- Commit subject ≤ 50 chars, no underscore in scope, no AI attribution, no `--no-verify`. No emojis. Keep `git commit` on its OWN command line (compound `grep && git commit` trips the block-no-verify hook).
- 91 ARKit joints (`JOINT_COUNT = 91`). The matrix is single-person.
- The 2D skeleton is sent VIEWPORT-NORMALIZED (0..1), `x` = left→right, `y` = top→down (image convention), matching MediaPipe's normalized landmark space so `/pose/center` etc. read identically.
- The iPhone app rebuild (`xcodegen generate` → Xcode → ⌘R on the device) is a MANUAL USER STEP — the agent cannot build/deploy Swift on the device. The Mac pure-function parts ARE unit-tested here; the live integration is verified on the rig after the rebuild.
- `swift test` runs in `shared/AVLiveWire` (the wire package) and is testable in this environment. The iPhone app Swift is NOT buildable here (needs ARKit/device).
- ARKit→MP33 mapping in `arkit_joint_map.py` (`ARKIT91_TO_MP33`, 14 body slots). Extend with a head/neck → MP33 slot 0 anchor.
## File Structure
- `shared/AVLiveWire/Sources/AVLiveWire/FrameHeader.swift` — add `skeleton2D = 5` tag.
- `shared/AVLiveWire/Sources/AVLiveWire/WirePayloads.swift` — add `Skeleton2DPayload` (91×(x,y) Float32 + 91 valid UInt8) encode/decode.
- `shared/AVLiveWire/Tests/AVLiveWireTests/` — round-trip test for the new payload.
- `iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift` — send the 2D skeleton.
- `data_only_viz/scripts/iphone_usb_bridge.py``TAG_SKELETON2D` + `decode_skeleton2D`.
- `data_only_viz/iphone_usb_source.py` — handle `TAG_SKELETON2D``state.persons_arkit_2d`.
- `data_only_viz/state.py``persons_arkit_2d` / `persons_arkit_2d_t` fields.
- `data_only_viz/arkit_joint_map.py``arkit_body_from_2d` / `arkit_body3d_from_world` builders + head mapping.
- `data_only_viz/multi.py` — under `--iphone-usb`, build the body from ARKit + skip MediaPipe pose/face.
- `data_only_viz/tests/test_arkit_body.py` — builder unit tests.
- `launcher/concert/launch_concert.sh` — drop `arkit_fuse` from POSE_FILTER.
---
### Task 1: AVLiveWire — `skeleton2D` tag + payload (Swift, testable)
**Files:** Modify `shared/AVLiveWire/Sources/AVLiveWire/FrameHeader.swift`, `WirePayloads.swift`; add a test in `shared/AVLiveWire/Tests/AVLiveWireTests/`.
**Interfaces:**
- Produces: `FrameTag.skeleton2D` (raw 5); `Skeleton2DPayload` with `static let jointCount = 91`, `var points: [SIMD2<Float>]`, `var valid: [Bool]`, `func encoded() -> Data`, `static func decode(_:) -> Skeleton2DPayload?`. Wire layout: 91×(x:Float32,y:Float32) big-endian then 91×UInt8 valid.
- [ ] **Step 1: Read the existing `SkeletonPayload`** in `WirePayloads.swift` to mirror its style (it has `jointCount`, `joints: [SIMD3<Float>]`, `valid`, `encoded()`, `decode`). Then add a failing round-trip test in a new/existing test file:
```swift
import XCTest
@testable import AVLiveWire
final class Skeleton2DPayloadTests: XCTestCase {
func testRoundTrip() {
var p = Skeleton2DPayload()
for i in 0..<Skeleton2DPayload.jointCount {
p.points[i] = SIMD2(Float(i) * 0.001, Float(i) * 0.002)
p.valid[i] = (i % 2 == 0)
}
let data = p.encoded()
let r = Skeleton2DPayload.decode(data)
XCTAssertNotNil(r)
XCTAssertEqual(r!.points[10].x, p.points[10].x, accuracy: 1e-6)
XCTAssertEqual(r!.valid[10], p.valid[10])
XCTAssertEqual(r!.valid[11], p.valid[11])
}
}
```
- [ ] **Step 2: Run — expect FAIL** (`Skeleton2DPayload` undefined).
Run: `cd shared/AVLiveWire && swift test --filter Skeleton2DPayloadTests`
Expected: build error / FAIL.
- [ ] **Step 3: Add the tag.** In `FrameHeader.swift`, add to `FrameTag`:
```swift
case skeleton2D = 5
```
- [ ] **Step 4: Add `Skeleton2DPayload`** in `WirePayloads.swift`, mirroring `SkeletonPayload` but 2 floats/joint:
```swift
public struct Skeleton2DPayload {
public static let jointCount = 91
public var points: [SIMD2<Float>]
public var valid: [Bool]
public init() {
points = Array(repeating: SIMD2<Float>(0, 0), count: Self.jointCount)
valid = Array(repeating: false, count: Self.jointCount)
}
public func encoded() -> Data {
var d = Data(capacity: Self.jointCount * 8 + Self.jointCount)
for p in points {
for v in [p.x, p.y] { d.append(contentsOf: withUnsafeBytes(of: v.bitPattern.bigEndian) { Data($0) }) }
}
for b in valid { d.append(b ? 1 : 0) }
return d
}
public static func decode(_ data: Data) -> Skeleton2DPayload? {
let floatBytes = jointCount * 2 * 4
guard data.count == floatBytes + jointCount else { return nil }
var out = Skeleton2DPayload()
let bytes = [UInt8](data)
for i in 0..<jointCount {
func f(_ off: Int) -> Float {
let b = (UInt32(bytes[off]) << 24) | (UInt32(bytes[off+1]) << 16) | (UInt32(bytes[off+2]) << 8) | UInt32(bytes[off+3])
return Float(bitPattern: b)
}
out.points[i] = SIMD2(f(i*8), f(i*8 + 4))
out.valid[i] = bytes[floatBytes + i] != 0
}
return out
}
}
```
(If `SkeletonPayload` uses a different encoding helper, MATCH its style — the round-trip test is the gate, not this exact code.)
- [ ] **Step 5: Run — expect PASS.**
Run: `cd shared/AVLiveWire && swift test --filter Skeleton2DPayloadTests` (and `swift test` for the full suite — no regression).
- [ ] **Step 6: Commit.**
```bash
git add shared/AVLiveWire/Sources/AVLiveWire/FrameHeader.swift shared/AVLiveWire/Sources/AVLiveWire/WirePayloads.swift shared/AVLiveWire/Tests/AVLiveWireTests
git commit -m "feat(wire): skeleton2D frame payload"
```
---
### Task 2: iPhone app — send the 2D skeleton (Swift, device-only)
**Files:** Modify `iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift`.
**Interfaces:**
- Consumes: `FrameTag.skeleton2D`, `Skeleton2DPayload` (Task 1); the app's `skeleton2D`/`projectPoint` viewport projection.
- Produces: a `usb.send(tag: .skeleton2D, ...)` per tracked body, points normalized 0..1 by viewport.
NOTE: this Swift is NOT buildable in the agent environment (needs ARKit/device). The implementer writes it from the brief; the USER rebuilds (`xcodegen generate` → Xcode → ⌘R) and verifies on device.
- [ ] **Step 1: Send the 2D skeleton in `publishUSB`** (after the existing 3D `.skeleton` send). The 91 viewport-projected points already exist via `ARCamera.projectPoint`; build a `Skeleton2DPayload` with `x = projected.x / viewportSize.width`, `y = projected.y / viewportSize.height` (clamped finite; `valid[i] = isJointTracked(i) && finite`), and send it:
```swift
var p2 = Skeleton2DPayload()
let orient = currentInterfaceOrientation()
for i in 0..<min(Skeleton2DPayload.jointCount, transforms.count) {
let w = root * transforms[i]
let p3 = SIMD3<Float>(w.columns.3.x, w.columns.3.y, w.columns.3.z)
let proj = camera.projectPoint(p3, orientation: orient, viewportSize: viewportSize)
let nx = Float(proj.x / max(viewportSize.width, 1)),
ny = Float(proj.y / max(viewportSize.height, 1))
if nx.isFinite && ny.isFinite {
p2.points[i] = SIMD2(nx, ny)
p2.valid[i] = skeleton.isJointTracked(i)
}
}
usb.send(tag: .skeleton2D, pid: Int16(clamping: pid), timestamp: timestamp, payload: p2.encoded())
```
(`camera` + `viewportSize` are the same the app uses for its overlay projection — wire them through to `publishUSB`; read the existing `updateSkeleton2D` for how the app gets `camera`/`viewportSize`/`orient`.)
- [ ] **Step 2: Commit** (the user rebuilds/deploys separately).
```bash
git add iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift
git commit -m "feat(iphone): send 2D projected skeleton"
```
---
### Task 3: Mac decode — `decode_skeleton2D` + state
**Files:** Modify `data_only_viz/scripts/iphone_usb_bridge.py`, `data_only_viz/iphone_usb_source.py`, `data_only_viz/state.py`; add `data_only_viz/tests/test_arkit_body.py`.
**Interfaces:**
- Produces: `TAG_SKELETON2D = 5`; `decode_skeleton2D(payload) -> list[(x, y, valid)] | None` (len 91); `state.persons_arkit_2d: dict[int, np.ndarray]` (91×2) + `persons_arkit_2d_t: dict[int, float]`.
- [ ] **Step 1: Failing test** in `data_only_viz/tests/test_arkit_body.py`:
```python
import struct
from data_only_viz.scripts.iphone_usb_bridge import decode_skeleton2D, JOINT_COUNT
def test_decode_skeleton2d_roundtrip():
floats = []
for i in range(JOINT_COUNT):
floats += [i * 0.001, i * 0.002]
payload = struct.pack(">" + "f" * (JOINT_COUNT * 2), *floats)
payload += bytes(1 if i % 2 == 0 else 0 for i in range(JOINT_COUNT))
out = decode_skeleton2D(payload)
assert out is not None and len(out) == JOINT_COUNT
assert abs(out[10][0] - 0.010) < 1e-5 and out[10][2] is True
assert out[11][2] is False
```
- [ ] **Step 2: Run — expect FAIL.**
Run: `cd data_only_viz && .venv/bin/python -m pytest tests/test_arkit_body.py -q`
Expected: ImportError on `decode_skeleton2D`.
- [ ] **Step 3: Add `TAG_SKELETON2D` + `decode_skeleton2D`** in `iphone_usb_bridge.py` (next to `decode_skeleton`):
```python
TAG_SKELETON2D = 5
SKEL2D_FLOAT_BYTES = JOINT_COUNT * 2 * 4
SKEL2D_BYTES = SKEL2D_FLOAT_BYTES + JOINT_COUNT
def decode_skeleton2D(payload: bytes):
"""Return [(x, y, valid), ...] of length 91 (normalized 0..1), or None."""
if len(payload) != SKEL2D_BYTES:
return None
floats = struct.unpack(">" + "f" * (JOINT_COUNT * 2), payload[:SKEL2D_FLOAT_BYTES])
valid = payload[SKEL2D_FLOAT_BYTES:]
return [(floats[i * 2], floats[i * 2 + 1], valid[i] != 0) for i in range(JOINT_COUNT)]
```
- [ ] **Step 4: Add the state fields** in `state.py` (near `persons_arkit_joints`):
```python
persons_arkit_2d: dict[int, "np.ndarray"] = field(default_factory=dict)
persons_arkit_2d_t: dict[int, float] = field(default_factory=dict)
```
- [ ] **Step 5: Handle `TAG_SKELETON2D` in `iphone_usb_source`** (mirror the `TAG_HANDS`/skeleton handlers; import `TAG_SKELETON2D, decode_skeleton2D`):
```python
elif tag == TAG_SKELETON2D and self.state is not None:
pts = decode_skeleton2D(payload)
if pts is not None:
import numpy as np
arr = np.array([[x, y] for (x, y, _v) in pts], dtype=np.float32)
with self.state.lock():
self.state.persons_arkit_2d[pid] = arr
self.state.persons_arkit_2d_t[pid] = time.perf_counter()
```
- [ ] **Step 6: Run — expect PASS.**
Run: `cd data_only_viz && .venv/bin/python -m pytest tests/test_arkit_body.py -q`
- [ ] **Step 7: Commit.**
```bash
git add data_only_viz/scripts/iphone_usb_bridge.py data_only_viz/iphone_usb_source.py data_only_viz/state.py data_only_viz/tests/test_arkit_body.py
git commit -m "feat(pose): decode iphone 2D skeleton"
```
---
### Task 4: Mac body-build from ARKit + cut MediaPipe
**Files:** Modify `data_only_viz/arkit_joint_map.py` (builders + head map), `data_only_viz/multi.py` (use them under iphone-usb, skip MP pose/face); extend `data_only_viz/tests/test_arkit_body.py`.
**Interfaces:**
- Consumes: `ARKIT91_TO_MP33`, `persons_arkit_2d`, `persons_arkit_joints`.
- Produces: `arkit_body_2d(arr2d) -> list[PoseKp]` (MP33, image-normalized, c=1 for mapped slots else 0), `arkit_body_3d(arr3d) -> list[Kp3D]`; `ARKIT91_TO_MP33` gains a head→0 entry.
- [ ] **Step 1: Failing builder test** — append to `test_arkit_body.py`:
```python
import numpy as np
from data_only_viz.arkit_joint_map import arkit_body_2d, ARKIT91_TO_MP33
def test_arkit_body_2d_maps_slots():
arr = np.zeros((91, 2), dtype=np.float32)
for ai, _mp in ARKIT91_TO_MP33:
arr[ai] = [0.3 + ai * 0.001, 0.4]
body = arkit_body_2d(arr)
assert len(body) == 33
# a mapped slot is confident; an unmapped face slot is not
mapped_mp = ARKIT91_TO_MP33[0][1]
assert body[mapped_mp].c > 0.5
assert body[1].c < 0.5 # MP slot 1 (eye) has no ARKit source
```
- [ ] **Step 2: Run — expect FAIL** (`arkit_body_2d` undefined).
Run: `cd data_only_viz && .venv/bin/python -m pytest tests/test_arkit_body.py::test_arkit_body_2d_maps_slots -q`
- [ ] **Step 3: Add the head map + builders** in `arkit_joint_map.py`. Add a head anchor (the ARKit `head_joint` index — find it in the Apple enum; commonly index 51 in the 91-list; CONFIRM by checking which ARKit index sits above the shoulders in a live frame, else use the neck/spine7. Use `(51, 0)` as the head→nose anchor and note it for live verification). Append it to `ARKIT91_TO_MP33`. Then:
```python
from .pose_filter import PoseKp # if PoseKp lives there; else from .state
# (use the project's PoseKp/Kp3D types — read multi.py imports)
def arkit_body_2d(arr2d):
"""91x2 normalized ARKit 2D -> MP33 PoseKp list (mapped slots c=1, else c=0)."""
from .state import PoseKp # adjust import to the real location
body = [PoseKp(x=0.5, y=0.5, z=0.0, c=0.0) for _ in range(33)]
for ai, mp in ARKIT91_TO_MP33:
if mp < 33 and ai < len(arr2d):
body[mp] = PoseKp(x=float(arr2d[ai][0]), y=float(arr2d[ai][1]), z=0.0, c=1.0)
return body
def arkit_body_3d(arr3d):
"""91x3 world ARKit -> MP33 Kp3D list (mapped slots c=1, else c=0)."""
from .state import Kp3D # adjust import
body = [Kp3D(x=0.0, y=0.0, z=0.0, c=0.0) for _ in range(33)]
for ai, mp in ARKIT91_TO_MP33:
if mp < 33 and ai < len(arr3d):
body[mp] = Kp3D(x=float(arr3d[ai][0]), y=float(arr3d[ai][1]), z=float(arr3d[ai][2]), c=1.0)
return body
```
(Read `multi.py`/`state.py` for the exact `PoseKp`/`Kp3D` constructors and import paths; the test gates correctness.)
- [ ] **Step 4: Run — expect PASS.**
Run: `cd data_only_viz && .venv/bin/python -m pytest tests/test_arkit_body.py -q`
- [ ] **Step 5: Wire into `multi.py` under `--iphone-usb`.** In the per-frame loop: skip the MediaPipe pose+face inference (`pose_res`/`face_res = None if self.iphone_usb else ...`); when `self.iphone_usb`, build `bodies`/`bodies3d` from `state.persons_arkit_2d`/`persons_arkit_joints` (one pid → one body) via `arkit_body_2d`/`arkit_body_3d` instead of the MediaPipe `pose_res` parse; set `faces = []`. Guard the MP-derived `bodies`/`faces` build + the body3d arkit_fuse so they don't run under iphone-usb (the ARKit body is the source). Keep the `/pose/*` emit + the `state` write (now fed by the ARKit body). Read the loop carefully — this mirrors the hands cut (commit `4c0794a`) but for pose+face.
- [ ] **Step 6: Verify syntax + the builder tests still pass.**
`python3 -m py_compile data_only_viz/multi.py` (no output); `cd data_only_viz && .venv/bin/python -m pytest tests/test_arkit_body.py -q` (PASS).
- [ ] **Step 7: Commit.**
```bash
git add data_only_viz/arkit_joint_map.py data_only_viz/multi.py data_only_viz/tests/test_arkit_body.py
git commit -m "feat(pose): build body from arkit, cut mediapipe pose"
```
---
### Task 5: Launcher — drop `arkit_fuse`
**Files:** Modify `launcher/concert/launch_concert.sh`.
- [ ] **Step 1:** Change `export POSE_FILTER=median+kalman+lookahead+ik+arkit_fuse` to `export POSE_FILTER=median+kalman+lookahead+ik` (ARKit is now the source, not a fuse). Lint: `zsh -n launcher/concert/launch_concert.sh`.
- [ ] **Step 2: Commit.**
```bash
git add launcher/concert/launch_concert.sh
git commit -m "chore(launcher): drop arkit_fuse, arkit is source"
```
---
### Task 6: Deploy + live smoke (USER-DRIVEN, iPhone rebuild)
**Files:** none.
- [ ] **Step 1:** `git push origin main`; `ssh macm1 'cd ~/Documents/Projets/AV-Live && git pull --ff-only'`. THE USER rebuilds the iPhone app: `cd iphone-arbody && xcodegen generate`, open in Xcode, ⌘R on the connected iPhone (so it sends `skeleton2D`). Then restart the pose pipeline (full launcher restart, or restart `data_only_viz.main` with the env).
- [ ] **Step 2:** Confirm: the body position tracks from ARKit (stable, no MediaPipe jitter); `/pose/center`/`kin`/`sho_span`/`limb_span` respond to movement; CPU drops (no MediaPipe pose/face inference); the head anchor (`/pose/head`) is sane (adjust the head ARKit index if off); the face mesh + `/pose/mouth` are gone (accepted). Report to the user; iterate the head index / 2D orientation if the position is mirrored/offset.
---
## Self-review
**Spec coverage:** skeleton2D wire tag+payload → Task 1; iPhone send → Task 2; Mac decode+state → Task 3; ARKit body build + cut MediaPipe + head map → Task 4; drop arkit_fuse → Task 5; deploy+iPhone-rebuild+smoke → Task 6. ✓
**Placeholder scan:** the Swift exact encoding + the `PoseKp`/`Kp3D` import paths + the head ARKit index are flagged "read the existing code / confirm live" rather than guessed — these are genuinely environment-specific and gated by the round-trip/builder tests + the live smoke, not inventable from here. All testable logic has concrete code.
**Type consistency:** `Skeleton2DPayload` (91×SIMD2 + valid) ↔ `decode_skeleton2D` (91×(x,y,valid)) ↔ `persons_arkit_2d` (91×2 ndarray) ↔ `arkit_body_2d` (33 PoseKp). `FrameTag.skeleton2D = 5``TAG_SKELETON2D = 5`. `ARKIT91_TO_MP33` extended with head→0, consumed by both builders. The matrix's `/pose/*` emit is unchanged (it reads `bodies`/`bodies3d`, now ARKit-fed).
## Note for the controller (live-iteration items)
These can only be nailed on the rig (after the iPhone rebuild): the head ARKit
joint index, the 2D orientation/mirroring (the app's `projectPoint` already
handles interface orientation — reuse it), and whether the dropped `arkit_fuse`
+ ik filter still feels right. Expect 1-2 iPhone-rebuild iterations.