Files
AV-Live/data_only_viz/multi.py
T
L'électron rare c0cde337c9 refactor(viz): consumers read VizConfig
multi.py reads 5 discrimination thresholds via VizConfig.
main.py reads ICP/Multi-HMR/AV_LIVE_* flags via VizConfig.
2026-07-02 11:24:19 +02:00

590 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Multi-personne : Pose+Face+Hand Landmarkers MediaPipe en parallele.
HolisticLandmarker est MONO-personne (par design). Pour multi-personnes
on utilise les 3 landmarkers spécialisés qui supportent `num_X=N` :
- PoseLandmarker(num_poses=4)
- FaceLandmarker(num_faces=4)
- HandLandmarker(num_hands=8) (jusqu'a 4 personnes × 2 mains)
Chaque inference tourne sur la MEME frame webcam. Les resultats sont
stockes independamment dans state.persons_body / persons_face /
persons_hands. Le renderer dessine TOUS les segments de toutes les
personnes, sans matching inter-modeles (acceptable visuellement).
"""
from __future__ import annotations
import logging
import threading
import time
import urllib.request
from pathlib import Path
import numpy as np
from .action_head_pub import ActionHeadPublisher
from .arkit_joint_map import arkit_body_2d, arkit_body_3d
from .euro_filter import SkeletonFilter
from .pose_bridge import PoseSoundBridge
from .pose_filter import PoseFilterChain
from .pose_filter import _is_finite # noqa: PLC2701 (intentional internal use)
from .state import Kp3D, PoseKp, State
from .tracker import IoUTracker
LOG = logging.getLogger("multi")
# Rotation cosmetique + detection de la frame video (env VIDEO_ROTATE).
# Appliquee AVANT MediaPipe : la detection tourne sur l'image redressee et
# l'overlay reste aligne. Les joints ARKit (monde 3D, gravity-aligned) sont
# invariants a l'orientation du device, donc non concernes.
_ROTATE_K = {"none": 0, "ccw": 1, "180": 2, "cw": 3}
def _apply_video_rotate(frame, mode: str):
"""Rotate a BGR frame by mode (none/ccw/cw/180) via numpy rot90.
Returns a C-contiguous array (MediaPipe / cv2 need contiguous input).
Unknown modes are treated as 'none' (no rotation).
"""
k = _ROTATE_K.get(mode, 0)
if k == 0:
return frame
return np.ascontiguousarray(np.rot90(frame, k))
MODELS = {
"pose": (
"https://storage.googleapis.com/mediapipe-models/pose_landmarker/"
"pose_landmarker_lite/float16/latest/pose_landmarker_lite.task"
),
"face": (
"https://storage.googleapis.com/mediapipe-models/face_landmarker/"
"face_landmarker/float16/latest/face_landmarker.task"
),
"hand": (
"https://storage.googleapis.com/mediapipe-models/hand_landmarker/"
"hand_landmarker/float16/latest/hand_landmarker.task"
),
}
CACHE_DIR = Path.home() / ".cache" / "av-live-mediapipe"
def _smooth_kps(skf: SkeletonFilter, pid: int, kps: list, t: float) -> list:
"""Applique le One Euro filter sur chaque keypoint d'une personne."""
if pid < 0:
return kps # detection orpheline (sans track), pas de lissage
out = []
for k, kp in enumerate(kps):
sx, sy, sz = skf.smooth(pid, k, kp.x, kp.y, kp.z, t)
out.append(PoseKp(x=sx, y=sy, z=sz, c=kp.c))
return out
def _ensure_model(name: str) -> Path:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
path = CACHE_DIR / f"{name}_landmarker.task"
if path.exists() and path.stat().st_size > 100_000:
return path
LOG.info("downloading %s model ...", name)
urllib.request.urlretrieve(MODELS[name], path)
LOG.info("%s OK (%d bytes)", name, path.stat().st_size)
return path
class MultiWorker:
"""Worker multi-personne (pose + face + hands landmarkers paralleles)."""
def __init__(
self,
state: State,
camera_index: int = 0,
target_fps: float = 18.0,
num_persons: int = 4,
min_conf: float = 0.4,
iphone_usb: bool = False,
) -> None:
self.state = state
self.camera_index = camera_index
self.iphone_usb = iphone_usb
self.period = 1.0 / max(1.0, target_fps)
self.num_persons = num_persons
self.min_conf = min_conf
self._stop = threading.Event()
self._thread: threading.Thread | None = None
# Lissage + tracking pour stabiliser les keypoints frame a frame
# et garder des IDs de couleur persistants entre frames.
self._tracker_body = IoUTracker(iou_threshold=0.20, max_miss=10)
self._tracker_face = IoUTracker(iou_threshold=0.15, max_miss=10)
self._tracker_hand = IoUTracker(iou_threshold=0.10, max_miss=6)
self._smooth_body = SkeletonFilter(min_cutoff=1.2, beta=0.06)
self._smooth_face = SkeletonFilter(min_cutoff=1.8, beta=0.04)
self._smooth_hand = SkeletonFilter(min_cutoff=2.0, beta=0.10)
# Pont OSC pose -> sclang
self._sound_bridge = PoseSoundBridge(throttle_hz=30.0)
self._action_pub = ActionHeadPublisher(state=self.state, bridge=self._sound_bridge)
self._action_pub.start()
# 3D pose filter chain : median, Kalman CV, lookahead, IK clamps.
self._filter_chain = PoseFilterChain(state=self.state)
# Discrimination state : per-pid frame counters for hysteresis.
# _pid_lifetime : frames since pid created (visible).
# _pid_last_bbox : last bbox seen for active pid (for re-association).
# _pid_missing : frames since pid disappeared (None when active).
self._pid_lifetime: dict[int, int] = {}
self._pid_missing: dict[int, int] = {}
self._pid_last_bbox: dict[int, tuple[float, float, float, float]] = {}
# Discrimination thresholds — tunable via env.
from .config import VizConfig as _VizConfig
_cfg = _VizConfig.from_env()
self._ghost_min_visible = _cfg.pose_ghost_min_visible
self._ghost_min_conf = _cfg.pose_ghost_min_conf
self._hand_min_visible = _cfg.pose_hand_min_visible
self._face_min_visible = _cfg.pose_face_min_visible
self._nms_iou = _cfg.pose_nms_iou
# Counters exposed for debug.
self._n_ghost_dropped = 0
self._n_hand_dropped = 0
self._n_face_dropped = 0
# ------------------------------------------------------------------
# Discrimination helpers — body ghost rejection, NMS, pid hysteresis,
# face/hand visibility gates. All return filtered (kps, ids) lists.
# ------------------------------------------------------------------
@staticmethod
def _bbox_from_kps(kps: list) -> tuple[float, float, float, float]:
if not kps:
return (0.0, 0.0, 0.0, 0.0)
xs = [kp.x for kp in kps]
ys = [kp.y for kp in kps]
return (min(xs), min(ys), max(xs), max(ys))
@staticmethod
def _iou(a: tuple[float, float, float, float],
b: tuple[float, float, float, float]) -> float:
ix1 = max(a[0], b[0]); iy1 = max(a[1], b[1])
ix2 = min(a[2], b[2]); iy2 = min(a[3], b[3])
iw = max(0.0, ix2 - ix1); ih = max(0.0, iy2 - iy1)
inter = iw * ih
aw = max(0.0, a[2] - a[0]) * max(0.0, a[3] - a[1])
bw = max(0.0, b[2] - b[0]) * max(0.0, b[3] - b[1])
u = aw + bw - inter
return inter / u if u > 1e-9 else 0.0
def _reject_ghosts_and_nms(
self,
bodies: list[list],
bodies3d: list[list[Kp3D]],
ids_body: list[int],
) -> tuple[list[list], list[list[Kp3D]], list[int]]:
"""Drop body detections with <N high-confidence joints, then NMS."""
if not bodies:
return bodies, bodies3d, ids_body
# Score each body by mean confidence ; track visibility count.
keep_mask = [True] * len(bodies)
scores: list[float] = []
for i, kps in enumerate(bodies):
n_visible = sum(
1 for kp in kps
if kp.c >= self._ghost_min_conf
and _is_finite(kp.x) and _is_finite(kp.y))
if n_visible < self._ghost_min_visible:
keep_mask[i] = False
self._n_ghost_dropped += 1
scores.append(
sum(kp.c for kp in kps) / len(kps) if kps else 0.0)
# NMS on remaining bboxes.
bboxes = [self._bbox_from_kps(kps) for kps in bodies]
order = sorted(
[i for i in range(len(bodies)) if keep_mask[i]],
key=lambda i: -scores[i])
kept_order: list[int] = []
for i in order:
drop = False
for j in kept_order:
if self._iou(bboxes[i], bboxes[j]) > self._nms_iou:
drop = True
break
if drop:
keep_mask[i] = False
else:
kept_order.append(i)
new_bodies = [bodies[i] for i in range(len(bodies)) if keep_mask[i]]
new_ids = [ids_body[i] for i in range(len(bodies))
if i < len(ids_body) and keep_mask[i]]
# bodies3d aligned 1:1 with bodies.
new_b3d: list[list[Kp3D]] = []
if bodies3d:
for i in range(min(len(bodies), len(bodies3d))):
if keep_mask[i]:
new_b3d.append(bodies3d[i])
return new_bodies, new_b3d, new_ids
def _apply_pid_hysteresis(
self,
bodies: list[list],
ids_body: list[int],
) -> list[int]:
"""Reuse a recently-disappeared pid when a young pid lands near
its last bbox. Mutates self._pid_lifetime / _pid_missing /
_pid_last_bbox in place. Returns possibly-remapped ids.
"""
# Tick all known pids missing counter ; will reset for visible ones.
for pid in list(self._pid_missing.keys()):
self._pid_missing[pid] += 1
if self._pid_missing[pid] > 60: # forget after 2 s @30 fps
self._pid_missing.pop(pid, None)
self._pid_last_bbox.pop(pid, None)
self._pid_lifetime.pop(pid, None)
new_ids = list(ids_body)
for i, pid in enumerate(ids_body):
if pid < 0 or i >= len(bodies):
continue
bbox_i = self._bbox_from_kps(bodies[i])
# If this pid is brand new (<10 frames) and we have an absent
# older pid (>=30 frames lifetime, <30 frames missing) with a
# close bbox, remap.
age = self._pid_lifetime.get(pid, 0)
if age < 10:
best_old: int | None = None
best_iou = 0.0
for old_pid, miss in self._pid_missing.items():
if old_pid == pid:
continue
if self._pid_lifetime.get(old_pid, 0) < 30:
continue
if miss > 30:
continue
old_bbox = self._pid_last_bbox.get(old_pid)
if old_bbox is None:
continue
iou = self._iou(bbox_i, old_bbox)
if iou > 0.3 and iou > best_iou:
best_iou = iou
best_old = old_pid
if best_old is not None:
new_ids[i] = best_old
pid = best_old
# Bookkeeping for visible pid.
self._pid_lifetime[pid] = self._pid_lifetime.get(pid, 0) + 1
self._pid_missing.pop(pid, None)
self._pid_last_bbox[pid] = bbox_i
# Pids previously visible but absent this frame -> mark missing.
visible = set(new_ids)
for pid in list(self._pid_lifetime.keys()):
if pid not in visible and pid not in self._pid_missing:
self._pid_missing[pid] = 1
return new_ids
def _drop_low_visibility(
self,
kps_list: list[list],
ids: list[int],
min_visible: int,
which: str,
) -> tuple[list[list], list[int]]:
out_kps: list[list] = []
out_ids: list[int] = []
for i, kps in enumerate(kps_list):
n_ok = sum(
1 for kp in kps
if _is_finite(kp.x) and _is_finite(kp.y)
and (kp.x != 0.0 or kp.y != 0.0))
if n_ok < min_visible:
if which == "face":
self._n_face_dropped += 1
else:
self._n_hand_dropped += 1
continue
out_kps.append(kps)
out_ids.append(ids[i] if i < len(ids) else -1)
return out_kps, out_ids
def start(self) -> None:
self._thread = threading.Thread(
target=self._run, name="multi", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
def _run(self) -> None:
from .config import VizConfig as _VizConfig
_cfg = _VizConfig.from_env()
_rot = _cfg.video_rotate
LOG.info("video rotate = %s (env VIDEO_ROTATE: none/ccw/cw/180)", _rot)
try:
import cv2
except ModuleNotFoundError as e:
LOG.error("deps manquantes (cv2) : %s", e)
return
# MediaPipe landmarkers: only loaded when Mac webcam is the source.
# Under --iphone-usb, body+face come from ARKit; loading MP here
# would waste RAM and slow concert boot.
pose = face = hand = _deleg = _mp = None
if not self.iphone_usb:
try:
import mediapipe as _mp
from mediapipe.tasks.python import BaseOptions
from mediapipe.tasks.python.vision import (
PoseLandmarker, PoseLandmarkerOptions,
FaceLandmarker, FaceLandmarkerOptions,
HandLandmarker, HandLandmarkerOptions,
RunningMode,
)
except ModuleNotFoundError as e:
LOG.error("deps manquantes : %s — uv sync --extra pose", e)
return
try:
pose_p = _ensure_model("pose")
face_p = _ensure_model("face")
hand_p = _ensure_model("hand")
except Exception as e: # noqa: BLE001
LOG.error("download models failed: %s", e)
return
# GPU delegate (Metal sur macOS) : libere le CPU pour OSC, state,
# mesh_rigger. Toggle via MEDIAPIPE_DELEGATE=cpu si plante.
_deleg_name = _cfg.mediapipe_delegate
_deleg = (BaseOptions.Delegate.GPU if _deleg_name == "gpu"
else BaseOptions.Delegate.CPU)
LOG.info("MediaPipe delegate = %s (env MEDIAPIPE_DELEGATE)",
_deleg.name)
pose = PoseLandmarker.create_from_options(PoseLandmarkerOptions(
base_options=BaseOptions(model_asset_path=str(pose_p),
delegate=_deleg),
running_mode=RunningMode.VIDEO,
num_poses=self.num_persons,
min_pose_detection_confidence=self.min_conf,
min_pose_presence_confidence=self.min_conf,
min_tracking_confidence=self.min_conf,
))
face = FaceLandmarker.create_from_options(FaceLandmarkerOptions(
base_options=BaseOptions(model_asset_path=str(face_p),
delegate=_deleg),
running_mode=RunningMode.VIDEO,
num_faces=self.num_persons,
min_face_detection_confidence=self.min_conf,
min_face_presence_confidence=self.min_conf,
min_tracking_confidence=self.min_conf,
))
hand = HandLandmarker.create_from_options(HandLandmarkerOptions(
base_options=BaseOptions(model_asset_path=str(hand_p),
delegate=_deleg),
running_mode=RunningMode.VIDEO,
num_hands=self.num_persons * 2,
min_hand_detection_confidence=self.min_conf,
min_hand_presence_confidence=self.min_conf,
min_tracking_confidence=self.min_conf,
))
LOG.info("3 landmarkers prets (num=%d, delegate=%s)",
self.num_persons, _deleg.name)
if self.iphone_usb:
from .iphone_usb_source import IphoneUSBSource # noqa: PLC0415
# write_hands=True: the iPhone Vision hands ARE the hand source under
# --iphone-usb (rendering + /pose/hands openness + pinch). The Mac
# MediaPipe hand detector is skipped below.
cap = IphoneUSBSource(self.state, write_hands=True)
if not cap.start():
LOG.error("iphone USB source unavailable (app running? phone unlocked?)")
return
LOG.info("iphone USB source")
else:
cap = cv2.VideoCapture(self.camera_index)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
if not cap.isOpened():
LOG.error("camera index %d indisponible (TCC ?)", self.camera_index)
return
LOG.info("camera ouverte (index %d)", self.camera_index)
t0_ms = int(time.monotonic() * 1000)
while not self._stop.is_set():
tA = time.monotonic()
ok, frame_bgr = cap.read()
if not ok or frame_bgr is None:
time.sleep(self.period)
continue
# Redresse la frame (iPhone tourne physiquement) AVANT MediaPipe
# et l'encodage JPEG : detection + overlay + affichage coherents.
frame_bgr = _apply_video_rotate(frame_bgr, _rot)
h, w = frame_bgr.shape[:2]
ts = int(time.monotonic() * 1000) - t0_ms
if self.iphone_usb:
# body+face come from ARKit (IphoneUSBSource); skip MP inference.
pose_res = face_res = hand_res = None
else:
# MediaPipe GPU delegate on macOS uploads via CVPixelBuffer
# which only accepts 4-channel formats. SRGB (3ch) crashes
# in gpu_buffer_storage_cv_pixel_buffer.cc with
# "unsupported ImageFrame format: 1". Use SRGBA when on GPU.
if _deleg == BaseOptions.Delegate.GPU:
frame_rgba = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGBA)
mp_img = _mp.Image(image_format=_mp.ImageFormat.SRGBA,
data=frame_rgba)
else:
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
mp_img = _mp.Image(image_format=_mp.ImageFormat.SRGB,
data=frame_rgb)
try:
pose_res = pose.detect_for_video(mp_img, ts)
face_res = face.detect_for_video(mp_img, ts)
hand_res = hand.detect_for_video(mp_img, ts)
except Exception as e: # noqa: BLE001
LOG.warning("inference: %s", e)
time.sleep(self.period)
continue
# Encode webcam JPEG pour overlay
ok2, jpg = cv2.imencode(".jpg", frame_bgr,
[int(cv2.IMWRITE_JPEG_QUALITY), 70])
jpg_bytes = bytes(jpg) if ok2 else None
# Bodies : x/y normalises (image) + z (relative depth, NormalizedLandmark
# fournit aussi z, plus precis que rien). pose_world_landmarks
# donnerait des metres mais on garde un repere coherent avec face/hands.
bodies = []
pose_list = (pose_res.pose_landmarks if pose_res is not None else None) or []
for landmarks_list in pose_list:
kp_list = []
for lm in landmarks_list[:33]:
v = lm.visibility if lm.visibility is not None else 1.0
z = float(lm.z) if lm.z is not None else 0.0
kp_list.append(PoseKp(
x=float(lm.x), y=float(lm.y), z=z, c=float(v)))
bodies.append(kp_list)
# pose_world_landmarks : xyz metric, relative to hip-center.
# Aligned 1:1 with pose_landmarks order. Empty fallback if
# the MediaPipe build doesn't populate it.
bodies3d: list[list[Kp3D]] = []
world_list = (getattr(pose_res, "pose_world_landmarks", None) if pose_res is not None else None) or []
for landmarks_list in world_list:
kp3_list: list[Kp3D] = []
for lm in landmarks_list[:33]:
v = lm.visibility if lm.visibility is not None else 1.0
kp3_list.append(Kp3D(
x=float(lm.x), y=float(lm.y),
z=float(lm.z if lm.z is not None else 0.0),
c=float(v)))
bodies3d.append(kp3_list)
faces = []
for landmarks_list in ((face_res.face_landmarks if face_res is not None else None) or []):
kp_list = []
for lm in landmarks_list[:478]:
z = float(lm.z) if lm.z is not None else 0.0
kp_list.append(PoseKp(
x=float(lm.x), y=float(lm.y), z=z, c=1.0))
faces.append(kp_list)
# iphone-usb: bodies + bodies3d come from ARKit skeleton (2D + 3D).
# MP inference was skipped above; rebuild from state.persons_arkit_*.
# faces stays [] — ARKit has no face landmarks at this layer.
if self.iphone_usb:
with self.state.lock():
_a2d = dict(self.state.persons_arkit_2d)
_a3d = dict(self.state.persons_arkit_joints)
_pid = min(_a2d.keys()) if _a2d else None
if _pid is not None:
bodies = [arkit_body_2d(_a2d[_pid])]
_arr3d = _a3d.get(_pid)
bodies3d = [arkit_body_3d(_arr3d)] if _arr3d is not None else []
hands = []
for landmarks_list in ((hand_res.hand_landmarks if hand_res is not None else None) or []):
kp_list = []
for lm in landmarks_list[:21]:
z = float(lm.z) if lm.z is not None else 0.0
kp_list.append(PoseKp(
x=float(lm.x), y=float(lm.y), z=z, c=1.0))
hands.append(kp_list)
# --- Tracking IDs persistants entre frames -----------------
ids_body = self._tracker_body.update(bodies)
ids_face = self._tracker_face.update(faces)
ids_hand = self._tracker_hand.update(hands)
# --- Discrimination : ghost reject + NMS + pid hysteresis --
bodies, bodies3d, ids_body = self._reject_ghosts_and_nms(
bodies, bodies3d, ids_body)
ids_body = self._apply_pid_hysteresis(bodies, ids_body)
faces, ids_face = self._drop_low_visibility(
faces, ids_face, self._face_min_visible, "face")
hands, ids_hand = self._drop_low_visibility(
hands, ids_hand, self._hand_min_visible, "hand")
# --- Lissage One Euro par keypoint -------------------------
t_now = time.monotonic()
bodies = [_smooth_kps(self._smooth_body, ids_body[i], kps, t_now)
for i, kps in enumerate(bodies)]
faces = [_smooth_kps(self._smooth_face, ids_face[i], kps, t_now)
for i, kps in enumerate(faces)]
hands = [_smooth_kps(self._smooth_hand, ids_hand[i], kps, t_now)
for i, kps in enumerate(hands)]
# --- Filter chain face + hands (median + Kalman 2D + lookahead)
faces = self._filter_chain.apply_face(faces, ids_face, t_now)
hands = self._filter_chain.apply_hand(hands, ids_hand, None, t_now)
# Pont sonore : envoi OSC /pose/* a sclang (body + face + hands)
# 3D world landmarks share ids with bodies (same MediaPipe
# detection, just a different coordinate space).
ids_body3d = ids_body[:len(bodies3d)] if bodies3d else []
if bodies3d:
bodies3d = self._filter_chain.apply(bodies3d, ids_body3d, t_now)
# Debug : log body3d count once / 5 s so we know MediaPipe
# actually populates pose_world_landmarks.
if not hasattr(self, "_dbg_b3d_t") or t_now - self._dbg_b3d_t > 5.0:
LOG.info("body3d: n=%d (pose_world_landmarks)", len(bodies3d))
self._dbg_b3d_t = t_now
# iphone-usb: action_head_pub emits /pose/hands from the iPhone hands
# (in persons_hands); don't double-emit a (skipped) MP hand set here.
self._sound_bridge.send(
bodies, ids_body, t_now,
persons_face=faces, persons_face_ids=ids_face,
persons_hands=(None if self.iphone_usb else hands),
persons_hands_ids=(None if self.iphone_usb else ids_hand),
persons_body3d=bodies3d, persons_body3d_ids=ids_body3d)
with self.state.lock():
self.state.persons_body = bodies
self.state.persons_face = faces
# iphone-usb: persons_hands is owned by IphoneUSBSource (iPhone
# Vision hands); don't clobber it with the skipped Mac MP result.
if not self.iphone_usb:
self.state.persons_hands = hands
self.state.persons_body_ids = ids_body
self.state.persons_body3d = bodies3d
self.state.persons_face_ids = ids_face
if not self.iphone_usb:
self.state.persons_hands_ids = ids_hand
# Compat single-person (1ere personne)
if bodies:
self.state.body_present = True
for k in range(33):
self.state.body_kp[k] = bodies[0][k] if k < len(bodies[0]) else PoseKp()
else:
self.state.body_present = False
if faces:
self.state.face_present = True
for k in range(478):
self.state.face_kp[k] = faces[0][k] if k < len(faces[0]) else PoseKp()
else:
self.state.face_present = False
self.state.hands_present = bool(hands)
self.state.pose_count = len(bodies)
self.state.pose_last_t = time.monotonic()
if jpg_bytes:
self.state.last_webcam_jpeg = jpg_bytes
dt = time.monotonic() - tA
if dt < self.period:
time.sleep(self.period - dt)
cap.release()
if pose is not None:
pose.close()
if face is not None:
face.close()
if hand is not None:
hand.close()
LOG.info("multi worker stopped")