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
304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""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
|
|
import os
|
|
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. The exported
|
|
# `multihmr_full_672_s.mlpackage` (2026-05-14 re-convert) renumbered
|
|
# the MIL vars; verified against the on-disk artifact's spec.
|
|
OUT_V3D = "var_2420" # (4, 10475, 3)
|
|
OUT_TRANSL = "var_2423" # (4, 1, 3)
|
|
OUT_SCORES = "var_2436" # (4,)
|
|
OUT_BETAS = "var_2439" # (4, 10)
|
|
OUT_EXPR = "var_2442" # (4, 10)
|
|
# var_2445 (4, 127, 3) = j3d joints — present but unused here.
|
|
|
|
# DINOv2 backbone was trained on ImageNet-normalized RGB; the public
|
|
# `infer()` contract takes [0,1] CHW input and applies this here so
|
|
# every caller stays normalization-agnostic. Feeding raw [0,1] to the
|
|
# model collapses all detection scores to ~0.01 ("0 detections" bug).
|
|
_IMG_NORM_MEAN = np.array([0.485, 0.456, 0.406],
|
|
dtype=np.float32).reshape(1, 3, 1, 1)
|
|
_IMG_NORM_STD = np.array([0.229, 0.224, 0.225],
|
|
dtype=np.float32).reshape(1, 3, 1, 1)
|
|
|
|
# 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()
|
|
# MLComputeUnits: 0=CPUOnly, 1=CPUAndGPU, 2=All (ANE+GPU+CPU),
|
|
# 3=CPUAndNeuralEngine. Bench M5 2026-05-14 (under live-worker
|
|
# contention, 30 iter median, full Multi-HMR predict+copy):
|
|
# CPU_AND_GPU = 252 ms (baseline)
|
|
# ALL = 246 ms (within noise, ANE doesn't help)
|
|
# CPU_AND_NE = 1301 ms (ANE solo catastrophic)
|
|
# CPU_ONLY = 1152 ms
|
|
# Standalone (no contention) FP32 = 139 ms = 7.2 fps. Default
|
|
# stays CPU+GPU. Override with COREML_COMPUTE_UNITS env var
|
|
# (`all`, `cpu_and_gpu`, `cpu_and_ne`, `cpu_only`) for A/B testing.
|
|
cu_env = os.environ.get("COREML_COMPUTE_UNITS", "").strip().lower()
|
|
cu_map = {"cpu_only": 0, "cpu_and_gpu": 1, "all": 2,
|
|
"cpu_and_ne": 3}
|
|
cu = cu_map.get(cu_env, 1)
|
|
try:
|
|
cfg.setComputeUnits_(cu)
|
|
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
|
|
cu_name = {0: "CPU_ONLY", 1: "CPU+GPU", 2: "ALL", 3: "CPU+NE"}.get(
|
|
cu, str(cu))
|
|
LOG.info("Multi-HMR CoreML model loaded (%s, computeUnits=%s)",
|
|
self.path.name, cu_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), RGB in
|
|
[0,1]. ImageNet normalization is applied internally.
|
|
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)")
|
|
|
|
img = (img - _IMG_NORM_MEAN) / _IMG_NORM_STD
|
|
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
|