Files
L'électron rare 7ed2e2764a feat(av-live): openpos 3D + DINO reid + filter
Three improvements wired end-to-end:

1. Openpos 3D skeleton visible: Skeleton3DRenderer attached to a
   RealityKit AnchorEntity in BodyView, toggled by showSkeleton
   or vizMode==9. PoseOSCListener now parses /pose3d/count and
   /pose3d/kp (plus restored /face/* and /hand/* paths).

2. DINO re-id (dinov2_vits14, ~9 ms ANE forward):
   MeshRigger combines Hungarian IoU with cosine similarity over
   a per-pid embedding history (deque maxlen=10), weighted by
   MULTIHMR_REID_ALPHA (default 0.5). Falls back to pure IoU if
   DINO mlpackage absent or scipy missing. state.last_frame_rgb
   buffer added so the rigger can crop bbox regions for embedding.

3. PoseFilterChain on pose_world_landmarks:
   median (anti-spike) -> Kalman constant-velocity ->
   50 ms lookahead -> IK elbow/knee/ankle clamp. Configurable
   via POSE_FILTER env (default median+kalman+lookahead+ik).
   <2 ms per frame for typical 1-2 persons.

Tests: 5 new in test_dino_reid.py + 6 new in test_pose_filter.py,
all green. Live validated by user: skeleton spawns, mesh stays
stable.
2026-05-14 00:30:42 +02:00

436 lines
17 KiB
Python

"""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 collections
import logging
import math
import threading
import time
from dataclasses import dataclass, field
import numpy as np
try:
from scipy.optimize import linear_sum_assignment
_HAVE_SCIPY = True
except ImportError: # noqa: BLE001
_HAVE_SCIPY = False
from .state import PoseKp, SMPLXPerson, State
LOG = logging.getLogger("mesh_rigger")
# 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 _body_bbox_norm(
body: list[PoseKp],
) -> tuple[float, float, float, float] | None:
"""Bbox image-normalized [0,1] from a list of body landmarks
(Vision 19 joints OR MediaPipe 33). None if not enough confident
points."""
if not body:
return None
xs = [kp.x for kp in body if kp.c > 0.05]
ys = [kp.y for kp in body if kp.c > 0.05]
if len(xs) < 4 or len(ys) < 4:
return None
x0, x1 = max(0.0, min(xs)), min(1.0, max(xs))
y0, y1 = max(0.0, min(ys)), min(1.0, max(ys))
# Pad 10% to capture full body silhouette.
dx = (x1 - x0) * 0.10
dy = (y1 - y0) * 0.10
x0 = max(0.0, x0 - dx); x1 = min(1.0, x1 + dx)
y0 = max(0.0, y0 - dy); y1 = min(1.0, y1 + dy)
if x1 - x0 < 0.02 or y1 - y0 < 0.02:
return None
return (x0, y0, x1, y1)
def _mesh_bbox_norm(p: SMPLXPerson) -> tuple[float, float, float, float] | None:
"""Project SMPL-X mesh vertices to image-normalized bbox.
Multi-HMR uses focal = IMG_SIZE camera intrinsics. World verts
have z>0 (in front of camera)."""
v = np.asarray(p.vertices_3d, dtype=np.float32)
if v.size == 0 or v.shape[0] < 100:
return None
z = v[:, 2]
valid = z > 1e-3
if not np.any(valid):
return None
x_img = (v[valid, 0] * _FOCAL / z[valid]) / _IMG_SIZE + 0.5
y_img = (v[valid, 1] * _FOCAL / z[valid]) / _IMG_SIZE + 0.5
x0, x1 = float(x_img.min()), float(x_img.max())
y0, y1 = float(y_img.min()), float(y_img.max())
x0 = max(0.0, x0); x1 = min(1.0, x1)
y0 = max(0.0, y0); y1 = min(1.0, y1)
if x1 - x0 < 0.02 or y1 - y0 < 0.02:
return None
return (x0, y0, x1, y1)
def _iou_norm(
a: tuple[float, float, float, float],
b: tuple[float, float, float, float],
) -> float:
ax0, ay0, ax1, ay1 = a
bx0, by0, bx1, by1 = b
ix0 = max(ax0, bx0); iy0 = max(ay0, by0)
ix1 = min(ax1, bx1); iy1 = min(ay1, by1)
iw = max(0.0, ix1 - ix0); ih = max(0.0, iy1 - iy0)
inter = iw * ih
if inter <= 0:
return 0.0
a_area = (ax1 - ax0) * (ay1 - ay0)
b_area = (bx1 - bx0) * (by1 - by0)
return float(inter / (a_area + b_area - inter + 1e-9))
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,
dino_weight: float = 0.5,
dino_reid=None) -> None:
self.state = state
self.hold_window_s = hold_window_s
self.dino_weight = float(dino_weight)
self.dino_reid = dino_reid
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] = {}
# pid Multi-HMR -> recent DINO embeddings (mean -> reid signature)
self._pid_embeddings: dict[int, collections.deque] = {}
# Cached log throttle
self._next_dino_log = 0.0
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)
self._pid_embeddings.pop(old_pid, None)
# 2) DINO fusion: if a reid backend is wired, try Hungarian
# over (mesh keyframe pids) x (Vision body pids) using
# alpha*IoU + (1-alpha)*cosine. This only kicks in when a
# keyframe is detected this call AND we have an RGB frame.
self._dino_match(persons_smplx, persons_body,
persons_body_ids)
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
# ------------------------------------------------------------------
# DINOv2 reid hooks
# ------------------------------------------------------------------
def _dino_match(
self,
persons_smplx: list[SMPLXPerson],
persons_body: list[list[PoseKp]],
persons_body_ids: list[int],
) -> None:
"""Update self._vision_pid_map and self._pid_embeddings by
matching mesh pids against Vision pids on alpha*IoU +
(1-alpha)*DINO cosine. No-op if any prerequisite missing.
Caller must hold self._lock."""
if self.dino_reid is None or not _HAVE_SCIPY:
return
if not persons_smplx or not persons_body:
return
# Need at least one new keyframe to be worth running DINO.
new_kf_pids: list[int] = []
for p in persons_smplx:
kf = self._keyframes.get(p.pid)
if kf is None or not np.allclose(
kf.translation, p.translation, atol=1e-4):
new_kf_pids.append(int(p.pid))
if not new_kf_pids:
return
# Acquire current RGB frame (best effort, no double lock).
frame = self.state.last_frame_rgb
if frame is None:
return
H, W = frame.shape[:2]
# Build Vision bboxes (image-normalized) and pixel crops.
v_bboxes_norm: list[tuple[float, float, float, float]] = []
v_crops: list[np.ndarray] = []
v_pids: list[int] = []
for body, vpid in zip(persons_body, persons_body_ids):
bb = _body_bbox_norm(body)
if bb is None:
continue
x0, y0, x1, y1 = bb
px0 = max(0, int(x0 * W))
py0 = max(0, int(y0 * H))
px1 = min(W, int(x1 * W))
py1 = min(H, int(y1 * H))
if px1 <= px0 + 4 or py1 <= py0 + 4:
continue
v_bboxes_norm.append(bb)
v_crops.append(frame[py0:py1, px0:px1].copy())
v_pids.append(int(vpid))
if not v_crops:
return
# Build mesh bboxes (image-normalized) from world pelvis proj.
m_bboxes_norm: list[tuple[float, float, float, float]] = []
m_pids_keep: list[int] = []
m_crops: list[np.ndarray] = []
for p in persons_smplx:
bb = _mesh_bbox_norm(p)
if bb is None:
continue
m_bboxes_norm.append(bb)
m_pids_keep.append(int(p.pid))
x0, y0, x1, y1 = bb
px0 = max(0, int(x0 * W))
py0 = max(0, int(y0 * H))
px1 = min(W, int(x1 * W))
py1 = min(H, int(y1 * H))
if px1 > px0 + 4 and py1 > py0 + 4:
m_crops.append(frame[py0:py1, px0:px1].copy())
else:
m_crops.append(None) # type: ignore[arg-type]
if not m_bboxes_norm:
return
# Embed Vision crops in one batch (still loops internally).
t0 = time.perf_counter()
try:
v_emb = self.dino_reid.embed_crops(v_crops)
except Exception as e: # noqa: BLE001
LOG.warning("dino_reid embed failed: %s", e)
return
# Build cost matrix mesh x vision : 1 - (alpha*IoU + (1-alpha)*cos)
n_m = len(m_bboxes_norm)
n_v = len(v_bboxes_norm)
alpha = float(np.clip(self.dino_weight, 0.0, 1.0))
cost = np.ones((n_m, n_v), dtype=np.float32)
for i, mbb in enumerate(m_bboxes_norm):
hist = self._pid_embeddings.get(m_pids_keep[i])
mean_emb = None
if hist:
stack = np.stack(list(hist), axis=0)
mean_emb = stack.mean(axis=0)
n = np.linalg.norm(mean_emb) + 1e-8
mean_emb = mean_emb / n
for j, vbb in enumerate(v_bboxes_norm):
iou = _iou_norm(mbb, vbb)
if mean_emb is not None:
cos = float(np.dot(mean_emb, v_emb[j]))
else:
cos = iou # no history -> trust IoU
score = alpha * iou + (1.0 - alpha) * max(0.0, cos)
cost[i, j] = 1.0 - score
rr, cc = linear_sum_assignment(cost)
for i, j in zip(rr, cc):
if cost[i, j] >= 0.95:
continue # weak match, ignore
mpid = m_pids_keep[i]
self._vision_pid_map[mpid] = v_pids[j]
# Update embedding history for THIS mesh pid using the
# Vision crop (most recent visual evidence).
dq = self._pid_embeddings.setdefault(
mpid, collections.deque(maxlen=10))
dq.append(v_emb[j].copy())
now = time.monotonic()
dt_ms = (time.perf_counter() - t0) * 1e3
if now >= self._next_dino_log:
LOG.info(
"dino_reid: embedded %d crops in %.1f ms (alpha=%.2f, "
"matched %d mesh<->vision pairs)",
len(v_crops), dt_ms, alpha, min(n_m, n_v))
self._next_dino_log = now + 5.0
@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)