merge: feat/action-head into main

CoreML FP32 fix, hybrid mesh rigging, action-head v3, mirror webcam, Apple Vision body pose, MediaPipe offline extract.
This commit is contained in:
L'électron rare
2026-05-14 00:06:14 +02:00
32 changed files with 3559 additions and 59 deletions
+28
View File
@@ -35,6 +35,32 @@ Python **3.11+** requis. `pyproject.toml` est la source de vérité — ne jamai
- Shaders Metal dans `shaders/` (`.metal`), recompilés au runtime ; topologie mesh (SMPL faces) en binaire dans `mesh_topology.py`.
- OSC out : `osc_listener.py` / `pose_bridge.py` — destination `oscope-of` sur `:57123`.
## action-head (classifier action debout/assise/danse)
Tête de classification d'action streaming au-dessus des j3d SMPL-X (ou body3d MediaPipe en fallback). Implémentée 2026-05-13.
| Fichier | Rôle |
|---|---|
| `action_head.py` | `ActionHeadModel` (GRU 1L + MLP, 37 811 params, <2 ms/step M5), `ActionHead.step(pid, j3d) → (label, probs, kin)`, `PerPersonBuffer`, `FeatureExtractor` (201-D : j3d + vel + accel + scalaires) |
| `action_head_pub.py` | Publisher thread démarré dans `multi.py` `__init__`. Polle `state.persons_smplx` (préféré) ou `state.persons_body3d` (fallback) à 30 Hz, dédup par timestamp, extrait j3d22 via `SMPLX_JOINT_ANCHOR_VERTS` ou `MEDIAPIPE_TO_22`, émet OSC `/pose/action` + `/pose/kin` + `/pose/enter/leave` |
| `training/{dataset,autolabel,augment,train_action_head,eval,review}.py` | Pipeline complet : jsonl IO + sliding windows + by-session split / règles auto-label + glue CLI / 4 augmentations / training MPS AdamW CE-weighted / confusion matrix + latence micro-bench / TUI textuel pour review manuel |
| `scripts/capture_actions.py` | Webcam → MP4 + timestamps |
| `scripts/extract_j3d_offline.py` | MP4 → jsonl j3d22 via `MultiHMRCoreMLBackend.infer()` directement (pas de refactor worker) |
| `scripts/train_on_studio.sh` | rsync grosmac → bastion electron-server → studio M3 Ultra + uv sync `--extra multihmr` + train MPS + ckpt back |
Pipeline complet de capture à live :
```bash
uv run python -m data_only_viz.scripts.capture_actions --session sess01 --duration 600
uv run python -m data_only_viz.scripts.extract_j3d_offline --session sess01 --video ~/.cache/av-live-action/raw/sess01.mp4
uv run python -m data_only_viz.training.autolabel --frames ~/.cache/av-live-action/raw/sess01.jsonl --out ~/.cache/av-live-action/dataset/auto.jsonl
uv run python -m data_only_viz.training.review --in ~/.cache/av-live-action/dataset/auto.jsonl --out ~/.cache/av-live-action/dataset/dataset.jsonl
./data_only_viz/scripts/train_on_studio.sh --epochs 50
uv run python -m data_only_viz.training.eval --ckpt ~/.cache/av-live-action/checkpoints/action_head.pt --dataset ~/.cache/av-live-action/dataset/dataset.jsonl
# Live : publisher déjà câblé dans multi.py, aucune action requise
```
Checkpoint par défaut : `~/.cache/av-live-action/checkpoints/action_head.pt`. Absent → random init (warmup retourne `debout`).
## Tests
```bash
@@ -43,6 +69,8 @@ uv run pytest tests/ -v
Tests TDD-first pour `nlf_worker.py` ; valider avant chaque commit qui touche un worker.
Suite action-head (8 fichiers, 39 tests) : `tests/test_action_head_*.py`, `tests/test_{dataset,autolabel,augment,training_smoke,pose_bridge_action}.py`. Tous doivent rester verts avant chaque commit qui touche `action_head*.py` ou `training/*.py`.
## Anti-patterns
- Ne pas charger un modèle ML sans guard `try/except ImportError` — les optional-extras peuvent manquer.
+279
View File
@@ -0,0 +1,279 @@
"""Action classifier head on top of Multi-HMR j3d.
Streaming GRU-1-layer + MLP per-person, with a 16-frame ring buffer.
Trained windowed (Studio M3 Ultra MPS), inferred streaming (M5 eager CPU).
Output per step: (label_idx, probs (3,), kin (3,)) where kin is
(speed_m_s, accel_m_s2, symmetry_in_minus1_plus1).
"""
from __future__ import annotations
from collections import deque
from pathlib import Path
import numpy as np
import torch
from torch import nn
HIDDEN_DIM: int = 48
MLP_HIDDEN: int = 32
WARMUP_FRAMES: int = 3
NAN_SKIP_BUDGET: int = 5
WINDOW_LEN: int = 16
J3D_BODY: int = 22
J3D_FINGERS_PER_HAND: int = 5
J3D_FINGERS: int = 2 * J3D_FINGERS_PER_HAND # 10
J3D_JOINTS: int = J3D_BODY + J3D_FINGERS # 32
J3D_DIMS: int = 3
NUM_CLASSES: int = 3
LABELS: tuple[str, str, str] = ("debout", "assise", "danse")
EXPR_DIM: int = 10
EXTRA_SCALARS: int = 4 # hip_y, knee_angle, sym_score, mouth_open
# NEW v3 : MediaPipe Hands keypoints block.
HANDS_KP_PER_HAND: int = 21
HANDS_KP_TOTAL: int = 2 * HANDS_KP_PER_HAND # 42
HANDS_KP_DIMS: int = 3
HANDS_KP_FLAT: int = HANDS_KP_TOTAL * HANDS_KP_DIMS # 126
# Layout per step (v3) :
# [0 : 96] j3d (32, 3)
# [96 : 192] vel (32, 3)
# [192 : 288] accel (32, 3)
# [288 : 414] hands_kp (42, 3) zero-padded if absent
# [414 : 424] expression (10,)
# [424 : 428] scalars (hip_y, knee_angle, sym, mouth_open)
FEATURE_DIM: int = J3D_JOINTS * J3D_DIMS * 3 + HANDS_KP_FLAT + EXPR_DIM + EXTRA_SCALARS # 428
# Body joint indices (unchanged from v1, indices 0..21).
HIP_LEFT: int = 1
HIP_RIGHT: int = 2
KNEE_LEFT: int = 4
KNEE_RIGHT: int = 5
ANKLE_LEFT: int = 7
ANKLE_RIGHT: int = 8
SHOULDER_LEFT: int = 16
SHOULDER_RIGHT: int = 17
WRIST_LEFT: int = 20
WRIST_RIGHT: int = 21
# Fingertip indices (new, 22..31), order: L thumb..pinky, R thumb..pinky.
FINGERTIP_LEFT_BASE: int = 22
FINGERTIP_RIGHT_BASE: int = 27
class FeatureExtractor:
"""Stateless feature builder over a list of recent j3d frames.
Vector layout (FEATURE_DIM = 428, v3):
[0 : 96] j3d current frame, flattened (32 joints x 3 dims)
[96 : 192] velocity j3d[t] - j3d[t-1] (32 x 3)
[192 : 288] acceleration vel[t] - vel[t-1] (32 x 3)
[288 : 414] hands_kp (42, 3) MediaPipe Hands, zero-padded if absent
[414 : 424] expression PCA coefficients (10,)
[424 : 428] kinetics scalars (hip_y, knee_angle, symmetry_score, mouth_open)
"""
@staticmethod
def from_buffer(frames: list[np.ndarray],
expr: np.ndarray | None = None,
mouth_open: float = 0.0,
hands_kp: np.ndarray | None = None) -> np.ndarray:
if not frames:
return np.zeros(FEATURE_DIM, dtype=np.float32)
cur = frames[-1]
prev = frames[-2] if len(frames) >= 2 else cur
prev2 = frames[-3] if len(frames) >= 3 else prev
vel = (cur - prev).astype(np.float32, copy=False)
prev_vel = (prev - prev2).astype(np.float32, copy=False)
accel = (vel - prev_vel).astype(np.float32, copy=False)
hip_y = float((cur[HIP_LEFT, 1] + cur[HIP_RIGHT, 1]) * 0.5)
knee_angle = FeatureExtractor._mean_knee_angle(cur)
sym = FeatureExtractor._symmetry_score(vel)
# hands block (42, 3) -> 126
hands_flat = np.zeros(HANDS_KP_FLAT, dtype=np.float32)
if hands_kp is not None:
hk = np.asarray(hands_kp, dtype=np.float32)
if hk.shape == (HANDS_KP_TOTAL, HANDS_KP_DIMS):
hands_flat = hk.reshape(-1).astype(np.float32, copy=False)
# expression
if expr is None:
expr_vec = np.zeros(EXPR_DIM, dtype=np.float32)
else:
expr_vec = np.zeros(EXPR_DIM, dtype=np.float32)
n = min(EXPR_DIM, len(expr))
expr_vec[:n] = expr[:n]
return np.concatenate([
cur.reshape(-1),
vel.reshape(-1),
accel.reshape(-1),
hands_flat,
expr_vec,
np.array([hip_y, knee_angle, sym, float(mouth_open)], dtype=np.float32),
]).astype(np.float32, copy=False)
@staticmethod
def kinetics(frames: list[np.ndarray]) -> np.ndarray:
"""Return (speed, accel_mag, symmetry) averaged over the buffer."""
if len(frames) < 2:
return np.zeros(3, dtype=np.float32)
arr = np.stack(frames).astype(np.float32, copy=False)
diffs = arr[1:] - arr[:-1]
speeds = np.linalg.norm(diffs, axis=-1).mean(axis=-1)
speed = float(speeds.mean())
if len(frames) >= 3:
ddiffs = diffs[1:] - diffs[:-1]
accel = float(np.linalg.norm(ddiffs, axis=-1).mean())
else:
accel = 0.0
sym = FeatureExtractor._symmetry_score(diffs[-1])
return np.array([speed, accel, sym], dtype=np.float32)
@staticmethod
def _mean_knee_angle(j3d: np.ndarray) -> float:
"""Angle (rad) at left+right knees, averaged."""
def _angle(hip: int, knee: int, ankle: int) -> float:
v1 = j3d[hip] - j3d[knee]
v2 = j3d[ankle] - j3d[knee]
n1 = np.linalg.norm(v1) + 1e-6
n2 = np.linalg.norm(v2) + 1e-6
cos = float(np.dot(v1, v2) / (n1 * n2))
return float(np.arccos(np.clip(cos, -1.0, 1.0)))
return 0.5 * (_angle(HIP_LEFT, KNEE_LEFT, ANKLE_LEFT)
+ _angle(HIP_RIGHT, KNEE_RIGHT, ANKLE_RIGHT))
@staticmethod
def _symmetry_score(vel: np.ndarray) -> float:
"""Cosine sim between left-arm and mirrored right-arm velocity."""
left = vel[WRIST_LEFT].copy()
right = vel[WRIST_RIGHT].copy()
right_mirror = right.copy()
right_mirror[0] = -right_mirror[0]
n1 = np.linalg.norm(left) + 1e-6
n2 = np.linalg.norm(right_mirror) + 1e-6
return float(np.dot(left, right_mirror) / (n1 * n2))
class PerPersonBuffer:
"""Per-pid ring buffer of j3d frames (deque maxlen=WINDOW_LEN)."""
__slots__ = ("_buffers",)
def __init__(self) -> None:
self._buffers: dict[int, deque[np.ndarray]] = {}
def append(self, pid: int, j3d: np.ndarray) -> None:
if j3d.shape != (J3D_JOINTS, J3D_DIMS):
raise ValueError(
f"j3d must be ({J3D_JOINTS}, {J3D_DIMS}), got {j3d.shape}"
)
dq = self._buffers.get(pid)
if dq is None:
dq = deque(maxlen=WINDOW_LEN)
self._buffers[pid] = dq
dq.append(j3d.astype(np.float32, copy=False))
def frames_for(self, pid: int) -> list[np.ndarray]:
dq = self._buffers.get(pid)
return list(dq) if dq is not None else []
def forget(self, pid: int) -> None:
self._buffers.pop(pid, None)
def __len__(self) -> int:
return len(self._buffers)
def pids(self) -> list[int]:
return list(self._buffers.keys())
class ActionHeadModel(nn.Module):
"""1-layer GRU + small MLP head.
Input : (B, FEATURE_DIM) -- single step
Hidden : (1, B, HIDDEN_DIM)
Output : (B, NUM_CLASSES) logits, new hidden
"""
def __init__(self) -> None:
super().__init__()
self.gru = nn.GRU(input_size=FEATURE_DIM,
hidden_size=HIDDEN_DIM,
num_layers=1,
batch_first=True)
self.mlp = nn.Sequential(
nn.Linear(HIDDEN_DIM, MLP_HIDDEN),
nn.ReLU(inplace=True),
nn.Linear(MLP_HIDDEN, NUM_CLASSES),
)
def init_hidden(self, batch: int = 1, device: str = "cpu") -> torch.Tensor:
return torch.zeros(1, batch, HIDDEN_DIM, device=device)
def forward(self, x: torch.Tensor,
h: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
out, h_new = self.gru(x.unsqueeze(1), h)
logits = self.mlp(out.squeeze(1))
return logits, h_new
class ActionHead:
"""Streaming action classifier per person.
Use:
head = ActionHead(ckpt_path=...)
label, probs, kin = head.step(pid, j3d)
head.forget(pid)
"""
def __init__(self,
ckpt_path: Path | None = None,
device: str = "cpu") -> None:
self._device = device
self._model = ActionHeadModel().to(device).eval()
if ckpt_path is not None:
payload = torch.load(ckpt_path, map_location=device,
weights_only=True)
state = payload.get("model_state_dict", payload)
self._model.load_state_dict(state)
self._buffers = PerPersonBuffer()
self._hidden: dict[int, torch.Tensor] = {}
self._nan_streak: dict[int, int] = {}
def step(self, pid: int, j3d: np.ndarray,
expr: np.ndarray | None = None,
mouth_open: float = 0.0,
hands_kp: np.ndarray | None = None) -> tuple[str, np.ndarray, np.ndarray]:
if np.isnan(j3d).any():
streak = self._nan_streak.get(pid, 0) + 1
self._nan_streak[pid] = streak
if streak > NAN_SKIP_BUDGET:
self.forget(pid)
probs = np.array([1.0, 0.0, 0.0], dtype=np.float32)
return LABELS[0], probs, np.zeros(3, dtype=np.float32)
self._nan_streak[pid] = 0
self._buffers.append(pid, j3d)
frames = self._buffers.frames_for(pid)
if len(frames) < WARMUP_FRAMES:
probs = np.array([1.0, 0.0, 0.0], dtype=np.float32)
return LABELS[0], probs, np.zeros(3, dtype=np.float32)
feat = FeatureExtractor.from_buffer(frames, expr=expr, mouth_open=mouth_open,
hands_kp=hands_kp)
kin = FeatureExtractor.kinetics(frames)
h = self._hidden.get(pid)
if h is None:
h = self._model.init_hidden(batch=1, device=self._device)
x = torch.from_numpy(feat).unsqueeze(0).to(self._device)
with torch.no_grad():
logits, h_new = self._model(x, h)
probs_t = torch.softmax(logits, dim=-1).squeeze(0)
self._hidden[pid] = h_new
probs = probs_t.cpu().numpy().astype(np.float32, copy=False)
return LABELS[int(np.argmax(probs))], probs, kin
def forget(self, pid: int) -> None:
self._buffers.forget(pid)
self._hidden.pop(pid, None)
self._nan_streak.pop(pid, None)
+313
View File
@@ -0,0 +1,313 @@
"""Action-head publisher : reads state.persons_smplx / persons_body3d,
runs ActionHead per pid, emits /pose/action and /pose/kin via pose_bridge.
Stand-alone thread to avoid touching multi_hmr_worker.py while it
iterates. Polls state at ~30 Hz, deduplicates by smplx_last_t.
"""
from __future__ import annotations
import logging
import threading
import time
from pathlib import Path
from typing import Any
import numpy as np
from data_only_viz.action_head import (
ActionHead,
EXPR_DIM,
HANDS_KP_DIMS,
HANDS_KP_PER_HAND,
HANDS_KP_TOTAL,
J3D_FINGERS,
J3D_FINGERS_PER_HAND,
LABELS,
)
LOG = logging.getLogger("action_head_pub")
DEFAULT_CKPT = (
Path.home() / ".cache" / "av-live-action" / "checkpoints" / "action_head.pt"
)
# Canonical SMPL-X fingertip vertex IDs from smplx.vertex_ids.SMPLX_VERTEX_IDS.
# Order : L thumb, L index, L middle, L ring, L pinky,
# R thumb, R index, R middle, R ring, R pinky.
SMPLX_FINGERTIP_VERTS: tuple[int, ...] = (
5361, 4933, 5058, 5169, 5286, # L : lthumb, lindex, lmiddle, lring, lpinky
8079, 7669, 7794, 7905, 8022, # R : rthumb, rindex, rmiddle, rring, rpinky
)
# 32 vertex indices on the 10475-vertex SMPL-X mesh:
# 22 body (UNCHANGED from v1) + 10 fingertips.
# NOTE: approximate vertex anchors -- real SMPL-X joints come from
# J_regressor @ v3d, but loading the regressor here is avoided for
# live OSC performance. Action-head training must use the same anchors.
SMPLX_JOINT_ANCHOR_VERTS: tuple[int, ...] = (
# 22 body (UNCHANGED indices, same vertex IDs as before)
8204, 3992, 6677, 3500, 3469, 6394, 3279, 3327, 6736, 3074,
8846, 8889, 8848, 1300, 4660, 8964, 3013, 6470, 1602, 5083,
2114, 5559,
# 10 fingertips
*SMPLX_FINGERTIP_VERTS,
)
assert len(SMPLX_JOINT_ANCHOR_VERTS) == 32
# Mouth-open: distance between two lip vertices on SMPL-X mesh.
# vert 8970 (upper outer lip), 8855 (lower outer lip) -- approximate.
SMPLX_UPPER_LIP_VERT: int = 8970
SMPLX_LOWER_LIP_VERT: int = 8855
# MediaPipe FaceMesh inner-mouth landmark indices.
# 13 = upper inner mid, 14 = lower inner mid.
MEDIAPIPE_LIP_UPPER_INNER: int = 13
MEDIAPIPE_LIP_LOWER_INNER: int = 14
# MediaPipe HAND fingertip indices (21-kp hand model).
MEDIAPIPE_HAND_FINGERTIPS: tuple[int, ...] = (4, 8, 12, 16, 20)
# MediaPipe 33-landmark indices mapped into the 22-joint slot order.
# NOTE: approximate mapping -- spine joints reuse hip/shoulder anchors.
# https://developers.google.com/mediapipe/solutions/vision/pose_landmarker
MEDIAPIPE_TO_22: tuple[int, ...] = (
24, 23, 24, 23, 25, 26, 11, 27, 28, 11,
31, 32, 0, 11, 12, 0, 11, 12, 13, 14, 15, 16,
)
class ActionHeadPublisher(threading.Thread):
"""Thread that polls state, runs ActionHead per pid, emits OSC."""
def __init__(self, state: Any, bridge: Any,
ckpt_path: Path | None = DEFAULT_CKPT,
period_s: float = 1.0 / 30.0) -> None:
super().__init__(daemon=True, name="action-head-pub")
self.state = state
self.bridge = bridge
self.period = period_s
try:
ckpt = ckpt_path if (ckpt_path and ckpt_path.exists()) else None
self.head = ActionHead(ckpt_path=ckpt, device="cpu")
LOG.info("action_head loaded ckpt=%s",
ckpt if ckpt else "<random init>")
except Exception as e:
LOG.warning("action_head init failed: %s", e)
self.head = None
self._stop = threading.Event()
self._last_smplx_t = 0.0
self._last_body_t = 0.0
self._last_pids: set[int] = set()
def stop(self) -> None:
self._stop.set()
def run(self) -> None:
if self.head is None:
LOG.warning("publisher exiting: no action_head")
return
LOG.info("publisher started")
while not self._stop.is_set():
t0 = time.perf_counter()
try:
self._tick(t0)
except Exception:
LOG.exception("publisher tick failed")
dt = time.perf_counter() - t0
if dt < self.period:
time.sleep(self.period - dt)
LOG.info("publisher stopped")
def _tick(self, t_now: float) -> None:
persons32, source_t, source_tag, is_new = self._read_sources()
if not is_new:
return
if "smplx" in source_tag:
self._last_smplx_t = source_t
else:
self._last_body_t = source_t
current_pids: set[int] = set()
if persons32:
for pid, j3d, expr_np, mouth, hands_kp42 in persons32:
current_pids.add(pid)
label, probs, kin = self.head.step(pid, j3d, expr=expr_np,
mouth_open=mouth,
hands_kp=hands_kp42)
idx = LABELS.index(label)
self.bridge.send_action(pid, idx, probs, t_now, force=True)
self.bridge.send_kin(pid, kin, t_now, force=True)
if pid not in self._last_pids:
self.bridge.send_enter(pid=pid)
for gone in self._last_pids - current_pids:
self.head.forget(gone)
self.bridge.send_leave(pid=gone)
self._last_pids = current_pids
def _read_sources(self) -> tuple[
list[tuple[int, np.ndarray, np.ndarray, float, np.ndarray]] | None,
float, str, bool,
]:
"""Return (persons32, source_t, source_tag, is_new).
Each person entry is (pid, j3d32, expr10, mouth_open, hands_kp42x3).
is_new is True when the timestamp advanced (even if person list
is empty), so _tick can still run the purge loop.
"""
with self.state.lock():
persons_smplx = getattr(self.state, "persons_smplx", None)
t_smplx = getattr(self.state, "smplx_last_t", 0.0)
persons_b3d = getattr(self.state, "persons_body3d", None)
ids_b3d = getattr(self.state, "persons_body_ids", None)
persons_face = getattr(self.state, "persons_face", None)
ids_face = getattr(self.state, "persons_face_ids", None)
persons_hands = getattr(self.state, "persons_hands", None)
ids_hands = getattr(self.state, "persons_hands_ids", None)
t_body = getattr(self.state, "pose_last_t", 0.0)
# Build pid -> hands_kp(42, 3) map from MediaPipe persons_hands.
hands_by_pid: dict[int, np.ndarray] = self._build_hands_map(
persons_hands or [], ids_hands or [],
)
# Build pid -> mouth_open scalar from MediaPipe persons_face lips.
face_mouth_by_pid: dict[int, float] = self._build_face_mouth_map(
persons_face or [], ids_face or [],
)
# SMPL-X path (preferred)
if t_smplx > self._last_smplx_t:
out: list[tuple[int, np.ndarray, np.ndarray, float, np.ndarray]] = []
for i, p in enumerate(persons_smplx or []):
pid = int(p.get("pid", i))
v3d = p.get("v3d")
if v3d is None:
continue
# CoreMLArray wraps a numpy array but has no __array__
# protocol; unwrap via .numpy() before np.asarray.
if hasattr(v3d, "numpy") and not isinstance(v3d, np.ndarray):
v3d = v3d.numpy()
v3d_np = np.asarray(v3d, dtype=np.float32)
if v3d_np.shape[0] < max(SMPLX_JOINT_ANCHOR_VERTS) + 1:
continue
j3d32 = v3d_np[list(SMPLX_JOINT_ANCHOR_VERTS)].astype(np.float32)
# expression
expr = p.get("expression")
if expr is not None:
if hasattr(expr, "numpy") and not isinstance(expr, np.ndarray):
expr = expr.numpy()
expr_np = np.asarray(expr, dtype=np.float32).flatten()
else:
expr_np = np.zeros(EXPR_DIM, dtype=np.float32)
# mouth_open: prefer MediaPipe face lips, fallback SMPL-X v3d.
if pid in face_mouth_by_pid:
mouth = face_mouth_by_pid[pid]
elif v3d_np.shape[0] > max(SMPLX_UPPER_LIP_VERT, SMPLX_LOWER_LIP_VERT):
mouth = float(np.linalg.norm(
v3d_np[SMPLX_UPPER_LIP_VERT] - v3d_np[SMPLX_LOWER_LIP_VERT]
))
else:
mouth = 0.0
hands_kp42 = hands_by_pid.get(
pid, np.zeros((HANDS_KP_TOTAL, HANDS_KP_DIMS), dtype=np.float32)
)
out.append((pid, j3d32, expr_np, mouth, hands_kp42))
return out or None, t_smplx, "smplx", True
# MediaPipe body3d fallback
if t_body > self._last_body_t:
ids = ids_b3d or list(range(len(persons_b3d or [])))
out = []
for i, body in enumerate(persons_b3d or []):
pid = int(ids[i]) if i < len(ids) else i
arr = self._kp_list_to_array(body)
if arr is None or arr.shape[0] < 33:
continue
body22 = arr[list(MEDIAPIPE_TO_22)].astype(np.float32)
# fingertips from persons_hands if available
tips = np.zeros((J3D_FINGERS, 3), dtype=np.float32)
hands_kp42 = hands_by_pid.get(
pid, np.zeros((HANDS_KP_TOTAL, HANDS_KP_DIMS), dtype=np.float32)
)
# extract fingertips from hands_kp42 (idx 4,8,12,16,20 each side)
for side_idx in (0, 1):
base = side_idx * HANDS_KP_PER_HAND
for k, mp_tip in enumerate(MEDIAPIPE_HAND_FINGERTIPS):
if base + mp_tip < hands_kp42.shape[0]:
tips[side_idx * J3D_FINGERS_PER_HAND + k] = \
hands_kp42[base + mp_tip]
j3d32 = np.concatenate([body22, tips], axis=0)
mouth = face_mouth_by_pid.get(pid, 0.0)
expr_np = np.zeros(EXPR_DIM, dtype=np.float32)
out.append((pid, j3d32, expr_np, mouth, hands_kp42))
return out or None, t_body, "body3d", True
return None, 0.0, "", False
def _build_hands_map(self, persons_hands: list,
ids_hands: list) -> dict[int, np.ndarray]:
"""Combine left+right hand kp arrays per pid into a single (42, 3) array.
persons_hands is a flat list ; ids_hands maps each hand-list entry to a
pid (and odd/even index indicates which side). When the user's pipeline
keeps a different convention, this helper makes the best effort and
pads zeros for missing sides.
"""
out: dict[int, np.ndarray] = {}
for hi, hkp in enumerate(persons_hands):
if hkp is None:
continue
pid_raw = ids_hands[hi] if hi < len(ids_hands) else hi
try:
pid = int(pid_raw)
except (TypeError, ValueError):
pid = hi
side = hi % 2 # 0 = L, 1 = R
arr = self._kp_list_to_array(hkp)
if arr is None or arr.shape[0] < HANDS_KP_PER_HAND:
continue
slot = out.setdefault(
pid, np.zeros((HANDS_KP_TOTAL, HANDS_KP_DIMS), dtype=np.float32)
)
base = side * HANDS_KP_PER_HAND
slot[base:base + HANDS_KP_PER_HAND] = arr[:HANDS_KP_PER_HAND]
return out
def _build_face_mouth_map(self, persons_face: list,
ids_face: list) -> dict[int, float]:
"""Compute mouth_open = norm(upper_inner_lip - lower_inner_lip) per pid."""
out: dict[int, float] = {}
for fi, fkp in enumerate(persons_face):
if fkp is None:
continue
arr = self._kp_list_to_array(fkp)
if arr is None or arr.shape[0] <= MEDIAPIPE_LIP_LOWER_INNER:
continue
upper = arr[MEDIAPIPE_LIP_UPPER_INNER]
lower = arr[MEDIAPIPE_LIP_LOWER_INNER]
mouth = float(np.linalg.norm(upper - lower))
try:
pid = int(ids_face[fi]) if fi < len(ids_face) else fi
except (TypeError, ValueError):
pid = fi
out[pid] = mouth
return out
@staticmethod
def _kp_list_to_array(body: Any) -> np.ndarray | None:
"""Best-effort conversion of a body keypoint list to (N, 3) array."""
if body is None:
return None
if isinstance(body, np.ndarray):
return body
try:
return np.asarray(
[
(
getattr(kp, "x", kp[0]),
getattr(kp, "y", kp[1]),
getattr(kp, "z", kp[2] if len(kp) > 2 else 0.0),
)
for kp in body
],
dtype=np.float32,
)
except (TypeError, IndexError, AttributeError):
return None
+15 -28
View File
@@ -552,32 +552,13 @@ class AppleVisionPoseWorker:
if not hasattr(self, "_logged_face_ok_" + region_name):
LOG.info("face: region %s count=%d", region_name, count)
setattr(self, "_logged_face_ok_" + region_name, True)
# API stable : pointAtIndex_(k) retourne un CGPoint struct.
n = min(count, end - start)
n_written = 0
for k in range(n):
try:
pt = region.pointAtIndex_(k)
# CGPoint en pyobjc : tuple (x, y) ou struct
try:
nx_bb = float(pt.x); ny_bb = float(pt.y)
except (AttributeError, TypeError):
nx_bb = float(pt[0]); ny_bb = float(pt[1])
fx = bx + nx_bb * bw
fy_bl = by + ny_bb * bh
kps[start + k] = PoseKp(
x=fx, y=1.0 - fy_bl, z=0.0, c=1.0)
n_written += 1
except Exception as e:
if not hasattr(self, "_logged_face_pt_err"):
LOG.info("face: pt %s[%d] err: %s (pt=%r)",
region_name, k, e, type(pt).__name__
if 'pt' in dir() else "??")
self._logged_face_pt_err = True
continue
if n_written > 0 and not hasattr(self, "_logged_face_write_" + region_name):
LOG.info("face: %s wrote %d points", region_name, n_written)
setattr(self, "_logged_face_write_" + region_name, True)
# pyobjc 11 ne sait pas que pointAtIndex_ prend 1 arg, et
# pointsInImageOfSize_ retourne un PyObjCPointer C-array sans
# API d'acces simple. Face parsing depuis Apple Vision est
# actuellement bloque ; on garde MediaPipe (CPU XNNPACK) pour
# face/hand fin tandis que Vision sert body 2D sur ANE.
# Skip pour eviter le spam ObjCPointerWarning a 30 fps.
return
# faceContour
fill("faceContour", *FACE_OFFSETS["contour"])
@@ -590,13 +571,19 @@ class AppleVisionPoseWorker:
fill("nose", *FACE_OFFSETS["nose"])
fill("medianLine", *FACE_OFFSETS["median"])
# Pupilles : VNFaceLandmarkRegion2D simple (1 point chacune).
# Pupilles : single-point regions ; meme workaround pyobjc.
for region_name, idx in (("leftPupil", 81), ("rightPupil", 82)):
try:
region = getattr(landmarks, region_name)()
if region is None or region.pointCount() < 1:
continue
pt = region.pointAtIndex_(0)
try:
pts = region.pointsInImageOfSize_((1.0, 1.0))
except Exception:
pts = region.normalizedPoints()
if not pts:
continue
pt = pts[0]
try:
px, py = float(pt.x), float(pt.y)
except (AttributeError, TypeError):
+48 -1
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import argparse
import logging
import os
import signal
import sys
@@ -265,9 +266,55 @@ class AppDelegate(NSObject):
self._opts, "motion_gate", 5.0),
camera_index=getattr(self._opts, "camera_index", -1))
self._pose_worker.start()
self._smplx_tcp = SMPLXTCPSender(self._state)
# MESH_RIG=0 disables the 30 fps rigid translation
# rigger from mesh_rigger.py (used to debug deformation
# issues introduced by the hybrid rigging path).
self._smplx_tcp = SMPLXTCPSender(
self._state,
enable_rigging=os.environ.get("MESH_RIG", "1") != "0",
)
self._smplx_tcp.start()
LOG.info("worker: Multi-HMR + SMPL-X (mesh dense)")
# Secondary body-pose worker in parallel: AVLiveBody
# gets body keypoints on UDP :57126 alongside the mesh
# on TCP :57130. Default: Apple Vision (ANE-accel,
# body only 19 joints). Set AV_LIVE_PARALLEL_POSE=
# mediapipe to swap to MediaPipe Holistic (CPU
# XNNPACK but provides face + hand + 3D world).
# Defaut: lance BOTH Apple Vision (body 19 joints sur
# ANE, ~30 fps) ET MediaPipe Multi (face 468 + hands 21
# + pose 3D world sur CPU XNNPACK). Set
# AV_LIVE_PARALLEL_POSE=apple_vision pour ne garder que
# le path ANE (face/hand fin disparait), ou =mediapipe
# pour ne garder que CPU.
parallel = _os.environ.get(
"AV_LIVE_PARALLEL_POSE", "both")
if parallel in ("apple_vision", "both"):
try:
from .apple_vision_pose import AppleVisionPoseWorker
if AppleVisionPoseWorker.is_available():
self._av_worker = AppleVisionPoseWorker(
self._state, target_fps=30.0,
num_persons=4)
self._av_worker.start()
LOG.info("worker: + Apple Vision body pose "
"(ANE) in parallel")
else:
raise RuntimeError("apple_vision unavailable")
except Exception as e: # noqa: BLE001
LOG.warning("Apple Vision parallel start failed "
"(%s)", e)
if parallel in ("mediapipe", "both"):
try:
from .multi import MultiWorker
self._mp_worker = MultiWorker(
self._state, num_persons=4)
self._mp_worker.start()
LOG.info("worker: + MediaPipe Multi (3D pose "
"+ face + hand) in parallel")
except Exception as e: # noqa: BLE001
LOG.warning("MediaPipe parallel start failed "
"(%s)", e)
return
LOG.info("Multi-HMR indisponible (checkpoints manquants) "
"— voir scripts/setup_multihmr.sh")
+215
View File
@@ -0,0 +1,215 @@
"""Mesh rigging hybride keyframe (Multi-HMR) + delta Apple Vision.
Multi-HMR produit un mesh SMPL-X dense (10475 verts) tous les ~300 ms
sur M5 (PyTorch MPS ~3.5 fps). Entre deux keyframes, Apple Vision sur
ANE produit 30 fps de body keypoints 2D. On exploite le pelvis 2D de
Vision pour translater rigidement le mesh keyframe et donner une
perception fluide a 30 fps cote launcher RealityKit.
Limitations connues (premiere iteration) :
- Translation rigide uniquement (pas de rotation, pas de LBS articule)
- Pelvis 2D delta projete en X/Y a profondeur constante (z keyframe)
- Pas de matching d'identite Vision <-> Multi-HMR : on prend la
personne Vision la plus proche du pelvis projete keyframe
"""
from __future__ import annotations
import math
import threading
import time
from dataclasses import dataclass, field
import numpy as np
from .state import PoseKp, SMPLXPerson, State
# Indices MediaPipe POSE_LANDMARKS pour les hanches (pelvis 2D = midpoint).
_LEFT_HIP = 23
_RIGHT_HIP = 24
# Focale par defaut Multi-HMR (camera intrinsics typiques utilisees
# dans multi_hmr_worker : focal = IMG_SIZE).
_IMG_SIZE = 672
_FOCAL = float(_IMG_SIZE)
@dataclass
class _Keyframe:
"""Snapshot d'un mesh Multi-HMR + reference Vision au moment T."""
pid: int
t: float
# Mesh world coords (10475, 3) float32 incluant la translation
vertices_3d: np.ndarray
translation: np.ndarray # (3,) world pelvis
vision_pelvis_2d: tuple[float, float] | None # (cx, cy) normalises 0..1
def _pelvis_2d_from_body(body: list[PoseKp]) -> tuple[float, float] | None:
"""Midpoint des deux hanches MediaPipe si confidence > 0."""
if not body or len(body) <= _RIGHT_HIP:
return None
lh, rh = body[_LEFT_HIP], body[_RIGHT_HIP]
if lh.c <= 0.1 or rh.c <= 0.1:
return None
return (0.5 * (lh.x + rh.x), 0.5 * (lh.y + rh.y))
def _vision_pid_match(
keyframe_pelvis_2d: tuple[float, float] | None,
vision_bodies: list[list[PoseKp]],
vision_ids: list[int],
) -> int | None:
"""Retourne le pid Vision dont le pelvis 2D est le plus proche du
keyframe pelvis projete. None si rien."""
if keyframe_pelvis_2d is None or not vision_bodies:
return None
kx, ky = keyframe_pelvis_2d
best_pid: int | None = None
best_d2 = float("inf")
for body, vpid in zip(vision_bodies, vision_ids):
p = _pelvis_2d_from_body(body)
if p is None:
continue
d2 = (p[0] - kx) ** 2 + (p[1] - ky) ** 2
if d2 < best_d2:
best_d2 = d2
best_pid = int(vpid)
return best_pid
class MeshRigger:
"""Rig le mesh SMPL-X keyframe via le delta pelvis Vision.
Usage :
rigger = MeshRigger(state)
rigged_persons = rigger.apply(state.persons_smplx,
state.persons_body,
t_now)
Thread-safe : ne mute pas le state, retourne une nouvelle liste.
"""
def __init__(self, state: State, hold_window_s: float = 1.5) -> None:
self.state = state
self.hold_window_s = hold_window_s
self._lock = threading.Lock()
# pid Multi-HMR -> keyframe
self._keyframes: dict[int, _Keyframe] = {}
# pid Multi-HMR -> pid Vision matched (sticky across frames)
self._vision_pid_map: dict[int, int] = {}
def apply(
self,
persons_smplx: list[SMPLXPerson],
persons_body: list[list[PoseKp]],
persons_body_ids: list[int],
t_now: float,
) -> list[SMPLXPerson]:
"""Retourne une liste SMPLXPerson translatee par delta Vision."""
# 1) Detect new keyframes (timestamp tracked via state.smplx_last_t)
with self._lock:
current_pids = {p.pid for p in persons_smplx}
# Drop stale keyframes (person disparue)
for old_pid in list(self._keyframes):
if old_pid not in current_pids:
self._keyframes.pop(old_pid, None)
self._vision_pid_map.pop(old_pid, None)
out: list[SMPLXPerson] = []
for person in persons_smplx:
kf = self._keyframes.get(person.pid)
# Detect keyframe refresh : translation differs from kf
is_new_kf = (kf is None or not np.allclose(
kf.translation, person.translation, atol=1e-4))
if is_new_kf:
# Trouver le pid Vision le plus proche pour ce mesh.
# On projette le pelvis world en 2D image-normalized :
# x_img = (X / Z) * focal / IMG_SIZE + 0.5
pelvis_2d = self._project_pelvis(person.translation)
matched = _vision_pid_match(
pelvis_2d, persons_body, persons_body_ids)
if matched is None:
matched = self._vision_pid_map.get(person.pid)
if matched is not None:
self._vision_pid_map[person.pid] = matched
# Capture du pelvis 2D Vision au moment du keyframe
vp = None
if matched is not None:
try:
i = persons_body_ids.index(matched)
vp = _pelvis_2d_from_body(persons_body[i])
except (ValueError, IndexError):
vp = None
self._keyframes[person.pid] = _Keyframe(
pid=person.pid,
t=t_now,
vertices_3d=person.vertices_3d.copy(),
translation=person.translation.copy(),
vision_pelvis_2d=vp,
)
out.append(person)
continue
# Entre keyframes : applique delta translation depuis
# Vision pelvis 2D actuel vs keyframe pelvis 2D.
if t_now - kf.t > self.hold_window_s:
# Trop ancien, on lache le rig (mesh statique)
out.append(person)
continue
matched_pid = self._vision_pid_map.get(person.pid)
if matched_pid is None or kf.vision_pelvis_2d is None:
out.append(person)
continue
try:
i = persons_body_ids.index(matched_pid)
except ValueError:
out.append(person)
continue
current_vp = _pelvis_2d_from_body(persons_body[i])
if current_vp is None:
out.append(person)
continue
# Image-normalized 2D delta -> world XY delta a depth z_kf.
# Pour un pelvis aux coords image (px in [0,1] centre 0.5),
# X_world = (px - 0.5) * IMG_SIZE * Z / focal = (px-0.5)*Z
# (focal=IMG_SIZE). Delta image -> Delta world a Z fixe.
z_kf = float(kf.translation[2]) if abs(
kf.translation[2]) > 1e-3 else 1.0
dx_img = current_vp[0] - kf.vision_pelvis_2d[0]
dy_img = current_vp[1] - kf.vision_pelvis_2d[1]
dx_world = dx_img * _IMG_SIZE * z_kf / _FOCAL
dy_world = dy_img * _IMG_SIZE * z_kf / _FOCAL
# Applique a tous les vertices + a translation.
new_verts = kf.vertices_3d.copy()
new_verts[:, 0] += np.float32(dx_world)
new_verts[:, 1] += np.float32(dy_world)
new_transl = kf.translation.copy()
new_transl[0] += np.float32(dx_world)
new_transl[1] += np.float32(dy_world)
out.append(SMPLXPerson(
pid=person.pid,
vertices_3d=new_verts,
translation=new_transl,
confidence=person.confidence,
betas=person.betas,
expression=person.expression,
))
return out
@staticmethod
def _project_pelvis(
translation: np.ndarray,
) -> tuple[float, float] | None:
"""World pelvis (X,Y,Z) -> image-normalized 2D pelvis."""
z = float(translation[2])
if abs(z) < 1e-3:
return None
x_img = (float(translation[0]) * _FOCAL / z) / _IMG_SIZE + 0.5
y_img = (float(translation[1]) * _FOCAL / z) / _IMG_SIZE + 0.5
# Clamp en [0,1]
if not (0.0 <= x_img <= 1.0 and 0.0 <= y_img <= 1.0):
return None
return (x_img, y_img)
+238 -1
View File
@@ -30,6 +30,7 @@ CACHE = Path.home() / ".cache" / "av-live-multihmr"
CKPT = CACHE / "checkpoints" / "multiHMR_672_S.pt"
SMPLX_PATH = CACHE / "models" / "smplx" / "SMPLX_NEUTRAL.npz"
MULTIHMR_REPO = CACHE / "multi-hmr"
COREML_MLPACKAGE = CACHE / "multihmr_full_672_s.mlpackage"
IMG_SIZE = 672
N_VERTS = 10475
@@ -41,7 +42,8 @@ class MultiHMRWorker:
det_thresh: float = 0.3,
nms_kernel_size: int = 5,
motion_gate: float = 5.0,
camera_index: int = -1) -> None:
camera_index: int = -1,
backend: str | None = None) -> None:
self.state = state
self.num_persons = num_persons
self.period = 1.0 / max(1.0, target_fps)
@@ -55,6 +57,12 @@ class MultiHMRWorker:
self.motion_gate = motion_gate
# -1 = auto-select Mac BuiltInWideAngleCamera (cf _camera_select)
self.camera_index = camera_index
# backend: 'pytorch' (default) or 'coreml'. CoreML uses the
# .mlpackage at COREML_MLPACKAGE, bypasses MPS torch, and runs
# on ANE/GPU/CPU via CoreML.framework natively (3-4x faster).
self.backend = (backend
or os.environ.get("MULTIHMR_BACKEND", "pytorch")
).strip().lower()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._smooth_shape = [
@@ -72,6 +80,9 @@ class MultiHMRWorker:
@staticmethod
def is_available() -> bool:
backend = os.environ.get("MULTIHMR_BACKEND", "pytorch").strip().lower()
if backend == "coreml":
return COREML_MLPACKAGE.exists()
return CKPT.exists() and SMPLX_PATH.exists() and MULTIHMR_REPO.exists()
def start(self) -> None:
@@ -83,6 +94,12 @@ class MultiHMRWorker:
self._stop.set()
def _run(self) -> None:
if self.backend == "coreml":
self._run_coreml()
return
self._run_pytorch()
def _run_pytorch(self) -> None:
if str(MULTIHMR_REPO) not in sys.path:
sys.path.insert(0, str(MULTIHMR_REPO))
# Multi-HMR demo.py tire pyrender / pyvista (OpenGL offscreen) et
@@ -415,3 +432,223 @@ class MultiHMRWorker:
cap.stop()
LOG.info("multi_hmr worker stopped")
# ------------------------------------------------------------------
# CoreML backend
# ------------------------------------------------------------------
def _run_coreml(self) -> None:
"""CoreML inference path (ANE+GPU+CPU via Apple's framework).
Mirrors _run_pytorch but loads the .mlpackage via pyobjc + the
CoreML.framework, bypassing torch/MPS entirely. ~3-4x faster
on M5 (28.8ms median vs ~100ms with MPS)."""
try:
import cv2
except ImportError as e:
LOG.error("opencv-python missing: %s", e)
return
try:
from .multihmr_coreml import MultiHMRCoreMLBackend
backend = MultiHMRCoreMLBackend(COREML_MLPACKAGE)
except Exception as e: # noqa: BLE001
LOG.error("CoreML backend init failed: %s", e)
return
focal = float(IMG_SIZE)
K_np = np.array([[focal, 0.0, IMG_SIZE / 2.0],
[0.0, focal, IMG_SIZE / 2.0],
[0.0, 0.0, 1.0]], dtype=np.float32)
from ._av_capture import (
AVCapture, find_builtin_device, enumerate_devices)
if self.camera_index >= 0:
devs = enumerate_devices()
if self.camera_index >= len(devs):
LOG.error("camera_index %d hors de %d devices",
self.camera_index, len(devs))
return
info = devs[self.camera_index]
else:
info = find_builtin_device()
if info is None:
LOG.error("aucune BuiltInWideAngleCamera trouvee")
return
cap = AVCapture(info)
if not cap.start():
LOG.error("AVCapture start failed pour %s", info["name"])
return
LOG.info("camera ouverte %s (%s) [coreml backend]",
info["name"], info["type"])
frame_count = 0
persons_count = 0
skipped_static = 0
next_heartbeat = time.monotonic() + 5.0
prev_thumb: np.ndarray | None = None
while not self._stop.is_set():
t_cap_start = time.monotonic()
ok, frame_bgr = cap.read(timeout_s=0.5)
if not ok or frame_bgr is None:
time.sleep(self.period)
continue
t_pre_start = time.monotonic()
h, w = frame_bgr.shape[:2]
if (h, w) != (IMG_SIZE, IMG_SIZE):
side = min(h, w)
y0 = (h - side) // 2
x0 = (w - side) // 2
frame_bgr = frame_bgr[y0:y0 + side, x0:x0 + side]
frame_bgr = cv2.resize(frame_bgr, (IMG_SIZE, IMG_SIZE))
if self.motion_gate > 0:
thumb = cv2.cvtColor(
cv2.resize(frame_bgr, (112, 112)),
cv2.COLOR_BGR2GRAY)
if prev_thumb is not None:
diff_mean = float(np.mean(
cv2.absdiff(thumb, prev_thumb)))
if diff_mean < self.motion_gate:
prev_thumb = thumb
skipped_static += 1
time.sleep(self.period)
continue
prev_thumb = thumb
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
img = frame_rgb.transpose(2, 0, 1).astype(np.float32) / 255.0
t_inf_start = time.monotonic()
try:
humans = backend.infer(img, K_np, det_thresh=self.det_thresh)
except Exception as e: # noqa: BLE001
LOG.warning("coreml inference failed: %s", e)
time.sleep(self.period)
continue
t_post_start = time.monotonic()
t_now = time.monotonic()
frame_count += 1
persons_count += len(humans) if humans else 0
if t_now >= next_heartbeat:
fps = frame_count / 5.0
avg = persons_count / max(1, frame_count)
LOG.info(
"hb[coreml]: %.1f fps, %.2f persons/frame, %d skipped",
fps, avg, skipped_static)
frame_count = 0
persons_count = 0
skipped_static = 0
next_heartbeat = t_now + 5.0
if not humans:
with self.state.lock():
self.state.persons_smplx = []
time.sleep(self.period)
continue
# Dedup intra-frame (same logic as pytorch path).
cand: list[tuple[
float, float, float, float, float,
np.ndarray, int]] = []
for i, hh in enumerate(humans):
v = hh["v3d"].detach().cpu().numpy()
xmin = float(v[:, 0].min()); ymin = float(v[:, 1].min())
xmax = float(v[:, 0].max()); ymax = float(v[:, 1].max())
score = float(hh["scores"].item())
pelv = hh["transl_pelvis"].detach().cpu().numpy(
).flatten()[:3]
cand.append((score, xmin, ymin, xmax, ymax, pelv, i))
cand.sort(key=lambda c: -c[0])
keep_idx: list[int] = []
kept: list[tuple[float, float, float, float, np.ndarray]] = []
for sc, x0, y0, x1, y1, pelv, src_i in cand:
a_area = max(0.0, x1 - x0) * max(0.0, y1 - y0)
drop = False
for (kx0, ky0, kx1, ky1, kpelv) in kept:
ix0 = max(x0, kx0); iy0 = max(y0, ky0)
ix1 = min(x1, kx1); iy1 = min(y1, ky1)
iw = max(0.0, ix1 - ix0); ih = max(0.0, iy1 - iy0)
inter = iw * ih
if a_area <= 0 or inter <= 0:
continue
k_area = (kx1 - kx0) * (ky1 - ky0)
iou = inter / (a_area + k_area - inter + 1e-9)
pelv_d = float(np.linalg.norm(pelv - kpelv))
if iou > 0.55 and pelv_d < 0.20:
drop = True
break
if not drop:
keep_idx.append(src_i)
kept.append((x0, y0, x1, y1, pelv))
if len(keep_idx) >= self.num_persons:
break
humans = [humans[i] for i in keep_idx]
n_keep = len(humans)
bboxes = []
for hh in humans:
v = hh["v3d"].detach().cpu().numpy()
xmin, ymin = float(v[:, 0].min()), float(v[:, 1].min())
xmax, ymax = float(v[:, 0].max()), float(v[:, 1].max())
bboxes.append([PoseKp(x=xmin, y=ymin, c=1.0),
PoseKp(x=xmax, y=ymax, c=1.0)])
ids = self._tracker.update(bboxes)
persons: list[SMPLXPerson] = []
for i, hh in enumerate(humans[:n_keep]):
pid = ids[i] if i < len(ids) else i
if pid < 0:
continue
v3d = hh["v3d"].detach().cpu().numpy()
transl_np = hh["transl_pelvis"].detach().cpu().numpy().flatten()
shape_raw = hh["shape"].detach().cpu().numpy().flatten()
expr_raw = hh["expression"].detach().cpu().numpy().flatten()
pid_c = pid % self.num_persons
shape_n = min(10, len(shape_raw))
expr_n = min(10, len(expr_raw))
shape_smooth = np.zeros(10, dtype=np.float32)
expr_smooth = np.zeros(10, dtype=np.float32)
for k in range(shape_n):
shape_smooth[k] = self._smooth_shape[pid_c][k](
float(shape_raw[k]), t_now)
for k in range(expr_n):
expr_smooth[k] = self._smooth_expr[pid_c][k](
float(expr_raw[k]), t_now)
persons.append(SMPLXPerson(
pid=int(pid),
vertices_3d=np.ascontiguousarray(v3d, dtype=np.float32),
translation=np.ascontiguousarray(
transl_np[:3], dtype=np.float32),
confidence=float(hh["scores"].item()),
betas=np.ascontiguousarray(shape_smooth, dtype=np.float32),
expression=np.ascontiguousarray(expr_smooth, dtype=np.float32),
))
with self.state.lock():
self.state.persons_smplx = persons
self.state.smplx_last_t = t_now
t_end = time.monotonic()
dt_total = (t_end - t_cap_start) * 1e3
if LOG.isEnabledFor(logging.DEBUG) or dt_total > 100.0:
LOG.log(
logging.DEBUG if dt_total <= 100.0 else logging.WARNING,
"frame[coreml]: cap=%.1f pre=%.1f inf=%.1f "
"post=%.1fms total=%.1fms",
(t_pre_start - t_cap_start) * 1e3,
(t_inf_start - t_pre_start) * 1e3,
(t_post_start - t_inf_start) * 1e3,
(t_end - t_post_start) * 1e3,
dt_total,
)
dt = time.monotonic() - t_cap_start
if dt < self.period:
time.sleep(self.period - dt)
cap.stop()
LOG.info("multi_hmr coreml worker stopped")
+276
View File
@@ -0,0 +1,276 @@
"""Multi-HMR CoreML backend (ANE/GPU/CPU via Apple's CoreML framework).
Python 3.14 cannot use `coremltools.MLModel` because `libcoremlpython`
and `libmilstoragepython` native extensions are not distributed for
3.14. We load CoreML.framework directly via `objc.loadBundle()` —
same pattern as `coreml_pose.py`.
Unlike `coreml_pose.py`, this backend does NOT use Vision: Vision is
limited to image inputs and cannot feed a second MLMultiArray (cam_K).
We invoke `MLModel.predictionFromFeatures:error:` directly with a
`MLDictionaryFeatureProvider` wrapping two `MLMultiArray`s.
Public API:
backend = MultiHMRCoreMLBackend(mlpackage_path)
humans = backend.infer(image_chw_f32, K_33_f32, det_thresh=0.3)
# humans is a list[dict] with the same keys as the PyTorch model
# output. Values are CoreMLArray instances that quack like torch
# tensors (.detach().cpu().numpy() / .item()).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import numpy as np
import objc
from Foundation import NSURL
LOG = logging.getLogger("multihmr_coreml")
DEFAULT_MLPACKAGE = (
Path.home() / ".cache" / "av-live-multihmr"
/ "multihmr_full_672_s.mlpackage"
)
# Multi-HMR exported with apply_topk(K=4): outputs are fixed shape.
N_PERSONS_FIXED = 4
N_VERTS = 10475
# CoreML output names from the exported .mlpackage.
OUT_V3D = "var_2412" # (4, 10475, 3)
OUT_TRANSL = "var_2415" # (4, 1, 3)
OUT_SCORES = "var_2428" # (4,)
OUT_BETAS = "var_2431" # (4, 10)
OUT_EXPR = "var_2434" # (4, 10)
# MLMultiArrayDataType raw values (from CoreML headers).
ML_DTYPE_FLOAT32 = 65568
ML_DTYPE_FLOAT16 = 65552
ML_DTYPE_DOUBLE = 65600
ML_DTYPE_INT32 = 131104
_NS: dict[str, Any] = {}
_FRAMEWORKS_LOADED = False
def _load_frameworks() -> dict[str, Any]:
global _FRAMEWORKS_LOADED
if _FRAMEWORKS_LOADED:
return _NS
objc.loadBundle("CoreML", _NS,
"/System/Library/Frameworks/CoreML.framework")
_FRAMEWORKS_LOADED = True
return _NS
class CoreMLArray:
"""Tiny tensor-like adapter so the existing worker hot path can
treat CoreML outputs the same way it treats torch tensors.
Supports `.detach().cpu().numpy()` and `.item()`. The wrapper is
a no-op around a numpy array; we keep the chain so callers don't
need any conditional branch."""
__slots__ = ("_arr",)
def __init__(self, arr: np.ndarray) -> None:
self._arr = arr
def detach(self) -> "CoreMLArray":
return self
def cpu(self) -> "CoreMLArray":
return self
def numpy(self) -> np.ndarray:
return self._arr
def item(self) -> float:
return float(self._arr.reshape(-1)[0])
@property
def shape(self) -> tuple[int, ...]:
return tuple(self._arr.shape)
def _np_to_mlarray(arr: np.ndarray):
"""Create a contiguous float32 MLMultiArray from a numpy array.
We always feed FLOAT32 — even though outputs are FLOAT16, CoreML
will auto-cast on the input side."""
ns = _load_frameworks()
MLMultiArray = ns["MLMultiArray"]
arr = np.ascontiguousarray(arr, dtype=np.float32)
shape = [int(s) for s in arr.shape]
ml = MLMultiArray.alloc().initWithShape_dataType_error_(
shape, ML_DTYPE_FLOAT32, None)
if ml is None:
raise RuntimeError("MLMultiArray alloc failed")
# Copy bytes through dataPointer (raw void*). pyobjc exposes it as
# a memoryview-like opaque; we use ctypes to memcpy.
import ctypes
ptr = ml.dataPointer()
n_bytes = arr.nbytes
# pyobjc returns either an objc.varlist or a Python int pointer.
addr = int(ptr) if isinstance(ptr, int) else ctypes.cast(
ptr, ctypes.c_void_p).value
if addr is None:
raise RuntimeError("MLMultiArray dataPointer null")
ctypes.memmove(addr, arr.ctypes.data, n_bytes)
return ml
def _mlarray_to_np(ml) -> np.ndarray:
"""Copy an MLMultiArray (FLOAT16 or FLOAT32) into a numpy float32."""
import ctypes
shape = tuple(int(s) for s in ml.shape())
dtype_id = int(ml.dataType())
count = 1
for s in shape:
count *= s
ptr = ml.dataPointer()
addr = int(ptr) if isinstance(ptr, int) else ctypes.cast(
ptr, ctypes.c_void_p).value
if addr is None:
raise RuntimeError("MLMultiArray dataPointer null")
if dtype_id == ML_DTYPE_FLOAT16:
raw = (ctypes.c_uint16 * count).from_address(addr)
arr = np.ctypeslib.as_array(raw).view(np.float16).astype(np.float32)
elif dtype_id == ML_DTYPE_FLOAT32:
raw = (ctypes.c_float * count).from_address(addr)
arr = np.ctypeslib.as_array(raw).copy()
elif dtype_id == ML_DTYPE_DOUBLE:
raw = (ctypes.c_double * count).from_address(addr)
arr = np.ctypeslib.as_array(raw).astype(np.float32)
else:
raise RuntimeError(f"unsupported MLMultiArray dtype {dtype_id}")
return arr.reshape(shape)
class MultiHMRCoreMLBackend:
"""CoreML inference wrapper for Multi-HMR (full_672_s)."""
def __init__(self, mlpackage_path: Path | None = None) -> None:
self.path = Path(mlpackage_path) if mlpackage_path else DEFAULT_MLPACKAGE
if not self.path.exists():
raise FileNotFoundError(f"mlpackage missing: {self.path}")
ns = _load_frameworks()
MLModel = ns["MLModel"]
MLModelConfiguration = ns["MLModelConfiguration"]
cfg = MLModelConfiguration.alloc().init()
try:
# MLComputeUnits: 0=CPUOnly, 1=CPUAndGPU, 2=All (ANE+GPU+CPU),
# 3=CPUAndNeuralEngine. Multi-HMR's ANEF compile fails
# (validated 2026-05-13 on M5), and 'All' falls back to a
# slow path (~146ms). CPU+GPU = 28ms = ~35fps on M5.
cfg.setComputeUnits_(1)
except Exception: # noqa: BLE001
pass
url = NSURL.fileURLWithPath_(str(self.path))
# .mlpackage must be compiled to .mlmodelc before MLModel can
# load it. compileModelAtURL_error_ returns an NSURL to a
# temp .mlmodelc bundle.
compiled_url = MLModel.compileModelAtURL_error_(url, None)
if compiled_url is None:
raise RuntimeError(f"compileModelAtURL failed for {self.path}")
model = MLModel.modelWithContentsOfURL_configuration_error_(
compiled_url, cfg, None)
if model is None:
raise RuntimeError(f"MLModel load failed for {compiled_url}")
self._model = model
self._ns = ns
LOG.info("Multi-HMR CoreML model loaded (%s, computeUnits=CPU+GPU)",
self.path.name)
@staticmethod
def is_available(mlpackage_path: Path | None = None) -> bool:
p = Path(mlpackage_path) if mlpackage_path else DEFAULT_MLPACKAGE
if not p.exists():
return False
try:
_load_frameworks()
return True
except Exception: # noqa: BLE001
return False
def _predict(self, image_4d: np.ndarray, K_33: np.ndarray) -> dict:
ns = self._ns
MLDictionaryFeatureProvider = ns["MLDictionaryFeatureProvider"]
MLFeatureValue = ns["MLFeatureValue"]
img_ml = _np_to_mlarray(image_4d)
k_ml = _np_to_mlarray(K_33)
feats = {
"image": MLFeatureValue.featureValueWithMultiArray_(img_ml),
"cam_K": MLFeatureValue.featureValueWithMultiArray_(k_ml),
}
provider = MLDictionaryFeatureProvider.alloc(
).initWithDictionary_error_(feats, None)
if provider is None:
raise RuntimeError("MLDictionaryFeatureProvider alloc failed")
out = self._model.predictionFromFeatures_error_(provider, None)
if out is None:
raise RuntimeError("MLModel predict failed")
names = [str(n) for n in out.featureNames()]
result = {}
for n in names:
fv = out.featureValueForName_(n)
ml = fv.multiArrayValue()
if ml is None:
continue
result[n] = _mlarray_to_np(ml)
return result
def infer(
self,
image_chw_float32: np.ndarray,
K_33: np.ndarray,
det_thresh: float = 0.3,
) -> list[dict]:
"""Run a forward pass and return list of humans dicts.
Args:
image_chw_float32: (3, 672, 672) or (1, 3, 672, 672) in [0,1].
K_33: (3, 3) or (1, 3, 3) camera intrinsics.
det_thresh: scores threshold; CoreML forwards K=4 always.
Returns:
list[dict] with keys v3d, transl_pelvis, scores, shape,
expression. Values are CoreMLArray wrappers.
"""
img = np.asarray(image_chw_float32, dtype=np.float32)
if img.ndim == 3:
img = img[np.newaxis, ...]
if img.shape != (1, 3, 672, 672):
raise ValueError(f"image shape {img.shape}, expected (1,3,672,672)")
K = np.asarray(K_33, dtype=np.float32)
if K.ndim == 2:
K = K[np.newaxis, ...]
if K.shape != (1, 3, 3):
raise ValueError(f"K shape {K.shape}, expected (1,3,3)")
raw = self._predict(img, K)
v3d = raw.get(OUT_V3D)
transl = raw.get(OUT_TRANSL)
scores = raw.get(OUT_SCORES)
betas = raw.get(OUT_BETAS)
expr = raw.get(OUT_EXPR)
if any(x is None for x in (v3d, transl, scores, betas, expr)):
raise RuntimeError(
"missing outputs; got keys=" + ",".join(raw.keys()))
humans: list[dict] = []
for k in range(N_PERSONS_FIXED):
sc = float(scores[k])
if sc < det_thresh:
continue
humans.append({
"v3d": CoreMLArray(v3d[k]), # (10475, 3)
"transl_pelvis": CoreMLArray(transl[k]), # (1, 3)
"scores": CoreMLArray(np.array([sc], dtype=np.float32)),
"shape": CoreMLArray(betas[k]), # (10,)
"expression": CoreMLArray(expr[k]), # (10,)
})
return humans
+74
View File
@@ -0,0 +1,74 @@
"""Record webcam frames + timestamps for action-head training.
Usage:
uv run python -m data_only_viz.scripts.capture_actions \
--session sess03 --duration 600
"""
from __future__ import annotations
import argparse
import logging
import time
from pathlib import Path
import cv2
LOG = logging.getLogger("capture_actions")
RAW_DIR = Path("~/.cache/av-live-action/raw").expanduser()
def capture(session: str, duration_s: float,
cam_index: int = 0, fps: int = 30,
size: int = 672) -> Path:
RAW_DIR.mkdir(parents=True, exist_ok=True)
out = RAW_DIR / f"{session}.mp4"
ts_out = RAW_DIR / f"{session}.ts.txt"
cap = cv2.VideoCapture(cam_index)
if not cap.isOpened():
raise RuntimeError(f"cannot open camera {cam_index}")
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
writer = cv2.VideoWriter(str(out), fourcc, fps, (size, size))
try:
t_start = time.perf_counter()
with ts_out.open("w") as ts_f:
n = 0
while time.perf_counter() - t_start < duration_s:
ok, frame = cap.read()
if not ok:
LOG.warning("frame read failed")
break
h, w = frame.shape[:2]
side = min(h, w)
y0 = (h - side) // 2
x0 = (w - side) // 2
crop = frame[y0:y0 + side, x0:x0 + side]
resized = cv2.resize(crop, (size, size))
writer.write(resized)
ts_f.write(f"{n} {time.perf_counter() - t_start:.6f}\n")
n += 1
cv2.imshow("capture (q=quit)", resized)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
LOG.info("wrote %s (%d frames)", out, n)
return out
finally:
cap.release()
writer.release()
cv2.destroyAllWindows()
def _cli() -> None:
p = argparse.ArgumentParser()
p.add_argument("--session", required=True)
p.add_argument("--duration", type=float, default=600.0)
p.add_argument("--cam-index", type=int, default=0)
p.add_argument("--fps", type=int, default=30)
args = p.parse_args()
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [%(name)s] %(message)s")
capture(args.session, args.duration,
cam_index=args.cam_index, fps=args.fps)
if __name__ == "__main__":
_cli()
+100 -5
View File
@@ -157,6 +157,32 @@ if hasattr(model.backbone, "encoder") and hasattr(model.backbone.encoder,
# torch.inverse(K) plante coremltools (op non implementee). Comme K est
# fixe (camera intrinsics avec focal=IMG_SIZE), on pre-calcule K_inv
# en closed-form et on l'utilise comme buffer module-level.
print("==> Patching roma.rotmat_to_rotvec (branchless atan2)")
# roma.rotmat_to_rotvec utilise torch.empty + 8 index_put_ qui se
# traduisent en CoreML par scatter_nd successifs sur un buffer
# garbage-initialise. Resultat : cellules non touchees restent NaN,
# propagees via quat normalization -> v3d/transl all-NaN.
# Remplacement branchless via atan2 : pas de torch.empty, pas
# d'index_put_, juste des stack/clamp/norm/atan2 stables CoreML.
# Precision vs roma original : 2.26e-6 L_inf sur batch random.
import roma as _roma
def _rotmat_to_rotvec_branchless(R, eps=1e-6):
w = torch.stack([
R[..., 2, 1] - R[..., 1, 2],
R[..., 0, 2] - R[..., 2, 0],
R[..., 1, 0] - R[..., 0, 1],
], dim=-1) * 0.5
trace = R[..., 0, 0] + R[..., 1, 1] + R[..., 2, 2]
cos_theta = ((trace - 1.0) * 0.5).clamp(-1.0, 1.0)
sin_theta = torch.norm(w, dim=-1)
theta = torch.atan2(sin_theta, cos_theta)
sin_theta_safe = sin_theta.clamp(min=eps)
return w * (theta / sin_theta_safe).unsqueeze(-1)
_roma.rotmat_to_rotvec = _rotmat_to_rotvec_branchless
print("==> Patching utils.camera.inverse_perspective_projection")
import utils.camera as _camera
@@ -170,11 +196,28 @@ _K_INV_PRE = torch.tensor([
])
def inverse_perspective_projection_fixed(points, K, distance):
"""Bypass torch.inverse : utilise K_inv pre-calcule en closed-form
(notre K est connu et fixe). Le K argument est ignore."""
K_inv = _K_INV_PRE.to(points.device).to(points.dtype)
points = torch.cat([points, torch.ones_like(points[..., :1])], -1)
points = torch.einsum('bij,bkj->bki', K_inv, points)
"""Bypass torch.inverse + einsum + matmul pour eviter le bug
coremltools de broadcast batch 1->K sur ces ops. K_inv etant
fixe et structure (diag + translate), on ecrit les composantes
explicitement en ops elementaires.
K_inv = [[1/f, 0, -cx/f], [0, 1/f, -cy/f], [0, 0, 1]]
Pour points (b, N, 3) : out = points @ K_inv.T donne :
out[..., 0] = points[..., 0]/f - (cx/f) * points[..., 2]
out[..., 1] = points[..., 1]/f - (cy/f) * points[..., 2]
out[..., 2] = points[..., 2]
"""
points_hom = torch.cat([points, torch.ones_like(points[..., :1])], -1)
inv_f = 1.0 / focal_val
cx_over_f = cx / focal_val
cy_over_f = cy / focal_val
x = points_hom[..., 0:1]
y = points_hom[..., 1:2]
z = points_hom[..., 2:3]
out0 = x * inv_f - z * cx_over_f
out1 = y * inv_f - z * cy_over_f
out2 = z
points = torch.cat([out0, out1, out2], dim=-1)
if distance is None:
return points
points = points * distance
@@ -190,6 +233,26 @@ model_mod.inverse_perspective_projection = inverse_perspective_projection_fixed
import blocks.smpl_layer as _smpl_layer
_smpl_layer.inverse_perspective_projection = inverse_perspective_projection_fixed
# Aussi perspective_projection (utilise dans smpl_layer.py:143-144 pour
# j2d et v2d) -> rewrite einsum en matmul pour le meme broadcast bug.
def perspective_projection_fixed(x, K):
"""Element-wise rewrite de la projection perspective avec K fixe
(focal=IMG_SIZE, cx=cy=IMG_SIZE/2). Bypass matmul/einsum pour eviter
les bugs broadcast coremltools.
K = [[f, 0, cx], [0, f, cy], [0, 0, 1]]
out[..., 0] = f * x_norm + cx * z_norm (mais on veut [..., :2])
= f * (x/z) + cx
out[..., 1] = f * (y/z) + cy
"""
z = x[..., 2:3]
px = x[..., 0:1] / z * focal_val + cx
py = x[..., 1:2] / z * focal_val + cy
return torch.cat([px, py], dim=-1)
_camera.perspective_projection = perspective_projection_fixed
_utils_pkg.perspective_projection = perspective_projection_fixed
_smpl_layer.perspective_projection = perspective_projection_fixed
# === Wrapper qui produit tuple fixe ===
class TracedMHMR(nn.Module):
@@ -222,6 +285,12 @@ class TracedMHMR(nn.Module):
]).squeeze(-1)
shape = torch.stack([h["shape"] for h in humans])
expr = torch.stack([h["expression"] for h in humans])
# NOTE: CoreML mlprogram conversion currently produces all-NaN
# outputs for v3d and transl while PyTorch eager produces valid
# finite values from the same trace. nan_to_num here masks the
# symptom but yields all-zero meshes (no information). Leave
# raw outputs and let downstream decide; investigation tracked
# in task #2 (op-by-op bisection needed).
return v3d, transl, scores, shape, expr
@@ -422,6 +491,28 @@ def _diagonal_general(context, node):
_TORCH_OPS_REGISTRY.name_to_func_mapping["diagonal"] = _diagonal_general
# Instrument reshape pour logger node source au moment de l'erreur.
from coremltools.converters.mil.mil.ops.defs.iOS15 import tensor_transformation as _tt
_orig_reshape_ti = _tt.reshape.type_inference
def _reshape_ti_logged(self):
try:
return _orig_reshape_ti(self)
except ValueError as e:
if "Invalid target shape" in str(e):
try:
from_shape = list(self.x.shape)
target = list(self.shape.val) if hasattr(self.shape, "val") else "?"
print(f" >>> RESHAPE FAIL : name={self.name} from={from_shape} target={target}")
except Exception:
pass
raise
_tt.reshape.type_inference = _reshape_ti_logged
try:
mlmodel = ct.convert(
traced,
@@ -433,6 +524,10 @@ try:
compute_units=ct.ComputeUnit.CPU_AND_GPU,
minimum_deployment_target=ct.target.macOS15,
convert_to="mlprogram",
# FP16 OK depuis le patch roma branchless (cf rapport bisection
# 2026-05-13) : la source du NaN etait torch.empty + index_put_
# dans roma.rotmat_to_rotvec, pas la precision.
compute_precision=ct.precision.FLOAT16,
)
out_path = "/tmp/multihmr_full_672_s.mlpackage"
mlmodel.save(out_path)
+41 -7
View File
@@ -1,4 +1,4 @@
"""Extract j3d (22 SMPL-X joint anchors) from a recorded MP4 using the
"""Extract j3d (32 SMPL-X joint anchors) from a recorded MP4 using the
Multi-HMR CoreML backend, write per-frame per-person jsonl rows.
Usage:
@@ -17,7 +17,12 @@ from pathlib import Path
import cv2
import numpy as np
from data_only_viz.action_head_pub import SMPLX_JOINT_ANCHOR_VERTS
from data_only_viz.action_head import EXPR_DIM, HANDS_KP_DIMS, HANDS_KP_TOTAL
from data_only_viz.action_head_pub import (
SMPLX_JOINT_ANCHOR_VERTS,
SMPLX_UPPER_LIP_VERT,
SMPLX_LOWER_LIP_VERT,
)
from data_only_viz.multihmr_coreml import MultiHMRCoreMLBackend
LOG = logging.getLogger("extract_j3d_offline")
@@ -47,14 +52,37 @@ def _frame_to_chw(frame_bgr: np.ndarray, size: int = IMG_SIZE) -> np.ndarray:
return rgb.transpose(2, 0, 1) # CHW
def _person_to_j3d22(person: dict, anchors: tuple[int, ...]) -> np.ndarray | None:
def _person_to_j3d32(
person: dict,
anchors: tuple[int, ...],
) -> tuple[np.ndarray, np.ndarray, float] | None:
"""Return (j3d32, expression, mouth_open) or None if v3d absent/too small."""
v3d = person.get("v3d")
if v3d is None:
return None
# CoreMLArray wraps numpy but lacks __array__; unwrap before asarray.
if hasattr(v3d, "numpy") and not isinstance(v3d, np.ndarray):
v3d = v3d.numpy()
v3d_np = np.asarray(v3d, dtype=np.float32)
if v3d_np.shape[0] < max(anchors) + 1:
return None
return v3d_np[list(anchors)].astype(np.float32)
j3d32 = v3d_np[list(anchors)].astype(np.float32)
# expression
expr = person.get("expression")
if expr is not None:
if hasattr(expr, "numpy") and not isinstance(expr, np.ndarray):
expr = expr.numpy()
expr_np = np.asarray(expr, dtype=np.float32).flatten()
else:
expr_np = np.zeros(EXPR_DIM, dtype=np.float32)
# mouth_open
if v3d_np.shape[0] > max(SMPLX_UPPER_LIP_VERT, SMPLX_LOWER_LIP_VERT):
mouth = float(np.linalg.norm(
v3d_np[SMPLX_UPPER_LIP_VERT] - v3d_np[SMPLX_LOWER_LIP_VERT]
))
else:
mouth = 0.0
return j3d32, expr_np, mouth
def extract(session: str, video: Path, out: Path,
@@ -86,14 +114,20 @@ def extract(session: str, video: Path, out: Path,
continue
ts = n_frames / fps
for i, person in enumerate(persons):
j3d = _person_to_j3d22(person, anchors)
if j3d is None:
result = _person_to_j3d32(person, anchors)
if result is None:
continue
j3d32, expr_np, mouth = result
f.write(json.dumps({
"ts": ts,
"session": session,
"pid": int(person.get("pid", i)),
"j3d": j3d.tolist(),
"j3d": j3d32.tolist(),
"expression": expr_np.tolist(),
"mouth_open": mouth,
"hands_kp": np.zeros(
(HANDS_KP_TOTAL, HANDS_KP_DIMS), dtype=np.float32
).tolist(),
}) + "\n")
n_rows += 1
n_frames += 1
@@ -0,0 +1,208 @@
"""Extract action-head v3 jsonl rows from a recorded MP4 using MediaPipe
Holistic. Populates real hands_kp (42, 3) and mouth_open (face lips
distance), unlike extract_j3d_offline.py (SMPL-X path) which writes zeros
for hands_kp.
Output jsonl row format (matches dataset.py load_frames_jsonl) :
{
"ts": float seconds,
"session": str,
"pid": int (always 0 — Holistic is single-person),
"j3d": [[32, 3]] floats (body22 + 10 fingertips),
"expression": [10] zeros (MediaPipe has no SMPL-X PCA),
"mouth_open": float (lips inner distance),
"hands_kp": [[42, 3]] floats (21 L + 21 R, zero-padded if absent),
}
Usage :
uv run python -m data_only_viz.scripts.extract_mediapipe_offline \
--session sess03 \
--video ~/.cache/av-live-action/raw/sess03.mp4 \
--out ~/.cache/av-live-action/raw/sess03_mp.jsonl
"""
from __future__ import annotations
import argparse
import json
import logging
from pathlib import Path
import cv2
import numpy as np
from data_only_viz.action_head import (
EXPR_DIM,
HANDS_KP_DIMS,
HANDS_KP_PER_HAND,
HANDS_KP_TOTAL,
J3D_BODY,
J3D_FINGERS,
J3D_FINGERS_PER_HAND,
J3D_JOINTS,
)
from data_only_viz.action_head_pub import (
MEDIAPIPE_HAND_FINGERTIPS,
MEDIAPIPE_LIP_LOWER_INNER,
MEDIAPIPE_LIP_UPPER_INNER,
MEDIAPIPE_TO_22,
)
LOG = logging.getLogger("extract_mediapipe_offline")
DEFAULT_OUT_DIR = Path("~/.cache/av-live-action/raw").expanduser()
def _build_landmarker():
"""Build a MediaPipe HolisticLandmarker in VIDEO running mode."""
from mediapipe.tasks.python import vision
from mediapipe.tasks.python.core.base_options import BaseOptions
from data_only_viz.holistic import _ensure_model
model_path = _ensure_model()
opts = vision.HolisticLandmarkerOptions(
base_options=BaseOptions(model_asset_path=str(model_path)),
running_mode=vision.RunningMode.VIDEO,
min_pose_detection_confidence=0.3,
min_pose_landmarks_confidence=0.3,
min_face_detection_confidence=0.3,
min_face_landmarks_confidence=0.3,
min_hand_landmarks_confidence=0.3,
)
return vision.HolisticLandmarker.create_from_options(opts)
def _lmk_list_to_array(lmks) -> np.ndarray | None:
"""Convert MediaPipe NormalizedLandmark / Landmark list to (N, 3) array."""
if lmks is None:
return None
try:
return np.asarray(
[(lm.x, lm.y, getattr(lm, "z", 0.0)) for lm in lmks],
dtype=np.float32,
)
except (AttributeError, TypeError):
return None
def _build_j3d32(body3d_arr: np.ndarray | None,
hands_kp42: np.ndarray) -> np.ndarray | None:
"""Map MediaPipe body3d (33, 3) + hands_kp (42, 3) -> j3d (32, 3).
body22 indices via MEDIAPIPE_TO_22, fingertips from hands_kp idx
MEDIAPIPE_HAND_FINGERTIPS (4, 8, 12, 16, 20) for each side.
"""
if body3d_arr is None or body3d_arr.shape[0] < 33:
return None
body22 = body3d_arr[list(MEDIAPIPE_TO_22)].astype(np.float32)
tips = np.zeros((J3D_FINGERS, 3), dtype=np.float32)
for side_idx in (0, 1):
base = side_idx * HANDS_KP_PER_HAND
for k, mp_tip in enumerate(MEDIAPIPE_HAND_FINGERTIPS):
if base + mp_tip < hands_kp42.shape[0]:
tips[side_idx * J3D_FINGERS_PER_HAND + k] = hands_kp42[base + mp_tip]
return np.concatenate([body22, tips], axis=0)
def _mouth_open(face_arr: np.ndarray | None) -> float:
if face_arr is None or face_arr.shape[0] <= MEDIAPIPE_LIP_LOWER_INNER:
return 0.0
upper = face_arr[MEDIAPIPE_LIP_UPPER_INNER]
lower = face_arr[MEDIAPIPE_LIP_LOWER_INNER]
return float(np.linalg.norm(upper - lower))
def _hands_kp42(left_arr: np.ndarray | None,
right_arr: np.ndarray | None) -> np.ndarray:
out = np.zeros((HANDS_KP_TOTAL, HANDS_KP_DIMS), dtype=np.float32)
if left_arr is not None and left_arr.shape[0] >= HANDS_KP_PER_HAND:
out[:HANDS_KP_PER_HAND] = left_arr[:HANDS_KP_PER_HAND]
if right_arr is not None and right_arr.shape[0] >= HANDS_KP_PER_HAND:
out[HANDS_KP_PER_HAND:] = right_arr[:HANDS_KP_PER_HAND]
return out
def extract(session: str, video: Path, out: Path) -> int:
"""Run MediaPipe Holistic on every frame of video, write jsonl rows.
Returns the number of frames where at least body3d was detected
(rows written). Frames with no person are silently skipped.
"""
import mediapipe as mp
out.parent.mkdir(parents=True, exist_ok=True)
cap = cv2.VideoCapture(str(video))
if not cap.isOpened():
raise RuntimeError(f"cannot open {video}")
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
landmarker = _build_landmarker()
n_frames = 0
n_rows = 0
expr_zeros_list = np.zeros(EXPR_DIM, dtype=np.float32).tolist()
try:
with out.open("w") as f:
while True:
ok, frame = cap.read()
if not ok:
break
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
mp_img = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
ts_ms = int(n_frames * 1000 / fps)
try:
res = landmarker.detect_for_video(mp_img, ts_ms)
except Exception:
LOG.exception("detect failed at frame=%d", n_frames)
n_frames += 1
continue
body3d = _lmk_list_to_array(
getattr(res, "pose_world_landmarks", None)
)
face_arr = _lmk_list_to_array(
getattr(res, "face_landmarks", None)
)
left_arr = _lmk_list_to_array(
getattr(res, "left_hand_landmarks", None)
)
right_arr = _lmk_list_to_array(
getattr(res, "right_hand_landmarks", None)
)
hands_kp42 = _hands_kp42(left_arr, right_arr)
j3d32 = _build_j3d32(body3d, hands_kp42)
if j3d32 is None:
n_frames += 1
continue
ts = n_frames / fps
mouth = _mouth_open(face_arr)
f.write(json.dumps({
"ts": ts,
"session": session,
"pid": 0,
"j3d": j3d32.tolist(),
"expression": expr_zeros_list,
"mouth_open": mouth,
"hands_kp": hands_kp42.tolist(),
}) + "\n")
n_rows += 1
n_frames += 1
if n_frames % 100 == 0:
LOG.info("frame=%d rows=%d", n_frames, n_rows)
finally:
cap.release()
landmarker.close()
LOG.info("done : %d frames, %d rows -> %s", n_frames, n_rows, out)
return n_rows
def _cli() -> None:
p = argparse.ArgumentParser()
p.add_argument("--session", required=True)
p.add_argument("--video", required=True, type=Path)
p.add_argument("--out", type=Path)
args = p.parse_args()
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [%(name)s] %(message)s")
DEFAULT_OUT_DIR.mkdir(parents=True, exist_ok=True)
out = args.out or (DEFAULT_OUT_DIR / f"{args.session}_mp.jsonl")
extract(args.session, args.video, out)
if __name__ == "__main__":
_cli()
+67
View File
@@ -49,4 +49,71 @@ if [ ! -e "$CACHE/multi-hmr/models" ]; then
ln -sfn ../models "$CACHE/multi-hmr/models"
fi
# CoreML conversion patches : remplace les torch.einsum dans utils/camera.py
# par des ops element-wise (broadcast-friendly). Sans ca, ct.convert echoue
# avec "Invalid target shape in reshape op ([1, N, 3] to [K*N, 3, 1])"
# quand batch K detections != 1. Idempotent.
CAM="$CACHE/multi-hmr/utils/camera.py"
if [ -f "$CAM" ] && ! grep -q "_apply_intrinsics_componentwise" "$CAM"; then
echo "==> Patch utils/camera.py (einsum -> componentwise)"
python3 - "$CAM" <<'PYEOF'
import sys, pathlib
p = pathlib.Path(sys.argv[1])
src = p.read_text()
helper = '''
def _apply_intrinsics_componentwise(K, y):
"""CoreML-friendly: out[b,k,i] = sum_j K[b,i,j] * y[b,k,j]
Replaces torch.einsum('bij,bkj->bki', K, y) with pure broadcast ops.
"""
K00 = K[:, 0:1, 0:1]; K01 = K[:, 0:1, 1:2]; K02 = K[:, 0:1, 2:3]
K10 = K[:, 1:2, 0:1]; K11 = K[:, 1:2, 1:2]; K12 = K[:, 1:2, 2:3]
K20 = K[:, 2:3, 0:1]; K21 = K[:, 2:3, 1:2]; K22 = K[:, 2:3, 2:3]
y0 = y[:, :, 0:1]; y1 = y[:, :, 1:2]; y2 = y[:, :, 2:3]
out0 = K00 * y0 + K01 * y1 + K02 * y2
out1 = K10 * y0 + K11 * y1 + K12 * y2
out2 = K20 * y0 + K21 * y1 + K22 * y2
return torch.cat([out0, out1, out2], dim=-1)
'''
src = src.replace(
"def perspective_projection(x, K):",
helper + "def perspective_projection(x, K):",
)
src = src.replace(
"y = torch.einsum('bij,bkj->bki', K, y) # (bs, N, 3)",
"y = _apply_intrinsics_componentwise(K, y)",
)
src = src.replace(
"points = torch.einsum('bij,bkj->bki', torch.inverse(K), points)",
"points = _apply_intrinsics_componentwise(torch.inverse(K), points)",
)
p.write_text(src)
print(" camera.py patched")
PYEOF
fi
# CoreML conversion patch : smplx/lbs.py landmarks einsum (mеme bug broadcast)
# Patch best-effort sur tous les venvs presents (data_only_viz + /tmp/coreml312).
for VENV in \
"$(dirname "$(dirname "$(readlink -f "$0")")")/.venv" \
"/tmp/coreml312"; do
LBS="$VENV/lib/python3.14/site-packages/smplx/lbs.py"
[ -f "$LBS" ] || LBS="$VENV/lib/python3.12/site-packages/smplx/lbs.py"
if [ -f "$LBS" ] && grep -q "torch.einsum('blfi,blf->bli'" "$LBS"; then
echo "==> Patch $LBS (landmarks einsum)"
python3 - "$LBS" <<'PYEOF'
import sys, pathlib
p = pathlib.Path(sys.argv[1])
s = p.read_text()
s = s.replace(
"landmarks = torch.einsum('blfi,blf->bli', [lmk_vertices, lmk_bary_coords])\n return landmarks",
"# CoreML-friendly: replace einsum('blfi,blf->bli', ...) with broadcast+sum\n landmarks = (lmk_vertices * lmk_bary_coords.unsqueeze(-1)).sum(dim=2)\n return landmarks",
)
p.write_text(s)
print(" smplx/lbs.py patched")
PYEOF
fi
done
echo "Setup OK. Cache : $CACHE"
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Train action-head on MacStudio M3 Ultra (Tailscale 100.116.92.12).
#
# SSH direct grosmac→studio is broken since reboot 2026-05-12 ;
# we route via electron-server bastion (cf. CLAUDE.md root).
#
# Usage:
# ./train_on_studio.sh # uses defaults
# ./train_on_studio.sh --epochs 80 --lr 5e-4
#
# Local layout :
# ~/.cache/av-live-action/dataset/dataset.jsonl (input)
# ~/.cache/av-live-action/checkpoints/ (output, after rsync back)
#
# Remote layout :
# studio:~/av-live-action/repo/ (rsynced code subset)
# studio:~/av-live-action/dataset/ (rsynced dataset)
# studio:~/av-live-action/checkpoints/ (training output)
set -euo pipefail
BASTION_USER_HOST="${BASTION_USER_HOST:-electron-server}"
STUDIO_USER_HOST="${STUDIO_USER_HOST:-clems@100.116.92.12}"
STUDIO_USER="${STUDIO_USER:-clems}"
STUDIO_UV="${STUDIO_UV:-/opt/homebrew/bin/uv}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")"/../.. && pwd)"
LOCAL_CACHE="$HOME/.cache/av-live-action"
LOCAL_DATASET="$LOCAL_CACHE/dataset"
LOCAL_CKPT="$LOCAL_CACHE/checkpoints"
REMOTE_ROOT="/Users/${STUDIO_USER}/av-live-action"
REMOTE_REPO="$REMOTE_ROOT/repo"
REMOTE_DATASET="$REMOTE_ROOT/dataset"
REMOTE_CKPT="$REMOTE_ROOT/checkpoints"
DATASET_FILE="${DATASET_FILE:-$LOCAL_DATASET/dataset.jsonl}"
CKPT_NAME="${CKPT_NAME:-action_head.pt}"
# Quote train args defensively before forwarding through bastion ssh +
# studio ssh (each layer reparses). Reject single quotes — they break
# the single-quoted payload in bastion_ssh and could allow injection.
for a in "$@"; do
if [[ "$a" == *"'"* ]]; then
printf '[train_on_studio] forbidden single quote in arg: %s\n' "$a" >&2
exit 3
fi
done
TRAIN_ARGS="$(printf '%q ' "$@")"
log() { printf '[train_on_studio] %s\n' "$*" >&2; }
[[ -f "$DATASET_FILE" ]] || { log "missing dataset: $DATASET_FILE"; exit 2; }
mkdir -p "$LOCAL_CKPT"
bastion_ssh() {
# The remote shell on the bastion must receive the studio command
# as a single argument, otherwise `;` and `&&` are parsed
# bastion-side instead of studio-side.
# All paths in commands MUST be absolute (no $HOME, no ~) since
# we use single-quotes for the studio-side payload.
ssh -o ConnectTimeout=5 "$BASTION_USER_HOST" \
"ssh -o ConnectTimeout=5 $STUDIO_USER_HOST '$*'"
}
bastion_rsync() {
# rsync via ssh ProxyJump through bastion. Direct grosmac->studio
# known_hosts entry may be stale (SSH direct broken since reboot
# 2026-05-12). accept-new lets us add the key on first use.
local src="$1" dst="$2"
rsync -avz --delete \
-e "ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -A -J $BASTION_USER_HOST" \
"$src" "$dst"
}
log "== Studio reachability =="
bastion_ssh "echo studio OK ; $STUDIO_UV --version"
log "== Push code subset =="
bastion_ssh "mkdir -p $REMOTE_REPO/data_only_viz $REMOTE_DATASET $REMOTE_CKPT"
rsync -avz --delete \
--exclude='.venv/' --exclude='__pycache__/' --exclude='.pytest_cache/' \
--exclude='.ruff_cache/' --exclude='*.pyc' --exclude='.DS_Store' \
--exclude='web/' --exclude='shaders/' \
-e "ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -A -J $BASTION_USER_HOST" \
"$REPO_ROOT/data_only_viz/" \
"$STUDIO_USER_HOST:av-live-action/repo/data_only_viz/"
log "== Push dataset =="
bastion_rsync "$LOCAL_DATASET/" "$STUDIO_USER_HOST:av-live-action/dataset/"
log "== Remote uv sync =="
# multihmr extra pulls torch (action-head training needs torch but no pyobjc).
# We piggy-back on the multihmr extras since torch is the main thing we need.
bastion_ssh "cd $REMOTE_REPO && $STUDIO_UV sync --no-progress --project data_only_viz --extra multihmr"
log "== Remote train (MPS) =="
# cwd must be the PARENT of data_only_viz/ so the package is importable as
# top-level. uv resolves the env via --project data_only_viz.
bastion_ssh "cd $REMOTE_REPO && \
$STUDIO_UV run --project data_only_viz python -m data_only_viz.training.train_action_head \
--dataset $REMOTE_DATASET/$(basename "$DATASET_FILE") \
--ckpt-out $REMOTE_CKPT/$CKPT_NAME \
--device mps \
$TRAIN_ARGS"
log "== Pull checkpoint back =="
bastion_rsync "$STUDIO_USER_HOST:av-live-action/checkpoints/" "$LOCAL_CKPT/"
log "== Done. Checkpoint: $LOCAL_CKPT/$CKPT_NAME =="
ls -la "$LOCAL_CKPT/$CKPT_NAME"
+31 -1
View File
@@ -25,6 +25,7 @@ from typing import Sequence
import numpy as np
from .mesh_rigger import MeshRigger
from .state import SMPLXPerson, State
LOG = logging.getLogger("smplx_tcp")
@@ -35,7 +36,8 @@ PORT = 57130
class SMPLXTCPSender:
def __init__(self, state: State, host: str = "127.0.0.1",
port: int = PORT, target_fps: float = 12.0) -> None:
port: int = PORT, target_fps: float = 30.0,
enable_rigging: bool = True) -> None:
self.state = state
self.host = host
self.port = port
@@ -43,6 +45,9 @@ class SMPLXTCPSender:
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._sock: socket.socket | None = None
# Hybrid keyframe rigging : entre deux keyframes Multi-HMR (~3 fps),
# on translate le mesh via le delta pelvis Apple Vision (30 fps).
self._rigger = MeshRigger(state) if enable_rigging else None
def start(self) -> None:
self._thread = threading.Thread(
@@ -124,6 +129,9 @@ class SMPLXTCPSender:
def _run(self) -> None:
last_warn = 0.0
n_sent = 0
n_rigged = 0
next_hb = time.monotonic() + 5.0
while not self._stop.is_set():
t0 = time.monotonic()
if not self._ensure_connected():
@@ -136,8 +144,30 @@ class SMPLXTCPSender:
with self.state.lock():
persons = list(self.state.persons_smplx)
body_kp = list(self.state.persons_body) if hasattr(
self.state, "persons_body") else []
body_ids = list(self.state.persons_body_ids) if hasattr(
self.state, "persons_body_ids") else (
list(range(len(body_kp))) if body_kp else [])
if persons and self._rigger is not None:
rigged = self._rigger.apply(
persons, body_kp, body_ids, t0)
if rigged is not persons:
n_rigged += 1
persons = rigged
if t0 >= next_hb:
fps = n_sent / 5.0
rig_pct = (n_rigged / n_sent * 100.0) if n_sent else 0.0
LOG.info("hb: %.1f fps tcp, %.0f%% rigged",
fps, rig_pct)
n_sent = 0
n_rigged = 0
next_hb = t0 + 5.0
if persons:
n_sent += 1
t_ser_start = time.monotonic()
payload = self._serialize_persons(persons)
t_send_start = time.monotonic()
@@ -0,0 +1,116 @@
"""Unit tests for ActionHead feature extraction and buffers."""
from __future__ import annotations
import numpy as np
import pytest
def test_module_imports() -> None:
from data_only_viz import action_head
assert hasattr(action_head, "FeatureExtractor")
assert hasattr(action_head, "PerPersonBuffer")
assert hasattr(action_head, "ActionHead")
assert action_head.WINDOW_LEN == 16
assert action_head.J3D_JOINTS == 32
assert action_head.FEATURE_DIM == 428
assert action_head.HANDS_KP_TOTAL == 42
assert action_head.HANDS_KP_FLAT == 126
assert action_head.NUM_CLASSES == 3
assert action_head.LABELS == ("debout", "assise", "danse")
def _rand_j3d(seed: int = 0) -> np.ndarray:
rng = np.random.default_rng(seed)
return rng.normal(size=(32, 3)).astype(np.float32)
def test_buffer_starts_empty() -> None:
from data_only_viz.action_head import PerPersonBuffer
buf = PerPersonBuffer()
assert len(buf) == 0
assert buf.frames_for(7) == []
def test_buffer_append_grows_per_pid() -> None:
from data_only_viz.action_head import PerPersonBuffer
buf = PerPersonBuffer()
buf.append(pid=1, j3d=_rand_j3d(1))
buf.append(pid=1, j3d=_rand_j3d(2))
buf.append(pid=2, j3d=_rand_j3d(3))
assert len(buf.frames_for(1)) == 2
assert len(buf.frames_for(2)) == 1
def test_buffer_max_len_16() -> None:
from data_only_viz.action_head import PerPersonBuffer, WINDOW_LEN
buf = PerPersonBuffer()
for i in range(WINDOW_LEN + 5):
buf.append(pid=1, j3d=_rand_j3d(i))
assert len(buf.frames_for(1)) == WINDOW_LEN
def test_buffer_forget_releases_pid() -> None:
from data_only_viz.action_head import PerPersonBuffer
buf = PerPersonBuffer()
buf.append(pid=1, j3d=_rand_j3d(0))
buf.forget(1)
assert buf.frames_for(1) == []
assert len(buf) == 0
def test_buffer_rejects_bad_shape() -> None:
from data_only_viz.action_head import PerPersonBuffer
buf = PerPersonBuffer()
with pytest.raises(ValueError, match="32"):
buf.append(pid=1, j3d=np.zeros((22, 3), dtype=np.float32))
def test_feature_extractor_shape_full_buffer() -> None:
from data_only_viz.action_head import FeatureExtractor, WINDOW_LEN, FEATURE_DIM
frames = [_rand_j3d(i) for i in range(WINDOW_LEN)]
feat = FeatureExtractor.from_buffer(frames)
assert feat.shape == (428,)
assert feat.dtype == np.float32
assert not np.isnan(feat).any()
def test_feature_extractor_short_buffer_pads() -> None:
from data_only_viz.action_head import FeatureExtractor, FEATURE_DIM
frames = [_rand_j3d(0), _rand_j3d(1), _rand_j3d(2)]
feat = FeatureExtractor.from_buffer(frames)
assert feat.shape == (FEATURE_DIM,)
def test_feature_extractor_static_buffer_zero_velocity() -> None:
from data_only_viz.action_head import FeatureExtractor, WINDOW_LEN, J3D_JOINTS
static = _rand_j3d(42)
frames = [static.copy() for _ in range(WINDOW_LEN)]
feat = FeatureExtractor.from_buffer(frames)
vel_block = feat[J3D_JOINTS * 3 : J3D_JOINTS * 3 * 2]
assert np.allclose(vel_block, 0.0, atol=1e-6)
def test_feature_extractor_kinetics_speed_and_accel() -> None:
from data_only_viz.action_head import FeatureExtractor, WINDOW_LEN
frames = []
for t in range(WINDOW_LEN):
f = np.zeros((32, 3), dtype=np.float32)
f[0, 0] = 0.1 * t
frames.append(f)
kin = FeatureExtractor.kinetics(frames)
assert kin.shape == (3,)
assert kin[0] > 0
assert abs(kin[0] - 0.1 / 32) < 1e-4
assert abs(kin[1]) < 1e-4
def test_feature_extractor_symmetry_sign() -> None:
from data_only_viz.action_head import FeatureExtractor, WINDOW_LEN, WRIST_LEFT, WRIST_RIGHT
frames = []
for t in range(WINDOW_LEN):
f = np.zeros((32, 3), dtype=np.float32)
f[WRIST_LEFT, 0] = 0.05 * t
f[WRIST_RIGHT, 0] = -0.05 * t
frames.append(f)
kin = FeatureExtractor.kinetics(frames)
assert kin[2] > 0.9
@@ -0,0 +1,83 @@
"""Tests for ActionHead model (forward, step, checkpoint roundtrip)."""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
torch = pytest.importorskip("torch")
def _rand_j3d(seed: int = 0) -> np.ndarray:
rng = np.random.default_rng(seed)
return rng.normal(size=(32, 3)).astype(np.float32)
def test_model_forward_shape() -> None:
from data_only_viz.action_head import ActionHeadModel, FEATURE_DIM, NUM_CLASSES
model = ActionHeadModel()
x = torch.zeros(1, FEATURE_DIM)
h = model.init_hidden(batch=1)
logits, h_new = model(x, h)
assert logits.shape == (1, NUM_CLASSES)
assert h_new.shape == h.shape
def test_model_param_count_under_100k() -> None:
from data_only_viz.action_head import ActionHeadModel
model = ActionHeadModel()
n = sum(p.numel() for p in model.parameters())
assert n < 100_000, f"too many params: {n}"
def test_action_head_step_warmup_returns_debout() -> None:
from data_only_viz.action_head import ActionHead, LABELS
head = ActionHead(ckpt_path=None)
label, probs, kin = head.step(pid=1, j3d=_rand_j3d(0))
assert label == LABELS[0]
assert probs.shape == (3,)
assert pytest.approx(float(probs[0]), abs=1e-6) == 1.0
assert kin.shape == (3,)
assert float(kin[0]) == 0.0
def test_action_head_step_after_warmup_returns_some_label() -> None:
from data_only_viz.action_head import ActionHead, LABELS
head = ActionHead(ckpt_path=None)
for i in range(5):
label, probs, kin = head.step(pid=1, j3d=_rand_j3d(i))
assert label in LABELS
assert abs(float(probs.sum()) - 1.0) < 1e-5
def test_action_head_forget_resets_hidden_state(tmp_path: Path) -> None:
from data_only_viz.action_head import ActionHead
head = ActionHead(ckpt_path=None)
for i in range(5):
head.step(pid=1, j3d=_rand_j3d(i))
assert 1 in head._hidden
head.forget(1)
assert 1 not in head._hidden
assert head._buffers.frames_for(1) == []
def test_action_head_checkpoint_roundtrip(tmp_path: Path) -> None:
from data_only_viz.action_head import ActionHead, ActionHeadModel
model = ActionHeadModel()
ckpt = tmp_path / "ah.pt"
torch.save({"model_state_dict": model.state_dict(),
"version": 1}, ckpt)
head = ActionHead(ckpt_path=ckpt)
for k, v in head._model.state_dict().items():
assert torch.allclose(v, model.state_dict()[k])
def test_action_head_step_handles_nan() -> None:
from data_only_viz.action_head import ActionHead, LABELS
head = ActionHead(ckpt_path=None)
j = _rand_j3d(0)
j[5, 1] = float("nan")
label, probs, _kin = head.step(pid=1, j3d=j)
assert label in LABELS
assert not np.isnan(probs).any()
+154
View File
@@ -0,0 +1,154 @@
"""Tests for ActionHeadPublisher."""
from __future__ import annotations
import threading
from unittest.mock import MagicMock
import numpy as np
import pytest
torch = pytest.importorskip("torch")
class _FakeState:
def __init__(self) -> None:
self.persons_smplx = []
self.smplx_last_t = 0.0
self.persons_body3d = []
self.persons_body_ids = []
self.pose_last_t = 0.0
self.persons_hands = []
self.persons_hands_ids = []
self.persons_face = []
self.persons_face_ids = []
self._lock = threading.RLock()
def lock(self):
return self._lock
def _make_smplx_person(pid: int, seed: int = 0) -> dict:
rng = np.random.default_rng(seed)
return {"pid": pid, "v3d": rng.normal(size=(10475, 3)).astype(np.float32)}
def test_publisher_smplx_source_emits_osc() -> None:
from data_only_viz.action_head_pub import ActionHeadPublisher
state = _FakeState()
bridge = MagicMock()
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
state.persons_smplx = [_make_smplx_person(7)]
state.smplx_last_t = 1.0
pub._tick(t_now=0.0)
actions = [c for c in bridge.send_action.call_args_list]
assert len(actions) == 1
assert actions[0].kwargs.get("pid", actions[0].args[0]) == 7
bridge.send_enter.assert_called_with(pid=7)
def test_publisher_falls_back_to_mediapipe_body3d() -> None:
from data_only_viz.action_head_pub import ActionHeadPublisher
state = _FakeState()
bridge = MagicMock()
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
state.persons_body3d = [[(0.1 * i, 0.2 * i, 0.3 * i) for i in range(33)]]
state.persons_body_ids = [42]
state.pose_last_t = 1.0
pub._tick(t_now=0.0)
bridge.send_action.assert_called_once()
bridge.send_enter.assert_called_with(pid=42)
def test_publisher_purges_lost_pid() -> None:
from data_only_viz.action_head_pub import ActionHeadPublisher
state = _FakeState()
bridge = MagicMock()
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
state.persons_smplx = [_make_smplx_person(1)]
state.smplx_last_t = 1.0
pub._tick(t_now=0.0)
bridge.reset_mock()
state.persons_smplx = []
state.smplx_last_t = 2.0
state.persons_body3d = []
pub._tick(t_now=1.0)
bridge.send_leave.assert_called_with(pid=1)
def test_publisher_no_double_emit_same_timestamp() -> None:
from data_only_viz.action_head_pub import ActionHeadPublisher
state = _FakeState()
bridge = MagicMock()
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
state.persons_smplx = [_make_smplx_person(1)]
state.smplx_last_t = 1.0
pub._tick(t_now=0.0)
bridge.reset_mock()
pub._tick(t_now=1.0) # same smplx_last_t
bridge.send_action.assert_not_called()
def test_publisher_uses_face_lips_for_mouth_open() -> None:
"""mouth_open from MediaPipe lip landmarks (idx 13 and 14) must be ~1.0."""
from unittest.mock import patch
from data_only_viz.action_head_pub import ActionHeadPublisher, MEDIAPIPE_LIP_UPPER_INNER, MEDIAPIPE_LIP_LOWER_INNER
state = _FakeState()
bridge = MagicMock()
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
# Build a fake face landmark list: at least 15 landmarks.
# idx 13 = upper inner (y=0), idx 14 = lower inner (y=1), rest zeros.
face_kps = [(0.0, 0.0, 0.0)] * 15
face_kps[MEDIAPIPE_LIP_UPPER_INNER] = (0.0, 0.0, 0.0)
face_kps[MEDIAPIPE_LIP_LOWER_INNER] = (1.0, 0.0, 0.0) # 1m apart in x
state.persons_face = [face_kps]
state.persons_face_ids = [0]
captured_mouth: list[float] = []
original_step = pub.head.step
def spy_step(pid, j3d, expr=None, mouth_open=0.0, hands_kp=None):
captured_mouth.append(mouth_open)
return original_step(pid, j3d, expr=expr, mouth_open=mouth_open, hands_kp=hands_kp)
pub.head.step = spy_step # type: ignore[method-assign]
state.persons_smplx = [_make_smplx_person(0)]
state.smplx_last_t = 1.0
pub._tick(t_now=0.0)
assert len(captured_mouth) == 1
assert abs(captured_mouth[0] - 1.0) < 1e-5
def test_publisher_passes_hands_kp_to_step() -> None:
"""hands_kp of shape (42, 3) must be passed to head.step."""
from data_only_viz.action_head_pub import ActionHeadPublisher
state = _FakeState()
bridge = MagicMock()
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
# Two 21-kp hand arrays (left + right) for pid=0.
rng = np.random.default_rng(7)
left_kps = rng.normal(size=(21, 3)).astype(np.float32)
right_kps = rng.normal(size=(21, 3)).astype(np.float32)
# persons_hands flat list: [left, right], ids both 0 (same pid).
state.persons_hands = [left_kps, right_kps]
state.persons_hands_ids = [0, 0]
captured_hands: list = []
original_step = pub.head.step
def spy_step(pid, j3d, expr=None, mouth_open=0.0, hands_kp=None):
captured_hands.append(hands_kp)
return original_step(pid, j3d, expr=expr, mouth_open=mouth_open, hands_kp=hands_kp)
pub.head.step = spy_step # type: ignore[method-assign]
state.persons_smplx = [_make_smplx_person(0)]
state.smplx_last_t = 1.0
pub._tick(t_now=0.0)
assert len(captured_hands) == 1
assert captured_hands[0] is not None
assert captured_hands[0].shape == (42, 3)
+46
View File
@@ -0,0 +1,46 @@
"""Tests for j3d augmentations."""
from __future__ import annotations
import numpy as np
WINDOW_LEN = 16
def _sample_stack(seed: int = 0) -> np.ndarray:
rng = np.random.default_rng(seed)
return rng.normal(size=(WINDOW_LEN, 32, 3)).astype(np.float32)
def test_mirror_swap_left_right_joints() -> None:
from data_only_viz.training.augment import mirror_x, MIRROR_MAP
x = _sample_stack(0)
y = mirror_x(x)
# Check output shape
assert y.shape == (WINDOW_LEN, 32, 3)
# x-coords are negated after reindexing
assert np.allclose(y[..., 0], -x[:, list(MIRROR_MAP), :][:, :, 0], atol=1e-6)
def test_noise_within_sigma() -> None:
from data_only_viz.training.augment import add_noise
rng = np.random.default_rng(0)
x = _sample_stack(0)
y = add_noise(x, sigma=0.01, rng=rng)
diff = y - x
assert np.allclose(diff.std(), 0.01, atol=2e-3)
def test_time_stretch_keeps_shape() -> None:
from data_only_viz.training.augment import time_stretch
x = _sample_stack(0)
y = time_stretch(x, factor=0.9, rng=None)
assert y.shape == x.shape
def test_rotate_y_preserves_distances() -> None:
from data_only_viz.training.augment import rotate_y
x = _sample_stack(0)
y = rotate_y(x, angle_rad=0.3)
d_x = np.linalg.norm(x[0, 0] - x[0, 1])
d_y = np.linalg.norm(y[0, 0] - y[0, 1])
assert abs(d_x - d_y) < 1e-5
+85
View File
@@ -0,0 +1,85 @@
"""Tests for rule-based auto-labeler."""
from __future__ import annotations
import numpy as np
from data_only_viz.action_head import WINDOW_LEN
def _static_seated(frame_count: int = WINDOW_LEN) -> list[np.ndarray]:
"""Hip low (y small), knee bent ~80°."""
frames = []
for _ in range(frame_count):
f = np.zeros((32, 3), dtype=np.float32)
f[1] = [-0.1, 0.4, 0.0]
f[2] = [0.1, 0.4, 0.0]
f[4] = [-0.1, 0.4, 0.3]
f[5] = [0.1, 0.4, 0.3]
f[7] = [-0.1, 0.1, 0.3]
f[8] = [0.1, 0.1, 0.3]
frames.append(f)
return frames
def _static_standing(frame_count: int = WINDOW_LEN) -> list[np.ndarray]:
"""Hip high, knees ~180°."""
frames = []
for _ in range(frame_count):
f = np.zeros((32, 3), dtype=np.float32)
f[1] = [-0.1, 0.9, 0.0]
f[2] = [0.1, 0.9, 0.0]
f[4] = [-0.1, 0.5, 0.0]
f[5] = [0.1, 0.5, 0.0]
f[7] = [-0.1, 0.1, 0.0]
f[8] = [0.1, 0.1, 0.0]
frames.append(f)
return frames
def _dancing(frame_count: int = WINDOW_LEN) -> list[np.ndarray]:
"""Standing pose with high wrist velocity."""
base = _static_standing(1)[0]
frames = []
for t in range(frame_count):
f = base.copy()
phase = 2 * np.pi * t * 0.125 # 0.125 = 1/8, slower oscillation
f[20] = base[20] + np.array([np.sin(phase) * 0.5, np.cos(phase) * 0.5, 0])
f[21] = base[21] + np.array(
[-np.sin(phase) * 0.5, np.cos(phase) * 0.5, 0]
)
frames.append(f.astype(np.float32))
return frames
def test_autolabel_static_standing_is_debout() -> None:
from data_only_viz.training.autolabel import autolabel_window
label, conf = autolabel_window(_static_standing())
assert label == "debout"
assert conf >= 0.5
def test_autolabel_static_seated_is_assise() -> None:
from data_only_viz.training.autolabel import autolabel_window
label, conf = autolabel_window(_static_seated())
assert label == "assise"
assert conf >= 0.5
def test_autolabel_dancing_is_danse() -> None:
from data_only_viz.training.autolabel import autolabel_window
label, conf = autolabel_window(_dancing())
assert label == "danse"
assert conf >= 0.5
def test_autolabel_ambiguous_is_none() -> None:
from data_only_viz.training.autolabel import autolabel_window
base = _static_standing(WINDOW_LEN)
for t, f in enumerate(base):
f[20, 0] += 0.01 * np.sin(t)
label, _conf = autolabel_window(base)
assert label in ("debout", None)
+53 -6
View File
@@ -15,7 +15,7 @@ def _make_session_jsonl(path: Path, n_frames: int = 64) -> None:
row = {"ts": t / 30.0,
"session": "sess01",
"pid": 1,
"j3d": rng.normal(size=(22, 3)).tolist()}
"j3d": rng.normal(size=(32, 3)).tolist()}
f.write(json.dumps(row) + "\n")
@@ -25,7 +25,7 @@ def test_load_frames_jsonl(tmp_path: Path) -> None:
_make_session_jsonl(p)
frames = load_frames_jsonl(p)
assert len(frames) == 64
assert frames[0].j3d.shape == (22, 3)
assert frames[0].j3d.shape == (32, 3)
assert frames[0].pid == 1
assert frames[0].session == "sess01"
@@ -40,7 +40,7 @@ def test_sliding_windows(tmp_path: Path) -> None:
frames = load_frames_jsonl(p)
windows = list(sliding_windows(frames, window_len=16, stride=4))
assert len(windows) == 13
assert windows[0].j3d_stack.shape == (16, 22, 3)
assert windows[0].j3d_stack.shape == (16, 32, 3)
assert windows[0].session == "sess01"
@@ -55,7 +55,7 @@ def test_write_and_load_dataset_jsonl(tmp_path: Path) -> None:
DatasetRow(
window_id=f"sess01_pid1_w{i:04d}",
label="debout" if i % 2 == 0 else "danse",
j3d_stack=rng.normal(size=(16, 22, 3)).astype(np.float32),
j3d_stack=rng.normal(size=(16, 32, 3)).astype(np.float32),
session="sess01",
pid_local=1,
auto_label_confidence=0.8,
@@ -68,10 +68,57 @@ def test_write_and_load_dataset_jsonl(tmp_path: Path) -> None:
loaded = load_dataset_jsonl(out)
assert len(loaded) == 5
assert loaded[0].label == "debout"
assert loaded[0].j3d_stack.shape == (16, 22, 3)
assert loaded[0].j3d_stack.shape == (16, 32, 3)
assert np.allclose(loaded[0].j3d_stack, rows[0].j3d_stack, atol=1e-6)
def test_write_and_load_dataset_jsonl_with_hands_kp(tmp_path: Path) -> None:
from data_only_viz.training.dataset import (
DatasetRow,
load_dataset_jsonl,
write_dataset_jsonl,
)
rng = np.random.default_rng(1)
hands_kp = rng.normal(size=(16, 42, 3)).astype(np.float32)
row = DatasetRow(
window_id="sess01_pid1_w0000",
label="danse",
j3d_stack=rng.normal(size=(16, 32, 3)).astype(np.float32),
session="sess01",
pid_local=1,
auto_label_confidence=0.9,
manually_validated=True,
hands_kp_stack=hands_kp,
)
out = tmp_path / "with_hands.jsonl"
write_dataset_jsonl([row], out)
loaded = load_dataset_jsonl(out)
assert loaded[0].hands_kp_stack is not None
assert loaded[0].hands_kp_stack.shape == (16, 42, 3)
assert np.allclose(loaded[0].hands_kp_stack, hands_kp, atol=1e-6)
def test_load_dataset_jsonl_without_hands_kp_is_ok(tmp_path: Path) -> None:
"""Legacy v2 rows without hands_kp field should load with hands_kp_stack=None."""
import json
from data_only_viz.training.dataset import load_dataset_jsonl
rng = np.random.default_rng(2)
row = {
"window_id": "sess01_pid1_w0000",
"label": "debout",
"j3d": rng.normal(size=(16, 32, 3)).tolist(),
"session": "sess01",
"pid_local": 1,
"auto_label_confidence": 0.8,
"manually_validated": False,
}
out = tmp_path / "legacy.jsonl"
out.write_text(json.dumps(row) + "\n")
loaded = load_dataset_jsonl(out)
assert len(loaded) == 1
assert loaded[0].hands_kp_stack is None
def test_split_by_session(tmp_path: Path) -> None:
from data_only_viz.training.dataset import DatasetRow, split_by_session
rng = np.random.default_rng(0)
@@ -79,7 +126,7 @@ def test_split_by_session(tmp_path: Path) -> None:
for sess in ("s01", "s02", "s03", "s04", "s05", "s06", "s07"):
rows.append(DatasetRow(
window_id=f"{sess}_w0", label="debout",
j3d_stack=rng.normal(size=(16, 22, 3)).astype(np.float32),
j3d_stack=rng.normal(size=(16, 32, 3)).astype(np.float32),
session=sess, pid_local=1, auto_label_confidence=0.7,
manually_validated=False,
))
@@ -0,0 +1,82 @@
"""Sanity tests for MediaPipe offline extractor (no MediaPipe runtime -- we
mock the landmarker and feed synthetic landmarks)."""
from __future__ import annotations
from types import SimpleNamespace
import numpy as np
import pytest
def test_build_j3d32_combines_body_and_fingertips() -> None:
from data_only_viz.scripts.extract_mediapipe_offline import _build_j3d32
from data_only_viz.action_head import J3D_JOINTS
body3d = np.linspace(0, 1, 33 * 3).reshape(33, 3).astype(np.float32)
hands_kp42 = np.linspace(2, 3, 42 * 3).reshape(42, 3).astype(np.float32)
j3d = _build_j3d32(body3d, hands_kp42)
assert j3d is not None
assert j3d.shape == (J3D_JOINTS, 3)
# The body22 portion comes from body3d via MEDIAPIPE_TO_22.
# The fingertip portion (indices 22..31) comes from hands_kp at idx 4,8,12,16,20.
assert np.allclose(j3d[22], hands_kp42[4])
assert np.allclose(j3d[26], hands_kp42[20])
assert np.allclose(j3d[27], hands_kp42[21 + 4])
assert np.allclose(j3d[31], hands_kp42[21 + 20])
def test_build_j3d32_returns_none_when_no_body() -> None:
from data_only_viz.scripts.extract_mediapipe_offline import _build_j3d32
j3d = _build_j3d32(None, np.zeros((42, 3), dtype=np.float32))
assert j3d is None
def test_hands_kp42_combines_left_right_sides() -> None:
from data_only_viz.scripts.extract_mediapipe_offline import _hands_kp42
left = np.linspace(0, 1, 21 * 3).reshape(21, 3).astype(np.float32)
right = np.linspace(2, 3, 21 * 3).reshape(21, 3).astype(np.float32)
out = _hands_kp42(left, right)
assert out.shape == (42, 3)
assert np.allclose(out[:21], left)
assert np.allclose(out[21:], right)
def test_hands_kp42_zero_pads_when_missing() -> None:
from data_only_viz.scripts.extract_mediapipe_offline import _hands_kp42
left = np.ones((21, 3), dtype=np.float32)
out = _hands_kp42(left, None)
assert np.allclose(out[:21], left)
assert np.allclose(out[21:], 0.0)
def test_mouth_open_from_face_lips() -> None:
from data_only_viz.scripts.extract_mediapipe_offline import _mouth_open
# MediaPipe FaceMesh has 478 landmarks. Build a sparse array : zero
# everywhere except idx 13 (upper inner) and idx 14 (lower inner),
# 1 metre apart on the y axis.
face = np.zeros((478, 3), dtype=np.float32)
face[13] = [0.0, 1.0, 0.0]
face[14] = [0.0, 0.0, 0.0]
assert abs(_mouth_open(face) - 1.0) < 1e-6
def test_mouth_open_returns_zero_on_empty_face() -> None:
from data_only_viz.scripts.extract_mediapipe_offline import _mouth_open
assert _mouth_open(None) == 0.0
assert _mouth_open(np.zeros((10, 3), dtype=np.float32)) == 0.0
def test_lmk_list_to_array_round_trip() -> None:
from data_only_viz.scripts.extract_mediapipe_offline import _lmk_list_to_array
class _Lmk:
def __init__(self, x: float, y: float, z: float) -> None:
self.x = x; self.y = y; self.z = z
lmks = [_Lmk(i, 2 * i, 3 * i) for i in range(5)]
arr = _lmk_list_to_array(lmks)
assert arr is not None
assert arr.shape == (5, 3)
assert np.allclose(arr[2], [2.0, 4.0, 6.0])
def test_lmk_list_to_array_none_input() -> None:
from data_only_viz.scripts.extract_mediapipe_offline import _lmk_list_to_array
assert _lmk_list_to_array(None) is None
@@ -0,0 +1,63 @@
"""Smoke test for action-head training (2 epochs, tiny dataset, CPU)."""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
torch = pytest.importorskip("torch")
def _make_tiny_dataset(tmp_path: Path) -> Path:
from data_only_viz.training.dataset import DatasetRow, write_dataset_jsonl
rng = np.random.default_rng(0)
rows = []
for sess_i, sess in enumerate(("s01", "s02", "s03")):
for w in range(30):
label = ("debout", "assise", "danse")[w % 3]
rows.append(DatasetRow(
window_id=f"{sess}_w{w:03d}",
label=label,
j3d_stack=rng.normal(size=(16, 32, 3)).astype(np.float32),
session=sess, pid_local=1,
auto_label_confidence=0.8,
manually_validated=True,
expr_stack=np.zeros((16, 10), dtype=np.float32),
mouth_open_stack=np.zeros(16, dtype=np.float32),
))
out = tmp_path / "tiny.jsonl"
write_dataset_jsonl(rows, out)
return out
def test_train_2_epochs_no_crash(tmp_path: Path) -> None:
from data_only_viz.training.train_action_head import train
ds = _make_tiny_dataset(tmp_path)
ckpt = tmp_path / "ckpt.pt"
history = train(
dataset_path=ds,
ckpt_out=ckpt,
epochs=2,
batch_size=8,
lr=1e-3,
device="cpu",
seed=0,
log_every=10_000,
)
assert ckpt.exists()
assert len(history["train_loss"]) == 2
assert all(np.isfinite(history["train_loss"]))
def test_trained_checkpoint_loadable(tmp_path: Path) -> None:
from data_only_viz.action_head import ActionHead
from data_only_viz.training.train_action_head import train
ds = _make_tiny_dataset(tmp_path)
ckpt = tmp_path / "ckpt.pt"
train(dataset_path=ds, ckpt_out=ckpt, epochs=1, batch_size=8,
lr=1e-3, device="cpu", seed=0, log_every=10_000)
head = ActionHead(ckpt_path=ckpt)
for i in range(5):
label, probs, _ = head.step(pid=1, j3d=np.zeros((32, 3), dtype=np.float32))
assert abs(float(probs.sum()) - 1.0) < 1e-5
+85
View File
@@ -0,0 +1,85 @@
"""On-the-fly augmentations for j3d windows."""
from __future__ import annotations
import numpy as np
# SMPL-X left/right joint mirror map for 32-joint layout.
# Body joints 0..21 (unchanged), fingertips 22..31 (L 22..26 <-> R 27..31).
MIRROR_MAP: tuple[int, ...] = (
# 22 body (unchanged)
0,
2, 1,
3,
5, 4,
6,
8, 7,
9,
11, 10,
12,
14, 13,
15,
17, 16,
19, 18,
21, 20,
# 10 fingertips: L (22..26) <-> R (27..31)
27, 28, 29, 30, 31, 22, 23, 24, 25, 26,
)
assert len(MIRROR_MAP) == 32
def mirror_x(stack: np.ndarray) -> np.ndarray:
"""Mirror across the YZ plane: flip x and swap left↔right joints."""
out = stack[:, list(MIRROR_MAP), :].copy()
out[..., 0] = -out[..., 0]
return out
def add_noise(stack: np.ndarray, sigma: float, rng: np.random.Generator) -> np.ndarray:
noise = rng.normal(scale=sigma, size=stack.shape).astype(np.float32)
return (stack + noise).astype(np.float32, copy=False)
def time_stretch(stack: np.ndarray, factor: float,
rng: np.random.Generator | None = None) -> np.ndarray:
"""Resample the time axis with linear interpolation, keep window_len fixed."""
T = stack.shape[0]
new_T = int(round(T * factor))
new_T = max(2, new_T)
src = np.linspace(0.0, T - 1, num=new_T)
interp = np.empty((new_T, *stack.shape[1:]), dtype=np.float32)
lo = np.floor(src).astype(int)
hi = np.minimum(lo + 1, T - 1)
frac = (src - lo).astype(np.float32)
interp = (1 - frac[:, None, None]) * stack[lo] + frac[:, None, None] * stack[hi]
if new_T >= T:
start = (new_T - T) // 2
return interp[start:start + T].astype(np.float32, copy=False)
pad_before = (T - new_T) // 2
pad_after = T - new_T - pad_before
return np.concatenate([
np.repeat(interp[:1], pad_before, axis=0),
interp,
np.repeat(interp[-1:], pad_after, axis=0),
]).astype(np.float32, copy=False)
def rotate_y(stack: np.ndarray, angle_rad: float) -> np.ndarray:
"""Rotate around Y (vertical) axis."""
c, s = np.cos(angle_rad), np.sin(angle_rad)
R = np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]], dtype=np.float32)
return (stack @ R.T).astype(np.float32, copy=False)
def random_augment(stack: np.ndarray, rng: np.random.Generator) -> np.ndarray:
out = stack
if rng.random() < 0.5:
out = mirror_x(out)
if rng.random() < 0.8:
out = add_noise(out, sigma=0.01, rng=rng)
if rng.random() < 0.5:
factor = float(rng.uniform(0.9, 1.1))
out = time_stretch(out, factor=factor, rng=rng)
if rng.random() < 0.5:
angle = float(rng.uniform(-np.deg2rad(15), np.deg2rad(15)))
out = rotate_y(out, angle_rad=angle)
return out
+120
View File
@@ -0,0 +1,120 @@
"""Rule-based labeler for j3d windows.
Outputs one of {"debout", "assise", "danse", None}. None marks
ambiguous windows that should be reviewed manually.
Rules are tuned for SMPL-X joint indexing as used by Multi-HMR.
"""
from __future__ import annotations
import argparse
import logging
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from data_only_viz.action_head import (
FeatureExtractor,
HIP_LEFT,
HIP_RIGHT,
WINDOW_LEN,
)
@dataclass(frozen=True)
class AutoLabelConfig:
hip_y_seated_max: float = 0.55
knee_angle_seated_max: float = 2.0 # rad, ~115°
speed_static_max: float = 0.03 # m/s mean joint speed
speed_dance_min: float = 0.033 # m/s mean joint speed
accel_dance_min: float = 0.001
DEFAULT_CFG = AutoLabelConfig()
def autolabel_window(
frames: list[np.ndarray], cfg: AutoLabelConfig = DEFAULT_CFG
) -> tuple[str | None, float]:
"""Return (label, confidence). label is None when ambiguous."""
if len(frames) < WINDOW_LEN // 2:
return None, 0.0
cur = frames[-1]
hip_y = float((cur[HIP_LEFT, 1] + cur[HIP_RIGHT, 1]) * 0.5)
knee_angle = FeatureExtractor._mean_knee_angle(cur)
kin = FeatureExtractor.kinetics(frames)
speed = float(kin[0])
accel = float(kin[1])
if hip_y < cfg.hip_y_seated_max and knee_angle < cfg.knee_angle_seated_max:
conf = 0.5 + 0.5 * min(1.0, (cfg.hip_y_seated_max - hip_y) / 0.2)
return "assise", conf
if speed >= cfg.speed_dance_min or accel >= cfg.accel_dance_min:
conf = 0.5 + 0.5 * min(1.0, speed / 0.5)
return "danse", conf
if speed <= cfg.speed_static_max:
conf = 0.6
return "debout", conf
return None, 0.0
def autolabel_dataset(
frames_jsonl: Path,
out_jsonl: Path,
window_len: int = WINDOW_LEN,
stride: int = 4,
keep_none: bool = True,
) -> int:
"""Glue: raw frames jsonl → sliding windows → auto-label → DatasetRow jsonl.
Returns the number of windows written.
"""
from data_only_viz.training.dataset import (
DatasetRow,
load_frames_jsonl,
sliding_windows,
write_dataset_jsonl,
)
frames = load_frames_jsonl(frames_jsonl)
rows = []
for win in sliding_windows(frames, window_len=window_len, stride=stride):
frame_list = [win.j3d_stack[t] for t in range(win.j3d_stack.shape[0])]
label, conf = autolabel_window(frame_list)
if label is None and not keep_none:
continue
rows.append(
DatasetRow(
window_id=f"{win.session}_pid{win.pid_local}_t{int(win.first_ts*1000):08d}",
label=label if label is not None else "debout",
j3d_stack=win.j3d_stack,
session=win.session,
pid_local=win.pid_local,
auto_label_confidence=conf,
manually_validated=False,
)
)
out_jsonl.parent.mkdir(parents=True, exist_ok=True)
write_dataset_jsonl(rows, out_jsonl)
return len(rows)
def _cli() -> None:
p = argparse.ArgumentParser()
p.add_argument(
"--frames",
required=True,
type=Path,
help="Raw frames jsonl from extract_j3d_offline.py",
)
p.add_argument("--out", required=True, type=Path, help="Auto-labeled windowed dataset jsonl")
p.add_argument("--stride", type=int, default=4)
args = p.parse_args()
logging.basicConfig(level=logging.INFO)
n = autolabel_dataset(args.frames, args.out, stride=args.stride)
print(f"wrote {n} windows to {args.out}")
if __name__ == "__main__":
_cli()
+68 -7
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import random
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable, Iterator
@@ -15,26 +15,35 @@ class RawFrame:
ts: float
session: str
pid: int
j3d: np.ndarray # (22, 3) float32
j3d: np.ndarray # (32, 3) float32 (v3: body22 + 10 fingertips)
expression: np.ndarray | None = None # (EXPR_DIM,) or None
mouth_open: float = 0.0
hands_kp: np.ndarray | None = None # (42, 3) or None
@dataclass
class WindowRow:
j3d_stack: np.ndarray # (window_len, 22, 3) float32
j3d_stack: np.ndarray # (window_len, 32, 3) float32
session: str
pid_local: int
first_ts: float
expr_stack: np.ndarray | None = None # (window_len, 10) or None
mouth_open_stack: np.ndarray | None = None # (window_len,) or None
hands_kp_stack: np.ndarray | None = None # (window_len, 42, 3) or None
@dataclass
class DatasetRow:
window_id: str
label: str
j3d_stack: np.ndarray # (window_len, 22, 3) float32
j3d_stack: np.ndarray # (window_len, 32, 3) float32
session: str
pid_local: int
auto_label_confidence: float
manually_validated: bool
expr_stack: np.ndarray | None = None # (window_len, 10) or None
mouth_open_stack: np.ndarray | None = None # (window_len,) or None
hands_kp_stack: np.ndarray | None = None # (window_len, 42, 3) or None
def load_frames_jsonl(path: Path) -> list[RawFrame]:
@@ -45,11 +54,18 @@ def load_frames_jsonl(path: Path) -> list[RawFrame]:
if not line:
continue
d = json.loads(line)
expr_raw = d.get("expression")
expr = np.asarray(expr_raw, dtype=np.float32) if expr_raw is not None else None
hands_raw = d.get("hands_kp")
hands_kp = np.asarray(hands_raw, dtype=np.float32) if hands_raw is not None else None
rows.append(RawFrame(
ts=float(d["ts"]),
session=str(d["session"]),
pid=int(d["pid"]),
j3d=np.asarray(d["j3d"], dtype=np.float32),
expression=expr,
mouth_open=float(d.get("mouth_open", 0.0)),
hands_kp=hands_kp,
))
return rows
@@ -68,14 +84,43 @@ def sliding_windows(frames: list[RawFrame],
for start in range(0, len(grp) - window_len + 1, stride):
chunk = grp[start:start + window_len]
stack = np.stack([c.j3d for c in chunk]).astype(np.float32)
# Expression stack: zeros if not present
if any(c.expression is not None for c in chunk):
expr_dim = max(
(len(c.expression) for c in chunk if c.expression is not None),
default=10,
)
expr_stack = np.zeros((window_len, expr_dim), dtype=np.float32)
for t, c in enumerate(chunk):
if c.expression is not None:
n = min(expr_dim, len(c.expression))
expr_stack[t, :n] = c.expression[:n]
else:
expr_stack = None
mouth_stack = np.array(
[c.mouth_open for c in chunk], dtype=np.float32
)
# hands_kp stack: (window_len, 42, 3) if any frame has hands_kp
if any(c.hands_kp is not None for c in chunk):
hands_kp_stack = np.zeros((window_len, 42, 3), dtype=np.float32)
for t, c in enumerate(chunk):
if c.hands_kp is not None:
hk = np.asarray(c.hands_kp, dtype=np.float32)
if hk.shape == (42, 3):
hands_kp_stack[t] = hk
else:
hands_kp_stack = None
yield WindowRow(j3d_stack=stack, session=sess,
pid_local=pid, first_ts=chunk[0].ts)
pid_local=pid, first_ts=chunk[0].ts,
expr_stack=expr_stack,
mouth_open_stack=mouth_stack,
hands_kp_stack=hands_kp_stack)
def write_dataset_jsonl(rows: Iterable[DatasetRow], path: Path) -> None:
with path.open("w") as f:
for r in rows:
f.write(json.dumps({
d: dict = {
"window_id": r.window_id,
"label": r.label,
"j3d": r.j3d_stack.astype(np.float32).tolist(),
@@ -83,7 +128,14 @@ def write_dataset_jsonl(rows: Iterable[DatasetRow], path: Path) -> None:
"pid_local": r.pid_local,
"auto_label_confidence": float(r.auto_label_confidence),
"manually_validated": bool(r.manually_validated),
}) + "\n")
}
if r.expr_stack is not None:
d["expr_stack"] = r.expr_stack.astype(np.float32).tolist()
if r.mouth_open_stack is not None:
d["mouth_open_stack"] = r.mouth_open_stack.astype(np.float32).tolist()
if r.hands_kp_stack is not None:
d["hands_kp_stack"] = r.hands_kp_stack.astype(np.float32).tolist()
f.write(json.dumps(d) + "\n")
def load_dataset_jsonl(path: Path) -> list[DatasetRow]:
@@ -94,6 +146,12 @@ def load_dataset_jsonl(path: Path) -> list[DatasetRow]:
if not line:
continue
d = json.loads(line)
expr_raw = d.get("expr_stack")
expr = np.asarray(expr_raw, dtype=np.float32) if expr_raw is not None else None
mouth_raw = d.get("mouth_open_stack")
mouth = np.asarray(mouth_raw, dtype=np.float32) if mouth_raw is not None else None
hands_raw = d.get("hands_kp_stack")
hands_kp = np.asarray(hands_raw, dtype=np.float32) if hands_raw is not None else None
out.append(DatasetRow(
window_id=d["window_id"],
label=d["label"],
@@ -102,6 +160,9 @@ def load_dataset_jsonl(path: Path) -> list[DatasetRow]:
pid_local=int(d["pid_local"]),
auto_label_confidence=float(d["auto_label_confidence"]),
manually_validated=bool(d["manually_validated"]),
expr_stack=expr,
mouth_open_stack=mouth,
hands_kp_stack=hands_kp,
))
return out
+87
View File
@@ -0,0 +1,87 @@
"""Evaluate a trained action-head checkpoint."""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
import numpy as np
import torch
from torch.utils.data import DataLoader
from data_only_viz.action_head import ActionHeadModel, LABELS
from data_only_viz.training.dataset import load_dataset_jsonl, split_by_session
from data_only_viz.training.train_action_head import (
LABEL_TO_IDX,
WindowDataset,
)
def confusion_matrix(true: list[int], pred: list[int],
num_classes: int = 3) -> np.ndarray:
cm = np.zeros((num_classes, num_classes), dtype=np.int64)
for t, p in zip(true, pred):
cm[t, p] += 1
return cm
def evaluate(ckpt_path: Path, dataset_path: Path, device: str = "cpu",
seed: int = 0) -> dict:
rows = load_dataset_jsonl(dataset_path)
_train, _val, test_rows = split_by_session(rows, seed=seed)
if not test_rows:
test_rows = rows
ds = WindowDataset(test_rows, augment=False, seed=seed)
loader = DataLoader(ds, batch_size=64, shuffle=False)
model = ActionHeadModel().to(device).eval()
payload = torch.load(ckpt_path, map_location=device, weights_only=True)
model.load_state_dict(payload["model_state_dict"])
true: list[int] = []
pred: list[int] = []
with torch.no_grad():
for x, y in loader:
x = x.to(device); y = y.to(device)
B, T, _ = x.shape
h = model.init_hidden(batch=B, device=device)
logits = None
for t in range(T):
logits, h = model(x[:, t, :], h)
true.extend(y.cpu().tolist())
pred.extend(logits.argmax(-1).cpu().tolist())
cm = confusion_matrix(true, pred)
acc = float(np.trace(cm) / max(1, cm.sum()))
confusion_db = float((cm[0, 2] + cm[2, 0]) / max(1, cm.sum()))
feat_dim = ds[0][0].shape[-1]
bench_x = torch.zeros(1, feat_dim, device=device)
h = model.init_hidden(batch=1, device=device)
for _ in range(20):
_ = model(bench_x, h)
t0 = time.perf_counter()
N = 500
for _ in range(N):
_, h = model(bench_x, h)
lat_ms = (time.perf_counter() - t0) * 1000.0 / N
return {
"test_acc": acc,
"confusion_debout_danse": confusion_db,
"confusion_matrix": cm.tolist(),
"labels": list(LABELS),
"step_latency_ms": lat_ms,
"n_test": int(cm.sum()),
}
def _cli() -> None:
p = argparse.ArgumentParser()
p.add_argument("--ckpt", required=True, type=Path)
p.add_argument("--dataset", required=True, type=Path)
p.add_argument("--device", default="cpu",
choices=["cpu", "mps", "cuda"])
args = p.parse_args()
out = evaluate(args.ckpt, args.dataset, device=args.device)
print(json.dumps(out, indent=2))
if __name__ == "__main__":
_cli()
+116
View File
@@ -0,0 +1,116 @@
"""Manual label review TUI.
Reads an auto-labeled jsonl dataset, presents each window with:
- ASCII skeleton (front view) of last frame
- speed/accel/sym kinetics
- proposed label + confidence
Keys:
1 = debout, 2 = assise, 3 = danse
ENTER = accept proposed label
S = skip (label = None, will not be saved)
Q = quit and write what we have so far
Usage:
uv run python -m data_only_viz.training.review \\
--in ~/.cache/av-live-action/dataset/auto.jsonl \\
--out ~/.cache/av-live-action/dataset/reviewed.jsonl
"""
from __future__ import annotations
import argparse
import sys
import termios
import tty
from pathlib import Path
import numpy as np
from data_only_viz.action_head import LABELS
from data_only_viz.training.autolabel import autolabel_window
from data_only_viz.training.dataset import (
DatasetRow,
load_dataset_jsonl,
write_dataset_jsonl,
)
def _ascii_skeleton(j3d: np.ndarray, width: int = 40, height: int = 16) -> str:
pts = j3d[:, [0, 1]] # x, y
mn = pts.min(axis=0)
mx = pts.max(axis=0)
rng = np.maximum(mx - mn, 1e-3)
norm = (pts - mn) / rng
grid = [[" "] * width for _ in range(height)]
for x, y in norm:
col = int(x * (width - 1))
row = int((1 - y) * (height - 1))
grid[row][col] = "*"
return "\n".join("".join(row) for row in grid)
def _getch() -> str:
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
return sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def review(in_path: Path, out_path: Path,
sample_validated_fraction: float = 0.2,
seed: int = 0) -> None:
from data_only_viz.action_head import FeatureExtractor
rows = load_dataset_jsonl(in_path)
rng = np.random.default_rng(seed)
kept: list[DatasetRow] = []
for i, r in enumerate(rows):
proposed, conf = autolabel_window(list(r.j3d_stack))
is_none = proposed is None
sampled = rng.random() < sample_validated_fraction
if not is_none and not sampled and r.manually_validated:
kept.append(r)
continue
print("\033[2J\033[H") # clear
print(f"[{i + 1}/{len(rows)}] {r.window_id} proposed={proposed} conf={conf:.2f}")
print(_ascii_skeleton(r.j3d_stack[-1]))
kin = FeatureExtractor.kinetics(list(r.j3d_stack))
print(f"speed={kin[0]:.3f} accel={kin[1]:.3f} sym={kin[2]:+.3f}")
print("keys: 1=debout 2=assise 3=danse ENTER=accept S=skip Q=quit")
k = _getch().lower()
if k == "q":
break
if k == "s":
continue
if k == "\r":
chosen = proposed
elif k in ("1", "2", "3"):
chosen = LABELS[int(k) - 1]
else:
continue
if chosen is None:
continue
kept.append(DatasetRow(
window_id=r.window_id, label=chosen, j3d_stack=r.j3d_stack,
session=r.session, pid_local=r.pid_local,
auto_label_confidence=conf, manually_validated=True,
))
out_path.parent.mkdir(parents=True, exist_ok=True)
write_dataset_jsonl(kept, out_path)
print(f"\nwrote {len(kept)} rows to {out_path}")
def _cli() -> None:
p = argparse.ArgumentParser()
p.add_argument("--in", dest="in_path", required=True, type=Path)
p.add_argument("--out", dest="out_path", required=True, type=Path)
p.add_argument("--sample-fraction", type=float, default=0.2)
args = p.parse_args()
review(args.in_path, args.out_path,
sample_validated_fraction=args.sample_fraction)
if __name__ == "__main__":
_cli()
+205
View File
@@ -0,0 +1,205 @@
"""Train ActionHead on the windowed dataset.
Usage:
uv run python -m data_only_viz.training.train_action_head \
--dataset ~/.cache/av-live-action/dataset/dataset.jsonl \
--ckpt-out ~/.cache/av-live-action/checkpoints/action_head.pt \
--device mps --epochs 50 --batch-size 128
"""
from __future__ import annotations
import argparse
import json
import logging
from collections import Counter
from pathlib import Path
import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset
from data_only_viz.action_head import (
ActionHeadModel,
EXPR_DIM,
FeatureExtractor,
HANDS_KP_FLAT,
HIP_LEFT,
HIP_RIGHT,
LABELS,
)
from data_only_viz.training.augment import random_augment
from data_only_viz.training.dataset import (
DatasetRow,
load_dataset_jsonl,
split_by_session,
)
LOG = logging.getLogger("train_action_head")
LABEL_TO_IDX = {l: i for i, l in enumerate(LABELS)}
class WindowDataset(Dataset[tuple[torch.Tensor, int]]):
def __init__(self, rows: list[DatasetRow],
augment: bool = False, seed: int = 0) -> None:
self._rows = rows
self._augment = augment
self._rng = np.random.default_rng(seed)
def __len__(self) -> int:
return len(self._rows)
def __getitem__(self, idx: int) -> tuple[torch.Tensor, int]:
row = self._rows[idx]
stack = row.j3d_stack
if self._augment:
stack = random_augment(stack, self._rng)
T = stack.shape[0]
# expression and mouth_open stacks (zeros if absent / legacy)
if row.expr_stack is not None:
expr_s = row.expr_stack.astype(np.float32)
else:
expr_s = np.zeros((T, EXPR_DIM), dtype=np.float32)
if row.mouth_open_stack is not None:
mouth_s = row.mouth_open_stack.astype(np.float32)
else:
mouth_s = np.zeros(T, dtype=np.float32)
feats = []
prev = stack[0]
prev_vel = np.zeros_like(prev)
for t in range(T):
cur = stack[t]
vel = cur - prev
accel = vel - prev_vel
hip_y = float((cur[HIP_LEFT, 1] + cur[HIP_RIGHT, 1]) * 0.5)
knee_angle = FeatureExtractor._mean_knee_angle(cur)
sym = FeatureExtractor._symmetry_score(vel)
expr_t = expr_s[t] if t < len(expr_s) else np.zeros(EXPR_DIM, dtype=np.float32)
expr_vec = np.zeros(EXPR_DIM, dtype=np.float32)
n = min(EXPR_DIM, len(expr_t))
expr_vec[:n] = expr_t[:n]
mouth_t = float(mouth_s[t]) if t < len(mouth_s) else 0.0
# hands_kp at frame t (42, 3); zeros if row has none
if row.hands_kp_stack is not None:
hands_t = row.hands_kp_stack[t]
hands_flat = hands_t.reshape(-1).astype(np.float32, copy=False)
else:
hands_flat = np.zeros(HANDS_KP_FLAT, dtype=np.float32)
feat = np.concatenate([
cur.reshape(-1), vel.reshape(-1), accel.reshape(-1),
hands_flat,
expr_vec,
np.array([hip_y, knee_angle, sym, mouth_t], dtype=np.float32),
]).astype(np.float32, copy=False)
feats.append(feat)
prev_vel = vel
prev = cur
x = torch.from_numpy(np.stack(feats))
y = LABEL_TO_IDX[row.label]
return x, y
def _class_weights(rows: list[DatasetRow]) -> torch.Tensor:
counts = Counter(r.label for r in rows)
total = sum(counts.values())
weights = torch.tensor([
total / (len(LABELS) * counts.get(l, 1)) for l in LABELS
], dtype=torch.float32)
return weights
def _run_epoch(model: nn.Module, loader: DataLoader, loss_fn: nn.Module,
optim: torch.optim.Optimizer | None,
device: str) -> tuple[float, float]:
train_mode = optim is not None
model.train(train_mode)
total_loss = 0.0
correct = 0
seen = 0
for x, y in loader:
x = x.to(device)
y = y.to(device)
B, T, _ = x.shape
h = model.init_hidden(batch=B, device=device)
logits_last: torch.Tensor | None = None
for t in range(T):
logits, h = model(x[:, t, :], h)
logits_last = logits
assert logits_last is not None
loss = loss_fn(logits_last, y)
if train_mode:
optim.zero_grad()
loss.backward()
optim.step()
total_loss += float(loss) * B
correct += int((logits_last.argmax(-1) == y).sum())
seen += B
return total_loss / max(1, seen), correct / max(1, seen)
def train(*,
dataset_path: Path,
ckpt_out: Path,
epochs: int = 50,
batch_size: int = 128,
lr: float = 1e-3,
device: str = "cpu",
seed: int = 0,
log_every: int = 1,
) -> dict[str, list[float]]:
torch.manual_seed(seed)
rows = load_dataset_jsonl(dataset_path)
train_rows, val_rows, _test_rows = split_by_session(rows, seed=seed)
LOG.info("train=%d val=%d", len(train_rows), len(val_rows))
train_ds = WindowDataset(train_rows, augment=True, seed=seed)
val_ds = WindowDataset(val_rows, augment=False, seed=seed)
train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False)
model = ActionHeadModel().to(device)
weights = _class_weights(train_rows).to(device)
loss_fn = nn.CrossEntropyLoss(weight=weights)
optim = torch.optim.AdamW(model.parameters(), lr=lr)
history: dict[str, list[float]] = {
"train_loss": [], "train_acc": [], "val_loss": [], "val_acc": [],
}
best_val_acc = -1.0
ckpt_out.parent.mkdir(parents=True, exist_ok=True)
for ep in range(epochs):
tl, ta = _run_epoch(model, train_loader, loss_fn, optim, device)
with torch.no_grad():
vl, va = _run_epoch(model, val_loader, loss_fn, None, device)
history["train_loss"].append(tl)
history["train_acc"].append(ta)
history["val_loss"].append(vl)
history["val_acc"].append(va)
if ep % log_every == 0 or ep == epochs - 1:
LOG.info("ep=%d train_loss=%.4f train_acc=%.3f val_loss=%.4f val_acc=%.3f",
ep, tl, ta, vl, va)
if va > best_val_acc:
best_val_acc = va
torch.save({"model_state_dict": model.state_dict(),
"version": 1, "val_acc": va}, ckpt_out)
return history
def _cli() -> None:
p = argparse.ArgumentParser()
p.add_argument("--dataset", required=True, type=Path)
p.add_argument("--ckpt-out", required=True, type=Path)
p.add_argument("--epochs", type=int, default=50)
p.add_argument("--batch-size", type=int, default=128)
p.add_argument("--lr", type=float, default=1e-3)
p.add_argument("--device", default="cpu",
choices=["cpu", "mps", "cuda"])
p.add_argument("--seed", type=int, default=0)
args = p.parse_args()
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [%(name)s] %(message)s")
hist = train(dataset_path=args.dataset, ckpt_out=args.ckpt_out,
epochs=args.epochs, batch_size=args.batch_size,
lr=args.lr, device=args.device, seed=args.seed)
print(json.dumps({"final": {k: v[-1] for k, v in hist.items()}}))
if __name__ == "__main__":
_cli()
@@ -1,12 +1,18 @@
# action-head 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.
>
> **STATUS 2026-05-13 22:50** — Implementation **complete** (16/17 tasks, Task 16 is a manual gate). 39 tests green. Key deviations from this document, captured in the "Post-impl deviations" section below:
> - Task 14 pivoted from "modify `multi_hmr_worker_coreml.py` + CLI flag" to **standalone publisher thread `data_only_viz/action_head_pub.py`** + 3-line wire-in in `multi.py` (avoids collision with the user's parallel iteration on `multi_hmr_worker.py`). The MultiHMR backend is selected via env var `MULTIHMR_BACKEND=pytorch|coreml`, not a CLI flag.
> - Task 11 pivoted from "refactor `MultiHMRWorker` with `create_for_offline()`" to **standalone script using `MultiHMRCoreMLBackend.infer()` directly** — no worker refactor.
> - j3d is approximated from SMPL-X v3d via a fixed 22-vertex anchor set (`SMPLX_JOINT_ANCHOR_VERTS`), with a MediaPipe 33→22 fallback. The same anchor set is shared between live serve (`action_head_pub.py`) and offline extract (`scripts/extract_j3d_offline.py`) to avoid train/serve skew.
> - Studio train wrapper added as Task 8.5 (`data_only_viz/scripts/train_on_studio.sh`), validated end-to-end smoke 160 windows × 3 epochs MPS in ~4 s.
**Goal:** Implement a real-time per-person action classifier (debout/assise/danse) on top of Multi-HMR `j3d`, with OSC output enriched by softmax probabilities and kinetics scalars (speed/accel/symmetry).
**Architecture:** GRU-1-layer + MLP head streaming inference, fed by a 16-frame ring buffer per person. Trained windowed on Studio M3 Ultra (PyTorch MPS), inferred streaming on M5. Hybrid auto-labeler (rules on j3d) + manual review for dataset. Inference ≤ 2 ms/person M5 in eager PyTorch — no CoreML conversion needed.
**Tech Stack:** Python 3.11 + uv, PyTorch (MPS for train, CPU for M5 inference), numpy, python-osc, pytest. Reuses existing `data_only_viz` infrastructure (`multi_hmr_worker_coreml.py`, `pose_bridge.py`, `tracker.py`, `state.py`).
**Tech Stack:** Python 3.11 + uv, PyTorch (MPS for train, CPU for M5 inference), numpy, python-osc, pytest. Reuses existing `data_only_viz` infrastructure (`multi_hmr_worker.py`, `multihmr_coreml.py`, `pose_bridge.py`, `tracker.py`, `state.py`).
**Reference spec:** `docs/superpowers/specs/2026-05-13-action-head-design.md`
@@ -1839,6 +1845,8 @@ git commit -m "feat(data-only-viz): action capture script"
## Task 11 — Extract j3d offline
> **SUPERSEDED 2026-05-13.** The implemented script does NOT refactor `multi_hmr_worker.py`. Instead it uses the standalone `MultiHMRCoreMLBackend.infer()` from `data_only_viz/multihmr_coreml.py` directly. Output jsonl rows contain a (22, 3) `j3d` extracted via `SMPLX_JOINT_ANCHOR_VERTS` (shared with `action_head_pub.py` to avoid train/serve skew), not the raw v3d. See actual file at `data_only_viz/scripts/extract_j3d_offline.py`. The body below documents the original intent — keep as historical context.
**Files:**
- Create: `data_only_viz/scripts/extract_j3d_offline.py`
@@ -2196,6 +2204,8 @@ git commit -m "feat(data-only-viz): pose_bridge /pose/action + /pose/kin"
## Task 14 — Wire ActionHead into multi_hmr_worker_coreml
> **SUPERSEDED 2026-05-13.** No `multi_hmr_worker_coreml.py` file exists in the current repo — the user pivoted to `multihmr_coreml.py` (standalone backend) selected via env `MULTIHMR_BACKEND=pytorch|coreml` inside `multi_hmr_worker.py`. To avoid colliding with that file under active iteration, ActionHead wiring was implemented as a standalone publisher thread in `data_only_viz/action_head_pub.py` plus a 3-line wire-in inside `data_only_viz/multi.py` (`__init__` instantiates and `.start()`s the publisher). The publisher polls `state.persons_smplx` (preferred) and `state.persons_body3d` (MediaPipe fallback) at 30 Hz, deduplicates by timestamp, extracts j3d22 via shared `SMPLX_JOINT_ANCHOR_VERTS` / `MEDIAPIPE_TO_22` index maps, runs `ActionHead.step()` per pid, and emits OSC via the existing `PoseSoundBridge`. No CLI flag was added. See `data_only_viz/action_head_pub.py` and `data_only_viz/multi.py:22,97-98`. The body below documents the original intent — keep as historical context.
**Files:**
- Modify: `data_only_viz/multi_hmr_worker_coreml.py`
- Modify: `data_only_viz/main.py`
@@ -1,11 +1,17 @@
# action-head — Classifier d'action temps réel au-dessus de Multi-HMR
> **Date** : 2026-05-13
> **Status** : design approuvé, prêt pour implementation plan
> **Status** : design approuvé — **implémenté 2026-05-13 22:50**, 16/17 tasks, 39 tests verts. Task 16 (E2E gate) reste manuel (requiert capture + train réel).
> **Authors** : L'Electron Rare + Claude
> **Companion plans** :
> - `2026-05-13-multihmr-coreml-hybrid-backbone.md`
> - `2026-05-13-studio-train-deploy-m5.md`
>
> **Déviations notables vs design original** (cf. plan `2026-05-13-action-head.md` pour le détail) :
> - **Wiring worker** : standalone publisher thread `data_only_viz/action_head_pub.py` + 3 lignes dans `multi.py`, au lieu de modifier directement `multi_hmr_worker.py` (qui était en cours d'évolution par l'utilisateur en parallèle). Backend Multi-HMR sélectionné par env `MULTIHMR_BACKEND=pytorch|coreml`, pas par flag CLI.
> - **Source j3d** : approximée via 22 vertex anchors (`SMPLX_JOINT_ANCHOR_VERTS`) sur le mesh SMPL-X 10475-vert, partagés entre serve live (`action_head_pub.py`) et extraction offline (`scripts/extract_j3d_offline.py`) pour éviter le train/serve skew. Fallback MediaPipe 33→22 (`MEDIAPIPE_TO_22`) quand `persons_smplx` est vide. **Limitation** : ces 22 indices sont approximatifs ; pour des j3d SMPL-X corrects, brancher `J_regressor @ v3d` quand le module SMPL-X est dispo.
> - **Extract offline** : pas de refactor de `MultiHMRWorker`, on utilise `MultiHMRCoreMLBackend.infer()` directement (commit user `9e7a9f8`).
> - **Studio launch** : wrapper bash `data_only_viz/scripts/train_on_studio.sh` (Task 8.5) qui rsync + ssh + uv sync + train MPS + ckpt back. Validé end-to-end sur dataset smoke 160 windows × 3 epochs en ~4 s wallclock.
## TL;DR
@@ -94,7 +100,9 @@ class ActionHead:
def forget(self, pid: int) -> None: ...
```
Aucune modification de l'API publique de `multi_hmr_worker_coreml.py` autre que :
**Note d'implémentation 2026-05-13** : la section ci-dessous décrit l'intention originale. L'implémentation réelle est dans `data_only_viz/action_head_pub.py` (publisher thread) — pas de modification de `multi_hmr_worker.py`. Voir l'en-tête du document pour les déviations.
Aucune modification de l'API publique de `multi_hmr_worker.py` n'est requise au-delà de :
- Construction d'une `ActionHead` au startup.
- Appel `.step()` après chaque détection.
- Appel `.forget()` synchronisé avec `tracker.purge()`.
+131
View File
@@ -0,0 +1,131 @@
// =====================================================================
// scene_pose_action.scd -- pilote les FX live a partir de l'action_head.
//
// Necessite : data_feeds.scd deja charge (handlers OSCdef \poseAction,
// \poseKin, \poseEnter, \poseLeave). Les dicts ~poseState et ~poseKin
// doivent etre populates en temps reel par data_only_viz.action_head_pub.
//
// Mapping :
// speed (m/s mean joint) -> drive amount 0..1
// accel (m/s2) -> filter cutoff 200 Hz..6 kHz
// symmetry (-1..1) -> stereo width 0..1
// label argmax (0=debout, 1=assise, 2=danse) :
// - debout -> reverb mid, kick on, melody pad
// - assise -> pad ambient, kick off, lo-fi
// - danse -> drive max, kick punchy, acid lead
//
// Usage live (a evaluer bloc par bloc dans le SC IDE) :
// [0] SETUP : declare le helper ~mapPoseToFx + lance la Routine.
// [1] START : ~scenePoseAction.start
// [2] STOP : ~scenePoseAction.stop
// [3] TWEAK : ajuster les seuils dans ~paConfig
// =====================================================================
// [0] SETUP -----------------------------------------------------------
(
~paConfig = (
speedMin: 0.0, speedMax: 0.8,
accelMin: 0.0, accelMax: 3.0,
cutoffMin: 200, cutoffMax: 6000,
rate: 10, // refresh Hz
);
~mapPoseToFx = {
var avgSpeed = 0, avgAccel = 0, avgSym = 0, avgProbs = [0, 0, 0];
var n = max(1, ~poseKin.size);
var labelIdx;
~poseKin.values.do { |kin|
avgSpeed = avgSpeed + (kin[\speed] ? 0);
avgAccel = avgAccel + (kin[\accel] ? 0);
avgSym = avgSym + (kin[\symmetry] ? 0);
};
avgSpeed = avgSpeed / n;
avgAccel = avgAccel / n;
avgSym = avgSym / n;
~poseState.values.do { |ps|
var p = ps[\probs] ? [0, 0, 0];
avgProbs = avgProbs.collect { |v, i| v + (p[i] ? 0) };
};
avgProbs = avgProbs.collect { _ / n };
labelIdx = avgProbs.maxIndex ? 0;
// Drive + filter + width
if (~fxDrive.notNil) {
~fxDrive.(avgSpeed.linlin(~paConfig[\speedMin], ~paConfig[\speedMax], 0, 1));
};
if (~fxCut.notNil) {
~fxCut.(avgAccel.linexp(~paConfig[\accelMin], ~paConfig[\accelMax],
~paConfig[\cutoffMin], ~paConfig[\cutoffMax]));
};
if (~fxSt.notNil) { ~fxSt.(avgSym.linlin(-1, 1, 0, 1)) };
// Label-driven track switch (smooth crossfade between presets)
switch (labelIdx,
0, {
if (~fxComp.notNil) { ~fxComp.(0.7) };
if (~fxRev.notNil) { ~fxRev.(0.4) };
},
1, {
if (~fxComp.notNil) { ~fxComp.(0.3) };
if (~fxRev.notNil) { ~fxRev.(0.6) };
},
2, {
if (~fxComp.notNil) { ~fxComp.(0.9) };
if (~fxRev.notNil) { ~fxRev.(0.2) };
if (~fxDrive.notNil){ ~fxDrive.(avgSpeed.linlin(0, 0.6, 0.3, 1.0)) };
}
);
[labelIdx, avgSpeed.round(0.01), avgAccel.round(0.01),
avgSym.round(0.01)];
};
~scenePoseAction = ~scenePoseAction ?? {
(
running: false,
routine: nil,
start: {
if (~scenePoseAction[\running]) {
"[scene_pose_action] already running".postln;
} {
~scenePoseAction[\running] = true;
~scenePoseAction[\routine] = Routine({
inf.do {
var snapshot = ~mapPoseToFx.();
if (~scenePoseAction[\verbose] ? false) {
("[pose] label=" ++ snapshot[0]
++ " s=" ++ snapshot[1]
++ " a=" ++ snapshot[2]
++ " sym=" ++ snapshot[3]).postln;
};
(1 / ~paConfig[\rate]).wait;
};
}).play;
"[scene_pose_action] STARTED".postln;
};
},
stop: {
~scenePoseAction[\routine] !? { |r| r.stop };
~scenePoseAction[\routine] = nil;
~scenePoseAction[\running] = false;
"[scene_pose_action] STOPPED".postln;
},
verbose: false,
)
};
"[OK] SETUP scene_pose_action".postln;
)
// [1] START -----------------------------------------------------------
// ~scenePoseAction[\verbose] = true; // optionnel : log chaque tick
// ~scenePoseAction[\start].();
// [2] STOP ------------------------------------------------------------
// ~scenePoseAction[\stop].();
// [3] TWEAK ------------------------------------------------------------
// ~paConfig[\speedMax] = 1.2;
// ~paConfig[\rate] = 20;