5c61112826
* docs(plans): action-head v3 + branch sync notes Update plan header : - v2 (Task 18) + v3 (Task 19) extensions chronology - Studio train validated, ckpt action_head_v3.pt landed - Mesh NaN-guard debug trail (commit4e7101c) - Branch convergence main == feat/action-head - Pointers to memories project_action_head_v3, etc. * 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. * fix(av-live-body): restore face+hand+3D (f540158) Three regressions after recent merges, all restored to match the originalf540158design: 1. FaceHandOverlay was no longer instantiated in ContentView. Added back as a SwiftUI Canvas overlay (68 dlib face landmarks with mouth slots 48-67, plus 21x2 hand landmarks cyan/magenta). 2. Skeleton3DRenderer was not attached. BodyView now creates an AnchorEntity at (0,0,-2.5), instantiates Skeleton3DRenderer and ties its visibility to vizMode==9 or showSkeleton toggle. 3. Joint and bone radii bumped to 4.5 cm / 2.2 cm so the 3D skeleton actually reads as 3D instead of looking flat. MeshRenderer exposes pelvisWorld map per pid for future interconnect uses (not auto-applied -- design keeps mesh and skeleton each in their own coord space perf540158). * feat(av-live): wireframe skel + face/hand filter Skeleton3DRenderer now renders a wireframe: joint radius 1 mm (quasi-invisible), bone radius 3 mm (line-like). Replaces the chunky bead armature with a clean filaire silhouette covering body 33 joints + face 68 dlib + hands 21x2, all 3D. FaceHandOverlay 2D Canvas removed from ContentView -- face and hand landmarks now live in the same 3D RealityKit armature as the body skeleton (Skeleton3DRenderer.applyFace / applyHands, anchored on nose joint 0 + wrist joints 15/16). pose_filter.py extended with FaceFilterChain (alpha-beta + 30 ms lookahead) and HandFilterChain. multi.py wires them after the 2D smoothers, plus ghost rejection (POSE_GHOST_MIN_VISIBLE), bbox NMS (POSE_NMS_IOU), and pid hysteresis. 10 new tests, all green. CoreML perf audit (bench_multihmr_coreml.py): predict() = 99% of wall-time on FP32. ANE catastrophic for DINOv2 (1300 ms), INT8 weight quant = no live gain (GPU compute-bound). 6.4-6.8 fps live is the hardware ceiling on this model. quantize_multihmr_int8.py left in scripts/ for future trials. * deps(icp): add open3d optional extra + smoke test Context: Task 1 of the ICP LiDAR <-> SMPL-X fusion plan needs a point-cloud library to align iPhone LiDAR scans with Multi-HMR SMPL-X meshes. Open3D's CPU-only ICP is sufficient at the 5-10 Hz LiDAR cadence. Approach: Add a dedicated `lidar` optional-dep group so the heavy dependency stays opt-in. Pin Python to 3.12 implicitly via the regenerated uv.lock because open3d 0.18-0.19 only ships cp311/cp312 wheels (cp314 absent). Smoke test guards future regressions. Changes: - pyproject.toml: new `lidar` extra with `open3d>=0.18,<0.20` - uv.lock: regenerated with open3d 0.19 + transitive deps (scikit-learn, scipy, dash stack, etc.) - tests/test_open3d_smoke.py: two-test smoke suite (PointCloud roundtrip + ICP convergence on translated copy), gated by `pytest.importorskip("open3d")` Impact: Unlocks subsequent ICP fusion tasks (LiDAR ingest, mesh alignment, transform publication) without forcing open3d on contributors who only run the base pose pipeline. * feat(icp): LiDAR TCP frame decoder + tests * feat(icp): LiDAR TCP socket reader with reconnect * feat(icp): extrinsic dataclass + JSON persistence * feat(icp): Kabsch + calibration CLI scaffold * feat(state): persons_arkit_joints fields * feat(viz): ARKit 91 -> MP 33 joint map * feat(viz): iphone OSC listener :57128 * feat(viz): arkit_fuse stage overrides 14 slots * feat(viz): arkit pelvis z locks cam translation * feat(viz): iphone OSC listener auto-start * docs: arkit fusion env vars * feat(icp): point-to-plane register + reject gate * feat(icp): partition LiDAR per pid by max-dist * feat(icp): FusionWorker + State.lidar_points * feat(icp): wire fusion thread behind ICP_FUSION Task 9 of the ICP LiDAR plan: integrate the FusionWorker built in earlier tasks into the live data_only_viz pipeline without disturbing the existing ARKit pelvis fuse path or the Multi-HMR worker thread. A new IcpFusionThread pulls LiDAR frames from LidarTCPReader, stages them into State, and applies in-place ICP registration on state.persons_smplx[*].vertices_3d. It runs as a separate daemon thread parallel to MultiHMRWorker rather than inline per frame — the autonomous-worker architecture didn't fit the plan's per-frame call site, so we adapted to a polling thread at 8 Hz. Activation is opt-in via ICP_FUSION=1 plus ICP_LIDAR_HOST; the default code path is untouched. Shutdown wired through applicationWillTerminate_. MultiHMRWorker.predict_once is added as a documented stub (NotImplementedError) because the existing PyTorch run loop is too coupled to the camera and MPS lifecycle for a clean single-shot extraction. calibrate_lidar.py keeps its placeholder until a follow-up refactor extracts a pure _infer(rgb) helper. * test(icp): synthetic latency + convergence bench * docs(icp): env vars + calibration procedure * docs(plans): icp lidar mesh + arkit joints Two complementary fusion plans landed in parallel on 2026-05-14: - iphone-lidar-multihmr-fusion : ARKit 91 joints -> MP33 fuse stage + pelvis z override (already implemented in 7 commits) - icp-lidar-smplx-fusion : LiDAR mesh point-to-plane ICP onto SMPL-X 10475 verts (12 tasks executed via subagent-driven-development) Both paths coexist; joints are sparse+fast (60 Hz), mesh is dense+slow (5-10 Hz). See docs/ICP_FUSION.md for the integration topology. * feat(icp): predict_once via CoreML backend * feat(av-live-body): wire ArkitOSCListener :57129 Receives /body3d/kp from iPhone ARBodyTracker on the diagnostic port (57129, distinct from Python's 57128 fuse input). Plumbed through ContentView -> BodyView -> Skeleton3DRenderer so the ARKit joints can be overlayed alongside Multi-HMR mesh. * feat(ios): iphone ARBodyTracker swiftpm app iOS 17+ Swift Package app (.swiftpm) streaming ARKit body joints via OSC UDP to two destinations: :57128 -> data_only_viz/iphone_osc_listener.py :57129 -> launcher/AV-Live-Body ArkitOSCListener.swift Features: - ARBodyTrackingConfiguration + sceneDepth (LiDAR) when supported - 91 joints per body, /body3d/kp pid joint_idx x y z - 30 fps throttle - SwiftUI UI: Host/Port fields, Start/Stop, live joints-per-second - Inline OSC encoder (no external dep) Env mesh (TCP :5500) NOT yet implemented; requires a separate ARWorldTrackingConfiguration session. ICP fusion path runs on bench data only until phase 2. * feat(data-feeds): 10 open-data OSC publisher * feat(viz): DataFeeds OSC listener + HUD * chore: gitignore tweaks * docs: network topology + mDNS hostnames Add a "Network topology" section to top-level CLAUDE.md doc the 3-host layout (GrosMac source, Supra-M1 sink via mDNS, iPhone via Personal Hotspot DHCP). mDNS is canonical now : AVBODY_HOST and MULTIHMR_REMOTE_HOST accept hostname.local instead of IPs, so the cluster survives DHCP rotations on iPhone hotspot (172.20.10.x). * fix(ios): add NSLocalNetworkUsageDescription iOS 14+ silently blocks UDP to LAN addresses without this key. The first time the app tries to send to 192.168.0.159, iOS will prompt the user to allow Local Network Access; the prompt must be accepted or the OSC stream never reaches the Mac. Also adds NSBonjourServices declaring _osc._udp so the system treats the connection as a recognised service. * docs: network topology + gitignore hygiene - CLAUDE.md: add mDNS hostname table (grosmac.local, supra-m1.local, iPhone hotspot 172.20.10.x). AVBODY_HOST / MULTIHMR_REMOTE_HOST accept hostnames — resilient to DHCP rotation. - .gitignore: exclude .remember/ tool state and iCloud '* 2' collision artifacts. * feat(ios): ARBody skeleton2D + overlay preview ARBodySession: publish 2D-projected skeleton snapshot for live overlay rendering on the iPhone screen alongside the camera feed. ContentView: SkeletonOverlay drawing on top of the AR view, with mock T-pose for Xcode previews (useMockBackground, useMockSkeleton). * docs: iPhone USB body-tracking link design Brainstormed design for replacing the OSC/network iPhone-Mac link with a wired USB transport via usbmuxd. iPhone streams ARKit skeleton + HEVC video; macOS app runs Multi-HMR CoreML and renders the mesh. Network-free, single native macOS app. * docs: iPhone USB transport plan (1 of 3) Bite-sized TDD plan for the network-free USB byte-pipe: shared AVLiveWire frame format, native usbmux client, iOS TCP frame server, incremental stream demuxer. * feat(avlivewire): shared wire package skeleton * feat(avlivewire): fixed 19-byte frame header codec Add FrameHeader, a fixed-size binary record so the demuxer can frame and resync the iPhone USB stream. Layout is big-endian: 4-byte magic AVL1, tag u8, pid i16, timestamp f64, length u32. The magic prefix lets a reader detect and skip corrupt bytes. Decoding rejects short buffers and bad magic by returning nil. Big-endian append/parse helpers are added as Data/UInt extensions to keep the codec self-contained. * chore: ignore SwiftPM .build artifacts Both AVLiveWire and AV-Live-Body produce .build/ on swift test; ignore them so they never get accidentally staged. * feat(avlivewire): skeleton and video codecs Add SkeletonPayload (91 ARKit joints + per-joint validity) and VideoPayload (one HEVC access unit + keyframe flag) with big-endian encode/decode. Reuses Task 2 Data/UInt32 helpers. * feat(avlivewire): incremental stream demuxer Add StreamDemuxer that accepts arbitrary byte chunks from a non-frame-aligned stream and emits complete (FrameHeader, Data) frames, resyncing on the magic prefix after corruption. * fix(avlivewire): cap demuxer payload length A corrupt header with a huge UInt32 length made feed buffer forever waiting for bytes that never arrive. Add an 8 MB max payload cap; a header exceeding it is treated as corrupt, its magic is skipped, and the demuxer resyncs on the next frame. * feat(av-live-body): usbmux message codec Add USBMuxProtocol, a codec for Apple's usbmuxd request/response protocol: a 16-byte little-endian header (length, version=1, message=8 plist, tag) followed by an XML property list. Wire an AVLiveBodyTests test target into Package.swift (none existed) so swift test runs the round-trip and header coverage. * feat(av-live-body): usbmux device client Add USBClient for usbmux device discovery and connect-to-port, with an injectable MuxTransport so tests need no real device. Harden USBMuxProtocol.readLE32 to return an optional with a bounds check, avoiding an out-of-range crash on truncated data. * feat(av-live-body): usbmuxd unix socket transport Add UnixMuxTransport, the production MuxTransport that opens a blocking AF_UNIX socket to /var/run/usbmuxd. Implements framed packet reads (4-byte LE length prefix) and raw stream reads for the tunneled byte stream after a successful Connect. * fix(av-live-body): harden unix socket transport Apply four code-review fixes to UnixMuxTransport: - send() now loops on partial writes and retries on EINTR instead of discarding write(2)'s return value. - Add deinit and an fd = -1 sentinel so close() is idempotent and the descriptor cannot leak. - precondition guards strcpy against sun_path overflow. - readN() distinguishes EOF from error and retries EINTR. * feat(ios): USB TCP frame server Add USBServer: an NWListener on a fixed local TCP port that usbmuxd tunnels to the tethered Mac. Sends AVLiveWire frames and exposes a connection-state callback. * build: depend on shared AVLiveWire package Both ARBodyTracker (iOS) and AVLiveBody (macOS) now depend on the local shared/AVLiveWire package so the wire format is defined once. iOS USBServer imports it; macOS use lands in Plan 3. * build(ios): add AVLiveWire package to xcodegen The xcodegen project did not declare the shared AVLiveWire package, so USBServer.swift would fail to import it in the generated Xcode project. Add it as a local package dep. * test(avlivewire): end-to-end chunked loopback Feeds 20 framed skeleton payloads through StreamDemuxer in 7-byte chunks (worst-case TCP fragmentation). Fixed a split range operator from the plan that did not parse. * fix(ios): guard USBServer listener and payload Report .idle (not .listening) when NWListener creation fails, and drop payloads larger than the demuxer's 8 MB cap so the receiver never silently discards an oversized frame. * chore: ignore .swiftpm editor state dirs swift test / Xcode create hidden .swiftpm dirs inside packages; ignore them so they never get staged. * docs: iPhone capture plan (2 of 3) Plan for HEVC video capture (VideoToolbox) over the USB transport and removal of the legacy OSC sender. Skeleton USB path already exists; this adds the video half. * feat(ios): HEVC video capture, drop OSC Add VideoEncoder (VideoToolbox HEVC) and stream encoded frames over USB as .video AVLiveWire frames alongside the skeleton. Remove the legacy OSC/UDP fanout and its host/port config UI — the iPhone link is now USB-only. * docs(ios): refresh stale OSC references ARBodySession header comment and Info.plist usage strings still described the removed OSC/UDP path; update them to the USB transport and drop the dead _osc._udp Bonjour service. * docs: macOS USB consumer plan (3a of 3) Plan for consuming the iPhone USB stream in AVLiveBody: USBSkeletonConsumer, VideoDecoder, 91-joint skeleton render. Multi-HMR dense mesh deferred to Plan 3b. * feat(av-live-body): USB skeleton consumer Background usbmux read loop feeding StreamDemuxer; republishes .skeleton frames as 91-joint ArkitBodyFrames and forwards .video payloads. Removed stale iCloud collision duplicate source files that broke the build. * fix(data-only): CoreML Multi-HMR usage bugs The CoreML Multi-HMR model was fine; the "0 detections" bug was caller-side. Add ImageNet normalization in infer() (the DINOv2 backbone needs it; raw [0,1] input collapsed all scores) and update stale hardcoded output var names to match the re-converted mlpackage. Bump the latency test threshold to the realistic ~140 ms full-model figure. * feat(av-live-body): HEVC video decoder VTDecompressionSession decoder for .video VideoPayloads. Rebuilds the format description from the parameter sets prepended to keyframe payloads by the iOS VideoEncoder. * feat(av-live-body): render 91-joint USB skeleton Complete the long-standing TODO: draw the 91 ARKit/USB skeleton joints as yellow markers, fed from lastArkit. Spawn entity trees for ARKit-only pids so the USB skeleton shows without a MediaPipe pose. * feat(av-live-body): wire USB consumer to renderer ContentView owns and starts a USBSkeletonConsumer, threaded through BodyView into Skeleton3DRenderer.attach. The renderer subscribes its $bodies into lastArkit, so the iPhone's USB skeleton drives the on-screen 91-joint markers. * docs: macOS Multi-HMR mesh plan (3b of 3) Final plan: bundle the validated FP32 mlpackage, MultiHMRCoreML Swift wrapper, BodyFusion (ARKit depth correction), mesh pipeline wiring. Completes the spec. * docs: AVLiveBody macOS rewrite design Clean-rewrite spec: fresh native macOS Xcode app for the iPhone-USB body pipeline. Reuses the tested USB components, single RealityKit scene (video quad + skeleton + mesh), drops all legacy MediaPipe/viz/data-feed code. * docs: AVLiveBody macOS rewrite plan 10-task plan: scaffold the xcodegen app, migrate the USB pipeline, build the RealityKit scene (video quad, skeleton, mesh), wire it, archive the legacy app. * feat(avlivebody-mac): scaffold xcode app Add an empty buildable native macOS app generated via xcodegen, sibling of iphone-arbody. Depends on the shared AVLiveWire package. Later tasks add the USB pipeline and RealityKit scene. * feat(avlivebody-mac): migrate usb transport Context: the new native AVLiveBody app needs the proven iPhone-Mac usbmux transport layer. These files are self-contained, depending only on AVLiveWire plus Apple system frameworks, so they cross the rewrite boundary unchanged. Approach: copy the three transport files and their unit tests byte-for-byte from launcher/AV-Live-Body, then make the test target buildable. Changes: - Add usb/USBMuxProtocol.swift, usb/USBClient.swift and usb/VideoDecoder.swift under Sources/AVLiveBody. - Add USBMuxProtocolTests.swift and USBClientTests.swift under Tests/AVLiveBodyTests. - Set GENERATE_INFOPLIST_FILE=YES on the AVLiveBodyTests target so xcodebuild can code sign the now-populated test bundle. Impact: the usbmux pipeline is available in the rewrite and its six unit tests run green under xcodebuild test. * feat(avlivebody-mac): usb skeleton consumer Add a cleaned USBSkeletonConsumer that publishes SkeletonPayload keyed by pid and owns video decoding directly, dropping the legacy ArkitOSCListener conversion layer. * fix(avlivebody-mac): guard thread store with lock Move the `thread` property write inside the stateLock-held region in start(); t.start() stays outside since the thread cannot run before start() is called. Removes a latent race. * feat(avlivebody-mac): multi-hmr and body fusion Context: Task 4 of the macOS rewrite needs the dense-mesh half of the pipeline alongside the USB skeleton consumer landed in task 3. Approach: Add a CoreML wrapper that mirrors the validated Python reference (data_only_viz/multihmr_coreml.py) and a pure-logic fusion stage that corrects the mesh pelvis depth using the LiDAR-precise USB skeleton. Changes: - MultiHMRCoreML.swift: 1x3x672x672 ImageNet-normalized image input, 1x3x3 cam_K input, K=4 SMPL-X person outputs at 10475 vertices, det threshold 0.3. - BodyFusion.swift: stateless fuse(persons, skeletons) overrides the highest-score mesh translation.z with the skeleton pelvis Z when available, passes through otherwise. - BodyFusionTests.swift: pelvis override and pass-through cases. Impact: Unlocks the mesh renderer wiring in later tasks and gives the macOS app metrically-correct depth in front of the camera. * fix(avlivebody-mac): load mlmodelc, clarify fusion Xcode compiles .mlpackage resources to .mlmodelc at build time; look up the compiled artifact directly and drop the redundant MLModel.compileModel step. Also rewrite BodyFusion docstring to match actual single-person pelvis-z override behaviour. * feat(avlivebody-mac): scene controller + view RealityKit scene plumbing: SceneController owns ARView, orbital camera, and holders for VideoQuad/SkeletonEntity/MeshEntity. SceneView is the SwiftUI NSViewRepresentable bridge. Build intentionally deferred to T8 (holder types land in T6-T8). * fix(avlivebody): orbit gesture + setUp guard Filter NSPanGestureRecognizer state in OrbitTarget.handlePan to dispatch only on .changed, replacing the Task wrapper with MainActor.assumeIsolated. Guard SceneController.setUp() with a didSetUp flag so duplicate makeNSView calls do not re-install gestures or re-add anchors. * feat(avlivebody-mac): 91-joint skeleton entity Yellow marker spheres pooled per pid; ARKit (x,y,z) -> RealityKit (x,-y,-z). Adapted .systemYellow to NSColor for macOS RealityKit Material.Color. Build deferred to T8. * feat(avlivebody-mac): video quad Flat 1.6x0.9m plane at z=-2m, textured per-frame from CVPixelBuffer via CIImage -> CGImage -> TextureResource. Per-frame TextureResource creation is the known perf hot spot, isolated here for later LowLevelTexture upgrade. * fix(avlivebody-mac): appkit import for orbit NSPanGestureRecognizer lives in AppKit on macOS; without the import the AVLiveBody module failed to emit. T5 leftover surfaced once T6/T7/T8 made the target compilable. * feat(avlivebody-mac): smpl-x mesh entity Render SMPL-X dense meshes (10475 verts) from Multi-HMR with pooled ModelEntity per person. Triangle indices loaded from the bundled smplx_faces.bin (flat UInt32 triplets, copied from the legacy launcher target). xcodegen folder-scanning bundles the .bin under Contents/Resources/ — no project.yml change needed. * feat(avlivebody-mac): wire scene + status bar Replace placeholder window with ContentView wiring USBSkeletonConsumer, SceneController, MultiHMRCoreML and BodyFusion per the T9 dataflow plan. * chore: archive legacy AV-Live-Body Superseded by avlivebody-mac/ on 2026-05-18. See docs/superpowers/specs/2026-05-18-avlivebody-macos-rewrite-design.md for the rewrite design and rationale. * fix(avlivebody): break onVideoFrame retain cycle Capture consumer weakly in the onVideoFrame closure so the USBSkeletonConsumer can be deallocated and its background thread exits cleanly. Guard the mesh-fusion path when consumer is gone. * fix(launcher): disable body spawn post-archive Legacy SwiftPM target archived to launcher/_archive-AV-Live-Body/. New native Xcode app lives at avlivebody-mac/; no swift run path. startBodyApp now logs + no-ops with FIXME(rewrite-2026-05-18). * docs(avlivebody-mac): contributor setup README Document prerequisites, mlpackage copy, signing xcconfig, and xcodegen/xcodebuild commands. Points at design spec and plan. * refactor(avlivebody): axis helper + cleanups - Extract arkitToRealityKit helper, dedupe 3 call sites. - Add onDisappear consumer.stop() to terminate USB read loop. - Replace @State with let for SceneController (stable class id). - Add NSLog diagnostics in VideoQuad+MeshEntity silent guards. * fix(avlivebody): ad-hoc signing for local dev Apple Development cert + Automatic signing makes Xcode demand a Mac Development cert that no one has. Switch to manual ad-hoc (CODE_SIGN_IDENTITY = -) so any contributor can build. Drop hardened runtime; re-enable for distribution builds. * feat(arbody): keep iphone awake while streaming iOS auto-lock tears down the USBServer TCP listener within seconds, breaking AVLiveBody Mac-side connect. Disable the idle timer for the lifetime of ContentView, restore on exit. * docs: add AGENTS.md skeleton
486 lines
21 KiB
Python
486 lines
21 KiB
Python
"""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
|
||
|
||
from .action_head_pub import ActionHeadPublisher
|
||
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")
|
||
|
||
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,
|
||
) -> None:
|
||
self.state = state
|
||
self.camera_index = camera_index
|
||
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.
|
||
import os as _os
|
||
self._ghost_min_visible = int(_os.environ.get("POSE_GHOST_MIN_VISIBLE", "10"))
|
||
self._ghost_min_conf = float(_os.environ.get("POSE_GHOST_MIN_CONF", "0.5"))
|
||
self._hand_min_visible = int(_os.environ.get("POSE_HAND_MIN_VISIBLE", "15"))
|
||
self._face_min_visible = int(_os.environ.get("POSE_FACE_MIN_VISIBLE", "50"))
|
||
self._nms_iou = float(_os.environ.get("POSE_NMS_IOU", "0.7"))
|
||
# 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:
|
||
try:
|
||
import cv2
|
||
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
|
||
|
||
pose = PoseLandmarker.create_from_options(PoseLandmarkerOptions(
|
||
base_options=BaseOptions(model_asset_path=str(pose_p)),
|
||
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)),
|
||
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)),
|
||
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)", self.num_persons)
|
||
|
||
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
|
||
h, w = frame_bgr.shape[:2]
|
||
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
|
||
mp_img = mp.Image(image_format=mp.ImageFormat.SRGB, data=frame_rgb)
|
||
ts = int(time.monotonic() * 1000) - t0_ms
|
||
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 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) 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 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)
|
||
|
||
hands = []
|
||
for landmarks_list in (hand_res.hand_landmarks 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
|
||
self._sound_bridge.send(
|
||
bodies, ids_body, t_now,
|
||
persons_face=faces, persons_face_ids=ids_face,
|
||
persons_hands=hands, persons_hands_ids=ids_hand,
|
||
persons_body3d=bodies3d, persons_body3d_ids=ids_body3d)
|
||
|
||
with self.state.lock():
|
||
self.state.persons_body = bodies
|
||
self.state.persons_face = faces
|
||
self.state.persons_hands = hands
|
||
self.state.persons_body_ids = ids_body
|
||
self.state.persons_body3d = bodies3d
|
||
self.state.persons_face_ids = ids_face
|
||
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()
|
||
pose.close(); face.close(); hand.close()
|
||
LOG.info("multi worker stopped")
|