docs: add AGENTS.md skeleton (#1)

* 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 (commit 4e7101c)
- 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
original f540158 design:

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 per f540158).

* 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
This commit was merged in pull request #1.
This commit is contained in:
Clément SAILLANT
2026-05-21 12:41:33 +02:00
committed by GitHub
parent d8aa727887
commit 5c61112826
146 changed files with 16585 additions and 285 deletions
+20
View File
@@ -10,6 +10,19 @@ launcher/**/xcuserdata/
launcher/**/*.xcuserstate
launcher/**/Package.resolved
# iphone-arbody — xcodeproj is generated by xcodegen, Local.xcconfig
# carries personal Apple Developer Team ID. Both are local-only.
iphone-arbody/ARBodyTracker.xcodeproj/
iphone-arbody/Config/Local.xcconfig
iphone-arbody/**/xcuserdata/
iphone-arbody/**/*.xcuserstate
iphone-arbody/build/
iphone-arbody/DerivedData/
# SwiftPM build + editor artifacts
.build/
.swiftpm/
# openFrameworks — on garde les shaders + settings.json pour qu'ils
# arrivent sur les autres machines, mais on ignore les binaires.
oscope-of/bin/*
@@ -44,3 +57,10 @@ sound_algo/*.log
.vscode/
*.swp
*~
# tool session state (claude-mem / remember skill)
.remember/
# macOS / iCloud collision artifacts (auto-created on rename)
*\ 2
*\ 2.*
+83
View File
@@ -0,0 +1,83 @@
# AGENTS.md
Guidance for AI coding agents (Claude Code, Aider, Cursor, etc.) working in this repo.
## Project
`AV-Live` — live-coding audio-visual performance system: SuperCollider sound engine, openFrameworks visualiser driven by a Hantek 6022BL oscilloscope, and a SwiftUI menubar launcher orchestrating everything. Public, GPL-3. Repo `electron-rare/AV-Live`, branch `main`. Multi-host: GrosMac (source), macm1 (sink / Multi-HMR + Apple Vision ANE), iPhone 16 Pro (ARKit/LiDAR pub).
## Tech stack (per sub-project)
| Sub-project | Stack |
|-------------|-------|
| `sound_algo/` | SuperCollider (sclang + scsynth), 1099 SynthDefs, 345 tracks |
| `oscope-of/` | openFrameworks C++, libusb (Hantek bulk), GLSL 150 / GL 3.2 core |
| `launcher/` | SwiftUI menubar app, Swift Package Manager |
| `data_only_viz/` | Python 3.11+ via `uv`, native Metal (pyobjc), multi-backend pose |
| `data_feeds/` | Python data ingestion |
| `web_realart/` | Node.js, Express, OSC bridge |
| `avlivebody-mac/` | SwiftUI body-tracking client (ARKit/SMPL-X mesh, ad-hoc signed for local dev) |
| `iphone-arbody/` | iOS app, ARBodyTracker, publishes `/body3d/kp` via OSC |
## Commands
```bash
# Python sub-projects (uv only)
cd data_only_viz && uv sync && uv run python -m data_only_viz
cd data_feeds && uv sync
# openFrameworks
cd oscope-of && make -j
# Web bridge
cd web_realart && npm install && npm start
# Swift
open launcher/Package.swift # or xcodebuild from CLI
open avlivebody-mac/avlivebody.xcodeproj
```
## Conventions
- Commits: subject ≤ 50 chars, body ≤ 72, no underscore in scope, no AI attribution, never `--no-verify` (hooks enforce).
- Branches: `feat/<name>`, `fix/<name>`, `docs/<name>`, `refactor/<name>`, `chore/<name>`.
- Language: French to the user, English in code/comments/commits.
- No emojis in code/docs/commits unless explicitly requested.
- Python: **always `uv`** (never pip/poetry/conda directly).
- `.gitignore` already excludes `*.pt`, `*.ckpt`, `*.safetensors`, `*.mlpackage` at root — don't commit weights.
- License: GPL-3 (whole repo) — keep new files under a compatible license header when adding third-party code.
## File layout
- `sound_algo/` — SC sound engine (own `CLAUDE.md`)
- `oscope-of/` — visualiser
- `launcher/` — macOS menubar
- `data_only_viz/` — pose / mesh / body tracking pipeline (Metal)
- `data_feeds/` — data ingestion
- `web_realart/` — web UI + OSC bridge
- `avlivebody-mac/`, `iphone-arbody/` — body-tracking clients
- `shared/` — cross-sub-project assets
- `third_party/` — vendored deps (CHECK before adding to root deps)
- `tools/` — helper scripts
- `docs/superpowers/plans/` — in-flight plans/specs
- `AV-Live-corrupted-20260514/` — quarantined corrupted snapshot, do not touch
## Domain-specific gotchas
- **mDNS hostnames are required** (`grosmac.local`, `supra-m1.local`) for `AVBODY_HOST` / `MULTIHMR_REMOTE_HOST`. They resist DHCP changes (iPhone hotspot reassigns 172.20.10.x routinely).
- **`POSE_FILTER` chain ordering is load-bearing**: default is `median+kalman+lookahead+ik`. Extras must be inserted at the right stage — `one_euro_joints` BEFORE kalman, `one_euro_bones` AFTER SMPL-X fusion in `multi.py`. `arkit_fuse` overrides 14 body slots with ARKit ARSkeleton3D from iOS app via `/body3d/kp` on `:57128` (always-on listener).
- **`ICP_FUSION=1`** requires `ICP_LIDAR_HOST` (iPhone IP), `ICP_LIDAR_PORT` (default 5500, iPhone ARMesh TCP), and an extrinsic JSON at `~/.config/av-live/lidar_extrinsic.json`. See `docs/ICP_FUSION.md`.
- **iPhone OSC port `57128`** is hardcoded as the publish target for `/body3d/kp` — don't reassign.
- **`avlivebody-mac` requires ad-hoc signing for local dev** (fixed in `85589f2`). Don't strip the signing identity.
- **`onVideoFrame` retain cycle in avlivebody** was fixed in `3b5f29e` — when adding new frame callbacks, mind the strong-self capture.
- **AVLive-Body legacy** has been archived (`9e1482e`); the canonical client is `avlivebody-mac`. Don't reintroduce paths to the old project.
- **macm1 = sink** (Multi-HMR CoreML + Apple Vision ANE + SMPL-X TCP); GrosMac = source. Mind the direction when wiring new OSC topics.
- **Each major sub-project has its own `CLAUDE.md`** — closest wins. Put cross-cutting rules here, sub-project specifics in the nested file.
## When in doubt
- Read root `CLAUDE.md` and the nested `CLAUDE.md` of the sub-project you're editing.
- Recent commits: `git log --oneline -20`.
- Plans: `docs/superpowers/plans/`.
- Cluster context: `~/CLAUDE.md` (GrosMac / macm1 / iPhone topology).
- For sound: read `sound_algo/CLAUDE.md` before touching SynthDefs.
+21
View File
@@ -27,6 +27,27 @@ Toujours répondre en français à l'utilisateur. Code, commentaires de code, co
| Bridge web / UI de live coding | `web_realart/` |
| Plans / specs en cours | `docs/superpowers/plans/` |
## Network topology
| Host | mDNS | IP (DHCP) | Role |
|------|------|-----------|------|
| GrosMac M5 | `grosmac.local` | LAN | Source + visualisation (AVLiveBody + data_only_viz + data_feeds) |
| macm1 M1 Max | `supra-m1.local` | `192.168.0.175` | Sink (Multi-HMR CoreML + Apple Vision ANE + SMPL-X TCP) |
| iPhone 16 Pro | (Personal Hotspot) | DHCP | ARKit/LiDAR pub via OSC `/body3d/kp` |
`AVBODY_HOST` / `MULTIHMR_REMOTE_HOST` accept mDNS hostnames — résiste aux changements DHCP (notamment iPhone hotspot 172.20.10.x).
## Environment variables
| Env | Default | Effect |
|-----|---------|--------|
| `POSE_FILTER` | `median+kalman+lookahead+ik` | filter chain stages — extra: `one_euro_joints` (joint-space CHI 2012 One Euro, inserted before kalman), `one_euro_bones` (bone-vector One Euro applied after SMPL-X fusion in multi.py), `arkit_fuse` (overrides 14 body slots with ARKit ARSkeleton3D from the iOS app, expects /body3d/kp on :57128) |
| `IPHONE_OSC_PORT` | `57128` | UDP port the iPhone ARBodyTracker app pushes /body3d/kp to (always-on listener in data_only_viz) |
| `ICP_FUSION` | `0` | `1` to enable LiDAR↔SMPL-X ICP fusion (cf. `docs/ICP_FUSION.md`) |
| `ICP_LIDAR_HOST` | _(unset)_ | iPhone ARBodyTracker IP when `ICP_FUSION=1` |
| `ICP_LIDAR_PORT` | `5500` | iPhone ARMesh TCP port |
| `ICP_LIDAR_EXTRINSIC` | `~/.config/av-live/lidar_extrinsic.json` | extrinsic JSON path |
## Conventions globales
- Python : **uv** systématiquement (jamais pip/poetry/conda directs).
+4
View File
@@ -0,0 +1,4 @@
*.xcodeproj/
Config/Local.xcconfig
.build/
.swiftpm/
@@ -0,0 +1,3 @@
// Copy to Config/Local.xcconfig and set your Apple Developer Team ID.
// Config/Local.xcconfig is gitignored.
DEVELOPMENT_TEAM = YOUR_TEAM_ID
+8
View File
@@ -0,0 +1,8 @@
#include? "Local.xcconfig"
MACOSX_DEPLOYMENT_TARGET = 15.0
SWIFT_VERSION = 5.10
// Manual ad-hoc signing for local dev (no Apple Mac Development cert
// required). Override here or via target settings for distribution.
CODE_SIGN_STYLE = Manual
CODE_SIGN_IDENTITY = -
+62
View File
@@ -0,0 +1,62 @@
# AVLiveBody (macOS)
Native macOS Xcode app that renders SMPL-X body meshes in RealityKit
from the USB iPhone body-tracking pipeline (ARBodyTracker -> Multi-HMR
worker -> AVLiveBody scene).
## Prerequisites
- macOS 15+
- Xcode 16+
- `xcodegen``brew install xcodegen`
## First-time setup
1. Copy the CoreML model into the app resources (required, gitignored,
~195 MB). Without it the app degrades to skeleton-only rendering:
```
cp -R ~/.cache/av-live-multihmr/multihmr_full_672_s.mlpackage \
avlivebody-mac/Sources/AVLiveBody/Resources/
```
2. Create your local xcconfig and set your signing team:
```
cp Config/Local.xcconfig.example Config/Local.xcconfig
# Edit Config/Local.xcconfig:
# DEVELOPMENT_TEAM = <your Apple Developer Team ID>
```
## Build
Generate the Xcode project (run after every `project.yml` change) then
open or build from the CLI:
```
cd avlivebody-mac
xcodegen generate
open AVLiveBody.xcodeproj
```
CLI build / test:
```
xcodebuild -project AVLiveBody.xcodeproj -scheme AVLiveBody \
-destination 'platform=macOS' build
xcodebuild -project AVLiveBody.xcodeproj -scheme AVLiveBody \
-destination 'platform=macOS' test
```
## Runtime requirements
A tethered iPhone running the matching `ARBodyTracker` iOS app over USB
is required for body input. See `iphone-arbody/` for the iOS side.
## Architecture
- Design spec:
`docs/superpowers/specs/2026-05-18-avlivebody-macos-rewrite-design.md`
- Implementation plan:
`docs/superpowers/plans/2026-05-18-avlivebody-macos-rewrite.md`
@@ -0,0 +1,68 @@
import Cocoa
import CoreVideo
import SwiftUI
/// Forces a regular, keyboard-focusable foreground app.
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.regular)
NSApp.activate()
}
}
@main
struct AVLiveBodyApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self)
private var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
.frame(minWidth: 900, minHeight: 600)
}
}
}
@MainActor
struct ContentView: View {
@StateObject private var consumer = USBSkeletonConsumer()
private let controller = SceneController()
private let multiHMR: MultiHMRCoreML? = MultiHMRCoreML()
/// Placeholder intrinsics until a `.meta` frame supplies real ones.
private let cameraK: [Float] = [
672, 0, 336, 0, 672, 336, 0, 0, 1,
]
var body: some View {
ZStack(alignment: .top) {
SceneView(controller: controller)
StatusBar(consumer: consumer)
}
.onAppear { wire() }
.onDisappear { consumer.stop() }
.onReceive(consumer.$skeletons) { skeletons in
controller.updateSkeleton(skeletons)
}
}
private func wire() {
let controller = self.controller
let multiHMR = self.multiHMR
let cameraK = self.cameraK
consumer.onVideoFrame = { [weak consumer] pixelBuffer in
MainActor.assumeIsolated {
controller.updateVideo(pixelBuffer)
guard let consumer else { return }
if let hmr = multiHMR {
let raw = hmr.infer(
pixelBuffer, cameraK: cameraK)
let fused = BodyFusion.fuse(
persons: raw,
skeletons: consumer.skeletons)
controller.updateMesh(fused)
}
}
}
consumer.start()
}
}
@@ -0,0 +1,10 @@
import Foundation
import simd
/// ARKit/Multi-HMR world coords (y up, z back) -> RealityKit world
/// coords (y up, z forward). Apply to every vertex/translation that
/// crosses from source pipeline space into the scene.
@inline(__always)
func arkitToRealityKit(_ v: SIMD3<Float>) -> SIMD3<Float> {
SIMD3<Float>(v.x, -v.y, -v.z)
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>AVLiveBody</string>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>LSMinimumSystemVersion</key><string>15.0</string>
<key>NSCameraUsageDescription</key>
<string>Receives the tethered iPhone camera over USB.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Connects to the tethered iPhone over USB (usbmuxd).</string>
</dict>
</plist>
@@ -0,0 +1,68 @@
import AppKit
import Foundation
import RealityKit
import simd
/// Renders SMPL-X dense body meshes (10475 vertices) from Multi-HMR.
/// Triangle indices come from the bundled `smplx_faces.bin`
/// (flat UInt32 triplets).
@MainActor
final class MeshEntity {
let root = Entity()
private static let vertexCount = 10475
private let faces: [UInt32]
private var pools: [Int: ModelEntity] = [:]
private let material = SimpleMaterial(
color: NSColor(white: 0.8, alpha: 1.0),
roughness: 0.5, isMetallic: false)
init() {
faces = MeshEntity.loadFaces()
}
func update(_ persons: [MultiHMRPerson]) {
for (idx, person) in persons.enumerated() {
let entity = pools[idx] ?? {
let e = ModelEntity()
root.addChild(e)
pools[idx] = e
return e
}()
guard let mesh = buildMesh(person.vertices) else { continue }
entity.model = ModelComponent(mesh: mesh,
materials: [material])
let t = person.translation
entity.transform.translation = arkitToRealityKit(t)
entity.isEnabled = true
}
for idx in pools.keys where idx >= persons.count {
pools[idx]?.isEnabled = false
}
}
private func buildMesh(_ verts: [SIMD3<Float>])
-> MeshResource? {
guard verts.count == Self.vertexCount, !faces.isEmpty else {
NSLog("MeshEntity: vertex count mismatch %d (expected %d), faces=%d",
verts.count, Self.vertexCount, faces.count)
return nil
}
var descriptor = MeshDescriptor(name: "smplx")
descriptor.positions = MeshBuffer(verts.map(arkitToRealityKit))
descriptor.primitives = .triangles(faces)
return try? MeshResource.generate(from: [descriptor])
}
private static func loadFaces() -> [UInt32] {
guard let url = Bundle.main.url(
forResource: "smplx_faces", withExtension: "bin"),
let data = try? Data(contentsOf: url) else {
NSLog("MeshEntity: smplx_faces.bin missing")
return []
}
return data.withUnsafeBytes { raw in
Array(raw.bindMemory(to: UInt32.self))
}
}
}
@@ -0,0 +1,117 @@
import AppKit
import Foundation
import CoreVideo
import RealityKit
import simd
import AVLiveWire
/// Owns the single RealityKit scene: the video quad, the body root,
/// and an orbital camera. The app calls `updateVideo/updateSkeleton/
/// updateMesh` from the main queue.
@MainActor
final class SceneController {
let arView = ARView(frame: .zero)
private let cameraAnchor = AnchorEntity(world: .zero)
private let camera = PerspectiveCamera()
private let worldAnchor = AnchorEntity(world: .zero)
private(set) var videoQuad: VideoQuad?
private(set) var skeleton: SkeletonEntity?
private(set) var mesh: MeshEntity?
/// Orbital camera state.
private var orbitYaw: Float = 0
private var orbitPitch: Float = 0
private var orbitRadius: Float = 3.0
private var didSetUp = false
func setUp() {
guard !didSetUp else { return }
didSetUp = true
arView.environment.background = .color(.black)
arView.scene.addAnchor(worldAnchor)
camera.camera.fieldOfViewInDegrees = 55
cameraAnchor.addChild(camera)
arView.scene.addAnchor(cameraAnchor)
applyCamera()
let q = VideoQuad()
worldAnchor.addChild(q.entity)
videoQuad = q
let s = SkeletonEntity()
worldAnchor.addChild(s.root)
skeleton = s
let m = MeshEntity()
worldAnchor.addChild(m.root)
mesh = m
installOrbitGestures()
}
func updateVideo(_ pixelBuffer: CVPixelBuffer) {
videoQuad?.update(pixelBuffer)
}
func updateSkeleton(_ skeletons: [Int: SkeletonPayload]) {
skeleton?.update(skeletons)
}
func updateMesh(_ persons: [MultiHMRPerson]) {
mesh?.update(persons)
}
// MARK: - Orbital camera
private func applyCamera() {
let cy = cos(orbitYaw), sy = sin(orbitYaw)
let cp = cos(orbitPitch), sp = sin(orbitPitch)
let pos = SIMD3<Float>(orbitRadius * cp * sy,
orbitRadius * sp,
orbitRadius * cp * cy)
cameraAnchor.transform.translation = pos
camera.look(at: .zero, from: pos, relativeTo: nil)
}
private func installOrbitGestures() {
let pan = NSPanGestureRecognizer(
target: OrbitTarget.shared, action: #selector(
OrbitTarget.handlePan(_:)))
OrbitTarget.shared.controller = self
arView.addGestureRecognizer(pan)
}
fileprivate func orbit(dx: Float, dy: Float) {
orbitYaw += dx * 0.01
orbitPitch = max(-1.4, min(1.4, orbitPitch + dy * 0.01))
applyCamera()
}
}
/// Bridges the AppKit pan gesture to `SceneController.orbit`.
final class OrbitTarget: NSObject {
static let shared = OrbitTarget()
weak var controller: SceneController?
private var last: CGPoint = .zero
@objc func handlePan(_ g: NSPanGestureRecognizer) {
switch g.state {
case .began:
last = g.translation(in: g.view)
case .changed:
let p = g.translation(in: g.view)
let dx = Float(p.x - last.x)
let dy = Float(p.y - last.y)
last = p
MainActor.assumeIsolated {
self.controller?.orbit(dx: dx, dy: -dy)
}
default:
break
}
}
}
@@ -0,0 +1,15 @@
import RealityKit
import SwiftUI
/// SwiftUI bridge that hands the SceneController's ARView to the
/// window and runs `setUp()` once.
struct SceneView: NSViewRepresentable {
let controller: SceneController
func makeNSView(context: Context) -> ARView {
controller.setUp()
return controller.arView
}
func updateNSView(_ view: ARView, context: Context) {}
}
@@ -0,0 +1,56 @@
import AVLiveWire
import AppKit
import Foundation
import RealityKit
import simd
/// Renders 91-joint skeletons as yellow marker spheres. One marker
/// pool per pid. ARKit world coords -> RealityKit space (x, -y, -z).
@MainActor
final class SkeletonEntity {
let root = Entity()
private static let jointCount = 91
private static let markerRadius: Float = 0.012
private var pools: [Int: [ModelEntity]] = [:]
private let mesh = MeshResource.generateSphere(radius: markerRadius)
private let material = SimpleMaterial(
color: NSColor.systemYellow, roughness: 0.6, isMetallic: false)
func update(_ skeletons: [Int: SkeletonPayload]) {
// Drop pools for pids no longer present.
for pid in pools.keys where skeletons[pid] == nil {
pools[pid]?.forEach { $0.removeFromParent() }
pools.removeValue(forKey: pid)
}
for (pid, payload) in skeletons {
let pool = pools[pid] ?? makePool()
pools[pid] = pool
let n = min(Self.jointCount, payload.joints.count,
payload.valid.count)
for i in 0..<n {
let marker = pool[i]
if payload.valid[i] {
let j = payload.joints[i]
marker.transform.translation = arkitToRealityKit(j)
marker.isEnabled = true
} else {
marker.isEnabled = false
}
}
}
}
private func makePool() -> [ModelEntity] {
var pool: [ModelEntity] = []
pool.reserveCapacity(Self.jointCount)
for _ in 0..<Self.jointCount {
let e = ModelEntity(mesh: mesh, materials: [material])
e.isEnabled = false
root.addChild(e)
pool.append(e)
}
return pool
}
}
@@ -0,0 +1,22 @@
import SwiftUI
/// A thin overlay showing the USB connection state.
struct StatusBar: View {
@ObservedObject var consumer: USBSkeletonConsumer
var body: some View {
HStack(spacing: 6) {
Circle()
.fill(consumer.connected ? Color.green : Color.orange)
.frame(width: 9, height: 9)
Text(consumer.connected
? "iPhone connected (USB)"
: "waiting for iPhone…")
.font(.caption)
.foregroundStyle(.white)
Spacer()
}
.padding(8)
.background(.black.opacity(0.5))
}
}
@@ -0,0 +1,46 @@
import CoreImage
import CoreVideo
import Foundation
import RealityKit
/// A flat plane at the back of the scene, textured with the iPhone
/// camera video. `update(_:)` is called on the main queue per frame.
@MainActor
final class VideoQuad {
let entity = ModelEntity()
private let ciContext = CIContext()
/// Plane is 1.6 m wide, 16:9; positioned 2 m behind the body.
private static let width: Float = 1.6
private static let height: Float = 0.9
private static let zBack: Float = -2.0
init() {
let plane = MeshResource.generatePlane(
width: Self.width, height: Self.height)
var material = UnlitMaterial()
material.color = .init(tint: .white)
entity.model = ModelComponent(mesh: plane,
materials: [material])
entity.transform.translation =
SIMD3<Float>(0, 0, Self.zBack)
}
/// Replace the plane's texture from a decoded camera frame.
func update(_ pixelBuffer: CVPixelBuffer) {
let ci = CIImage(cvPixelBuffer: pixelBuffer)
guard let cg = ciContext.createCGImage(
ci, from: ci.extent) else { return }
guard let texture = try? TextureResource(
image: cg, options: .init(semantic: .color)) else {
NSLog("VideoQuad: TextureResource creation failed (%dx%d)",
CVPixelBufferGetWidth(pixelBuffer),
CVPixelBufferGetHeight(pixelBuffer))
return
}
var material = UnlitMaterial()
material.color = .init(tint: .white,
texture: .init(texture))
entity.model?.materials = [material]
}
}
@@ -0,0 +1,29 @@
import AVLiveWire
import Foundation
import simd
/// Overrides the highest-scoring Multi-HMR mesh's pelvis depth with
/// the first valid USB skeleton pelvis z. Single-person assumption:
/// with multiple skeletons in the dict the source pelvis is arbitrary
/// (dict iteration order). Pure, stateless unit-testable.
enum BodyFusion {
/// ARSkeleton3D joint 0 = root (hips), per ARSkeletonDefinition.defaultBody3D.
static let pelvisJoint = 0
static func fuse(persons: [MultiHMRPerson],
skeletons: [Int: SkeletonPayload])
-> [MultiHMRPerson] {
let pelvisZs: [Float] = skeletons.values.compactMap { s in
guard pelvisJoint < s.valid.count,
s.valid[pelvisJoint] else { return nil }
return s.joints[pelvisJoint].z
}
guard !pelvisZs.isEmpty,
let primaryIdx = persons.indices.max(by: {
persons[$0].score < persons[$1].score
}) else { return persons }
var out = persons
out[primaryIdx].translation.z = pelvisZs[0]
return out
}
}
@@ -0,0 +1,155 @@
import CoreML
import CoreVideo
import CoreImage
import Foundation
/// One detected SMPL-X body from Multi-HMR.
struct MultiHMRPerson {
var vertices: [SIMD3<Float>] // 10475 SMPL-X verts, model space
var translation: SIMD3<Float> // pelvis translation
var score: Float
}
/// CoreML wrapper around the bundled `multihmr_full_672_s.mlpackage`.
/// Mirrors `data_only_viz/multihmr_coreml.py`: two MLMultiArray inputs
/// (`image` 1x3x672x672 ImageNet-normalized, `cam_K` 1x3x3), fixed
/// K=4 person outputs.
final class MultiHMRCoreML {
static let inputSize = 672
static let vertexCount = 10475
static let maxPersons = 4
private static let detThreshold: Float = 0.3
private static let normMean: [Float] = [0.485, 0.456, 0.406]
private static let normStd: [Float] = [0.229, 0.224, 0.225]
private let model: MLModel
private let ciContext = CIContext()
/// Loads the bundled model. Returns nil if the resource or load
/// fails callers fall back to skeleton-only rendering.
init?() {
guard let url = Bundle.main.url(
forResource: "multihmr_full_672_s",
withExtension: "mlmodelc") else {
NSLog("MultiHMRCoreML: mlpackage resource missing")
return nil
}
let cfg = MLModelConfiguration()
cfg.computeUnits = .cpuAndGPU
do {
model = try MLModel(contentsOf: url, configuration: cfg)
} catch {
NSLog("MultiHMRCoreML: load failed %@",
String(describing: error))
return nil
}
}
/// Run inference on one camera frame. `cameraK` is the 3x3 camera
/// intrinsics row-major.
func infer(_ pixelBuffer: CVPixelBuffer,
cameraK: [Float]) -> [MultiHMRPerson] {
guard let image = makeImageInput(pixelBuffer),
let k = makeKInput(cameraK) else { return [] }
let inputs: [String: MLFeatureValue] = [
"image": MLFeatureValue(multiArray: image),
"cam_K": MLFeatureValue(multiArray: k),
]
guard let provider = try? MLDictionaryFeatureProvider(
dictionary: inputs),
let out = try? model.prediction(from: provider) else {
return []
}
return parse(out)
}
// MARK: - Input preprocessing
/// `CVPixelBuffer` -> [1,3,672,672] Float32, RGB, ImageNet-normed.
private func makeImageInput(_ pb: CVPixelBuffer) -> MLMultiArray? {
let n = Self.inputSize
// Resize to n x n BGRA via CoreImage.
let ci = CIImage(cvPixelBuffer: pb)
let sx = CGFloat(n) / ci.extent.width
let sy = CGFloat(n) / ci.extent.height
let scaled = ci.transformed(
by: CGAffineTransform(scaleX: sx, y: sy))
var dst: CVPixelBuffer?
CVPixelBufferCreate(kCFAllocatorDefault, n, n,
kCVPixelFormatType_32BGRA, nil, &dst)
guard let dst else { return nil }
ciContext.render(scaled, to: dst)
CVPixelBufferLockBaseAddress(dst, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(dst, .readOnly) }
guard let base = CVPixelBufferGetBaseAddress(dst) else {
return nil
}
let rowBytes = CVPixelBufferGetBytesPerRow(dst)
let px = base.assumingMemoryBound(to: UInt8.self)
guard let arr = try? MLMultiArray(
shape: [1, 3, NSNumber(value: n), NSNumber(value: n)],
dataType: .float32) else { return nil }
let ptr = arr.dataPointer.assumingMemoryBound(to: Float.self)
let plane = n * n
for y in 0..<n {
for x in 0..<n {
let p = y * rowBytes + x * 4 // BGRA
let b = Float(px[p]) / 255.0
let g = Float(px[p + 1]) / 255.0
let r = Float(px[p + 2]) / 255.0
let idx = y * n + x
ptr[idx] =
(r - Self.normMean[0]) / Self.normStd[0]
ptr[plane + idx] =
(g - Self.normMean[1]) / Self.normStd[1]
ptr[2 * plane + idx] =
(b - Self.normMean[2]) / Self.normStd[2]
}
}
return arr
}
/// 9 row-major intrinsics -> [1,3,3] Float32.
private func makeKInput(_ k: [Float]) -> MLMultiArray? {
guard k.count == 9,
let arr = try? MLMultiArray(
shape: [1, 3, 3], dataType: .float32) else { return nil }
let ptr = arr.dataPointer.assumingMemoryBound(to: Float.self)
for i in 0..<9 { ptr[i] = k[i] }
return arr
}
// MARK: - Output parsing
private func parse(_ out: MLFeatureProvider) -> [MultiHMRPerson] {
guard let v3d = out.featureValue(for: "var_2420")?
.multiArrayValue,
let transl = out.featureValue(for: "var_2423")?
.multiArrayValue,
let scores = out.featureValue(for: "var_2436")?
.multiArrayValue else { return [] }
var persons: [MultiHMRPerson] = []
let vc = Self.vertexCount
for k in 0..<Self.maxPersons {
let score = scores[k].floatValue
if score < Self.detThreshold { continue }
var verts = [SIMD3<Float>](
repeating: .zero, count: vc)
let base = k * vc * 3
for i in 0..<vc {
let o = base + i * 3
verts[i] = SIMD3(v3d[o].floatValue,
v3d[o + 1].floatValue,
v3d[o + 2].floatValue)
}
let tb = k * 3
persons.append(MultiHMRPerson(
vertices: verts,
translation: SIMD3(transl[tb].floatValue,
transl[tb + 1].floatValue,
transl[tb + 2].floatValue),
score: score))
}
return persons
}
}
@@ -0,0 +1,135 @@
import Foundation
import Darwin
/// Transport abstraction over the usbmuxd Unix socket. The real
/// implementation wraps a `socket(AF_UNIX)`; tests inject a mock.
protocol MuxTransport {
func send(_ data: Data)
func receivePacket() -> Data?
func close()
}
/// usbmux client: device discovery + connect-to-port. After a
/// successful `connect`, the same transport carries the raw tunneled
/// byte stream from the device.
final class USBClient {
private let transport: MuxTransport
private var tag: UInt32 = 0
init(transport: MuxTransport) {
self.transport = transport
}
func listDevices() -> [Int] {
tag += 1
transport.send(USBMuxProtocol.encode(
plist: ["MessageType": "ListDevices"], tag: tag))
guard let reply = transport.receivePacket(),
let plist = USBMuxProtocol.decode(reply),
let list = plist["DeviceList"] as? [[String: Any]]
else { return [] }
return list.compactMap { $0["DeviceID"] as? Int }
}
/// Returns true once the transport is tunneled to `port` on the
/// device. usbmux wants the TCP port in big-endian order.
func connect(deviceID: Int, port: UInt16) -> Bool {
tag += 1
let swapped = Int((port << 8) | (port >> 8))
transport.send(USBMuxProtocol.encode(plist: [
"MessageType": "Connect",
"DeviceID": deviceID,
"PortNumber": swapped,
], tag: tag))
guard let reply = transport.receivePacket(),
let plist = USBMuxProtocol.decode(reply),
let number = plist["Number"] as? Int
else { return false }
return number == 0
}
}
/// Production transport: blocking AF_UNIX socket to usbmuxd.
final class UnixMuxTransport: MuxTransport {
private var fd: Int32 = -1
init?(path: String = "/var/run/usbmuxd") {
fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
precondition(path.utf8.count < 104,
"usbmuxd socket path exceeds sun_path limit")
_ = path.withCString { src in
withUnsafeMutablePointer(to: &addr.sun_path) {
$0.withMemoryRebound(to: CChar.self, capacity: 104) {
strcpy($0, src)
}
}
}
let size = socklen_t(MemoryLayout<sockaddr_un>.size)
let ok = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.connect(fd, $0, size)
}
}
if ok != 0 { Darwin.close(fd); return nil }
}
func send(_ data: Data) {
guard fd >= 0 else { return }
data.withUnsafeBytes { buf in
guard let base = buf.baseAddress else { return }
var off = 0
while off < data.count {
let w = Darwin.write(fd, base.advanced(by: off),
data.count - off)
if w <= 0 {
if w < 0 && errno == EINTR { continue }
break
}
off += w
}
}
}
/// Read one usbmux packet: 4-byte LE length prefix then body.
func receivePacket() -> Data? {
guard let head = readN(4) else { return nil }
guard let len = USBMuxProtocol.readLE32(head, 0) else { return nil }
let total = Int(len)
guard total >= 16, let rest = readN(total - 4) else { return nil }
return head + rest
}
/// Read raw tunneled bytes after a successful Connect.
func readStream(max: Int = 65536) -> Data? {
readN(max, exact: false)
}
private func readN(_ n: Int, exact: Bool = true) -> Data? {
var buf = [UInt8](repeating: 0, count: n)
var got = 0
while got < n {
let r = buf.withUnsafeMutableBytes {
Darwin.read(fd, $0.baseAddress!.advanced(by: got), n - got)
}
if r < 0 {
if errno == EINTR { continue }
return got > 0 && !exact ? Data(buf[0..<got]) : nil
}
if r == 0 { // EOF peer closed
return got > 0 && !exact ? Data(buf[0..<got]) : nil
}
got += r
if !exact { break }
}
return Data(buf[0..<got])
}
deinit { close() }
func close() {
if fd >= 0 { Darwin.close(fd); fd = -1 }
}
}
@@ -0,0 +1,38 @@
import Foundation
/// Codec for the usbmuxd request/response protocol. 16-byte
/// little-endian header (length, version=1, message=8, tag) then an
/// XML property list.
enum USBMuxProtocol {
static func encode(plist: [String: Any], tag: UInt32) -> Data {
let body = (try? PropertyListSerialization.data(
fromPropertyList: plist, format: .xml, options: 0))
?? Data()
var d = Data()
appendLE32(&d, UInt32(16 + body.count)) // length
appendLE32(&d, 1) // version
appendLE32(&d, 8) // message: plist
appendLE32(&d, tag)
d.append(body)
return d
}
static func decode(_ packet: Data) -> [String: Any]? {
guard packet.count >= 16 else { return nil }
let body = packet.dropFirst(16)
return (try? PropertyListSerialization.propertyList(
from: body, options: [], format: nil)) as? [String: Any]
}
static func appendLE32(_ d: inout Data, _ v: UInt32) {
for i in 0..<4 { d.append(UInt8((v >> (8 * i)) & 0xFF)) }
}
static func readLE32(_ d: Data, _ offset: Int) -> UInt32? {
guard offset >= 0, d.count >= offset + 4 else { return nil }
let b = [UInt8](d)
var v: UInt32 = 0
for i in 0..<4 { v |= UInt32(b[offset + i]) << (8 * i) }
return v
}
}
@@ -0,0 +1,107 @@
import AVLiveWire
import Combine
import CoreVideo
import Foundation
/// Connects to the tethered iPhone over USB (usbmuxd), demuxes the
/// AVLiveWire stream, republishes skeleton payloads (keyed by pid)
/// and forwards decoded camera frames. Blocking transport runs on a
/// dedicated background thread; only `@Published` writes hop to main.
final class USBSkeletonConsumer: ObservableObject {
/// 91-joint skeleton payloads keyed by pid.
@Published var skeletons: [Int: SkeletonPayload] = [:]
@Published var connected = false
/// Called on the main queue for every decoded camera frame.
var onVideoFrame: ((CVPixelBuffer) -> Void)?
/// TCP port the iPhone `USBServer` listens on.
static let devicePort: UInt16 = 7000
private let videoDecoder = VideoDecoder()
private let stateLock = NSLock()
private var running = false
private var thread: Thread?
init() {
videoDecoder.onFrame = { [weak self] pixelBuffer in
DispatchQueue.main.async {
self?.onVideoFrame?(pixelBuffer)
}
}
}
private var isRunning: Bool {
stateLock.lock(); defer { stateLock.unlock() }
return running
}
func start() {
stateLock.lock()
if running { stateLock.unlock(); return }
running = true
let t = Thread { [weak self] in self?.loop() }
t.name = "cc.avlive.usbconsumer"
thread = t
stateLock.unlock()
t.start()
}
func stop() {
stateLock.lock(); running = false; stateLock.unlock()
}
private func loop() {
while isRunning {
guard let transport = UnixMuxTransport() else {
NSLog("USBSkeletonConsumer: no usbmuxd; retry")
Thread.sleep(forTimeInterval: 1.0); continue
}
let client = USBClient(transport: transport)
let devices = client.listDevices()
guard let dev = devices.first,
client.connect(deviceID: dev,
port: Self.devicePort) else {
NSLog("USBSkeletonConsumer: no device; retry")
transport.close()
Thread.sleep(forTimeInterval: 1.0); continue
}
NSLog("USBSkeletonConsumer: connected to device %d", dev)
publishConnected(true)
var demux = StreamDemuxer()
while isRunning {
guard let chunk = transport.readStream(),
!chunk.isEmpty else { break }
for frame in demux.feed(chunk) { route(frame) }
}
transport.close()
publishConnected(false)
NSLog("USBSkeletonConsumer: disconnected")
if isRunning { Thread.sleep(forTimeInterval: 1.0) }
}
}
private func route(_ frame: StreamDemuxer.Frame) {
switch frame.header.tag {
case .skeleton:
guard let payload =
SkeletonPayload(decoding: frame.payload) else { return }
let pid = Int(frame.header.pid)
DispatchQueue.main.async { [weak self] in
self?.skeletons[pid] = payload
}
case .video:
guard let payload =
VideoPayload(decoding: frame.payload) else { return }
videoDecoder.decode(payload)
case .meta:
break
}
}
private func publishConnected(_ value: Bool) {
DispatchQueue.main.async { [weak self] in
self?.connected = value
}
}
}
@@ -0,0 +1,184 @@
import AVLiveWire
import CoreMedia
import CoreVideo
import Foundation
import VideoToolbox
/// HEVC decoder. Feed `VideoPayload`s in; receive `CVPixelBuffer`s via
/// `onFrame`. Keyframe payloads must carry the VPS/SPS/PPS parameter
/// sets prepended as 4-byte-length-prefixed NAL units (the layout the
/// iOS `VideoEncoder` emits); the decoder (re)builds its format
/// description from those.
final class VideoDecoder {
var onFrame: ((CVPixelBuffer) -> Void)?
private var session: VTDecompressionSession?
private var formatDesc: CMVideoFormatDescription?
/// Decode one access unit.
func decode(_ payload: VideoPayload) {
var au = payload.data
if payload.isKeyframe {
let (params, rest) = Self.splitParameterSets(au)
if !params.isEmpty {
rebuildFormat(params)
}
au = rest
}
guard let fmt = formatDesc, !au.isEmpty else { return }
if session == nil { makeSession(fmt) }
guard let session, let block = Self.blockBuffer(au) else {
return
}
var sample: CMSampleBuffer?
var sampleSize = au.count
guard CMSampleBufferCreateReady(
allocator: kCFAllocatorDefault, dataBuffer: block,
formatDescription: fmt, sampleCount: 1,
sampleTimingEntryCount: 0, sampleTimingArray: nil,
sampleSizeEntryCount: 1, sampleSizeArray: &sampleSize,
sampleBufferOut: &sample) == noErr, let sample else {
return
}
VTDecompressionSessionDecodeFrame(
session, sampleBuffer: sample, flags: [],
infoFlagsOut: nil) { [weak self] status, _, image, _, _ in
guard status == noErr, let image else { return }
self?.onFrame?(image)
}
}
func stop() {
if let session { VTDecompressionSessionInvalidate(session) }
session = nil
formatDesc = nil
}
deinit { stop() }
// MARK: - Helpers
/// Leading 4-byte-length-prefixed NAL units of HEVC parameter-set
/// type (VPS=32, SPS=33, PPS=34) are split from the frame data.
/// Returns (parameterSetData, frameData).
private static func splitParameterSets(_ data: Data)
-> (Data, Data) {
let bytes = [UInt8](data)
var offset = 0
var paramEnd = 0
while offset + 4 <= bytes.count {
let len = (Int(bytes[offset]) << 24)
| (Int(bytes[offset + 1]) << 16)
| (Int(bytes[offset + 2]) << 8)
| Int(bytes[offset + 3])
let nalStart = offset + 4
guard len > 0, nalStart + len <= bytes.count else { break }
let nalType = (Int(bytes[nalStart]) >> 1) & 0x3F
if nalType == 32 || nalType == 33 || nalType == 34 {
offset = nalStart + len
paramEnd = offset
} else {
break
}
}
return (data.prefix(paramEnd),
data.suffix(from: data.startIndex
.advanced(by: paramEnd)))
}
private func rebuildFormat(_ paramData: Data) {
var sets: [[UInt8]] = []
let bytes = [UInt8](paramData)
var offset = 0
while offset + 4 <= bytes.count {
let len = (Int(bytes[offset]) << 24)
| (Int(bytes[offset + 1]) << 16)
| (Int(bytes[offset + 2]) << 8)
| Int(bytes[offset + 3])
let start = offset + 4
guard len > 0, start + len <= bytes.count else { break }
sets.append(Array(bytes[start..<start + len]))
offset = start + len
}
guard sets.count >= 3 else { return }
var fmt: CMFormatDescription?
let status = withParameterSetPointers(sets) { pBuf, sBuf in
CMVideoFormatDescriptionCreateFromHEVCParameterSets(
allocator: kCFAllocatorDefault,
parameterSetCount: sets.count,
parameterSetPointers: pBuf,
parameterSetSizes: sBuf,
nalUnitHeaderLength: 4, extensions: nil,
formatDescriptionOut: &fmt)
}
if status == noErr, let fmt {
formatDesc = fmt
if let session { VTDecompressionSessionInvalidate(session) }
session = nil
}
}
/// Build the C-style parallel arrays of parameter-set pointers and
/// sizes that `CMVideoFormatDescriptionCreateFromHEVCParameterSets`
/// requires, keeping the backing storage alive for the call.
private func withParameterSetPointers(
_ sets: [[UInt8]],
_ body: (UnsafePointer<UnsafePointer<UInt8>>,
UnsafePointer<Int>) -> OSStatus) -> OSStatus {
func recurse(_ index: Int,
_ ptrs: inout [UnsafePointer<UInt8>],
_ sizes: inout [Int]) -> OSStatus {
if index == sets.count {
return ptrs.withUnsafeBufferPointer { pBuf in
sizes.withUnsafeBufferPointer { sBuf in
body(pBuf.baseAddress!, sBuf.baseAddress!)
}
}
}
return sets[index].withUnsafeBufferPointer { buf in
ptrs.append(buf.baseAddress!)
sizes.append(buf.count)
return recurse(index + 1, &ptrs, &sizes)
}
}
var ptrs: [UnsafePointer<UInt8>] = []
var sizes: [Int] = []
ptrs.reserveCapacity(sets.count)
sizes.reserveCapacity(sets.count)
return recurse(0, &ptrs, &sizes)
}
private func makeSession(_ fmt: CMVideoFormatDescription) {
let attrs: [CFString: Any] = [
kCVPixelBufferPixelFormatTypeKey:
kCVPixelFormatType_32BGRA,
]
VTDecompressionSessionCreate(
allocator: kCFAllocatorDefault, formatDescription: fmt,
decoderSpecification: nil,
imageBufferAttributes: attrs as CFDictionary,
outputCallback: nil, decompressionSessionOut: &session)
}
private static func blockBuffer(_ data: Data) -> CMBlockBuffer? {
var block: CMBlockBuffer?
guard CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault, memoryBlock: nil,
blockLength: data.count,
blockAllocator: kCFAllocatorDefault,
customBlockSource: nil, offsetToData: 0,
dataLength: data.count, flags: 0,
blockBufferOut: &block) == noErr, let block else {
return nil
}
var ok = false
data.withUnsafeBytes { raw in
if let base = raw.baseAddress,
CMBlockBufferReplaceDataBytes(
with: base, blockBuffer: block,
offsetIntoDestination: 0,
dataLength: data.count) == noErr { ok = true }
}
return ok ? block : nil
}
}
@@ -0,0 +1,29 @@
import XCTest
import AVLiveWire
@testable import AVLiveBody
final class BodyFusionTests: XCTestCase {
private func skeleton(pelvisZ: Float) -> SkeletonPayload {
var p = SkeletonPayload()
p.joints[0] = SIMD3(0, 0, pelvisZ)
p.valid[0] = true
return p
}
func testPelvisDepthOverride() {
let mesh = MultiHMRPerson(
vertices: [SIMD3<Float>](repeating: .zero, count: 1),
translation: SIMD3(0, 0, -1.0), score: 0.9)
let fused = BodyFusion.fuse(
persons: [mesh], skeletons: [0: skeleton(pelvisZ: -2.5)])
XCTAssertEqual(fused[0].translation.z, -2.5, accuracy: 1e-4)
}
func testPassthroughWhenNoSkeleton() {
let mesh = MultiHMRPerson(
vertices: [SIMD3<Float>](repeating: .zero, count: 1),
translation: SIMD3(0, 0, -1.0), score: 0.9)
let fused = BodyFusion.fuse(persons: [mesh], skeletons: [:])
XCTAssertEqual(fused[0].translation.z, -1.0, accuracy: 1e-4)
}
}
@@ -0,0 +1,49 @@
import XCTest
@testable import AVLiveBody
/// In-memory stand-in for the usbmuxd Unix socket.
final class MockMuxTransport: MuxTransport {
var sent: [Data] = []
var canned: [Data] = []
func send(_ data: Data) { sent.append(data) }
func receivePacket() -> Data? {
canned.isEmpty ? nil : canned.removeFirst()
}
func close() {}
}
final class USBClientTests: XCTestCase {
func testListDevicesParsesDeviceIDs() {
let mock = MockMuxTransport()
mock.canned = [USBMuxProtocol.encode(plist: [
"DeviceList": [
["DeviceID": 42,
"Properties": ["ConnectionType": "USB"]],
]], tag: 0)]
let client = USBClient(transport: mock)
let devices = client.listDevices()
XCTAssertEqual(devices, [42])
}
func testConnectSendsConnectRequest() {
let mock = MockMuxTransport()
mock.canned = [USBMuxProtocol.encode(
plist: ["MessageType": "Result", "Number": 0], tag: 0)]
let client = USBClient(transport: mock)
let ok = client.connect(deviceID: 42, port: 7000)
XCTAssertTrue(ok)
let req = USBMuxProtocol.decode(mock.sent.last!)
XCTAssertEqual(req?["MessageType"] as? String, "Connect")
XCTAssertEqual(req?["DeviceID"] as? Int, 42)
XCTAssertEqual(req?["PortNumber"] as? Int,
Int((UInt16(7000) << 8) | (UInt16(7000) >> 8)))
}
func testConnectFailsOnNonZeroResult() {
let mock = MockMuxTransport()
mock.canned = [USBMuxProtocol.encode(
plist: ["MessageType": "Result", "Number": 3], tag: 0)]
let client = USBClient(transport: mock)
XCTAssertFalse(client.connect(deviceID: 1, port: 7000))
}
}
@@ -0,0 +1,27 @@
import XCTest
@testable import AVLiveBody
final class USBMuxProtocolTests: XCTestCase {
func testEncodeWrapsPlistWith16ByteHeader() {
let body: [String: Any] = ["MessageType": "ListDevices"]
let packet = USBMuxProtocol.encode(plist: body, tag: 3)
XCTAssertGreaterThan(packet.count, 16)
XCTAssertEqual(USBMuxProtocol.readLE32(packet, 0).map(Int.init),
packet.count)
XCTAssertEqual(USBMuxProtocol.readLE32(packet, 4), 1)
XCTAssertEqual(USBMuxProtocol.readLE32(packet, 8), 8)
XCTAssertEqual(USBMuxProtocol.readLE32(packet, 12), 3)
}
func testDecodeRoundTrip() {
let packet = USBMuxProtocol.encode(
plist: ["MessageType": "Result", "Number": 0], tag: 1)
let decoded = USBMuxProtocol.decode(packet)
XCTAssertEqual(decoded?["MessageType"] as? String, "Result")
XCTAssertEqual(decoded?["Number"] as? Int, 0)
}
func testDecodeRejectsShortPacket() {
XCTAssertNil(USBMuxProtocol.decode(Data([0, 1, 2])))
}
}
+54
View File
@@ -0,0 +1,54 @@
name: AVLiveBody
options:
bundleIdPrefix: cc.saillant
deploymentTarget:
macOS: "15.0"
createIntermediateGroups: true
configFiles:
Debug: Config/Shared.xcconfig
Release: Config/Shared.xcconfig
packages:
AVLiveWire:
path: ../shared/AVLiveWire
targets:
AVLiveBody:
type: application
platform: macOS
deploymentTarget: "15.0"
sources:
- path: Sources/AVLiveBody
excludes:
- Info.plist
dependencies:
- package: AVLiveWire
product: AVLiveWire
configFiles:
Debug: Config/Shared.xcconfig
Release: Config/Shared.xcconfig
settings:
base:
PRODUCT_NAME: AVLiveBody
PRODUCT_BUNDLE_IDENTIFIER: cc.saillant.AVLiveBody
INFOPLIST_FILE: Sources/AVLiveBody/Info.plist
GENERATE_INFOPLIST_FILE: NO
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "-"
CODE_SIGNING_REQUIRED: NO
CODE_SIGNING_ALLOWED: NO
SWIFT_VERSION: "5.10"
ENABLE_HARDENED_RUNTIME: NO
AVLiveBodyTests:
type: bundle.unit-test
platform: macOS
sources:
- path: Tests/AVLiveBodyTests
dependencies:
- target: AVLiveBody
- package: AVLiveWire
product: AVLiveWire
settings:
base:
GENERATE_INFOPLIST_FILE: YES
View File
+37
View File
@@ -0,0 +1,37 @@
[osc]
host = "127.0.0.1"
port = 57127
[feeds.eco2mix]
enabled = true
interval_sec = 60
[feeds.velib]
enabled = true
interval_sec = 120
station_codes = []
[feeds.hubeau]
enabled = true
interval_sec = 300
codes = ["F050001001"]
[feeds.gbfs]
enabled = false
interval_sec = 120
url = "https://velib-metropole-opendata.smoove.pro/opendata/Velib_Metropole/station_status.json"
[feeds.ais]
enabled = false
[feeds.carburants]
enabled = false
[feeds.prim]
enabled = false
[feeds.sytadin]
enabled = false
[feeds.teleray]
enabled = false
+27
View File
@@ -0,0 +1,27 @@
"""Registry of available feed classes (auto-discovery on import)."""
from __future__ import annotations
from .base import Feed
from .eco2mix import Eco2MixFeed
from .gbfs import GBFSFeed
from .hubeau import HubeauFeed
from .velib import VelibFeed
from .ais import AISFeed
from .carburants import CarburantsFeed
from .prim import PRIMFeed
from .sytadin import SytadinFeed
from .teleray import TelerayFeed
REGISTRY: dict[str, type[Feed]] = {
"eco2mix": Eco2MixFeed,
"gbfs": GBFSFeed,
"hubeau": HubeauFeed,
"velib": VelibFeed,
"ais": AISFeed,
"carburants": CarburantsFeed,
"prim": PRIMFeed,
"sytadin": SytadinFeed,
"teleray": TelerayFeed,
}
__all__ = ["Feed", "REGISTRY"]
+22
View File
@@ -0,0 +1,22 @@
"""AIS vessel positions feed — STUB.
TODO: needs aisstream.io API key + websocket subscription.
"""
from __future__ import annotations
import logging
from .base import Feed
LOG = logging.getLogger("data_feeds.ais")
class AISFeed(Feed):
name = "ais"
interval_sec = 60.0
def fetch(self):
return None
def publish(self, payload) -> None:
LOG.info("stub")
+55
View File
@@ -0,0 +1,55 @@
"""Abstract base class for data feeds."""
from __future__ import annotations
import abc
import logging
import time
import threading
from typing import Any
LOG = logging.getLogger("data_feeds.base")
class Feed(abc.ABC):
name: str = "feed"
interval_sec: float = 60.0
def __init__(self, osc_send, **cfg) -> None:
self.osc_send = osc_send
self.cfg = cfg
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self.last_t: float = 0.0
def configure(self, **kwargs) -> None:
self.cfg.update(kwargs)
if "interval_sec" in kwargs:
self.interval_sec = float(kwargs["interval_sec"])
@abc.abstractmethod
def fetch(self) -> Any: ...
@abc.abstractmethod
def publish(self, payload: Any) -> None: ...
def tick(self) -> None:
try:
payload = self.fetch()
self.publish(payload)
self.last_t = time.time()
self.osc_send(f"/data/{self.name}/heartbeat", [self.last_t])
except Exception as e: # noqa: BLE001
LOG.warning("%s fetch failed: %s", self.name, e)
def start(self) -> None:
self._thread = threading.Thread(
target=self._run, name=f"feed-{self.name}", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
def _run(self) -> None:
while not self._stop.is_set():
self.tick()
self._stop.wait(self.interval_sec)
+22
View File
@@ -0,0 +1,22 @@
"""Prix carburants feed — STUB.
TODO: needs prix-carburants.gouv.fr GeoJSON cache + station selection.
"""
from __future__ import annotations
import logging
from .base import Feed
LOG = logging.getLogger("data_feeds.carburants")
class CarburantsFeed(Feed):
name = "carburants"
interval_sec = 3600.0
def fetch(self):
return None
def publish(self, payload) -> None:
LOG.info("stub")
+58
View File
@@ -0,0 +1,58 @@
"""RTE eco2mix feed — France electricity production mix in MW.
Uses the public OpenDataSoft mirror of RTE eco2mix-national-tr (temps reel,
15-min resolution). Stdlib HTTP only.
"""
from __future__ import annotations
import json
import logging
import urllib.parse
import urllib.request
from typing import Any
from .base import Feed
LOG = logging.getLogger("data_feeds.eco2mix")
# OpenDataSoft public mirror — no key required.
URL = (
"https://odre.opendatasoft.com/api/records/1.0/search/"
"?dataset=eco2mix-national-tr&rows=1&sort=-date_heure"
)
class Eco2MixFeed(Feed):
name = "eco2mix"
interval_sec = 60.0
def fetch(self) -> Any:
req = urllib.request.Request(URL, headers={"User-Agent": "av-live/0.1"})
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read().decode("utf-8"))
records = data.get("records") or []
if not records:
return None
return records[0].get("fields") or {}
def publish(self, payload: Any) -> None:
if not isinstance(payload, dict):
return
# Pick a representative subset (MW). Keys per eco2mix-national-tr.
keys = [
"consommation", "nucleaire", "gaz", "charbon", "fioul",
"eolien", "solaire", "hydraulique", "bioenergies",
"ech_physiques",
]
count = 0
for k in keys:
v = payload.get(k)
if v is None:
continue
try:
fv = float(v)
except (TypeError, ValueError):
continue
self.osc_send(f"/data/{self.name}/sample", [k, fv])
count += 1
self.osc_send(f"/data/{self.name}/count", [count])
+53
View File
@@ -0,0 +1,53 @@
"""Generic GBFS (General Bikeshare Feed Specification) reader.
Reads a `station_status.json` URL and publishes aggregate counters.
"""
from __future__ import annotations
import json
import logging
import urllib.request
from typing import Any
from .base import Feed
LOG = logging.getLogger("data_feeds.gbfs")
class GBFSFeed(Feed):
name = "gbfs"
interval_sec = 120.0
def fetch(self) -> Any:
url = self.cfg.get("url")
if not url:
LOG.info("gbfs disabled: no url configured")
return None
req = urllib.request.Request(url, headers={"User-Agent": "av-live/0.1"})
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read().decode("utf-8"))
def publish(self, payload: Any) -> None:
if not isinstance(payload, dict):
return
stations = (payload.get("data") or {}).get("stations") or []
if not stations:
return
codes = set(self.cfg.get("station_codes") or [])
bikes = 0
docks = 0
operative = 0
sampled = 0
for s in stations:
sid = str(s.get("station_id", ""))
if codes and sid not in codes:
continue
bikes += int(s.get("num_bikes_available") or 0)
docks += int(s.get("num_docks_available") or 0)
if s.get("is_renting") or s.get("is_installed"):
operative += 1
sampled += 1
self.osc_send(f"/data/{self.name}/sample", ["bikes_available", float(bikes)])
self.osc_send(f"/data/{self.name}/sample", ["docks_available", float(docks)])
self.osc_send(f"/data/{self.name}/sample", ["stations_active", float(operative)])
self.osc_send(f"/data/{self.name}/count", [sampled])
+66
View File
@@ -0,0 +1,66 @@
"""Hub'Eau hydrometrie feed — water level and flow rate for French rivers.
API: https://hubeau.eaufrance.fr/api/v1/hydrometrie/observations_tr
Open, no API key required.
"""
from __future__ import annotations
import json
import logging
import urllib.parse
import urllib.request
from typing import Any
from .base import Feed
LOG = logging.getLogger("data_feeds.hubeau")
BASE = "https://hubeau.eaufrance.fr/api/v1/hydrometrie/observations_tr"
class HubeauFeed(Feed):
name = "hubeau"
interval_sec = 300.0
def fetch(self) -> Any:
codes = self.cfg.get("codes") or ["F050001001"]
out: dict[str, dict[str, float]] = {}
for code in codes:
params = {
"code_entite": code,
"size": 1,
"sort": "desc",
"fields": "code_station,grandeur_hydro,resultat_obs,date_obs",
}
url = BASE + "?" + urllib.parse.urlencode(params)
try:
req = urllib.request.Request(
url, headers={"User-Agent": "av-live/0.1"})
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read().decode("utf-8"))
except Exception as e: # noqa: BLE001
LOG.warning("hubeau %s failed: %s", code, e)
continue
for obs in data.get("data") or []:
station = obs.get("code_station") or code
gr = obs.get("grandeur_hydro") or "X"
v = obs.get("resultat_obs")
if v is None:
continue
try:
fv = float(v)
except (TypeError, ValueError):
continue
out.setdefault(station, {})[gr] = fv
return out
def publish(self, payload: Any) -> None:
if not isinstance(payload, dict) or not payload:
return
count = 0
for station, vals in payload.items():
for gr, v in vals.items():
key = f"{station}_{gr}"
self.osc_send(f"/data/{self.name}/sample", [key, float(v)])
count += 1
self.osc_send(f"/data/{self.name}/count", [count])
+22
View File
@@ -0,0 +1,22 @@
"""PRIM Ile-de-France Mobilites feed — STUB.
TODO: needs API key (https://prim.iledefrance-mobilites.fr/).
"""
from __future__ import annotations
import logging
from .base import Feed
LOG = logging.getLogger("data_feeds.prim")
class PRIMFeed(Feed):
name = "prim"
interval_sec = 60.0
def fetch(self):
return None
def publish(self, payload) -> None:
LOG.info("stub")
+22
View File
@@ -0,0 +1,22 @@
"""Sytadin Paris traffic feed — STUB.
TODO: needs sytadin.fr scraping / cumulative km of congestion.
"""
from __future__ import annotations
import logging
from .base import Feed
LOG = logging.getLogger("data_feeds.sytadin")
class SytadinFeed(Feed):
name = "sytadin"
interval_sec = 300.0
def fetch(self):
return None
def publish(self, payload) -> None:
LOG.info("stub")
+22
View File
@@ -0,0 +1,22 @@
"""IRSN Teleray radiation feed — STUB.
TODO: needs https://teleray.irsn.fr/data endpoint research.
"""
from __future__ import annotations
import logging
from .base import Feed
LOG = logging.getLogger("data_feeds.teleray")
class TelerayFeed(Feed):
name = "teleray"
interval_sec = 600.0
def fetch(self):
return None
def publish(self, payload) -> None:
LOG.info("stub")
+25
View File
@@ -0,0 +1,25 @@
"""Velib Metropole feed — specialization of GBFS against the Paris URL."""
from __future__ import annotations
import logging
from .gbfs import GBFSFeed
LOG = logging.getLogger("data_feeds.velib")
VELIB_URL = (
"https://velib-metropole-opendata.smoove.pro/opendata/"
"Velib_Metropole/station_status.json"
)
class VelibFeed(GBFSFeed):
name = "velib"
interval_sec = 120.0
def configure(self, **kwargs) -> None:
# Force the URL if caller did not provide one.
kwargs.setdefault("url", VELIB_URL)
super().configure(**kwargs)
if not self.cfg.get("url"):
self.cfg["url"] = VELIB_URL
+57
View File
@@ -0,0 +1,57 @@
"""Run all enabled feeds, publish OSC to AVLiveBody."""
from __future__ import annotations
import argparse
import logging
import sys
import time
import tomllib
from pathlib import Path
from .feeds import REGISTRY
from .osc_sender import OscSender
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser()
p.add_argument("--config", default="data_feeds/config.avlivedata.toml")
p.add_argument("--osc-host")
p.add_argument("--osc-port", type=int)
p.add_argument("-v", "--verbose", action="store_true")
args = p.parse_args(argv)
logging.basicConfig(
level=logging.INFO if args.verbose else logging.WARNING,
format="%(asctime)s %(levelname)s %(name)s %(message)s")
cfg = tomllib.loads(Path(args.config).read_text())
osc_cfg = cfg.get("osc", {})
host = args.osc_host or osc_cfg.get("host", "127.0.0.1")
port = args.osc_port or osc_cfg.get("port", 57127)
sender = OscSender(host, port)
feeds = []
for name, kwargs in (cfg.get("feeds") or {}).items():
if not kwargs.get("enabled", False):
continue
cls = REGISTRY.get(name)
if cls is None:
logging.warning("Unknown feed: %s", name)
continue
f = cls(sender.send)
f.configure(**kwargs)
f.start()
feeds.append(f)
logging.info("started feed %s (interval %.0fs)", name, f.interval_sec)
if not feeds:
logging.warning("No feeds enabled. Exiting.")
return 1
try:
while True:
time.sleep(60)
except KeyboardInterrupt:
return 0
finally:
for f in feeds:
f.stop()
if __name__ == "__main__":
sys.exit(main())
+22
View File
@@ -0,0 +1,22 @@
"""Wrapper around python-osc SimpleUDPClient with per-route helpers."""
from __future__ import annotations
import logging
from typing import Any
from pythonosc.udp_client import SimpleUDPClient
LOG = logging.getLogger("data_feeds.osc")
class OscSender:
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
self._client = SimpleUDPClient(host, port)
def send(self, addr: str, args: list[Any]) -> None:
try:
self._client.send_message(addr, args)
except OSError as e:
LOG.warning("send %s failed: %s", addr, e)
+6
View File
@@ -31,6 +31,12 @@ Python **3.11+** requis. `pyproject.toml` est la source de vérité — ne jamai
- État partagé multi-thread : `state.py` expose `State.lock()` — toujours mutationner sous lock.
- Filtrage temporel : `euro_filter.py` (One Euro Filter) sur les keypoints avant tracker.
- ARKit fusion : `iphone_osc_listener.py` consume /body3d/kp UDP :57128
`state.persons_arkit_joints`. `pose_filter.py::ArkitFuse` (stage
`arkit_fuse`) splices the 14 mapped body slots into MediaPipe pose
before kalman ; `multi_hmr_worker::arkit_pelvis_z_override` locks the
SMPL-X cam translation z to the ARKit pelvis. Mapping in
`arkit_joint_map.py`.
- Association multi-personne : `tracker.py` IoU-based, `scipy.optimize.linear_sum_assignment`.
- Shaders Metal dans `shaders/` (`.metal`), recompilés au runtime ; topologie mesh (SMPL faces) en binaire dans `mesh_topology.py`.
- OSC out : `osc_listener.py` / `pose_bridge.py` — destination `oscope-of` sur `:57123`.
+45
View File
@@ -0,0 +1,45 @@
"""ARKit ARSkeleton3D 91-joint indices → MediaPipe Pose 33 indices.
The ARKit ARSkeleton.JointName enum (Apple SDK) orders 91 joints
starting with the root, hips, spine chain, shoulders, etc. We pick
only the joints with a clear 1:1 anatomical correspondence to the
MediaPipe Pose 33 landmark set (which is what AVLiveBody renders).
Face/hand sub-joints (fingers, eyes) are skipped — those keep their
existing data sources (MediaPipe Face/Hand + HaMeR MANO).
Reference for ARKit joint order : Apple developer docs
"ARSkeleton.JointName" — the canonical 91-joint list runs from
root_joint=0 down to right_handThumbEndJoint=90.
The selection here mirrors `multi.py::SMPLX_TO_MP33` so the same 14
body slots are overridden by ARKit when fresh. Confidence comes
from ARKit's tracking state but is not currently fanned out — we
trust ARKit body tracking when its OSC frame is present.
"""
from __future__ import annotations
# MediaPipe Pose 33 cardinality (cf. mediapipe pose_world_landmarks).
MP33_NUM_LANDMARKS = 33
# Pelvis = ARKit hips_joint, slot 1 in the canonical enum order.
# Used by multi_hmr_worker for cam-translation z lock.
ARKIT_PELVIS_IDX = 1
# (arkit_joint_idx, mediapipe_pose_idx). Match the body slots used
# by the SMPL-X body fusion in multi.py.
ARKIT91_TO_MP33: tuple[tuple[int, int], ...] = (
(50, 11), # left_shoulder_1_joint -> L_SHOULDER
(32, 12), # right_shoulder_1_joint -> R_SHOULDER
(53, 13), # left_arm_joint -> L_ELBOW
(35, 14), # right_arm_joint -> R_ELBOW
(54, 15), # left_forearm_joint -> L_WRIST
(36, 16), # right_forearm_joint -> R_WRIST
(62, 23), # left_upLeg_joint -> L_HIP
(57, 24), # right_upLeg_joint -> R_HIP
(63, 25), # left_leg_joint -> L_KNEE
(58, 26), # right_leg_joint -> R_KNEE
(64, 27), # left_foot_joint -> L_ANKLE
(59, 28), # right_foot_joint -> R_ANKLE
(65, 31), # left_toes_joint -> L_FOOT_INDEX
(60, 32), # right_toes_joint -> R_FOOT_INDEX
)
+204
View File
@@ -0,0 +1,204 @@
"""DINOv2 ViT-S/14 person re-id backend (CoreML via pyobjc).
Loads the .mlpackage produced by ``scripts/convert_dinov2.py`` and runs
inference one crop at a time (pyobjc + MLDictionaryFeatureProvider).
Same pattern as ``multihmr_coreml.py`` so Python 3.14 works (no
coremltools dependency at runtime).
Embeddings are L2-normalised inside the CoreML graph, so cosine sim
between two outputs is a plain dot product.
Public API::
reid = DinoReid(mlpackage_path) # optional path
emb = reid.embed_crops(list_of_uint8_HWC) # -> np.ndarray (N, 384)
DinoReid.is_available() # bool
"""
from __future__ import annotations
import logging
import time
from pathlib import Path
from typing import Sequence
import numpy as np
LOG = logging.getLogger("dino_reid")
DEFAULT_MLPACKAGE = (
Path.home() / ".cache" / "av-live-multihmr" / "dinov2_vits14.mlpackage"
)
EMBED_DIM = 384
INPUT_SIZE = 224
# MLMultiArrayDataType raw values (from CoreML headers).
ML_DTYPE_FLOAT32 = 65568
ML_DTYPE_FLOAT16 = 65552
ML_DTYPE_DOUBLE = 65600
def _resize_crop(crop_uint8: np.ndarray) -> np.ndarray:
"""Resize an HxWx3 uint8 crop to (3, 224, 224) float32 in [0, 1].
Uses ``cv2.resize`` when available, falls back to a simple stride
sampler otherwise (avoids hard cv2 dep in test envs)."""
if crop_uint8.ndim != 3 or crop_uint8.shape[2] != 3:
raise ValueError(f"crop must be HxWx3 uint8, got {crop_uint8.shape}")
if crop_uint8.shape[0] == INPUT_SIZE and crop_uint8.shape[1] == INPUT_SIZE:
rgb = crop_uint8
else:
try:
import cv2
rgb = cv2.resize(crop_uint8, (INPUT_SIZE, INPUT_SIZE),
interpolation=cv2.INTER_AREA)
except ImportError:
h, w = crop_uint8.shape[:2]
ys = (np.linspace(0, h - 1, INPUT_SIZE)).astype(np.int32)
xs = (np.linspace(0, w - 1, INPUT_SIZE)).astype(np.int32)
rgb = crop_uint8[ys][:, xs]
return (rgb.astype(np.float32) / 255.0).transpose(2, 0, 1)
class DinoReid:
"""Forward DINOv2 ViT-S/14 over RGB crops, return L2-normalised
embeddings (N, 384)."""
def __init__(self, mlpackage_path: Path | str | 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}")
import objc
from Foundation import NSURL
self._objc = objc
self._NSURL = NSURL
ns: dict = {}
objc.loadBundle("CoreML", ns,
"/System/Library/Frameworks/CoreML.framework")
self._ns = ns
MLModel = ns["MLModel"]
MLModelConfiguration = ns["MLModelConfiguration"]
cfg = MLModelConfiguration.alloc().init()
try:
# 2 = MLComputeUnitsAll (CPU+GPU+ANE). DINOv2 ViT-S/14
# converts cleanly and ANE serves it well.
cfg.setComputeUnits_(2)
except Exception: # noqa: BLE001
pass
url = NSURL.fileURLWithPath_(str(self.path))
compiled = MLModel.compileModelAtURL_error_(url, None)
if compiled is None:
raise RuntimeError(f"compile failed for {self.path}")
model = MLModel.modelWithContentsOfURL_configuration_error_(
compiled, cfg, None)
if model is None:
raise RuntimeError(f"load failed for {compiled}")
self._model = model
# Discover the output feature name (single tensor).
desc = model.modelDescription()
out_names = [str(n) for n in desc.outputDescriptionsByName().keys()]
self._out_name = out_names[0] if out_names else "embedding"
LOG.info("dino_reid loaded (%s, out=%s)", self.path.name,
self._out_name)
@classmethod
def is_available(cls, mlpackage_path: Path | str | None = None) -> bool:
p = Path(mlpackage_path) if mlpackage_path else DEFAULT_MLPACKAGE
if not p.exists():
return False
try:
import objc # noqa: F401
from Foundation import NSURL # noqa: F401
return True
except Exception: # noqa: BLE001
return False
# ------------------------------------------------------------------
# MLMultiArray plumbing — mirrors multihmr_coreml._np_to_mlarray /
# _mlarray_to_np. Float32 in, float32-or-float16 out.
# ------------------------------------------------------------------
def _np_to_mlarray(self, arr: np.ndarray):
import ctypes
MLMultiArray = self._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")
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("dataPointer null")
ctypes.memmove(addr, arr.ctypes.data, arr.nbytes)
return ml
def _mlarray_to_np(self, ml) -> np.ndarray:
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("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 dtype {dtype_id}")
return arr.reshape(shape)
def _predict_one(self, image_chw: np.ndarray) -> np.ndarray:
MLDictionaryFeatureProvider = self._ns["MLDictionaryFeatureProvider"]
MLFeatureValue = self._ns["MLFeatureValue"]
x4 = image_chw[np.newaxis, ...] if image_chw.ndim == 3 else image_chw
img_ml = self._np_to_mlarray(x4)
feats = {"image": MLFeatureValue.featureValueWithMultiArray_(img_ml)}
provider = MLDictionaryFeatureProvider.alloc(
).initWithDictionary_error_(feats, None)
if provider is None:
raise RuntimeError("provider alloc failed")
out = self._model.predictionFromFeatures_error_(provider, None)
if out is None:
raise RuntimeError("predict failed")
fv = out.featureValueForName_(self._out_name)
ml = fv.multiArrayValue()
return self._mlarray_to_np(ml).reshape(-1)
def embed_crops(
self, crops_uint8: Sequence[np.ndarray],
) -> np.ndarray:
"""Embed a list of HxWx3 uint8 RGB crops -> (N, 384) float32.
Loops one crop at a time (the CoreML model is traced for B=1).
For typical N <= 4 this is still 10-15 ms total on M5."""
if not crops_uint8:
return np.zeros((0, EMBED_DIM), dtype=np.float32)
t0 = time.perf_counter()
out = np.zeros((len(crops_uint8), EMBED_DIM), dtype=np.float32)
for i, c in enumerate(crops_uint8):
chw = _resize_crop(c)
out[i] = self._predict_one(chw)
dt_ms = (time.perf_counter() - t0) * 1e3
if LOG.isEnabledFor(logging.DEBUG) or dt_ms > 50.0:
LOG.log(
logging.DEBUG if dt_ms <= 50.0 else logging.INFO,
"embedded %d crops in %.1f ms", len(crops_uint8), dt_ms)
return out
+215
View File
@@ -0,0 +1,215 @@
"""ICP fusion between Multi-HMR SMPL-X meshes and iPhone LiDAR point clouds.
All operations happen in the **webcam camera frame** (meters, OpenCV
convention: +X right, +Y down, +Z forward). LiDAR points must be
pre-transformed via `Extrinsic.T_arkit_to_cam`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
import numpy as np
try:
import open3d as o3d
except ImportError: # pragma: no cover - exercised via skipif at import sites
o3d = None # type: ignore[assignment]
_LOG = logging.getLogger(__name__)
MIN_LIDAR_POINTS = 200
MIN_FITNESS = 0.30
MAX_RMSE_M = 0.05
CROP_MARGIN_M = 0.30
@dataclass
class IcpConfig:
voxel_size_m: float = 0.02
max_correspondence_m: float = 0.05
max_iterations: int = 30
@dataclass
class IcpResult:
vertices_registered: np.ndarray
accepted: bool
fitness: float
rmse_m: float
iterations: int
def register_mesh_to_lidar(
smplx_verts_cam: np.ndarray,
lidar_points_cam: np.ndarray,
config: IcpConfig | None = None,
) -> IcpResult:
"""Register SMPL-X verts onto a cropped LiDAR neighborhood."""
if o3d is None:
raise RuntimeError("open3d not installed — install with `uv sync --extra lidar`")
cfg = config or IcpConfig()
src = np.ascontiguousarray(smplx_verts_cam, dtype=np.float32)
if not np.isfinite(src).all():
_LOG.debug("ICP rejected: NaN/Inf in SMPL-X verts")
return IcpResult(src, False, 0.0, float("inf"), 0)
lidar = _crop_to_bbox(lidar_points_cam, src, margin_m=CROP_MARGIN_M)
if lidar.shape[0] < MIN_LIDAR_POINTS or not np.isfinite(lidar).all():
_LOG.debug("ICP rejected: insufficient LiDAR points (%d)", lidar.shape[0])
return IcpResult(src, False, 0.0, float("inf"), 0)
src_pcd = _to_pcd(src, cfg.voxel_size_m, estimate_normals=True)
tgt_pcd = _to_pcd(lidar, cfg.voxel_size_m, estimate_normals=True)
if len(src_pcd.points) < 10 or len(tgt_pcd.points) < 10:
return IcpResult(src, False, 0.0, float("inf"), 0)
criteria = o3d.pipelines.registration.ICPConvergenceCriteria(
max_iteration=cfg.max_iterations,
relative_fitness=1e-6,
relative_rmse=1e-6,
)
# Coarse-to-fine: a wide first pass handles translations larger than the
# final correspondence threshold, then the strict pass refines and gates.
coarse = o3d.pipelines.registration.registration_icp(
src_pcd, tgt_pcd, max(cfg.max_correspondence_m * 5.0, 0.20),
np.eye(4),
o3d.pipelines.registration.TransformationEstimationPointToPlane(),
criteria,
)
result = o3d.pipelines.registration.registration_icp(
src_pcd, tgt_pcd, cfg.max_correspondence_m,
coarse.transformation,
o3d.pipelines.registration.TransformationEstimationPointToPlane(),
criteria,
)
accepted = (result.fitness >= MIN_FITNESS) and (result.inlier_rmse <= MAX_RMSE_M)
if not accepted:
_LOG.debug("ICP rejected: fitness=%.3f rmse=%.4f", result.fitness, result.inlier_rmse)
return IcpResult(src, False, float(result.fitness), float(result.inlier_rmse), 0)
T = np.asarray(result.transformation, dtype=np.float32)
homog = np.concatenate([src, np.ones((src.shape[0], 1), dtype=np.float32)], axis=1)
fused = (homog @ T.T)[:, :3]
if not np.isfinite(fused).all():
return IcpResult(src, False, float(result.fitness), float(result.inlier_rmse), 0)
return IcpResult(
vertices_registered=np.ascontiguousarray(fused, dtype=np.float32),
accepted=True,
fitness=float(result.fitness),
rmse_m=float(result.inlier_rmse),
iterations=cfg.max_iterations,
)
def _crop_to_bbox(points: np.ndarray, anchor: np.ndarray, margin_m: float) -> np.ndarray:
if points.size == 0:
return points.astype(np.float32, copy=False)
lo = anchor.min(axis=0) - margin_m
hi = anchor.max(axis=0) + margin_m
mask = np.all((points >= lo) & (points <= hi), axis=1)
return points[mask].astype(np.float32, copy=False)
def _to_pcd(points: np.ndarray, voxel_size_m: float, estimate_normals: bool):
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points.astype(np.float64, copy=False))
if voxel_size_m > 0:
pcd = pcd.voxel_down_sample(voxel_size_m)
if estimate_normals:
pcd.estimate_normals(
search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=voxel_size_m * 2, max_nn=30),
)
return pcd
def partition_lidar_by_pid(
lidar_points_cam: np.ndarray,
pelvises: dict[int, np.ndarray],
max_dist_m: float = 1.0,
) -> dict[int, np.ndarray]:
"""Assign each LiDAR point to the closest pelvis within ``max_dist_m``.
Points beyond ``max_dist_m`` from every pelvis (background, furniture)
are dropped. Returns ``{pid: (M, 3) float32}`` — pids with zero assigned
points are omitted.
"""
if not pelvises or lidar_points_cam.size == 0:
return {}
pids = list(pelvises.keys())
centers = np.stack([pelvises[p] for p in pids]).astype(np.float32)
pts = np.ascontiguousarray(lidar_points_cam, dtype=np.float32)
diff = pts[:, None, :] - centers[None, :, :]
d2 = np.einsum("npk,npk->np", diff, diff)
nearest = d2.argmin(axis=1)
nearest_d = np.sqrt(d2[np.arange(d2.shape[0]), nearest])
mask = nearest_d <= max_dist_m
out: dict[int, np.ndarray] = {}
for idx, pid in enumerate(pids):
sel = mask & (nearest == idx)
if not sel.any():
continue
out[pid] = pts[sel]
return out
PELVIS_VERT_INDEX = 5559 # SMPL-X canonical pelvis vertex
@dataclass
class FusionMetadata:
applied: set[int]
fitness: dict[int, float]
rmse_m: dict[int, float]
n_lidar_points_used: int
class FusionWorker:
"""Per-frame ICP fusion orchestrator (caller-driven, no internal thread)."""
def __init__(self, extrinsic, config: IcpConfig | None = None) -> None:
self._extrinsic = extrinsic
self._config = config or IcpConfig()
def set_extrinsic(self, extrinsic) -> None:
self._extrinsic = extrinsic
def run_once(self, state) -> FusionMetadata:
applied: set[int] = set()
fitness: dict[int, float] = {}
rmse: dict[int, float] = {}
lidar = getattr(state, "lidar_points", None)
if lidar is None or getattr(lidar, "size", 0) == 0 or not state.persons_smplx:
return FusionMetadata(applied, fitness, rmse, 0)
T = np.asarray(self._extrinsic.T_arkit_to_cam, dtype=np.float32)
homog = np.concatenate([lidar, np.ones((lidar.shape[0], 1), dtype=np.float32)], axis=1)
lidar_cam = (homog @ T.T)[:, :3]
pelvises = {
p.pid: p.vertices_3d[PELVIS_VERT_INDEX]
for p in state.persons_smplx
if p.vertices_3d is not None
}
parts = partition_lidar_by_pid(lidar_cam, pelvises, max_dist_m=1.0)
for person in state.persons_smplx:
pts = parts.get(person.pid)
if pts is None:
continue
result = register_mesh_to_lidar(person.vertices_3d, pts, self._config)
fitness[person.pid] = result.fitness
rmse[person.pid] = result.rmse_m
if result.accepted:
person.vertices_3d = result.vertices_registered
applied.add(person.pid)
return FusionMetadata(applied, fitness, rmse, lidar_cam.shape[0])
+72
View File
@@ -0,0 +1,72 @@
"""Threaded wrapper that polls State and calls FusionWorker.run_once.
ICP fusion runs as a background thread parallel to the autonomous
Multi-HMR worker. It pulls the latest LiDAR frame from a
LidarTCPReader, stages it into State, and applies in-place ICP
registration to ``state.persons_smplx[*].vertices_3d``.
Opt-in via ``ICP_FUSION=1`` from main.py.
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Optional
from .icp_fusion import FusionWorker, IcpConfig
from .lidar_calib import load_extrinsic
from .lidar_receiver import LidarTCPReader
_LOG = logging.getLogger(__name__)
class IcpFusionThread:
"""Background thread: pull LiDAR frames, run FusionWorker on state."""
def __init__(self, state, host: str, port: int,
target_hz: float = 8.0) -> None:
self._state = state
self._reader = LidarTCPReader(host=host, port=port)
self._worker = FusionWorker(extrinsic=load_extrinsic(),
config=IcpConfig())
self._period_s = 1.0 / max(target_hz, 0.5)
self._stop = threading.Event()
self._thread: Optional[threading.Thread] = None
def start(self) -> None:
if self._thread is not None:
return
self._reader.start()
self._thread = threading.Thread(
target=self._run, name="icp-fusion", daemon=True)
self._thread.start()
_LOG.info("icp-fusion thread started")
def stop(self) -> None:
self._stop.set()
self._reader.stop()
if self._thread is not None:
self._thread.join(timeout=2.0)
self._thread = None
def _run(self) -> None:
while not self._stop.is_set():
t0 = time.monotonic()
frame = self._reader.latest()
if frame is not None and self._state.persons_smplx:
# State doesn't expose a fine-grained lock for these
# fields here; rely on FusionWorker.run_once being
# write-only on persons_smplx[*].vertices_3d (replace in
# place) and the readers being tolerant of mid-update.
self._state.lidar_points = frame.points
self._state.lidar_timestamp_ns = frame.timestamp_ns
try:
self._state.icp_metadata = self._worker.run_once(
self._state)
except Exception as exc: # noqa: BLE001
_LOG.warning("icp fusion failed: %s", exc)
self._state.icp_metadata = None
elapsed = time.monotonic() - t0
if self._stop.wait(max(0.0, self._period_s - elapsed)):
return
+118
View File
@@ -0,0 +1,118 @@
"""OSC UDP listener for the iOS ARBodyTracker app.
Subscribes to /body3d/kp on UDP :57128 (distinct from MediaPipe
output :57126). Each /body3d/kp pid joint_idx x y z message stores
one joint of ARKit's 91-joint ARSkeleton3D into
state.persons_arkit_joints[pid] (np.ndarray shape (91, 3), float32).
A background GC drops pids whose last_t is older than 1.0 s.
Worker pattern mirrors osc_listener.OscListener.
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any
import numpy as np
from pythonosc import dispatcher, osc_server
from .state import State
LOG = logging.getLogger("iphone_osc")
IPHONE_OSC_PORT = 57128
ARKIT_NUM_JOINTS = 91
STALE_SEC = 1.0
class IphoneOSCListener:
def __init__(self, state: State, host: str = "0.0.0.0",
port: int = IPHONE_OSC_PORT) -> None:
self.state = state
self.host = host
self.port = port
self._server: osc_server.ThreadingOSCUDPServer | None = None
self._server_thread: threading.Thread | None = None
self._gc_thread: threading.Thread | None = None
self._stop = threading.Event()
self._last_hb: float = 0.0
def start(self) -> None:
d = dispatcher.Dispatcher()
d.map("/body3d/kp", self._on_kp)
d.map("/body3d/count", self._on_count)
self._server = osc_server.ThreadingOSCUDPServer(
(self.host, self.port), d)
self._server_thread = threading.Thread(
target=self._server.serve_forever,
name="iphone_osc", daemon=True)
self._server_thread.start()
self._gc_thread = threading.Thread(
target=self._gc_loop, name="iphone_gc", daemon=True)
self._gc_thread.start()
LOG.info("iphone OSC listening on %s:%d", self.host, self.port)
def stop(self) -> None:
self._stop.set()
if self._server is not None:
self._server.shutdown()
self._server.server_close()
self._server = None
if self._server_thread is not None:
self._server_thread.join(timeout=2.0)
self._server_thread = None
if self._gc_thread is not None:
self._gc_thread.join(timeout=2.0)
self._gc_thread = None
def _on_kp(self, _addr: str, *args: Any) -> None:
if len(args) < 5:
return
try:
pid = int(args[0])
joint_idx = int(args[1])
x = float(args[2])
y = float(args[3])
z = float(args[4])
except (TypeError, ValueError):
return
if not (0 <= joint_idx < ARKIT_NUM_JOINTS):
return
with self.state.lock():
arr = self.state.persons_arkit_joints.get(pid)
if arr is None or arr.shape != (ARKIT_NUM_JOINTS, 3):
arr = np.zeros((ARKIT_NUM_JOINTS, 3), dtype=np.float32)
self.state.persons_arkit_joints[pid] = arr
arr[joint_idx] = (x, y, z)
self.state.persons_arkit_last_t[pid] = time.perf_counter()
def _on_count(self, _addr: str, *args: Any) -> None:
# Optional : we currently don't gate on count, but parse for log.
if not args:
return
try:
n = int(args[0])
except (TypeError, ValueError):
return
now = time.monotonic()
if now - self._last_hb > 5.0:
self._last_hb = now
LOG.info("hb: %d ARKit bodies live", n)
def _gc_stale(self) -> None:
cutoff = time.perf_counter() - STALE_SEC
with self.state.lock():
drop = [
pid for pid, t in self.state.persons_arkit_last_t.items()
if t < cutoff
]
for pid in drop:
self.state.persons_arkit_joints.pop(pid, None)
self.state.persons_arkit_last_t.pop(pid, None)
def _gc_loop(self) -> None:
while not self._stop.is_set():
self._gc_stale()
time.sleep(0.5)
+83
View File
@@ -0,0 +1,83 @@
"""iPhone LiDAR (ARKit world) <-> webcam (Multi-HMR camera) extrinsic.
Persisted as a small JSON document so calibration survives across launches.
The default location is ``~/.config/av-live/lidar_extrinsic.json``; override
with the ``ICP_LIDAR_EXTRINSIC`` env var.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
DEFAULT_EXTRINSIC_PATH = Path.home() / ".config" / "av-live" / "lidar_extrinsic.json"
@dataclass
class Extrinsic:
"""4x4 rigid transform from ARKit world frame to Multi-HMR camera frame."""
T_arkit_to_cam: np.ndarray = field(default_factory=lambda: np.eye(4))
confidence: float = 0.0
captured_at_iso: str = ""
@staticmethod
def identity() -> "Extrinsic":
return Extrinsic(T_arkit_to_cam=np.eye(4), confidence=0.0, captured_at_iso="")
def save_extrinsic(e: Extrinsic, path: Path | None = None) -> Path:
path = Path(path) if path is not None else _path_from_env()
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"T_arkit_to_cam": e.T_arkit_to_cam.astype(float).tolist(),
"confidence": float(e.confidence),
"captured_at_iso": e.captured_at_iso,
}
path.write_text(json.dumps(payload, indent=2))
return path
def load_extrinsic(path: Path | None = None) -> Extrinsic:
path = Path(path) if path is not None else _path_from_env()
if not path.exists():
return Extrinsic.identity()
payload = json.loads(path.read_text())
return Extrinsic(
T_arkit_to_cam=np.array(payload["T_arkit_to_cam"], dtype=np.float64),
confidence=float(payload.get("confidence", 0.0)),
captured_at_iso=str(payload.get("captured_at_iso", "")),
)
def _path_from_env() -> Path:
p = os.environ.get("ICP_LIDAR_EXTRINSIC")
return Path(p) if p else DEFAULT_EXTRINSIC_PATH
def kabsch_rigid(src: np.ndarray, tgt: np.ndarray) -> np.ndarray:
"""Closed-form rigid alignment (Kabsch via SVD).
Returns a 4x4 transform T such that ``tgt ≈ (src @ R.T) + t``.
"""
src = np.asarray(src, dtype=np.float64)
tgt = np.asarray(tgt, dtype=np.float64)
if src.shape != tgt.shape:
raise ValueError(f"shape mismatch: src={src.shape} tgt={tgt.shape}")
if src.shape[0] < 3 or src.shape[1] != 3:
raise ValueError("kabsch_rigid needs at least 3 paired 3D points")
src_c = src.mean(axis=0)
tgt_c = tgt.mean(axis=0)
H = (src - src_c).T @ (tgt - tgt_c)
U, _, Vt = np.linalg.svd(H)
d = np.linalg.det(Vt.T @ U.T)
D = np.diag([1.0, 1.0, np.sign(d)])
R = Vt.T @ D @ U.T
t = tgt_c - R @ src_c
T = np.eye(4)
T[:3, :3] = R
T[:3, 3] = t
return T
+130
View File
@@ -0,0 +1,130 @@
"""TCP receiver for iPhone ARBodyTracker LiDAR ARMeshAnchor stream.
Wire format (per frame, after the 4-byte big-endian length prefix consumed
by the socket reader):
[uint64 BE timestamp_ns]
[uint32 BE vertex_count]
[float32 LE x y z] * vertex_count
The decoder is pure and side-effect-free so it can be unit-tested without a
socket. The socket reader lives in a separate class (LidarTCPReader) so its
threading model is independently testable.
"""
from __future__ import annotations
import struct
from dataclasses import dataclass
import numpy as np
_HEADER = struct.Struct(">QI") # timestamp_ns, vertex_count
@dataclass(frozen=True)
class LidarFrame:
"""One decoded LiDAR frame from the iPhone."""
timestamp_ns: int
points: np.ndarray # shape (N, 3), float32, ARKit world frame (meters)
def decode_frame(body: bytes) -> LidarFrame:
"""Decode a frame body (length prefix already stripped)."""
if len(body) < _HEADER.size:
raise ValueError(f"truncated frame: header needs {_HEADER.size} bytes, got {len(body)}")
timestamp_ns, vertex_count = _HEADER.unpack_from(body, 0)
if vertex_count == 0:
raise ValueError("vertex_count must be > 0")
expected = _HEADER.size + vertex_count * 12
if len(body) < expected:
raise ValueError(f"truncated frame: need {expected} bytes for {vertex_count} verts, got {len(body)}")
raw = body[_HEADER.size : expected]
pts = np.frombuffer(raw, dtype="<f4").reshape(vertex_count, 3).astype(np.float32, copy=True)
return LidarFrame(timestamp_ns=int(timestamp_ns), points=pts)
import logging
import socket
import threading
from typing import Optional
_LOG = logging.getLogger(__name__)
_LEN_PREFIX = struct.Struct(">I")
class LidarTCPReader:
"""Background TCP reader producing a single-slot latest-frame mailbox.
Reconnects on transient failures with linear backoff up to 5s.
"""
def __init__(self, host: str, port: int, connect_timeout_s: float = 2.0) -> None:
self._host = host
self._port = port
self._connect_timeout_s = connect_timeout_s
self._stop = threading.Event()
self._lock = threading.Lock()
self._latest: Optional[LidarFrame] = None
self._thread: Optional[threading.Thread] = None
def start(self) -> None:
if self._thread is not None:
return
self._thread = threading.Thread(target=self._run, name="lidar-tcp", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=2.0)
self._thread = None
def latest(self) -> Optional[LidarFrame]:
with self._lock:
return self._latest
def _run(self) -> None:
backoff_s = 0.5
while not self._stop.is_set():
try:
with socket.create_connection((self._host, self._port), timeout=self._connect_timeout_s) as sock:
sock.settimeout(1.0)
backoff_s = 0.5
self._read_loop(sock)
except (OSError, ValueError) as exc:
_LOG.warning("lidar reader: %s; reconnecting in %.1fs", exc, backoff_s)
if self._stop.wait(backoff_s):
return
backoff_s = min(backoff_s * 2.0, 5.0)
def _read_loop(self, sock: socket.socket) -> None:
while not self._stop.is_set():
header = self._recv_exact(sock, _LEN_PREFIX.size)
if header is None:
return
(length,) = _LEN_PREFIX.unpack(header)
if length <= 0 or length > 8_000_000: # sanity cap: 8 MB per frame
raise ValueError(f"implausible frame length {length}")
body = self._recv_exact(sock, length)
if body is None:
return
frame = decode_frame(body)
with self._lock:
self._latest = frame
def _recv_exact(self, sock: socket.socket, n: int) -> Optional[bytes]:
buf = bytearray(n)
view = memoryview(buf)
got = 0
while got < n:
if self._stop.is_set():
return None
try:
k = sock.recv_into(view[got:])
except socket.timeout:
continue
if k == 0:
return None
got += k
return bytes(buf)
+40
View File
@@ -249,6 +249,40 @@ class AppDelegate(NSObject):
# 2. Apple Vision body pose (fallback si MediaPipe casse)
# 3. CoreML pose, DETRPose, Holistic, YOLO — fallbacks
import os as _os
# iPhone ARBodyTracker (option 2 LiDAR fusion) : always-on
# listener on :57128. Harmless if no iPhone is broadcasting ;
# state.persons_arkit_joints stays empty and the arkit_fuse
# stage no-ops. Activated via POSE_FILTER=...+arkit_fuse.
try:
from .iphone_osc_listener import IphoneOSCListener
self._iphone_osc = IphoneOSCListener(self._state)
self._iphone_osc.start()
LOG.info("worker: + iPhone OSC listener :57128")
except Exception as e: # noqa: BLE001
LOG.warning("iphone OSC listener start failed (%s)", e)
# ICP LiDAR fusion (opt-in via ICP_FUSION=1). Parallel to the
# ARKit pelvis fuse: ICP operates on SMPL-X dense vertices, not
# joints. Requires a calibrated extrinsic on disk (see
# scripts/calibrate_lidar.py) and an iPhone LiDAR stream
# broadcasting on ICP_LIDAR_HOST:ICP_LIDAR_PORT.
if _os.environ.get("ICP_FUSION", "0") == "1":
host = _os.environ.get("ICP_LIDAR_HOST")
if not host:
LOG.warning("ICP_FUSION=1 but ICP_LIDAR_HOST unset — "
"fusion disabled")
else:
try:
from .icp_fusion_worker import IcpFusionThread
self._icp_fusion = IcpFusionThread(
self._state,
host=host,
port=int(_os.environ.get("ICP_LIDAR_PORT", "5500")),
)
self._icp_fusion.start()
LOG.info("worker: + ICP LiDAR fusion -> %s:%s", host,
_os.environ.get("ICP_LIDAR_PORT", "5500"))
except Exception as e: # noqa: BLE001
LOG.warning("icp fusion start failed (%s)", e)
# 0. Multi-HMR (SMPL-X 10475 verts mesh dense) — opt-in via flag
if getattr(self._opts, "multi_hmr", False):
try:
@@ -585,6 +619,12 @@ class AppDelegate(NSObject):
self._listener.stop()
if self._pose_worker is not None:
self._pose_worker.stop()
icp = getattr(self, "_icp_fusion", None)
if icp is not None:
try:
icp.stop()
except Exception as e: # noqa: BLE001
LOG.warning("icp fusion stop failed (%s)", e)
LOG.info("bye")
+221 -1
View File
@@ -14,6 +14,8 @@ Limitations connues (premiere iteration) :
"""
from __future__ import annotations
import collections
import logging
import math
import threading
import time
@@ -21,8 +23,16 @@ from dataclasses import dataclass, field
import numpy as np
try:
from scipy.optimize import linear_sum_assignment
_HAVE_SCIPY = True
except ImportError: # noqa: BLE001
_HAVE_SCIPY = False
from .state import PoseKp, SMPLXPerson, State
LOG = logging.getLogger("mesh_rigger")
# Indices MediaPipe POSE_LANDMARKS pour les hanches (pelvis 2D = midpoint).
_LEFT_HIP = 23
@@ -55,6 +65,70 @@ def _pelvis_2d_from_body(body: list[PoseKp]) -> tuple[float, float] | None:
return (0.5 * (lh.x + rh.x), 0.5 * (lh.y + rh.y))
def _body_bbox_norm(
body: list[PoseKp],
) -> tuple[float, float, float, float] | None:
"""Bbox image-normalized [0,1] from a list of body landmarks
(Vision 19 joints OR MediaPipe 33). None if not enough confident
points."""
if not body:
return None
xs = [kp.x for kp in body if kp.c > 0.05]
ys = [kp.y for kp in body if kp.c > 0.05]
if len(xs) < 4 or len(ys) < 4:
return None
x0, x1 = max(0.0, min(xs)), min(1.0, max(xs))
y0, y1 = max(0.0, min(ys)), min(1.0, max(ys))
# Pad 10% to capture full body silhouette.
dx = (x1 - x0) * 0.10
dy = (y1 - y0) * 0.10
x0 = max(0.0, x0 - dx); x1 = min(1.0, x1 + dx)
y0 = max(0.0, y0 - dy); y1 = min(1.0, y1 + dy)
if x1 - x0 < 0.02 or y1 - y0 < 0.02:
return None
return (x0, y0, x1, y1)
def _mesh_bbox_norm(p: SMPLXPerson) -> tuple[float, float, float, float] | None:
"""Project SMPL-X mesh vertices to image-normalized bbox.
Multi-HMR uses focal = IMG_SIZE camera intrinsics. World verts
have z>0 (in front of camera)."""
v = np.asarray(p.vertices_3d, dtype=np.float32)
if v.size == 0 or v.shape[0] < 100:
return None
z = v[:, 2]
valid = z > 1e-3
if not np.any(valid):
return None
x_img = (v[valid, 0] * _FOCAL / z[valid]) / _IMG_SIZE + 0.5
y_img = (v[valid, 1] * _FOCAL / z[valid]) / _IMG_SIZE + 0.5
x0, x1 = float(x_img.min()), float(x_img.max())
y0, y1 = float(y_img.min()), float(y_img.max())
x0 = max(0.0, x0); x1 = min(1.0, x1)
y0 = max(0.0, y0); y1 = min(1.0, y1)
if x1 - x0 < 0.02 or y1 - y0 < 0.02:
return None
return (x0, y0, x1, y1)
def _iou_norm(
a: tuple[float, float, float, float],
b: tuple[float, float, float, float],
) -> float:
ax0, ay0, ax1, ay1 = a
bx0, by0, bx1, by1 = b
ix0 = max(ax0, bx0); iy0 = max(ay0, by0)
ix1 = min(ax1, bx1); iy1 = min(ay1, by1)
iw = max(0.0, ix1 - ix0); ih = max(0.0, iy1 - iy0)
inter = iw * ih
if inter <= 0:
return 0.0
a_area = (ax1 - ax0) * (ay1 - ay0)
b_area = (bx1 - bx0) * (by1 - by0)
return float(inter / (a_area + b_area - inter + 1e-9))
def _vision_pid_match(
keyframe_pelvis_2d: tuple[float, float] | None,
vision_bodies: list[list[PoseKp]],
@@ -89,14 +163,22 @@ class MeshRigger:
Thread-safe : ne mute pas le state, retourne une nouvelle liste.
"""
def __init__(self, state: State, hold_window_s: float = 1.5) -> None:
def __init__(self, state: State, hold_window_s: float = 1.5,
dino_weight: float = 0.5,
dino_reid=None) -> None:
self.state = state
self.hold_window_s = hold_window_s
self.dino_weight = float(dino_weight)
self.dino_reid = dino_reid
self._lock = threading.Lock()
# pid Multi-HMR -> keyframe
self._keyframes: dict[int, _Keyframe] = {}
# pid Multi-HMR -> pid Vision matched (sticky across frames)
self._vision_pid_map: dict[int, int] = {}
# pid Multi-HMR -> recent DINO embeddings (mean -> reid signature)
self._pid_embeddings: dict[int, collections.deque] = {}
# Cached log throttle
self._next_dino_log = 0.0
def apply(
self,
@@ -114,6 +196,14 @@ class MeshRigger:
if old_pid not in current_pids:
self._keyframes.pop(old_pid, None)
self._vision_pid_map.pop(old_pid, None)
self._pid_embeddings.pop(old_pid, None)
# 2) DINO fusion: if a reid backend is wired, try Hungarian
# over (mesh keyframe pids) x (Vision body pids) using
# alpha*IoU + (1-alpha)*cosine. This only kicks in when a
# keyframe is detected this call AND we have an RGB frame.
self._dino_match(persons_smplx, persons_body,
persons_body_ids)
out: list[SMPLXPerson] = []
for person in persons_smplx:
@@ -199,6 +289,136 @@ class MeshRigger:
))
return out
# ------------------------------------------------------------------
# DINOv2 reid hooks
# ------------------------------------------------------------------
def _dino_match(
self,
persons_smplx: list[SMPLXPerson],
persons_body: list[list[PoseKp]],
persons_body_ids: list[int],
) -> None:
"""Update self._vision_pid_map and self._pid_embeddings by
matching mesh pids against Vision pids on alpha*IoU +
(1-alpha)*DINO cosine. No-op if any prerequisite missing.
Caller must hold self._lock."""
if self.dino_reid is None or not _HAVE_SCIPY:
return
if not persons_smplx or not persons_body:
return
# Need at least one new keyframe to be worth running DINO.
new_kf_pids: list[int] = []
for p in persons_smplx:
kf = self._keyframes.get(p.pid)
if kf is None or not np.allclose(
kf.translation, p.translation, atol=1e-4):
new_kf_pids.append(int(p.pid))
if not new_kf_pids:
return
# Acquire current RGB frame (best effort, no double lock).
frame = self.state.last_frame_rgb
if frame is None:
return
H, W = frame.shape[:2]
# Build Vision bboxes (image-normalized) and pixel crops.
v_bboxes_norm: list[tuple[float, float, float, float]] = []
v_crops: list[np.ndarray] = []
v_pids: list[int] = []
for body, vpid in zip(persons_body, persons_body_ids):
bb = _body_bbox_norm(body)
if bb is None:
continue
x0, y0, x1, y1 = bb
px0 = max(0, int(x0 * W))
py0 = max(0, int(y0 * H))
px1 = min(W, int(x1 * W))
py1 = min(H, int(y1 * H))
if px1 <= px0 + 4 or py1 <= py0 + 4:
continue
v_bboxes_norm.append(bb)
v_crops.append(frame[py0:py1, px0:px1].copy())
v_pids.append(int(vpid))
if not v_crops:
return
# Build mesh bboxes (image-normalized) from world pelvis proj.
m_bboxes_norm: list[tuple[float, float, float, float]] = []
m_pids_keep: list[int] = []
m_crops: list[np.ndarray] = []
for p in persons_smplx:
bb = _mesh_bbox_norm(p)
if bb is None:
continue
m_bboxes_norm.append(bb)
m_pids_keep.append(int(p.pid))
x0, y0, x1, y1 = bb
px0 = max(0, int(x0 * W))
py0 = max(0, int(y0 * H))
px1 = min(W, int(x1 * W))
py1 = min(H, int(y1 * H))
if px1 > px0 + 4 and py1 > py0 + 4:
m_crops.append(frame[py0:py1, px0:px1].copy())
else:
m_crops.append(None) # type: ignore[arg-type]
if not m_bboxes_norm:
return
# Embed Vision crops in one batch (still loops internally).
t0 = time.perf_counter()
try:
v_emb = self.dino_reid.embed_crops(v_crops)
except Exception as e: # noqa: BLE001
LOG.warning("dino_reid embed failed: %s", e)
return
# Build cost matrix mesh x vision : 1 - (alpha*IoU + (1-alpha)*cos)
n_m = len(m_bboxes_norm)
n_v = len(v_bboxes_norm)
alpha = float(np.clip(self.dino_weight, 0.0, 1.0))
cost = np.ones((n_m, n_v), dtype=np.float32)
for i, mbb in enumerate(m_bboxes_norm):
hist = self._pid_embeddings.get(m_pids_keep[i])
mean_emb = None
if hist:
stack = np.stack(list(hist), axis=0)
mean_emb = stack.mean(axis=0)
n = np.linalg.norm(mean_emb) + 1e-8
mean_emb = mean_emb / n
for j, vbb in enumerate(v_bboxes_norm):
iou = _iou_norm(mbb, vbb)
if mean_emb is not None:
cos = float(np.dot(mean_emb, v_emb[j]))
else:
cos = iou # no history -> trust IoU
score = alpha * iou + (1.0 - alpha) * max(0.0, cos)
cost[i, j] = 1.0 - score
rr, cc = linear_sum_assignment(cost)
for i, j in zip(rr, cc):
if cost[i, j] >= 0.95:
continue # weak match, ignore
mpid = m_pids_keep[i]
self._vision_pid_map[mpid] = v_pids[j]
# Update embedding history for THIS mesh pid using the
# Vision crop (most recent visual evidence).
dq = self._pid_embeddings.setdefault(
mpid, collections.deque(maxlen=10))
dq.append(v_emb[j].copy())
now = time.monotonic()
dt_ms = (time.perf_counter() - t0) * 1e3
if now >= self._next_dino_log:
LOG.info(
"dino_reid: embedded %d crops in %.1f ms (alpha=%.2f, "
"matched %d mesh<->vision pairs)",
len(v_crops), dt_ms, alpha, min(n_m, n_v))
self._next_dino_log = now + 5.0
@staticmethod
def _project_pelvis(
translation: np.ndarray,
+193
View File
@@ -22,6 +22,8 @@ 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
@@ -96,6 +98,179 @@ class MultiWorker:
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(
@@ -238,6 +413,14 @@ class MultiWorker:
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)
@@ -246,11 +429,21 @@ class MultiWorker:
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,
+120 -1
View File
@@ -20,6 +20,7 @@ from pathlib import Path
import numpy as np
from .arkit_joint_map import ARKIT_PELVIS_IDX
from .euro_filter import OneEuroFilter
from .state import PoseKp, SMPLXPerson, State
from .tracker import IoUTracker
@@ -30,12 +31,34 @@ CACHE = Path.home() / ".cache" / "av-live-multihmr"
CKPT = CACHE / "checkpoints" / "multiHMR_672_S.pt"
SMPLX_PATH = CACHE / "models" / "smplx" / "SMPLX_NEUTRAL.npz"
MULTIHMR_REPO = CACHE / "multi-hmr"
COREML_MLPACKAGE = CACHE / "multihmr_full_672_s.mlpackage"
COREML_MLPACKAGE = Path(
os.environ.get("COREML_MLPACKAGE")
or str(CACHE / "multihmr_full_672_s.mlpackage"))
IMG_SIZE = 672
N_VERTS = 10475
def arkit_pelvis_z_override(state, pid: int, z_pred: float,
fresh_sec: float = 1.0) -> float:
"""Return ARKit pelvis world-z if a fresh ARKit frame exists for
this pid, otherwise return the Multi-HMR predicted z unchanged.
Used to resolve Multi-HMR's monocular scale ambiguity: ARKit's
LiDAR-anchored pelvis position is ground truth in the iPhone
world frame, which (after extrinsics calibration) is the same
metric scale as the SMPL-X cam-space output.
"""
with state.lock():
arr = state.persons_arkit_joints.get(pid)
last_t = state.persons_arkit_last_t.get(pid, 0.0)
if arr is None:
return float(z_pred)
if time.perf_counter() - last_t > fresh_sec:
return float(z_pred)
return float(arr[ARKIT_PELVIS_IDX, 2])
class MultiHMRWorker:
def __init__(self, state: State, num_persons: int = 4,
target_fps: float = 10.0, device: str = "mps",
@@ -77,6 +100,10 @@ class MultiHMRWorker:
# (cf tracker.py) pour resister aux occlusions et au mouvement
# rapide. Multi-HMR a 3 fps -> 30 frames = 10s de survie.
self._tracker = IoUTracker(iou_threshold=0.15, max_miss=30)
# Lazily-loaded CoreML backend for predict_once (single-shot,
# off-thread). Independent of the worker thread's _run_coreml
# backend instance — predict_once must work even without start().
self._coreml_backend_singleshot = None
@staticmethod
def is_available() -> bool:
@@ -93,6 +120,83 @@ class MultiHMRWorker:
def stop(self) -> None:
self._stop.set()
def _get_or_load_coreml_backend(self):
"""Lazily load the CoreML backend for single-shot inference.
Returns the cached `MultiHMRCoreMLBackend` instance, or None if
the backend cannot be imported / the .mlpackage is missing.
Thread-safe enough for our use (calibration CLI is single-
threaded; the worker thread uses its own backend in _run_coreml).
"""
if self._coreml_backend_singleshot is not None:
return self._coreml_backend_singleshot
try:
from .multihmr_coreml import MultiHMRCoreMLBackend
backend = MultiHMRCoreMLBackend(COREML_MLPACKAGE)
except (ImportError, FileNotFoundError) as e:
LOG.info("predict_once: CoreML backend unavailable: %s", e)
return None
except Exception as e: # noqa: BLE001
LOG.warning("predict_once: CoreML backend init failed: %s", e)
return None
self._coreml_backend_singleshot = backend
return backend
def predict_once(self, rgb_image):
"""Single-shot SMPL-X prediction on one RGB image.
Args:
rgb_image: (H, W, 3) uint8 RGB array. Will be center-
cropped + resized to 672x672 internally.
Returns:
First `SMPLXPerson` detection (pid=0) or None if no
humans pass the detection threshold.
Raises:
NotImplementedError: if the CoreML backend is unavailable
(PyTorch single-shot path is TBD).
"""
backend = self._get_or_load_coreml_backend()
if backend is None:
raise NotImplementedError(
"CoreML backend unavailable; PyTorch single-shot path TBD")
try:
import cv2
except ImportError as e:
raise NotImplementedError(
"opencv-python required for predict_once: %s" % e)
rgb = np.asarray(rgb_image)
if rgb.ndim != 3 or rgb.shape[2] != 3:
raise ValueError(
f"rgb_image must be (H,W,3), got {rgb.shape}")
h, w = rgb.shape[:2]
if (h, w) != (IMG_SIZE, IMG_SIZE):
side = min(h, w)
y0 = (h - side) // 2
x0 = (w - side) // 2
rgb = rgb[y0:y0 + side, x0:x0 + side]
rgb = cv2.resize(rgb, (IMG_SIZE, IMG_SIZE))
img = rgb.transpose(2, 0, 1).astype(np.float32) / 255.0
focal = float(IMG_SIZE)
K_np = np.array([[focal, 0.0, IMG_SIZE / 2.0],
[0.0, focal, IMG_SIZE / 2.0],
[0.0, 0.0, 1.0]], dtype=np.float32)
humans = backend.infer(img, K_np, det_thresh=self.det_thresh)
if not humans:
return None
hh = humans[0]
v3d = hh["v3d"].detach().cpu().numpy()
return SMPLXPerson(
pid=0,
vertices_3d=np.ascontiguousarray(v3d, dtype=np.float32),
)
def _run(self) -> None:
if self.backend == "coreml":
self._run_coreml()
@@ -238,6 +342,10 @@ class MultiHMRWorker:
prev_thumb = thumb
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
# Publish to state for DINOv2 reid in MeshRigger.
with self.state.lock():
self.state.last_frame_rgb = frame_rgb
self.state.last_frame_rgb_t = time.monotonic()
tensor = torch.from_numpy(frame_rgb).permute(2, 0, 1).float()
tensor = (tensor / 255.0).unsqueeze(0).to(device)
@@ -365,6 +473,10 @@ class MultiHMRWorker:
v3d = hh["v3d"].detach().cpu().numpy()
transl = hh.get("transl_pelvis", hh.get("transl"))
transl_np = transl.detach().cpu().numpy().flatten()
if transl_np.size >= 3:
transl_np = transl_np.copy()
transl_np[2] = arkit_pelvis_z_override(
self.state, pid, float(transl_np[2]))
shape_raw = hh["shape"].detach().cpu().numpy().flatten()
expr_raw = hh["expression"].detach().cpu().numpy().flatten()
@@ -517,6 +629,9 @@ class MultiHMRWorker:
prev_thumb = thumb
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
with self.state.lock():
self.state.last_frame_rgb = frame_rgb
self.state.last_frame_rgb_t = time.monotonic()
img = frame_rgb.transpose(2, 0, 1).astype(np.float32) / 255.0
t_inf_start = time.monotonic()
@@ -603,6 +718,10 @@ class MultiHMRWorker:
continue
v3d = hh["v3d"].detach().cpu().numpy()
transl_np = hh["transl_pelvis"].detach().cpu().numpy().flatten()
if transl_np.size >= 3:
transl_np = transl_np.copy()
transl_np[2] = arkit_pelvis_z_override(
self.state, pid, float(transl_np[2]))
shape_raw = hh["shape"].detach().cpu().numpy().flatten()
expr_raw = hh["expression"].detach().cpu().numpy().flatten()
+41 -14
View File
@@ -20,6 +20,7 @@ Public API:
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any
@@ -38,12 +39,24 @@ DEFAULT_MLPACKAGE = (
N_PERSONS_FIXED = 4
N_VERTS = 10475
# CoreML output names from the exported .mlpackage.
OUT_V3D = "var_2412" # (4, 10475, 3)
OUT_TRANSL = "var_2415" # (4, 1, 3)
OUT_SCORES = "var_2428" # (4,)
OUT_BETAS = "var_2431" # (4, 10)
OUT_EXPR = "var_2434" # (4, 10)
# 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
@@ -161,12 +174,22 @@ class MultiHMRCoreMLBackend:
MLModel = ns["MLModel"]
MLModelConfiguration = ns["MLModelConfiguration"]
cfg = MLModelConfiguration.alloc().init()
try:
# MLComputeUnits: 0=CPUOnly, 1=CPUAndGPU, 2=All (ANE+GPU+CPU),
# 3=CPUAndNeuralEngine. Multi-HMR's ANEF compile fails
# (validated 2026-05-13 on M5), and 'All' falls back to a
# slow path (~146ms). CPU+GPU = 28ms = ~35fps on M5.
cfg.setComputeUnits_(1)
# 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))
@@ -182,8 +205,10 @@ class MultiHMRCoreMLBackend:
raise RuntimeError(f"MLModel load failed for {compiled_url}")
self._model = model
self._ns = ns
LOG.info("Multi-HMR CoreML model loaded (%s, computeUnits=CPU+GPU)",
self.path.name)
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:
@@ -232,7 +257,8 @@ class MultiHMRCoreMLBackend:
"""Run a forward pass and return list of humans dicts.
Args:
image_chw_float32: (3, 672, 672) or (1, 3, 672, 672) in [0,1].
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.
@@ -251,6 +277,7 @@ class MultiHMRCoreMLBackend:
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)
+896
View File
@@ -0,0 +1,896 @@
"""3D pose filtering chain : median spike removal, Kalman CV smoothing,
spring-damper organic inertia, lookahead extrapolation, IK angular clamps.
Operates on lists of Kp3D (metric, hip-centered) keyed by track id.
Stages are toggleable via the POSE_FILTER env var :
POSE_FILTER=median+kalman+lookahead+ik (default)
POSE_FILTER=median
POSE_FILTER=off
"""
from __future__ import annotations
import logging
import math
import os
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Iterable
from .arkit_joint_map import ARKIT91_TO_MP33
from .euro_filter import OneEuroFilter, SkeletonFilter
from .state import Kp3D, State
LOG = logging.getLogger("pose_filter")
NUM_JOINTS = 33
DEFAULT_STAGES = ("median", "kalman", "lookahead", "ik")
ALL_STAGES = (
"median", "kalman", "spring", "lookahead", "ik",
"one_euro_joints", "one_euro_bones", "arkit_fuse",
)
# MediaPipe POSE_LANDMARKS indices used by IK constraints.
L_SHOULDER, R_SHOULDER = 11, 12
L_ELBOW, R_ELBOW = 13, 14
L_WRIST, R_WRIST = 15, 16
L_HIP, R_HIP = 23, 24
L_KNEE, R_KNEE = 25, 26
L_ANKLE, R_ANKLE = 27, 28
L_FOOT, R_FOOT = 31, 32
# (parent_idx, joint_idx, child_idx, min_deg, max_deg)
JOINT_LIMITS: tuple[tuple[int, int, int, float, float], ...] = (
(L_SHOULDER, L_ELBOW, L_WRIST, 0.0, 175.0),
(R_SHOULDER, R_ELBOW, R_WRIST, 0.0, 175.0),
(L_HIP, L_KNEE, L_ANKLE, 0.0, 175.0),
(R_HIP, R_KNEE, R_ANKLE, 0.0, 175.0),
(L_KNEE, L_ANKLE, L_FOOT, 60.0, 135.0),
(R_KNEE, R_ANKLE, R_FOOT, 60.0, 135.0),
)
# ----------------------------- utilities --------------------------------
def _is_finite(v: float) -> bool:
return v == v and v not in (float("inf"), float("-inf"))
def _kp_finite(kp: Kp3D) -> bool:
return _is_finite(kp.x) and _is_finite(kp.y) and _is_finite(kp.z)
def _median(values: list[float]) -> float:
s = sorted(values)
n = len(s)
if n == 0:
return 0.0
if n % 2 == 1:
return s[n // 2]
return 0.5 * (s[n // 2 - 1] + s[n // 2])
def _std(values: list[float], mu: float) -> float:
if not values:
return 0.0
var = sum((v - mu) ** 2 for v in values) / len(values)
return math.sqrt(var)
# ----------------------------- median filter ----------------------------
class MedianFilter:
"""Per (pid, joint) ring buffer ; replaces spikes outside 3σ by median."""
def __init__(self, window: int = 3) -> None:
self.window = max(1, window)
self._buf: dict[tuple[int, int], deque[tuple[float, float, float]]] = {}
def reset(self) -> None:
self._buf.clear()
def apply(self, pid: int, joint_idx: int, x: float, y: float, z: float
) -> tuple[float, float, float]:
key = (pid, joint_idx)
buf = self._buf.get(key)
if buf is None:
buf = deque(maxlen=self.window)
self._buf[key] = buf
# Spike detection requires history.
out = (x, y, z)
if not (_is_finite(x) and _is_finite(y) and _is_finite(z)):
if buf:
med = (_median([v[0] for v in buf]),
_median([v[1] for v in buf]),
_median([v[2] for v in buf]))
out = med
else:
out = (0.0, 0.0, 0.0)
elif len(buf) >= self.window:
for axis_idx, val in enumerate(out):
col = [v[axis_idx] for v in buf]
med = _median(col)
sigma = _std(col, med)
if sigma > 1e-6 and abs(val - med) > 3.0 * sigma:
out = tuple(med if i == axis_idx else out[i]
for i in range(3)) # type: ignore[assignment]
buf.append(out)
return out
# ----------------------------- Kalman CV --------------------------------
@dataclass
class _KalmanState:
# State vector [x, y, z, vx, vy, vz]
x: list[float] = field(default_factory=lambda: [0.0] * 6)
# 6x6 covariance flattened
P: list[list[float]] = field(default_factory=lambda: [[0.0] * 6 for _ in range(6)])
initialised: bool = False
last_t: float = 0.0
def _mat_eye(n: int, s: float = 1.0) -> list[list[float]]:
return [[s if i == j else 0.0 for j in range(n)] for i in range(n)]
def _mat_mul(A: list[list[float]], B: list[list[float]]) -> list[list[float]]:
ra, ca = len(A), len(A[0])
cb = len(B[0])
out = [[0.0] * cb for _ in range(ra)]
for i in range(ra):
Ai = A[i]
for k in range(ca):
aik = Ai[k]
if aik == 0.0:
continue
Bk = B[k]
for j in range(cb):
out[i][j] += aik * Bk[j]
return out
def _mat_add(A: list[list[float]], B: list[list[float]]) -> list[list[float]]:
return [[A[i][j] + B[i][j] for j in range(len(A[0]))] for i in range(len(A))]
def _mat_sub(A: list[list[float]], B: list[list[float]]) -> list[list[float]]:
return [[A[i][j] - B[i][j] for j in range(len(A[0]))] for i in range(len(A))]
def _mat_T(A: list[list[float]]) -> list[list[float]]:
return [[A[i][j] for i in range(len(A))] for j in range(len(A[0]))]
def _mat_inv3(M: list[list[float]]) -> list[list[float]]:
a, b, c = M[0]
d, e, f = M[1]
g, h, i = M[2]
A = e * i - f * h
B = -(d * i - f * g)
C = d * h - e * g
det = a * A + b * B + c * C
if abs(det) < 1e-12:
return _mat_eye(3, 1.0)
inv_det = 1.0 / det
return [
[A * inv_det, -(b * i - c * h) * inv_det, (b * f - c * e) * inv_det],
[B * inv_det, (a * i - c * g) * inv_det, -(a * f - c * d) * inv_det],
[C * inv_det, -(a * h - b * g) * inv_det, (a * e - b * d) * inv_det],
]
class KalmanCV:
"""Constant-velocity Kalman per (pid, joint_idx) on R^3."""
def __init__(self, q: float = 1e-3, r: float = 1e-2) -> None:
self.q = q
self.r = r
self._states: dict[tuple[int, int], _KalmanState] = {}
self._H = [
[1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0, 0.0, 0.0],
]
def reset(self) -> None:
self._states.clear()
def get_velocity(self, pid: int, joint_idx: int) -> tuple[float, float, float]:
st = self._states.get((pid, joint_idx))
if st is None or not st.initialised:
return (0.0, 0.0, 0.0)
return (st.x[3], st.x[4], st.x[5])
def step(self, pid: int, joint_idx: int, mx: float, my: float, mz: float,
t_now: float) -> tuple[float, float, float]:
key = (pid, joint_idx)
st = self._states.get(key)
if st is None:
st = _KalmanState()
self._states[key] = st
if not st.initialised:
st.x = [mx, my, mz, 0.0, 0.0, 0.0]
st.P = _mat_eye(6, 1.0)
st.initialised = True
st.last_t = t_now
return (mx, my, mz)
dt = max(1e-3, min(0.2, t_now - st.last_t))
st.last_t = t_now
# Predict
F = _mat_eye(6, 1.0)
F[0][3] = dt
F[1][4] = dt
F[2][5] = dt
x_pred = [
st.x[0] + dt * st.x[3],
st.x[1] + dt * st.x[4],
st.x[2] + dt * st.x[5],
st.x[3], st.x[4], st.x[5],
]
Q = _mat_eye(6, self.q)
P_pred = _mat_add(_mat_mul(_mat_mul(F, st.P), _mat_T(F)), Q)
# Update
z = [mx, my, mz]
# y = z - H x_pred
Hx = [x_pred[0], x_pred[1], x_pred[2]]
y = [z[i] - Hx[i] for i in range(3)]
# S = H P H^T + R (3x3)
HP = _mat_mul(self._H, P_pred)
S = [[HP[i][j] for j in range(3)] for i in range(3)]
# add HP*H^T rest cols (cols 3..5) -> 0 contribution since H rest zero
for i in range(3):
S[i][i] += self.r
S_inv = _mat_inv3(S)
# K = P H^T S^-1 (6x3)
PHt = [[P_pred[i][j] for j in range(3)] for i in range(6)]
K = _mat_mul(PHt, S_inv)
# x = x_pred + K y
x_new = [x_pred[i] + sum(K[i][j] * y[j] for j in range(3))
for i in range(6)]
# P = (I - K H) P_pred
KH = [[K[i][0] if j == 0 else (K[i][1] if j == 1 else (K[i][2] if j == 2 else 0.0))
for j in range(6)] for i in range(6)]
I6 = _mat_eye(6, 1.0)
st.P = _mat_mul(_mat_sub(I6, KH), P_pred)
st.x = x_new
return (x_new[0], x_new[1], x_new[2])
# --------------------------- spring damper ------------------------------
class SpringDamper:
"""Critically-tunable spring-damper per (pid, joint_idx) on R^3."""
def __init__(self, stiffness: float = 200.0, damping: float = 15.0,
mass: float = 1.0, enabled: bool = True) -> None:
self.k = stiffness
self.c = damping
self.m = max(1e-3, mass)
self.enabled = enabled
self._pos: dict[tuple[int, int], list[float]] = {}
self._vel: dict[tuple[int, int], list[float]] = {}
self._last_t: dict[tuple[int, int], float] = {}
def reset(self) -> None:
self._pos.clear()
self._vel.clear()
self._last_t.clear()
def step(self, pid: int, joint_idx: int, tx: float, ty: float, tz: float,
t_now: float) -> tuple[float, float, float]:
if not self.enabled:
return (tx, ty, tz)
key = (pid, joint_idx)
pos = self._pos.get(key)
if pos is None:
self._pos[key] = [tx, ty, tz]
self._vel[key] = [0.0, 0.0, 0.0]
self._last_t[key] = t_now
return (tx, ty, tz)
dt = max(1e-3, min(0.1, t_now - self._last_t[key]))
self._last_t[key] = t_now
vel = self._vel[key]
target = (tx, ty, tz)
for i in range(3):
# F = k(target - pos) - c * vel
f = self.k * (target[i] - pos[i]) - self.c * vel[i]
a = f / self.m
vel[i] += a * dt
pos[i] += vel[i] * dt
return (pos[0], pos[1], pos[2])
# --------------------------- lookahead ----------------------------------
class LookaheadPredictor:
"""Linear extrapolation using Kalman velocities, capped to avoid blow-ups."""
def __init__(self, lookahead_ms: float = 50.0, max_velocity: float = 5.0
) -> None:
self.lookahead_s = lookahead_ms / 1000.0
self.max_v = max_velocity
def step(self, x: float, y: float, z: float,
vx: float, vy: float, vz: float) -> tuple[float, float, float]:
def clamp(v: float) -> float:
if v > self.max_v:
return self.max_v
if v < -self.max_v:
return -self.max_v
return v
dt = self.lookahead_s
return (x + clamp(vx) * dt, y + clamp(vy) * dt, z + clamp(vz) * dt)
# --------------------------- IK constraints -----------------------------
def _vec_sub(a: tuple[float, float, float], b: tuple[float, float, float]
) -> tuple[float, float, float]:
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def _vec_add(a: tuple[float, float, float], b: tuple[float, float, float]
) -> tuple[float, float, float]:
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
def _vec_scale(a: tuple[float, float, float], s: float
) -> tuple[float, float, float]:
return (a[0] * s, a[1] * s, a[2] * s)
def _vec_dot(a: tuple[float, float, float], b: tuple[float, float, float]
) -> float:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _vec_norm(a: tuple[float, float, float]) -> float:
return math.sqrt(_vec_dot(a, a))
def _vec_normalize(a: tuple[float, float, float], eps: float = 1e-9
) -> tuple[float, float, float]:
n = _vec_norm(a)
if n < eps:
return (1.0, 0.0, 0.0)
return (a[0] / n, a[1] / n, a[2] / n)
def _slerp_dir(d_from: tuple[float, float, float],
d_to: tuple[float, float, float],
t: float) -> tuple[float, float, float]:
"""Slerp between two unit-ish vectors."""
a = _vec_normalize(d_from)
b = _vec_normalize(d_to)
cos_a = max(-1.0, min(1.0, _vec_dot(a, b)))
ang = math.acos(cos_a)
if ang < 1e-6:
return a
sa = math.sin(ang)
if abs(sa) < 1e-6:
# antiparallel : pick an arbitrary perpendicular, then rotate.
ortho = (1.0, 0.0, 0.0) if abs(a[0]) < 0.9 else (0.0, 1.0, 0.0)
# Gram-Schmidt
d = _vec_dot(ortho, a)
perp = (ortho[0] - d * a[0], ortho[1] - d * a[1], ortho[2] - d * a[2])
perp = _vec_normalize(perp)
# rotate a by t*pi around perp axis : Rodrigues for angle = t*pi
theta = t * ang
cs, sn = math.cos(theta), math.sin(theta)
# cross(perp, a)
cx = perp[1] * a[2] - perp[2] * a[1]
cy = perp[2] * a[0] - perp[0] * a[2]
cz = perp[0] * a[1] - perp[1] * a[0]
dot_pa = _vec_dot(perp, a)
return (a[0] * cs + cx * sn + perp[0] * dot_pa * (1 - cs),
a[1] * cs + cy * sn + perp[1] * dot_pa * (1 - cs),
a[2] * cs + cz * sn + perp[2] * dot_pa * (1 - cs))
w1 = math.sin((1.0 - t) * ang) / sa
w2 = math.sin(t * ang) / sa
return (a[0] * w1 + b[0] * w2,
a[1] * w1 + b[1] * w2,
a[2] * w1 + b[2] * w2)
class IKConstraints:
"""Clamp interior joint angles for elbows, knees, ankles."""
def __init__(self, limits: Iterable[tuple[int, int, int, float, float]]
= JOINT_LIMITS) -> None:
self.limits = tuple(limits)
def apply(self, kps: list[Kp3D]) -> list[Kp3D]:
if len(kps) < NUM_JOINTS:
return kps
out = list(kps)
for parent_i, joint_i, child_i, min_deg, max_deg in self.limits:
if max(parent_i, joint_i, child_i) >= len(out):
continue
p = (out[parent_i].x, out[parent_i].y, out[parent_i].z)
j = (out[joint_i].x, out[joint_i].y, out[joint_i].z)
c = (out[child_i].x, out[child_i].y, out[child_i].z)
v_pj = _vec_sub(p, j) # from joint to parent
v_cj = _vec_sub(c, j) # from joint to child
n_pj = _vec_norm(v_pj)
n_cj = _vec_norm(v_cj)
if n_pj < 1e-6 or n_cj < 1e-6:
continue
cos_a = max(-1.0, min(1.0, _vec_dot(v_pj, v_cj) / (n_pj * n_cj)))
ang_deg = math.degrees(math.acos(cos_a))
min_r = math.radians(min_deg)
max_r = math.radians(max_deg)
target_r: float | None = None
if ang_deg < min_deg:
target_r = min_r
elif ang_deg > max_deg:
target_r = max_r
if target_r is None:
continue
# Interpolate child direction toward parent direction (or away)
# so the new angle matches target_r.
cur_r = math.acos(cos_a)
# t such that new_angle = (1-t)*cur + t*pi between dirs ; use slerp.
# Find t in [0,1] s.t. slerp(d_cj, d_pj, t) makes angle = target_r
# The angle between slerp result and d_pj is (1-t)*cur_r.
# So target_r = (1 - t) * cur_r -> t = 1 - target_r / cur_r
if cur_r < 1e-6:
continue
t = 1.0 - (target_r / cur_r)
t = max(0.0, min(1.0, t))
d_cj = _vec_normalize(v_cj)
d_pj = _vec_normalize(v_pj)
new_dir = _slerp_dir(d_cj, d_pj, t)
new_child = _vec_add(j, _vec_scale(new_dir, n_cj))
old = out[child_i]
out[child_i] = Kp3D(x=new_child[0], y=new_child[1],
z=new_child[2], c=old.c)
return out
# --------------------------- chain wrapper ------------------------------
def _parse_env_stages() -> tuple[str, ...]:
raw = os.environ.get("POSE_FILTER")
if raw is None:
return DEFAULT_STAGES
raw = raw.strip().lower()
if raw in ("off", "none", "0", "false"):
return ()
parts = tuple(p.strip() for p in raw.replace(",", "+").split("+") if p.strip())
return tuple(p for p in parts if p in ALL_STAGES)
class PoseFilterChain:
"""Chain : median → kalman → spring → lookahead → ik."""
def __init__(self, state: State | None = None,
enabled_stages: Iterable[str] | None = None) -> None:
self.state = state
if enabled_stages is None:
stages = _parse_env_stages()
else:
stages = tuple(s for s in enabled_stages if s in ALL_STAGES)
self.enabled = stages
self.median = MedianFilter(window=3)
self.kalman = KalmanCV()
self.spring = SpringDamper(enabled="spring" in self.enabled)
self.lookahead = LookaheadPredictor()
self.ik = IKConstraints()
# One Euro filters (CHI 2012) — adaptive low-pass driven by speed.
# Joints variant: applied in joint-space, per (pid, joint_idx).
# Bones variant: applied to bone vectors (child - parent) along
# the MediaPipe Pose 33 subset that overlaps SMPL-X fused joints.
self.one_euro_joints = SkeletonFilter(min_cutoff=1.2, beta=0.08)
self.one_euro_bones = BoneOneEuroFilter(min_cutoff=1.0, beta=0.05)
self.arkit_fuse = ArkitFuse()
self.last_apply_ms: float = 0.0
self.last_apply_bones_ms: float = 0.0
LOG.info("PoseFilterChain stages=%s", self.enabled or ("off",))
def reset(self) -> None:
self.median.reset()
self.kalman.reset()
self.spring.reset()
self.one_euro_joints.reset_all()
self.one_euro_bones.reset_all()
def apply(self, bodies3d: list[list[Kp3D]], ids: list[int],
t_now: float) -> list[list[Kp3D]]:
if not bodies3d or not self.enabled:
self.last_apply_ms = 0.0
return bodies3d
t0 = time.perf_counter()
out: list[list[Kp3D]] = []
use_median = "median" in self.enabled
use_kalman = "kalman" in self.enabled
use_spring = "spring" in self.enabled
use_lookahead = "lookahead" in self.enabled
use_ik = "ik" in self.enabled
use_one_euro_joints = "one_euro_joints" in self.enabled
use_arkit_fuse = "arkit_fuse" in self.enabled
for body_i, kps in enumerate(bodies3d):
pid = ids[body_i] if body_i < len(ids) else -1
if use_arkit_fuse and self.state is not None:
kps = self.arkit_fuse.apply(self.state, pid, kps, t_now)
new_kps: list[Kp3D] = []
for j_idx, kp in enumerate(kps):
x, y, z, c = kp.x, kp.y, kp.z, kp.c
if use_median:
x, y, z = self.median.apply(pid, j_idx, x, y, z)
if use_one_euro_joints:
x, y, z = self.one_euro_joints.smooth(
pid, j_idx, x, y, z, t_now)
if use_kalman:
x, y, z = self.kalman.step(pid, j_idx, x, y, z, t_now)
if use_spring:
x, y, z = self.spring.step(pid, j_idx, x, y, z, t_now)
if use_lookahead and use_kalman:
vx, vy, vz = self.kalman.get_velocity(pid, j_idx)
x, y, z = self.lookahead.step(x, y, z, vx, vy, vz)
new_kps.append(Kp3D(x=x, y=y, z=z, c=c))
if use_ik:
new_kps = self.ik.apply(new_kps)
out.append(new_kps)
self.last_apply_ms = (time.perf_counter() - t0) * 1000.0
return out
# ---- Face / hand smoothing entry points ---------------------------
def apply_face(self, faces: list[list], ids: list[int],
t_now: float) -> list[list]:
if not hasattr(self, "_face_chain"):
self._face_chain = FaceFilterChain()
return self._face_chain.apply(faces, ids, t_now)
def apply_hand(self, hands: list[list], ids: list[int],
handedness: list[str] | None,
t_now: float) -> list[list]:
if not hasattr(self, "_hand_chain"):
self._hand_chain = HandFilterChain()
return self._hand_chain.apply(hands, ids, handedness, t_now)
# ---- Bone-space One Euro (Point B) --------------------------------
def apply_bones(self, bodies3d: list[list[Kp3D]], ids: list[int],
t_now: float) -> list[list[Kp3D]]:
"""Filter bone vectors (child - parent) for the body skeleton.
Called *after* SMPL-X fusion in multi.py. No-op unless
``one_euro_bones`` is in POSE_FILTER. Mutates the child slot
of each bone in-place — parents are walked in topological
order (root → leaves) so children always see updated parents.
"""
if not bodies3d or "one_euro_bones" not in self.enabled:
self.last_apply_bones_ms = 0.0
return bodies3d
t0 = time.perf_counter()
for body_i, kps in enumerate(bodies3d):
pid = ids[body_i] if body_i < len(ids) else -1
self.one_euro_bones.apply_body(pid, kps, t_now)
self.last_apply_bones_ms = (time.perf_counter() - t0) * 1000.0
return bodies3d
def forget_person(self, pid: int) -> None:
"""Drop per-pid state on track loss (caller responsibility)."""
try:
self.one_euro_joints.forget(pid)
self.one_euro_bones.forget(pid)
except Exception: # noqa: BLE001
pass
# ============================ bone One Euro ===============================
# Body skeleton bones expressed as (parent_idx, child_idx) over the
# MediaPipe Pose 33 indexing — chosen to overlap with the 14
# SMPL-X-fused slots (cf. multi.py SMPLX_TO_MP33). Topological order:
# legs first, then arms, then bridges (clavicle, pelvis), then torso.
BODY_BONES: tuple[tuple[int, int], ...] = (
(L_HIP, L_KNEE), # 23 -> 25
(L_KNEE, L_ANKLE), # 25 -> 27
(L_ANKLE, L_FOOT), # 27 -> 31
(R_HIP, R_KNEE), # 24 -> 26
(R_KNEE, R_ANKLE), # 26 -> 28
(R_ANKLE, R_FOOT), # 28 -> 32
(L_SHOULDER, L_ELBOW), # 11 -> 13
(L_ELBOW, L_WRIST), # 13 -> 15
(R_SHOULDER, R_ELBOW), # 12 -> 14
(R_ELBOW, R_WRIST), # 14 -> 16
(L_SHOULDER, R_SHOULDER), # clavicle bridge
(L_HIP, R_HIP), # pelvis bridge
(L_SHOULDER, L_HIP), # left torso
(R_SHOULDER, R_HIP), # right torso
)
class BoneOneEuroFilter:
"""One Euro filter applied to bone vectors of the body skeleton.
For each bone (parent, child), the vector ``child - parent`` is
smoothed component-wise. Child position is then reconstructed as
``parent + smoothed_bone``. This preserves bone *direction*
stability frame-to-frame while remaining responsive to genuine
pose changes (One Euro adaptive cutoff).
State is keyed by ``(pid, bone_idx)`` and lives in three
OneEuroFilter instances per bone (one per axis).
"""
def __init__(self, min_cutoff: float = 1.0, beta: float = 0.05) -> None:
self._min_cutoff = min_cutoff
self._beta = beta
# (pid, bone_idx) -> (fx, fy, fz)
self._table: dict[tuple[int, int], tuple[
OneEuroFilter, OneEuroFilter, OneEuroFilter]] = {}
def _filters_for(self, pid: int, bone_idx: int) -> tuple[
OneEuroFilter, OneEuroFilter, OneEuroFilter]:
key = (pid, bone_idx)
f = self._table.get(key)
if f is None:
f = (
OneEuroFilter(self._min_cutoff, self._beta),
OneEuroFilter(self._min_cutoff, self._beta),
OneEuroFilter(self._min_cutoff, self._beta),
)
self._table[key] = f
return f
def apply_body(self, pid: int, kps: list[Kp3D], t: float) -> None:
n = len(kps)
for bone_idx, (p_idx, c_idx) in enumerate(BODY_BONES):
if p_idx >= n or c_idx >= n:
continue
p = kps[p_idx]
c = kps[c_idx]
if not (_kp_finite(p) and _kp_finite(c)):
continue
dx = c.x - p.x
dy = c.y - p.y
dz = c.z - p.z
fx, fy, fz = self._filters_for(pid, bone_idx)
sx = fx(dx, t)
sy = fy(dy, t)
sz = fz(dz, t)
kps[c_idx] = Kp3D(
x=p.x + sx, y=p.y + sy, z=p.z + sz, c=c.c)
def forget(self, pid: int) -> None:
self._table = {k: v for k, v in self._table.items() if k[0] != pid}
def reset_all(self) -> None:
self._table.clear()
class ArkitFuse:
"""Splice ARKit 91-joint world-space data into MediaPipe Pose 33.
Reads ``state.persons_arkit_joints[pid]`` (shape (91, 3)) when fresh
(last_t within FRESH_SEC). Writes the 14 body slots covered by
ARKIT91_TO_MP33 ; everything else (face landmarks, finger tips)
stays MediaPipe-driven.
"""
FRESH_SEC: float = 1.0
def apply(self, state: "State", pid: int,
kps: list[Kp3D], t_now: float) -> list[Kp3D]:
with state.lock():
arr = state.persons_arkit_joints.get(pid)
last_t = state.persons_arkit_last_t.get(pid, 0.0)
if arr is None:
return kps
if t_now - last_t > self.FRESH_SEC:
return kps
out = list(kps)
n = len(out)
for arkit_idx, mp33_idx in ARKIT91_TO_MP33:
if mp33_idx >= n:
continue
x = float(arr[arkit_idx, 0])
y = float(arr[arkit_idx, 1])
z = float(arr[arkit_idx, 2])
old = out[mp33_idx]
out[mp33_idx] = Kp3D(x=x, y=y, z=z, c=getattr(old, "c", 1.0))
return out
# ============================ face / hand =================================
# Face and hand filtering operate on PoseKp lists (normalized x,y in [0,1]
# + z relative depth + confidence). We only apply temporal smoothing
# (median + Kalman 2D + lookahead) — no IK, no spring.
def _parse_env_face_stages() -> tuple[str, ...]:
raw = os.environ.get("POSE_FILTER_FACE")
if raw is None:
return ("median", "kalman", "lookahead")
raw = raw.strip().lower()
if raw in ("off", "none", "0", "false"):
return ()
parts = tuple(p.strip() for p in raw.replace(",", "+").split("+") if p.strip())
return tuple(p for p in parts if p in ("median", "kalman", "lookahead"))
def _parse_env_hand_stages() -> tuple[str, ...]:
raw = os.environ.get("POSE_FILTER_HAND")
if raw is None:
return ("median", "kalman", "lookahead")
raw = raw.strip().lower()
if raw in ("off", "none", "0", "false"):
return ()
parts = tuple(p.strip() for p in raw.replace(",", "+").split("+") if p.strip())
return tuple(p for p in parts if p in ("median", "kalman", "lookahead"))
class AlphaBetaCV:
"""Lightweight alpha-beta filter (scalar Kalman approximation).
Far cheaper than the 6x6 KalmanCV : O(1) per joint per axis with no
matrix algebra. Suited to face/hand smoothing where the full CV
Kalman is overkill.
"""
def __init__(self, alpha: float = 0.55, beta: float = 0.15) -> None:
self.alpha = alpha
self.beta = beta
# state[key] = [x, y, z, vx, vy, vz, last_t]
self._st: dict[tuple[int, int], list[float]] = {}
def reset(self) -> None:
self._st.clear()
def get_velocity(self, pid: int, joint_idx: int
) -> tuple[float, float, float]:
s = self._st.get((pid, joint_idx))
if s is None:
return (0.0, 0.0, 0.0)
return (s[3], s[4], s[5])
def step(self, pid: int, joint_idx: int, mx: float, my: float,
mz: float, t_now: float) -> tuple[float, float, float]:
key = (pid, joint_idx)
s = self._st.get(key)
if s is None:
self._st[key] = [mx, my, mz, 0.0, 0.0, 0.0, t_now]
return (mx, my, mz)
dt = max(1e-3, min(0.2, t_now - s[6]))
s[6] = t_now
# Predict
x_pred = s[0] + s[3] * dt
y_pred = s[1] + s[4] * dt
z_pred = s[2] + s[5] * dt
# Residual
rx = mx - x_pred
ry = my - y_pred
rz = mz - z_pred
# Update
s[0] = x_pred + self.alpha * rx
s[1] = y_pred + self.alpha * ry
s[2] = z_pred + self.alpha * rz
s[3] += (self.beta / dt) * rx
s[4] += (self.beta / dt) * ry
s[5] += (self.beta / dt) * rz
return (s[0], s[1], s[2])
class FaceFilterChain:
"""Per-pid temporal smoothing for face landmarks (median + Kalman + lookahead).
Lookahead 30 ms ; max velocity in normalized units/s.
"""
def __init__(self, lookahead_ms: float = 30.0,
enabled_stages: Iterable[str] | None = None) -> None:
if enabled_stages is None:
stages = _parse_env_face_stages()
else:
stages = tuple(s for s in enabled_stages
if s in ("median", "kalman", "lookahead"))
self.enabled = stages
self.median = MedianFilter(window=3)
self.kalman = AlphaBetaCV(alpha=0.55, beta=0.15)
self.lookahead = LookaheadPredictor(
lookahead_ms=lookahead_ms, max_velocity=2.0)
self.last_apply_ms: float = 0.0
def reset(self) -> None:
self.median.reset()
self.kalman.reset()
def apply(self, faces: list[list], ids: list[int],
t_now: float) -> list[list]:
if not faces or not self.enabled:
self.last_apply_ms = 0.0
return faces
t0 = time.perf_counter()
use_median = "median" in self.enabled
use_kalman = "kalman" in self.enabled
use_lookahead = "lookahead" in self.enabled
out: list[list] = []
for f_i, kps in enumerate(faces):
pid = ids[f_i] if f_i < len(ids) else -1
# Encode pid with a face-side namespace to avoid colliding with
# body and hand kalman/median caches.
key_pid = pid * 13 + 1 if pid >= 0 else pid
new_kps = []
for j_idx, kp in enumerate(kps):
x, y, z, c = kp.x, kp.y, kp.z, kp.c
if use_median:
x, y, z = self.median.apply(key_pid, j_idx, x, y, z)
if use_kalman:
x, y, z = self.kalman.step(key_pid, j_idx, x, y, z, t_now)
if use_lookahead and use_kalman:
vx, vy, vz = self.kalman.get_velocity(key_pid, j_idx)
x, y, z = self.lookahead.step(x, y, z, vx, vy, vz)
new_kps.append(type(kp)(x=x, y=y, z=z, c=c))
out.append(new_kps)
self.last_apply_ms = (time.perf_counter() - t0) * 1000.0
return out
class HandFilterChain:
"""Per-pid+side temporal smoothing for hand landmarks.
Left and right hands keep independent filter state via a namespaced
pid (pid*2 for left, pid*2+1 for right). When handedness is not
provided, hands fall back to a side-agnostic namespace.
"""
def __init__(self, lookahead_ms: float = 30.0,
enabled_stages: Iterable[str] | None = None) -> None:
if enabled_stages is None:
stages = _parse_env_hand_stages()
else:
stages = tuple(s for s in enabled_stages
if s in ("median", "kalman", "lookahead"))
self.enabled = stages
self.median = MedianFilter(window=3)
self.kalman = AlphaBetaCV(alpha=0.6, beta=0.2)
self.lookahead = LookaheadPredictor(
lookahead_ms=lookahead_ms, max_velocity=4.0)
self.last_apply_ms: float = 0.0
def reset(self) -> None:
self.median.reset()
self.kalman.reset()
def apply(self, hands: list[list], ids: list[int],
handedness: list[str] | None,
t_now: float) -> list[list]:
if not hands or not self.enabled:
self.last_apply_ms = 0.0
return hands
t0 = time.perf_counter()
use_median = "median" in self.enabled
use_kalman = "kalman" in self.enabled
use_lookahead = "lookahead" in self.enabled
out: list[list] = []
for h_i, kps in enumerate(hands):
pid = ids[h_i] if h_i < len(ids) else -1
side = (handedness[h_i] if handedness and h_i < len(handedness)
else "u").lower()
side_bit = 0 if side.startswith("l") else (1 if side.startswith("r") else 2)
# Namespace : (pid << 2) | side_bit — keeps L/R independent.
key_pid = (pid * 4 + side_bit + 7) if pid >= 0 else pid
new_kps = []
for j_idx, kp in enumerate(kps):
x, y, z, c = kp.x, kp.y, kp.z, kp.c
if use_median:
x, y, z = self.median.apply(key_pid, j_idx, x, y, z)
if use_kalman:
x, y, z = self.kalman.step(key_pid, j_idx, x, y, z, t_now)
if use_lookahead and use_kalman:
vx, vy, vz = self.kalman.get_velocity(key_pid, j_idx)
x, y, z = self.lookahead.step(x, y, z, vx, vy, vz)
new_kps.append(type(kp)(x=x, y=y, z=z, c=c))
out.append(new_kps)
self.last_apply_ms = (time.perf_counter() - t0) * 1000.0
return out
+5
View File
@@ -38,6 +38,11 @@ detrpose = [
"iopath>=0.1.10",
"opencv-python>=4.10",
]
# Open3D for ICP fusion between iPhone LiDAR and Multi-HMR SMPL-X meshes.
# CPU-only is sufficient at 5-10 Hz LiDAR cadence.
lidar = [
"open3d>=0.18,<0.20",
]
nlf = [
"torch>=2.4",
"torchvision>=0.19",
+77
View File
@@ -0,0 +1,77 @@
"""Latency / convergence bench for the ICP fusion worker.
Usage:
cd data_only_viz
uv run --extra lidar python -m data_only_viz.scripts.bench_icp_fusion \
--n-frames 200 --n-people 2 --seed 0
"""
from __future__ import annotations
import argparse
import json
import time
import numpy as np
from data_only_viz.icp_fusion import FusionWorker, IcpConfig
from data_only_viz.lidar_calib import Extrinsic
from data_only_viz.state import SMPLXPerson, State
def _synth_person(seed: int, offset_x: float) -> SMPLXPerson:
rng = np.random.RandomState(seed)
verts = np.zeros((10475, 3), dtype=np.float32)
pts = rng.randn(2000, 3).astype(np.float32) * 0.1
verts[: pts.shape[0]] = pts + np.array([offset_x, 0, 1.5], dtype=np.float32)
verts[5559] = pts.mean(axis=0) + np.array([offset_x, 0, 1.5], dtype=np.float32)
return SMPLXPerson(pid=seed, vertices_3d=verts)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser()
p.add_argument("--n-frames", type=int, default=200)
p.add_argument("--n-people", type=int, default=2)
p.add_argument("--seed", type=int, default=0)
args = p.parse_args(argv)
rng = np.random.RandomState(args.seed)
persons = [_synth_person(i, offset_x=-0.6 + 1.2 * i) for i in range(args.n_people)]
state = State()
state.persons_smplx = persons
worker = FusionWorker(extrinsic=Extrinsic.identity(), config=IcpConfig())
latencies_ms: list[float] = []
accepted = 0
pelvis_delta_m: list[float] = []
for _ in range(args.n_frames):
all_pts = np.concatenate([
pers.vertices_3d[: 2000] + np.array([0, 0.05, 0], dtype=np.float32) +
0.02 * rng.randn(2000, 3).astype(np.float32)
for pers in persons
])
state.lidar_points = all_pts
before = np.stack([p.vertices_3d[5559].copy() for p in state.persons_smplx])
t0 = time.perf_counter()
meta = worker.run_once(state)
latencies_ms.append((time.perf_counter() - t0) * 1000.0)
accepted += len(meta.applied)
after = np.stack([p.vertices_3d[5559] for p in state.persons_smplx])
pelvis_delta_m.extend(np.linalg.norm(after - before, axis=1).tolist())
report = {
"n_frames": args.n_frames,
"n_people": args.n_people,
"latency_ms_p50": float(np.percentile(latencies_ms, 50)),
"latency_ms_p95": float(np.percentile(latencies_ms, 95)),
"acceptance_rate": accepted / (args.n_frames * args.n_people),
"pelvis_delta_m_mean": float(np.mean(pelvis_delta_m)),
"pelvis_delta_m_max": float(np.max(pelvis_delta_m)),
}
print(json.dumps(report, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,235 @@
"""Bench Multi-HMR CoreML — compute_units sweep + section split.
Bench Multi-HMR `.mlpackage` inference latency on M5 (or any Apple
Silicon). Decomposes the per-frame cost into copy_in / predict /
copy_out so we can see where time goes, then sweeps compute_units
(CPU_AND_GPU vs ALL vs CPU_AND_NE vs CPU_ONLY) and tests the
"reused MLMultiArray buffer" optimization.
Usage:
uv run --project data_only_viz \
python -m data_only_viz.scripts.bench_multihmr_coreml
The result reproduces the 2026-05-14 finding: predict() is ~99% of
latency, copy_in is <2 ms, copy_out is <1 ms. None of the I/O
micro-optims (reused buffer, vImage preprocess, async copy) can
help meaningfully — only changing the model itself does (INT8 quant
via `scripts/quantize_multihmr_int8.py`, lower resolution, or a
smaller architecture).
Pause the live worker before running for clean numbers:
pgrep -f 'data_only_viz.main.*multi-hmr' | xargs kill -STOP
# ...run bench...
pgrep -f 'data_only_viz.main.*multi-hmr' | xargs kill -CONT
"""
from __future__ import annotations
import ctypes
import sys
import time
from pathlib import Path
import numpy as np
from Foundation import NSURL
from data_only_viz.multihmr_coreml import (
DEFAULT_MLPACKAGE,
_load_frameworks,
_mlarray_to_np,
_np_to_mlarray,
)
H = W = 672
NITER = 30
NWARM = 5
def _make_inputs():
img = np.random.rand(1, 3, H, W).astype(np.float32)
focal = float(H)
K = np.array(
[[[focal, 0, H / 2], [0, focal, H / 2], [0, 0, 1.0]]],
dtype=np.float32,
)
return img, K
def _load_model(compute_units: int, mlpackage: Path):
ns = _load_frameworks()
MLModel = ns["MLModel"]
MLModelConfiguration = ns["MLModelConfiguration"]
cfg = MLModelConfiguration.alloc().init()
cfg.setComputeUnits_(compute_units)
url = NSURL.fileURLWithPath_(str(mlpackage))
compiled = MLModel.compileModelAtURL_error_(url, None)
if compiled is None:
raise RuntimeError(f"compile failed cu={compute_units}")
model = MLModel.modelWithContentsOfURL_configuration_error_(
compiled, cfg, None)
if model is None:
raise RuntimeError(f"load failed cu={compute_units}")
return model, ns
def _stats(ts):
ts = sorted(ts)
return (ts[len(ts) // 2],
ts[len(ts) // 10],
ts[(len(ts) * 9) // 10])
def bench_basic(label: str, compute_units: int, mlpackage: Path):
try:
model, ns = _load_model(compute_units, mlpackage)
except Exception as e: # noqa: BLE001
print(f"[{label}] LOAD FAILED: {e}")
return None
MLDictionaryFeatureProvider = ns["MLDictionaryFeatureProvider"]
MLFeatureValue = ns["MLFeatureValue"]
img, K = _make_inputs()
for _ in range(NWARM):
img_ml = _np_to_mlarray(img); k_ml = _np_to_mlarray(K)
feats = {"image": MLFeatureValue.featureValueWithMultiArray_(img_ml),
"cam_K": MLFeatureValue.featureValueWithMultiArray_(k_ml)}
prov = MLDictionaryFeatureProvider.alloc(
).initWithDictionary_error_(feats, None)
out = model.predictionFromFeatures_error_(prov, None)
if out is None:
print(f"[{label}] predict returned None")
return None
ts = []
for _ in range(NITER):
t0 = time.perf_counter()
img_ml = _np_to_mlarray(img); k_ml = _np_to_mlarray(K)
feats = {"image": MLFeatureValue.featureValueWithMultiArray_(img_ml),
"cam_K": MLFeatureValue.featureValueWithMultiArray_(k_ml)}
prov = MLDictionaryFeatureProvider.alloc(
).initWithDictionary_error_(feats, None)
out = model.predictionFromFeatures_error_(prov, None)
for name in out.featureNames():
fv = out.featureValueForName_(name)
ml = fv.multiArrayValue()
if ml is None:
continue
_ = _mlarray_to_np(ml)
ts.append((time.perf_counter() - t0) * 1e3)
med, p10, p90 = _stats(ts)
print(f"[{label:34s}] med={med:6.1f}ms p10={p10:6.1f} "
f"p90={p90:6.1f} fps={1000/med:5.1f}")
return med
def bench_reused_input(label: str, compute_units: int, mlpackage: Path):
try:
model, ns = _load_model(compute_units, mlpackage)
except Exception as e: # noqa: BLE001
print(f"[{label}] LOAD FAILED: {e}")
return None
MLDictionaryFeatureProvider = ns["MLDictionaryFeatureProvider"]
MLFeatureValue = ns["MLFeatureValue"]
img, K = _make_inputs()
img_ml = _np_to_mlarray(img); k_ml = _np_to_mlarray(K)
ptr_img = img_ml.dataPointer()
addr_img = int(ptr_img) if isinstance(ptr_img, int) else \
ctypes.cast(ptr_img, ctypes.c_void_p).value
ptr_k = k_ml.dataPointer()
addr_k = int(ptr_k) if isinstance(ptr_k, int) else \
ctypes.cast(ptr_k, ctypes.c_void_p).value
img_bytes = img.nbytes
k_bytes = K.nbytes
feats = {"image": MLFeatureValue.featureValueWithMultiArray_(img_ml),
"cam_K": MLFeatureValue.featureValueWithMultiArray_(k_ml)}
for _ in range(NWARM):
ctypes.memmove(addr_img, img.ctypes.data, img_bytes)
ctypes.memmove(addr_k, K.ctypes.data, k_bytes)
prov = MLDictionaryFeatureProvider.alloc(
).initWithDictionary_error_(feats, None)
_ = model.predictionFromFeatures_error_(prov, None)
ts = []
for _ in range(NITER):
t0 = time.perf_counter()
ctypes.memmove(addr_img, img.ctypes.data, img_bytes)
ctypes.memmove(addr_k, K.ctypes.data, k_bytes)
prov = MLDictionaryFeatureProvider.alloc(
).initWithDictionary_error_(feats, None)
out = model.predictionFromFeatures_error_(prov, None)
for name in out.featureNames():
fv = out.featureValueForName_(name)
ml = fv.multiArrayValue()
if ml is None:
continue
_ = _mlarray_to_np(ml)
ts.append((time.perf_counter() - t0) * 1e3)
med, p10, p90 = _stats(ts)
print(f"[{label:34s}] med={med:6.1f}ms p10={p10:6.1f} "
f"p90={p90:6.1f} fps={1000/med:5.1f}")
return med
def bench_section_split(compute_units: int, mlpackage: Path):
model, ns = _load_model(compute_units, mlpackage)
MLDictionaryFeatureProvider = ns["MLDictionaryFeatureProvider"]
MLFeatureValue = ns["MLFeatureValue"]
img, K = _make_inputs()
for _ in range(NWARM):
img_ml = _np_to_mlarray(img); k_ml = _np_to_mlarray(K)
feats = {"image": MLFeatureValue.featureValueWithMultiArray_(img_ml),
"cam_K": MLFeatureValue.featureValueWithMultiArray_(k_ml)}
prov = MLDictionaryFeatureProvider.alloc(
).initWithDictionary_error_(feats, None)
_ = model.predictionFromFeatures_error_(prov, None)
t_in, t_pred, t_out = [], [], []
for _ in range(NITER):
t0 = time.perf_counter()
img_ml = _np_to_mlarray(img); k_ml = _np_to_mlarray(K)
feats = {"image": MLFeatureValue.featureValueWithMultiArray_(img_ml),
"cam_K": MLFeatureValue.featureValueWithMultiArray_(k_ml)}
prov = MLDictionaryFeatureProvider.alloc(
).initWithDictionary_error_(feats, None)
t1 = time.perf_counter()
out = model.predictionFromFeatures_error_(prov, None)
t2 = time.perf_counter()
for name in out.featureNames():
fv = out.featureValueForName_(name)
ml = fv.multiArrayValue()
if ml is None:
continue
_ = _mlarray_to_np(ml)
t3 = time.perf_counter()
t_in.append((t1 - t0) * 1e3)
t_pred.append((t2 - t1) * 1e3)
t_out.append((t3 - t2) * 1e3)
mi = lambda a: sorted(a)[len(a) // 2]
print("[section-split CPU_AND_GPU]")
print(f" copy_in : {mi(t_in):6.2f} ms")
print(f" predict : {mi(t_pred):6.2f} ms")
print(f" copy_out : {mi(t_out):6.2f} ms")
print(f" total : {mi(t_in)+mi(t_pred)+mi(t_out):6.2f} ms")
def main(argv: list[str]) -> int:
mlpackage = DEFAULT_MLPACKAGE
if len(argv) > 1:
mlpackage = Path(argv[1])
if not mlpackage.exists():
print(f"mlpackage missing: {mlpackage}", file=sys.stderr)
return 1
print(f"bench target: {mlpackage}")
print("=" * 70)
print("Section split (alloc/predict/copy)")
print("=" * 70)
bench_section_split(1, mlpackage)
print()
print("=" * 70)
print("Compute-units sweep (30 iter median)")
print("=" * 70)
bench_basic("A. CPU_AND_GPU (baseline)", 1, mlpackage)
bench_basic("B. ALL (ANE+GPU+CPU)", 2, mlpackage)
bench_basic("C. CPU_AND_NE (ANE-only)", 3, mlpackage)
bench_basic("D. CPU_ONLY", 0, mlpackage)
bench_reused_input("E. CPU_AND_GPU + reused buffer", 1, mlpackage)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+97
View File
@@ -0,0 +1,97 @@
"""Interactive one-shot extrinsic calibration between iPhone LiDAR and webcam.
Usage:
cd data_only_viz
uv run --extra lidar python -m data_only_viz.scripts.calibrate_lidar \
--lidar-host 192.168.0.42 --lidar-port 5500 --webcam-index 0
The script prompts the user to assume 4 stances (front, left, right, back),
captures paired pelvis points (webcam: Multi-HMR vertex 5559; LiDAR: centroid
of the largest mesh anchor), solves Kabsch, and writes the result to
ICP_LIDAR_EXTRINSIC or the default path.
Multi-HMR worker is launched in-process for this script (single-shot mode).
"""
from __future__ import annotations
import argparse
import datetime as dt
import logging
import sys
import time
import numpy as np
from data_only_viz.lidar_calib import Extrinsic, kabsch_rigid, save_extrinsic
from data_only_viz.lidar_receiver import LidarTCPReader
_LOG = logging.getLogger("calibrate_lidar")
_PELVIS_VERT_INDEX = 5559 # SMPL-X canonical pelvis vertex
def _wait_for_lidar(reader: LidarTCPReader, timeout_s: float = 5.0):
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
latest = reader.latest()
if latest is not None and latest.points.shape[0] > 50:
return latest
time.sleep(0.05)
raise RuntimeError("LiDAR frame never arrived")
def _capture_one_pair(reader: LidarTCPReader, get_smplx_pelvis_cam) -> tuple[np.ndarray, np.ndarray]:
input("Hold still, then press ENTER to capture...")
lidar = _wait_for_lidar(reader)
pelvis_cam = get_smplx_pelvis_cam()
pelvis_arkit = lidar.points.mean(axis=0)
_LOG.info("captured: cam=%s arkit=%s", pelvis_cam, pelvis_arkit)
return pelvis_cam, pelvis_arkit
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser()
p.add_argument("--lidar-host", required=True)
p.add_argument("--lidar-port", type=int, default=5500)
p.add_argument("--webcam-index", type=int, default=0)
p.add_argument("--stances", type=int, default=4)
args = p.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
reader = LidarTCPReader(host=args.lidar_host, port=args.lidar_port)
reader.start()
# Task 9 added the ``MultiHMRWorker.predict_once`` API surface but
# left the body as ``NotImplementedError`` — the existing PyTorch
# path is too coupled to the worker thread for a clean extraction.
# When ``predict_once`` is wired (follow-up task), replace this
# placeholder by opening cv2.VideoCapture(args.webcam_index),
# running ``worker.predict_once(rgb)`` and returning
# ``person.vertices_3d[_PELVIS_VERT_INDEX]``.
def _placeholder_pelvis_cam() -> np.ndarray:
raise SystemExit(
"calibrate_lidar needs MultiHMRWorker.predict_once to be "
"implemented (currently NotImplementedError)")
pairs_cam, pairs_arkit = [], []
try:
for i in range(args.stances):
_LOG.info("stance %d/%d", i + 1, args.stances)
cam, arkit = _capture_one_pair(reader, _placeholder_pelvis_cam)
pairs_cam.append(cam)
pairs_arkit.append(arkit)
finally:
reader.stop()
T = kabsch_rigid(np.asarray(pairs_arkit), np.asarray(pairs_cam))
path = save_extrinsic(Extrinsic(
T_arkit_to_cam=T,
confidence=1.0,
captured_at_iso=dt.datetime.now(dt.timezone.utc).isoformat(),
))
_LOG.info("extrinsic saved to %s", path)
return 0
if __name__ == "__main__":
sys.exit(main())
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""Convert DINOv2 ViT-S/14 to a CoreML .mlpackage for ANE-friendly inference.
The wrapped module takes (1, 3, 224, 224) RGB float32 in [0, 1], applies
ImageNet normalization internally, runs the ViT, and returns the CLS
embedding (1, 384) L2-normalised. We trace + convert with
``coremltools.convert(... compute_units=ComputeUnit.ALL, compute_precision=FP16)``.
Run with the Python 3.12 venv that has coremltools and torch::
/tmp/coreml312/bin/python -m data_only_viz.scripts.convert_dinov2 [--force]
Output:
~/.cache/av-live-multihmr/dinov2_vits14.mlpackage
"""
from __future__ import annotations
import argparse
import logging
import sys
import time
import types
from pathlib import Path
import numpy as np
LOG = logging.getLogger("convert_dinov2")
OUT_DIR = Path.home() / ".cache" / "av-live-multihmr"
OUT_PATH = OUT_DIR / "dinov2_vits14.mlpackage"
_IMAGENET_MEAN = (0.485, 0.456, 0.406)
_IMAGENET_STD = (0.229, 0.224, 0.225)
def _build_wrapper():
import torch
import torch.nn as nn
import torch.nn.functional as F
backbone = torch.hub.load(
"facebookresearch/dinov2",
"dinov2_vits14",
source="github",
trust_repo=True,
)
backbone.eval()
# Pretrained pos_embed is at 37x37 (518/14). We pre-resample to
# 16x16 (224/14) once so the traced graph never needs an upsample.
pe = backbone.pos_embed.data # (1, 1+37*37, 384)
cls_pe = pe[:, :1]
patch_pe = pe[:, 1:]
n_old = int(round((patch_pe.shape[1]) ** 0.5))
dim = patch_pe.shape[-1]
patch_pe = patch_pe.reshape(1, n_old, n_old, dim).permute(0, 3, 1, 2)
patch_pe = F.interpolate(patch_pe, size=(16, 16), mode="bilinear",
align_corners=False)
patch_pe = patch_pe.permute(0, 2, 3, 1).reshape(1, 16 * 16, dim)
new_pe = torch.cat([cls_pe, patch_pe], dim=1).contiguous()
backbone.pos_embed = nn.Parameter(new_pe, requires_grad=False)
mean = torch.tensor(_IMAGENET_MEAN, dtype=torch.float32).view(1, 3, 1, 1)
std = torch.tensor(_IMAGENET_STD, dtype=torch.float32).view(1, 3, 1, 1)
class DinoV2Wrapper(nn.Module):
def __init__(self):
super().__init__()
self.backbone = backbone
self.register_buffer("mean", mean)
self.register_buffer("std", std)
def forward(self, x):
x = (x - self.mean) / self.std
bb = self.backbone
x = bb.patch_embed(x)
# cls_token is (1,1,384). Concat directly (B=1 fixed).
x = torch.cat((bb.cls_token, x), dim=1)
x = x + bb.pos_embed
for blk in bb.blocks:
x = blk(x)
x = bb.norm(x)
cls = x[:, 0]
cls = cls / (cls.norm(dim=-1, keepdim=True) + 1e-8)
return cls
return DinoV2Wrapper().eval()
def _patch_coremltools_cast():
"""coremltools 9.0 _cast assumes x.val is a 0-d scalar. With recent
torch (2.12) some aten::Int args land as 1-D length-1 arrays. Patch
the helper to flatten before scalar-casting."""
from coremltools.converters.mil.frontend.torch import ops as _ops
from coremltools.converters.mil.mil import Builder as mb
_orig = _ops._cast
def _patched_cast(context, node, dtype, dtype_name):
# Inputs are read inside _orig from context; we wrap the failure
# path by checking the first input's val first.
inputs = _ops._get_inputs(context, node, expected=1)
x = inputs[0]
if x.can_be_folded_to_const():
val = x.val
if hasattr(val, "shape") and getattr(val, "shape", ()) != ():
# 1-D length-1 (or all-ones shape) -> extract scalar
import numpy as _np
arr = _np.asarray(val).reshape(-1)
if arr.size == 1:
res = mb.const(val=dtype(arr[0]), name=node.name)
context.add(res, node.name)
return
return _orig(context, node, dtype, dtype_name)
_ops._cast = _patched_cast
def convert(force: bool = False) -> Path:
import torch
import coremltools as ct
_patch_coremltools_cast()
OUT_DIR.mkdir(parents=True, exist_ok=True)
if OUT_PATH.exists() and not force:
LOG.info("already converted: %s", OUT_PATH)
return OUT_PATH
LOG.info("loading DINOv2 ViT-S/14 ...")
wrap = _build_wrapper()
example = torch.rand(1, 3, 224, 224, dtype=torch.float32)
with torch.no_grad():
ref_out = wrap(example)
LOG.info("torch out shape=%s norm=%.4f", tuple(ref_out.shape),
float(ref_out.norm(dim=-1).mean()))
LOG.info("tracing ...")
with torch.no_grad():
traced = torch.jit.trace(wrap, example, strict=False)
LOG.info("ct.convert (mlprogram FP16, computeUnits=ALL) ...")
mlmodel = ct.convert(
traced,
source="pytorch",
convert_to="mlprogram",
inputs=[ct.TensorType(name="image", shape=example.shape,
dtype=np.float32)],
outputs=[ct.TensorType(name="embedding", dtype=np.float32)],
compute_precision=ct.precision.FLOAT16,
compute_units=ct.ComputeUnit.ALL,
minimum_deployment_target=ct.target.macOS14,
)
mlmodel.short_description = "DINOv2 ViT-S/14 person re-id (384-D, L2)"
mlmodel.save(str(OUT_PATH))
LOG.info("saved %s", OUT_PATH)
pred = mlmodel.predict({"image": example.numpy().astype(np.float32)})
coreml_out = list(pred.values())[0].reshape(-1)
ref_np = ref_out.numpy().reshape(-1)
cos = float(np.dot(coreml_out, ref_np) /
(np.linalg.norm(coreml_out) * np.linalg.norm(ref_np) + 1e-8))
LOG.info("CoreML vs Torch cosine on random input: %.4f", cos)
return OUT_PATH
def bench(n_iter: int = 30) -> None:
import coremltools as ct
LOG.info("bench: load mlpackage ...")
m = ct.models.MLModel(str(OUT_PATH),
compute_units=ct.ComputeUnit.ALL)
crop = np.random.rand(1, 3, 224, 224).astype(np.float32)
for _ in range(3):
m.predict({"image": crop})
times = []
for _ in range(n_iter):
t0 = time.perf_counter()
m.predict({"image": crop})
times.append((time.perf_counter() - t0) * 1e3)
times.sort()
p50 = times[len(times) // 2]
p95 = times[int(len(times) * 0.95)]
LOG.info("bench %d iter: p50=%.2f ms p95=%.2f ms mean=%.2f ms (~%.1f fps)",
n_iter, p50, p95, sum(times) / len(times), 1000.0 / p50)
def main() -> int:
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(name)s %(message)s")
ap = argparse.ArgumentParser()
ap.add_argument("--force", action="store_true")
ap.add_argument("--bench-only", action="store_true")
ap.add_argument("--n-iter", type=int, default=30)
args = ap.parse_args()
if not args.bench_only:
convert(force=args.force)
bench(n_iter=args.n_iter)
return 0
if __name__ == "__main__":
sys.exit(main())
+4 -4
View File
@@ -524,10 +524,10 @@ try:
compute_units=ct.ComputeUnit.CPU_AND_GPU,
minimum_deployment_target=ct.target.macOS15,
convert_to="mlprogram",
# FP16 OK depuis le patch roma branchless (cf rapport bisection
# 2026-05-13) : la source du NaN etait torch.empty + index_put_
# dans roma.rotmat_to_rotvec, pas la precision.
compute_precision=ct.precision.FLOAT16,
# FP32 mandatory : FP16 (global ou hybride op_selector) degrade
# visiblement le mesh sur poses extremes. INT8 weight quant
# teste 2026-05-14 : aucun gain sur GPU compute-bound.
compute_precision=ct.precision.FLOAT32,
)
out_path = "/tmp/multihmr_full_672_s.mlpackage"
mlmodel.save(out_path)
@@ -0,0 +1,81 @@
"""Quantize Multi-HMR mlpackage to INT8 (weight-only) for M5 speedup.
Run in the Python 3.12 conversion venv (coremltools cannot run on 3.14):
/tmp/coreml312/.venv/bin/python \
data_only_viz/scripts/quantize_multihmr_int8.py
Produces `multihmr_full_672_s_int8.mlpackage` next to the FP32 file.
Bench after with `scripts/coreml_full_probe.py` or just load with
`MultiHMRCoreMLBackend(path=...new path...)`.
Strategy:
- Linear 8-bit weight palettization (per-tensor symmetric). Activations
stay FP16 — that's the "weight-only quant" path, lowest accuracy
hit and what CoreML's GPU runtime accelerates best.
- Skip the SMPL-X decoder branch ops that are sensitive to numeric
drift (skipped by name pattern below — adjust if v3d shows mesh
artefacts after quantization).
Validation:
- After producing the int8 mlpackage, run the live worker briefly
with COREML_MLPACKAGE pointing to the new file and visually check
the mesh. If v3d shows tearing on extreme poses, retry with
`granularity="per_channel"` instead of `per_tensor`.
"""
from __future__ import annotations
import sys
from pathlib import Path
try:
import coremltools as ct
from coremltools.optimize.coreml import (
linear_quantize_weights,
OptimizationConfig,
OpLinearQuantizerConfig,
)
except ImportError as e:
print(f"coremltools missing in this venv: {e}", file=sys.stderr)
print("Run from the Python 3.12 conversion venv (coremltools "
"is not available on 3.14).", file=sys.stderr)
sys.exit(1)
SRC = Path.home() / ".cache" / "av-live-multihmr" / \
"multihmr_full_672_s.mlpackage"
DST = Path.home() / ".cache" / "av-live-multihmr" / \
"multihmr_full_672_s_int8.mlpackage"
def main() -> int:
if not SRC.exists():
print(f"source mlpackage missing: {SRC}", file=sys.stderr)
return 1
print(f"loading FP32 model from {SRC}")
model = ct.models.MLModel(str(SRC))
# Per-tensor symmetric int8 weight quant. Per-tensor keeps the
# quantized model small and GPU-friendly; per-channel is a safer
# fallback if mesh quality degrades.
op_cfg = OpLinearQuantizerConfig(
mode="linear_symmetric",
dtype="int8",
granularity="per_tensor",
)
cfg = OptimizationConfig(global_config=op_cfg)
print("running linear_quantize_weights (per_tensor int8)...")
quant = linear_quantize_weights(model, config=cfg)
print(f"saving quantized model to {DST}")
quant.save(str(DST))
print("done. Test with:")
print(f" COREML_MLPACKAGE={DST} \\\n"
f" MULTIHMR_BACKEND=coreml \\\n"
f" uv run --project data_only_viz \\\n"
f" python -m data_only_viz.main --multi-hmr "
f"--motion-gate 0")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+26 -1
View File
@@ -25,9 +25,16 @@ from typing import Sequence
import numpy as np
import os
from .mesh_rigger import MeshRigger
from .state import SMPLXPerson, State
try:
from .dino_reid import DinoReid
except Exception: # noqa: BLE001
DinoReid = None # type: ignore[assignment]
LOG = logging.getLogger("smplx_tcp")
MAGIC = b"SMPX"
@@ -47,7 +54,25 @@ class SMPLXTCPSender:
self._sock: socket.socket | None = None
# Hybrid keyframe rigging : entre deux keyframes Multi-HMR (~3 fps),
# on translate le mesh via le delta pelvis Apple Vision (30 fps).
self._rigger = MeshRigger(state) if enable_rigging else None
# MULTIHMR_REID: 'dino' (try DINOv2 + IoU fusion, fallback IoU) /
# 'iou' (pure IoU). Default: 'dino' if mlpackage exists.
reid_mode = os.environ.get("MULTIHMR_REID", "dino").lower()
dino = None
if enable_rigging and reid_mode == "dino" and DinoReid is not None:
try:
if DinoReid.is_available():
dino = DinoReid()
LOG.info("MeshRigger: DINOv2 reid enabled")
else:
LOG.info(
"MeshRigger: dino mlpackage absent, IoU only")
except Exception as e: # noqa: BLE001
LOG.warning("MeshRigger: dino load failed (%s), IoU only", e)
dino = None
dino_weight = float(os.environ.get("MULTIHMR_REID_ALPHA", "0.5"))
self._rigger = MeshRigger(
state, dino_weight=dino_weight,
dino_reid=dino) if enable_rigging else None
def start(self) -> None:
self._thread = threading.Thread(
+42
View File
@@ -119,6 +119,43 @@ class State:
# Multi-HMR (SMPL-X 10475 verts x N personnes)
persons_smplx: list = field(default_factory=list) # list[SMPLXPerson]
smplx_last_t: float = 0.0
# SMPL-X joint positions (127 joints incl. body + jaw + eyes + hands)
# per pid, shape (127, 3) float32, camera coords (z>0 forward).
# Indices 25-39 = left hand 15 finger joints, 40-54 = right hand.
persons_smplx_joints: dict = field(default_factory=dict)
# HaMeR MANO hand meshes (v1.2 task #26-28). Keyed by pid -> side
# (0=left, 1=right) -> ndarray shape (778, 3) in camera-space metres.
# Companion arrays per pid/side:
# persons_hands_mesh_t : last_update timestamp (perf_counter)
# persons_hands_mesh_cam_t : (3,) translation of the hand mesh root.
persons_hands_mesh: dict = field(default_factory=dict)
persons_hands_mesh_cam_t: dict = field(default_factory=dict)
persons_hands_mesh_last_t: float = 0.0
# ARKit body tracking (iOS ARBodyTracker app) : 91 joints world
# space per pid. Same units as MediaPipe pose_world_landmarks
# (metres, hip-centered). Fresh = updated within < 1 s.
persons_arkit_joints: dict = field(default_factory=dict)
persons_arkit_last_t: dict = field(default_factory=dict)
# ---- LiDAR / ICP mesh fusion (Task 8 - 2026-05-14) ----
# Set by the LidarTCPReader poller; consumed by FusionWorker.run_once.
# The mesh-level fusion is complementary to the ARKit *joint* fusion
# above: joints are sparse + 60 Hz, LiDAR is dense + 5-10 Hz.
lidar_points: object = None # np.ndarray (N, 3) float32 ARKit world; None if no frame
lidar_timestamp_ns: int = 0
icp_metadata: object = None # FusionMetadata from icp_fusion or None
# v1.3: centralised webcam source. WebcamSource owns the single
# cv2.VideoCapture on the host and writes BGR frames here so all
# consumers (MediaPipe Multi, Apple Vision, Multi-HMR worker,
# HaMeR) read from one shared buffer instead of fighting over the
# camera device. ``latest_bgr_id`` is a monotonic counter so a
# consumer can detect new frames vs. re-reads.
latest_bgr: object = None # np.ndarray (H, W, 3) BGR uint8
latest_bgr_id: int = 0
latest_bgr_t: float = 0.0
# Renderer
width: int = 1280
@@ -140,6 +177,11 @@ class State:
# Derniere frame webcam au format JPEG bytes (pour NSImageView overlay).
# Le pose worker la met a jour ; le HUD timer lit et l'affiche.
last_webcam_jpeg: bytes | None = None
# Last full RGB frame fed to Multi-HMR (uint8 HxWx3, typ. 672x672).
# Updated by multi_hmr_worker right before inference. Read by
# MeshRigger for DINOv2-based person re-id. None when absent.
last_frame_rgb: np.ndarray | None = None
last_frame_rgb_t: float = 0.0
_lock: threading.RLock = field(default_factory=threading.RLock, repr=False)
+42
View File
@@ -0,0 +1,42 @@
"""ArkitFuse stage overrides 14 body slots with ARKit data when fresh."""
import time
import numpy as np
from data_only_viz.state import Kp3D, State
from data_only_viz.pose_filter import PoseFilterChain
def _mp33_zero_body():
return [Kp3D(x=0.0, y=0.0, z=0.0, c=1.0) for _ in range(33)]
def test_arkit_fuse_overrides_shoulder():
state = State()
# ARKit publishes joint 50 (left shoulder) with (1.0, 2.0, 3.0)
arr = np.zeros((91, 3), dtype=np.float32)
arr[50] = (1.0, 2.0, 3.0)
with state.lock():
state.persons_arkit_joints[0] = arr
state.persons_arkit_last_t[0] = time.perf_counter()
chain = PoseFilterChain(state=state, enabled_stages=("arkit_fuse",))
bodies = [_mp33_zero_body()]
out = chain.apply(bodies, ids=[0], t_now=time.perf_counter())
# Slot 11 = L_SHOULDER (from ARKIT91_TO_MP33).
assert out[0][11].x == 1.0
assert out[0][11].y == 2.0
assert out[0][11].z == 3.0
def test_arkit_fuse_skips_stale():
state = State()
arr = np.zeros((91, 3), dtype=np.float32)
arr[50] = (9.0, 9.0, 9.0)
with state.lock():
state.persons_arkit_joints[0] = arr
state.persons_arkit_last_t[0] = time.perf_counter() - 5.0
chain = PoseFilterChain(state=state, enabled_stages=("arkit_fuse",))
bodies = [_mp33_zero_body()]
out = chain.apply(bodies, ids=[0], t_now=time.perf_counter())
# Stale -> not applied, MediaPipe zero left intact.
assert out[0][11].x == 0.0
@@ -0,0 +1,32 @@
"""ARKit 91 joints → MediaPipe Pose 33 mapping integrity."""
from data_only_viz.arkit_joint_map import (
ARKIT91_TO_MP33, ARKIT_PELVIS_IDX, MP33_NUM_LANDMARKS,
)
def test_mapping_is_tuple_of_pairs():
assert isinstance(ARKIT91_TO_MP33, tuple)
assert len(ARKIT91_TO_MP33) > 0
for pair in ARKIT91_TO_MP33:
assert isinstance(pair, tuple)
assert len(pair) == 2
def test_mapping_indices_in_range():
for arkit_idx, mp33_idx in ARKIT91_TO_MP33:
assert 0 <= arkit_idx < 91, f"arkit idx out of range: {arkit_idx}"
assert 0 <= mp33_idx < MP33_NUM_LANDMARKS, \
f"mp33 idx out of range: {mp33_idx}"
def test_pelvis_index_valid():
assert 0 <= ARKIT_PELVIS_IDX < 91
def test_no_duplicate_mp33_targets():
"""Each MediaPipe slot must be written by at most one ARKit joint."""
mp33_seen = set()
for _, mp33_idx in ARKIT91_TO_MP33:
assert mp33_idx not in mp33_seen, \
f"mp33 slot {mp33_idx} mapped twice"
mp33_seen.add(mp33_idx)
+77
View File
@@ -0,0 +1,77 @@
"""Tests for the DINOv2 reid backend.
These tests are skipped automatically if the .mlpackage is not present
(`scripts/convert_dinov2.py` was never run) or pyobjc is unavailable.
"""
from __future__ import annotations
import time
from pathlib import Path
import numpy as np
import pytest
from data_only_viz.dino_reid import DEFAULT_MLPACKAGE, EMBED_DIM, DinoReid
pytestmark = pytest.mark.skipif(
not DEFAULT_MLPACKAGE.exists(),
reason=f"DINOv2 mlpackage missing at {DEFAULT_MLPACKAGE}; "
"run scripts/convert_dinov2.py first",
)
@pytest.fixture(scope="module")
def reid() -> DinoReid:
return DinoReid()
def test_is_available() -> None:
assert DinoReid.is_available() is True
def test_load(reid: DinoReid) -> None:
assert reid is not None
assert reid._out_name
def test_embed_random_crops_different(reid: DinoReid) -> None:
# Two crops with very different visual content. DINOv2 CLS tokens
# for two iid noise patches are surprisingly close (~0.98), so we
# build crops that are visually distinct: one is mostly red, the
# other is mostly green with a striped pattern.
a = np.zeros((224, 224, 3), dtype=np.uint8)
a[..., 0] = 220 # red
a[40:80, 40:180] = (240, 30, 30)
b = np.zeros((224, 224, 3), dtype=np.uint8)
b[..., 1] = 200 # green
for i in range(0, 224, 16):
b[i:i + 8] = (10, 30, 220) # blue stripes
embs = reid.embed_crops([a, b])
assert embs.shape == (2, EMBED_DIM)
norms = np.linalg.norm(embs, axis=1)
assert np.allclose(norms, 1.0, atol=1e-3)
cos = float(np.dot(embs[0], embs[1]))
assert cos < 0.95, f"distinct crops too similar: cos={cos:.3f}"
def test_embed_identical_crops_same(reid: DinoReid) -> None:
rng = np.random.default_rng(7)
a = rng.integers(0, 255, size=(224, 224, 3), dtype=np.uint8)
embs = reid.embed_crops([a, a.copy()])
assert embs.shape == (2, EMBED_DIM)
cos = float(np.dot(embs[0], embs[1]))
assert cos > 0.999, f"identical crops cos={cos:.4f} (expected ~1.0)"
def test_latency_batch4(reid: DinoReid) -> None:
rng = np.random.default_rng(0)
crops = [rng.integers(0, 255, size=(180, 90, 3), dtype=np.uint8)
for _ in range(4)]
# warmup
reid.embed_crops(crops)
t0 = time.perf_counter()
reid.embed_crops(crops)
dt_ms = (time.perf_counter() - t0) * 1e3
# Spec target: < 30 ms for batch=4 on M5.
assert dt_ms < 80.0, f"batch=4 too slow: {dt_ms:.1f} ms"
@@ -0,0 +1,214 @@
"""Tests for FaceFilterChain, HandFilterChain, and multi.py discrimination."""
from __future__ import annotations
import random
import time
import pytest
from data_only_viz.pose_filter import (
FaceFilterChain,
HandFilterChain,
PoseFilterChain,
)
from data_only_viz.state import Kp3D, PoseKp
def _jitter_face(n_pts: int, base_x: float, base_y: float,
amp: float, rng: random.Random) -> list[PoseKp]:
return [
PoseKp(
x=base_x + rng.uniform(-amp, amp),
y=base_y + rng.uniform(-amp, amp),
z=rng.uniform(-amp, amp),
c=1.0,
)
for _ in range(n_pts)
]
def test_face_filter_reduces_jitter() -> None:
chain = FaceFilterChain()
rng = random.Random(42)
n_pts = 68
base_x, base_y = 0.5, 0.5
amp = 0.01
outputs: list[list[PoseKp]] = []
t = 0.0
for k in range(8):
t += 1.0 / 30.0
faces = [_jitter_face(n_pts, base_x, base_y, amp, rng)]
out = chain.apply(faces, [0], t)
outputs.append(out[0])
# Compute variance on x of joint 0 across the last 5 frames.
last = outputs[-5:]
xs = [f[0].x for f in last]
mean = sum(xs) / len(xs)
var = sum((v - mean) ** 2 for v in xs) / len(xs)
assert var < 0.005, f"face filter variance too high: {var}"
def test_hand_filter_left_right_independent() -> None:
chain = HandFilterChain()
rng = random.Random(7)
n_pts = 21
t = 0.0
last_l: list[PoseKp] = []
last_r: list[PoseKp] = []
for k in range(6):
t += 1.0 / 30.0
left_hand = _jitter_face(n_pts, 0.2, 0.5, 0.008, rng)
right_hand = _jitter_face(n_pts, 0.8, 0.5, 0.008, rng)
out = chain.apply([left_hand, right_hand], [0, 0],
["Left", "Right"], t)
last_l, last_r = out[0], out[1]
# Left and right hands keep distinct positions despite same pid.
assert abs(last_l[0].x - last_r[0].x) > 0.4
# Filter reduced jitter on each side.
assert 0.1 < last_l[0].x < 0.35
assert 0.65 < last_r[0].x < 0.9
def test_hand_filter_chain_wrapper_smoke() -> None:
chain = PoseFilterChain()
rng = random.Random(0)
hands = [_jitter_face(21, 0.5, 0.5, 0.01, rng) for _ in range(2)]
out = chain.apply_hand(hands, [0, 1], ["Left", "Right"], t_now=0.1)
assert len(out) == 2
assert len(out[0]) == 21
def test_face_filter_disabled_passthrough() -> None:
chain = FaceFilterChain(enabled_stages=())
faces = [[PoseKp(x=0.5, y=0.5, z=0.0, c=1.0) for _ in range(68)]]
out = chain.apply(faces, [0], t_now=0.0)
assert out[0][0].x == 0.5
def test_face_hand_latency_under_5ms() -> None:
"""Full chain (body 33 + face 68 + hand 21x2) < 5 ms per frame."""
body_chain = PoseFilterChain(
enabled_stages=("median", "kalman", "lookahead", "ik"))
face_chain = FaceFilterChain()
hand_chain = HandFilterChain()
rng = random.Random(0)
body = [Kp3D(x=i * 0.01, y=i * 0.02, z=i * 0.03, c=1.0)
for i in range(33)]
face = _jitter_face(68, 0.5, 0.5, 0.01, rng)
hand_l = _jitter_face(21, 0.2, 0.5, 0.01, rng)
hand_r = _jitter_face(21, 0.8, 0.5, 0.01, rng)
# Warm-up
for k in range(5):
t = k * 0.033
body_chain.apply([body], [0], t)
face_chain.apply([face], [0], t)
hand_chain.apply([hand_l, hand_r], [0, 0], ["Left", "Right"], t)
# Measure
durs: list[float] = []
for k in range(30):
t = (k + 5) * 0.033
t0 = time.perf_counter()
body_chain.apply([body], [0], t)
face_chain.apply([face], [0], t)
hand_chain.apply([hand_l, hand_r], [0, 0], ["Left", "Right"], t)
durs.append((time.perf_counter() - t0) * 1000.0)
avg = sum(durs) / len(durs)
# CI margin : actual M-class target is < 5 ms ; allow 25 ms in tests.
assert avg < 25.0, f"chain too slow: {avg:.2f} ms"
# ----------------------- multi.py discrimination ---------------------------
def _make_body(n_visible: int) -> list[PoseKp]:
"""Make a 33-joint body with `n_visible` high-conf joints, rest low."""
out: list[PoseKp] = []
for i in range(33):
c = 1.0 if i < n_visible else 0.05
# Spread across both x and y so the bbox has non-zero area.
out.append(PoseKp(x=0.1 + i * 0.01, y=0.2 + i * 0.005, z=0.0, c=c))
return out
def _make_body3d(n: int = 33) -> list[Kp3D]:
return [Kp3D(x=0.0, y=0.0, z=0.0, c=1.0) for _ in range(n)]
def _instantiate_worker():
"""Build a MultiWorker without starting the thread (skip if cv2 missing)."""
pytest.importorskip("cv2", reason="opencv not installed")
from data_only_viz.multi import MultiWorker
from data_only_viz.state import State
return MultiWorker(state=State(), camera_index=-1)
def test_ghost_rejection_drops_low_visibility_body() -> None:
w = _instantiate_worker()
bodies = [_make_body(n_visible=5), _make_body(n_visible=25)]
b3d = [_make_body3d(), _make_body3d()]
ids = [0, 1]
new_bodies, new_b3d, new_ids = w._reject_ghosts_and_nms(bodies, b3d, ids)
assert len(new_bodies) == 1
assert len(new_b3d) == 1
assert new_ids == [1]
assert w._n_ghost_dropped == 1
def test_nms_keeps_best_score() -> None:
w = _instantiate_worker()
# Two heavily overlapping bodies, second has higher mean confidence.
b1 = _make_body(n_visible=20)
b2 = _make_body(n_visible=33)
new_bodies, _, new_ids = w._reject_ghosts_and_nms([b1, b2], [], [0, 1])
# IoU of identical bbox => one dropped, the higher-score one kept.
assert len(new_bodies) == 1
assert new_ids == [1]
def test_pid_persistence_through_short_absence() -> None:
w = _instantiate_worker()
body = _make_body(n_visible=30)
# Frame 1..30 : pid 0 present.
for _ in range(30):
new_ids = w._apply_pid_hysteresis([body], [0])
assert new_ids == [0]
# Frames 31..35 : pid 0 absent (no detection).
for _ in range(5):
w._apply_pid_hysteresis([], [])
# Frame 36 : a NEW pid 9 appears at the same bbox -> should be remapped.
new_ids = w._apply_pid_hysteresis([body], [9])
assert new_ids == [0], f"expected hysteresis remap to 0, got {new_ids}"
def test_drop_low_visibility_face() -> None:
w = _instantiate_worker()
# 30 valid (non-zero) + 38 zeros.
face_bad = [
PoseKp(x=(0.1 if i < 30 else 0.0),
y=(0.1 if i < 30 else 0.0), z=0.0, c=1.0)
for i in range(68)
]
face_ok = [
PoseKp(x=0.1 + i * 0.001, y=0.2, z=0.0, c=1.0)
for i in range(68)
]
kept, ids = w._drop_low_visibility(
[face_bad, face_ok], [0, 1], min_visible=50, which="face")
assert len(kept) == 1
assert ids == [1]
assert w._n_face_dropped == 1
def test_drop_low_visibility_hand() -> None:
w = _instantiate_worker()
hand_bad = [PoseKp(x=0.0, y=0.0, z=0.0, c=1.0) for _ in range(21)]
# Only 10 visible (others are zero) -> drop.
for i in range(10):
hand_bad[i] = PoseKp(x=0.5, y=0.5, z=0.0, c=1.0)
hand_ok = [PoseKp(x=0.1 + i * 0.01, y=0.2, z=0.0, c=1.0)
for i in range(21)]
kept, ids = w._drop_low_visibility(
[hand_bad, hand_ok], [0, 1], min_visible=15, which="hand")
assert len(kept) == 1
assert ids == [1]
assert w._n_hand_dropped == 1
+144
View File
@@ -0,0 +1,144 @@
"""Tests for ICP registration of SMPL-X verts onto LiDAR point clouds."""
from __future__ import annotations
import numpy as np
import pytest
pytest.importorskip("open3d")
def _synthetic_smplx_torso(n: int = 1500, seed: int = 0) -> np.ndarray:
"""Generate a coarse capsule-like point cloud standing in for SMPL-X verts."""
rng = np.random.RandomState(seed)
z = rng.uniform(0.0, 1.7, size=n)
r = 0.12 + 0.02 * rng.randn(n)
theta = rng.uniform(0, 2 * np.pi, size=n)
x = r * np.cos(theta)
y = r * np.sin(theta)
return np.stack([x, y, z], axis=1).astype(np.float32)
def test_icp_recovers_small_translation() -> None:
from data_only_viz.icp_fusion import IcpConfig, register_mesh_to_lidar
src = _synthetic_smplx_torso(seed=1)
translation = np.array([0.05, 0.02, 0.10], dtype=np.float32)
tgt = src + translation + 0.005 * np.random.RandomState(2).randn(*src.shape).astype(np.float32)
out = register_mesh_to_lidar(src, tgt, config=IcpConfig())
assert out.accepted, f"ICP should accept, got fitness={out.fitness:.3f}"
truth = src + translation
err_before = np.linalg.norm(src - truth, axis=1).mean()
err_after = np.linalg.norm(out.vertices_registered - truth, axis=1).mean()
assert err_after < err_before * 0.5, f"err before={err_before:.4f} after={err_after:.4f}"
def test_icp_rejects_when_lidar_too_sparse() -> None:
from data_only_viz.icp_fusion import IcpConfig, register_mesh_to_lidar
src = _synthetic_smplx_torso(seed=3)
tgt = src[:5]
out = register_mesh_to_lidar(src, tgt, config=IcpConfig())
assert not out.accepted
np.testing.assert_array_equal(out.vertices_registered, src)
def test_icp_rejects_on_nan_input() -> None:
from data_only_viz.icp_fusion import IcpConfig, register_mesh_to_lidar
src = _synthetic_smplx_torso(seed=4)
src[10, 1] = np.nan
tgt = src.copy()
tgt = np.nan_to_num(tgt, nan=0.0)
out = register_mesh_to_lidar(src, tgt, config=IcpConfig())
assert not out.accepted
np.testing.assert_array_equal(out.vertices_registered, src)
def test_icp_preserves_dtype_and_shape() -> None:
from data_only_viz.icp_fusion import IcpConfig, register_mesh_to_lidar
src = _synthetic_smplx_torso(seed=5)
tgt = src + np.array([0.0, 0.0, 0.02], dtype=np.float32)
out = register_mesh_to_lidar(src, tgt, config=IcpConfig())
assert out.vertices_registered.shape == src.shape
assert out.vertices_registered.dtype == np.float32
def test_partition_lidar_by_pid_two_people() -> None:
from data_only_viz.icp_fusion import partition_lidar_by_pid
src_a = _synthetic_smplx_torso(seed=10) + np.array([-0.75, 0.0, 0.0], dtype=np.float32)
src_b = _synthetic_smplx_torso(seed=11) + np.array([+0.75, 0.0, 0.0], dtype=np.float32)
pelvis_a = src_a.mean(axis=0)
pelvis_b = src_b.mean(axis=0)
lidar = np.concatenate([
src_a + 0.01 * np.random.RandomState(20).randn(*src_a.shape).astype(np.float32),
src_b + 0.01 * np.random.RandomState(21).randn(*src_b.shape).astype(np.float32),
np.array([[10.0, 10.0, 10.0]] * 100, dtype=np.float32),
])
parts = partition_lidar_by_pid(lidar, pelvises={0: pelvis_a, 1: pelvis_b}, max_dist_m=1.0)
assert set(parts.keys()) == {0, 1}
assert parts[0].shape[0] > 1000
assert parts[1].shape[0] > 1000
assert not np.any(np.linalg.norm(parts[0] - np.array([10, 10, 10]), axis=1) < 0.5)
assert not np.any(np.linalg.norm(parts[1] - np.array([10, 10, 10]), axis=1) < 0.5)
def test_partition_returns_empty_dict_when_no_pelvises() -> None:
from data_only_viz.icp_fusion import partition_lidar_by_pid
out = partition_lidar_by_pid(np.zeros((100, 3), dtype=np.float32), pelvises={}, max_dist_m=1.0)
assert out == {}
def test_fusion_worker_in_place_update(monkeypatch) -> None:
from data_only_viz.icp_fusion import FusionWorker, IcpConfig
from data_only_viz.lidar_calib import Extrinsic
from data_only_viz.state import SMPLXPerson, State
src = _synthetic_smplx_torso(seed=30)
verts = np.zeros((10475, 3), dtype=np.float32)
verts[: src.shape[0]] = src
verts[5559] = src.mean(axis=0)
person = SMPLXPerson(pid=0, vertices_3d=verts.copy())
state = State()
state.persons_smplx = [person]
lidar_pts = src + np.array([0.0, 0.04, 0.0], dtype=np.float32)
state.lidar_points = lidar_pts
state.lidar_timestamp_ns = 1
worker = FusionWorker(
extrinsic=Extrinsic.identity(),
config=IcpConfig(),
)
metadata = worker.run_once(state)
assert metadata.applied == {0}
delta = state.persons_smplx[0].vertices_3d[5559] - verts[5559]
assert 0.02 <= delta[1] <= 0.06
def test_fusion_worker_skips_when_no_lidar() -> None:
from data_only_viz.icp_fusion import FusionWorker, IcpConfig
from data_only_viz.lidar_calib import Extrinsic
from data_only_viz.state import SMPLXPerson, State
verts = np.zeros((10475, 3), dtype=np.float32)
verts[5559] = [0.0, 1.0, 2.0]
state = State()
state.persons_smplx = [SMPLXPerson(pid=0, vertices_3d=verts.copy())]
state.lidar_points = None
worker = FusionWorker(extrinsic=Extrinsic.identity(), config=IcpConfig())
metadata = worker.run_once(state)
assert metadata.applied == set()
np.testing.assert_array_equal(state.persons_smplx[0].vertices_3d, verts)
@@ -0,0 +1,51 @@
"""IphoneOSCListener writes ARKit joints to state from OSC packets."""
import time
import numpy as np
import pytest
from pythonosc.udp_client import SimpleUDPClient
from data_only_viz.state import State
from data_only_viz.iphone_osc_listener import (
IphoneOSCListener, IPHONE_OSC_PORT,
)
@pytest.fixture()
def listener():
state = State()
listener = IphoneOSCListener(state, port=IPHONE_OSC_PORT + 100)
listener.start()
yield state, listener
listener.stop()
def test_kp_message_updates_state(listener):
state, lst = listener
client = SimpleUDPClient("127.0.0.1", lst.port)
client.send_message("/body3d/kp", [0, 1, 0.1, 0.2, 0.3])
# Settle
deadline = time.monotonic() + 1.0
while time.monotonic() < deadline:
with state.lock():
if 0 in state.persons_arkit_joints:
arr = state.persons_arkit_joints[0]
if arr[1, 0] != 0.0:
break
time.sleep(0.02)
with state.lock():
assert 0 in state.persons_arkit_joints, \
"OSC /body3d/kp message not received within 1s"
arr = state.persons_arkit_joints[0]
assert arr.shape == (91, 3)
assert np.allclose(arr[1], [0.1, 0.2, 0.3])
def test_gc_drops_stale_pids(listener):
state, lst = listener
with state.lock():
state.persons_arkit_joints[7] = np.zeros((91, 3), dtype=np.float32)
state.persons_arkit_last_t[7] = time.perf_counter() - 5.0
lst._gc_stale()
with state.lock():
assert 7 not in state.persons_arkit_joints
+76
View File
@@ -0,0 +1,76 @@
"""Tests for LiDAR <-> webcam extrinsic calibration persistence."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
def test_extrinsic_default_is_identity() -> None:
from data_only_viz.lidar_calib import Extrinsic
e = Extrinsic.identity()
np.testing.assert_allclose(e.T_arkit_to_cam, np.eye(4))
assert e.confidence == 0.0
assert e.captured_at_iso == ""
def test_extrinsic_roundtrip_json(tmp_path: Path) -> None:
from data_only_viz.lidar_calib import Extrinsic, load_extrinsic, save_extrinsic
T = np.eye(4)
T[:3, 3] = [0.1, -0.05, 0.30]
e = Extrinsic(T_arkit_to_cam=T, confidence=0.95, captured_at_iso="2026-05-14T12:00:00Z")
path = tmp_path / "extrinsic.json"
save_extrinsic(e, path)
loaded = load_extrinsic(path)
np.testing.assert_allclose(loaded.T_arkit_to_cam, T, atol=1e-10)
assert loaded.confidence == pytest.approx(0.95)
assert loaded.captured_at_iso == "2026-05-14T12:00:00Z"
def test_load_extrinsic_missing_path_returns_identity(tmp_path: Path) -> None:
from data_only_viz.lidar_calib import load_extrinsic
e = load_extrinsic(tmp_path / "does-not-exist.json")
np.testing.assert_allclose(e.T_arkit_to_cam, np.eye(4))
assert e.confidence == 0.0
def test_kabsch_recovers_known_rigid_transform() -> None:
from data_only_viz.lidar_calib import kabsch_rigid
rng = np.random.RandomState(7)
src = rng.randn(20, 3)
theta = np.deg2rad(30.0)
R = np.array([
[np.cos(theta), 0, np.sin(theta)],
[0, 1, 0],
[-np.sin(theta), 0, np.cos(theta)],
])
t = np.array([0.1, -0.2, 0.5])
tgt = src @ R.T + t
T = kabsch_rigid(src, tgt)
R_est = T[:3, :3]
t_est = T[:3, 3]
np.testing.assert_allclose(R_est, R, atol=1e-6)
np.testing.assert_allclose(t_est, t, atol=1e-6)
def test_kabsch_requires_at_least_three_pairs() -> None:
from data_only_viz.lidar_calib import kabsch_rigid
with pytest.raises(ValueError, match="at least 3"):
kabsch_rigid(np.zeros((2, 3)), np.zeros((2, 3)))
def test_kabsch_rejects_mismatched_shapes() -> None:
from data_only_viz.lidar_calib import kabsch_rigid
with pytest.raises(ValueError, match="shape"):
kabsch_rigid(np.zeros((5, 3)), np.zeros((4, 3)))
+111
View File
@@ -0,0 +1,111 @@
"""Unit tests for the iPhone LiDAR TCP frame decoder."""
from __future__ import annotations
import struct
import numpy as np
import pytest
def _encode_frame(points: np.ndarray, timestamp_ns: int) -> bytes:
"""Mimic the iPhone-side encoder for round-trip testing."""
n = points.shape[0]
body = struct.pack(">Q", timestamp_ns) + struct.pack(">I", n) + points.astype("<f4").tobytes()
header = struct.pack(">I", len(body))
return header + body
def test_decode_lidar_frame_roundtrip() -> None:
from data_only_viz.lidar_receiver import LidarFrame, decode_frame
pts = np.array([[0.1, 0.2, 0.3], [-1.0, 2.0, 5.5]], dtype=np.float32)
payload = _encode_frame(pts, timestamp_ns=1_700_000_000_000_000_000)
body = payload[4:]
frame = decode_frame(body)
assert isinstance(frame, LidarFrame)
assert frame.timestamp_ns == 1_700_000_000_000_000_000
np.testing.assert_allclose(frame.points, pts, atol=1e-6)
def test_decode_lidar_frame_rejects_truncated() -> None:
from data_only_viz.lidar_receiver import decode_frame
pts = np.array([[1.0, 2.0, 3.0]], dtype=np.float32)
body = (
struct.pack(">Q", 0) +
struct.pack(">I", 1) +
pts.astype("<f4").tobytes()[:8] # truncated
)
with pytest.raises(ValueError, match="truncated"):
decode_frame(body)
def test_decode_lidar_frame_rejects_zero_vertex_count() -> None:
from data_only_viz.lidar_receiver import decode_frame
body = struct.pack(">Q", 0) + struct.pack(">I", 0)
with pytest.raises(ValueError, match="vertex_count"):
decode_frame(body)
import socket
import threading
import time
@pytest.fixture
def unused_tcp_port() -> int:
"""Bind to port 0 to grab a free port from the OS, then release it."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
def _serve_one_frame(port: int, frame_bytes: bytes) -> None:
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", port))
srv.listen(1)
conn, _ = srv.accept()
conn.sendall(frame_bytes)
time.sleep(0.1)
conn.close()
srv.close()
def test_reader_grabs_latest_frame(unused_tcp_port: int) -> None:
from data_only_viz.lidar_receiver import LidarTCPReader
pts = np.array([[1.0, 2.0, 3.0]], dtype=np.float32)
frame = _encode_frame(pts, timestamp_ns=42)
t = threading.Thread(target=_serve_one_frame, args=(unused_tcp_port, frame), daemon=True)
t.start()
time.sleep(0.05)
reader = LidarTCPReader(host="127.0.0.1", port=unused_tcp_port, connect_timeout_s=2.0)
reader.start()
deadline = time.monotonic() + 2.0
latest = None
while time.monotonic() < deadline:
latest = reader.latest()
if latest is not None:
break
time.sleep(0.02)
reader.stop()
t.join(timeout=1.0)
assert latest is not None
assert latest.timestamp_ns == 42
np.testing.assert_allclose(latest.points, pts, atol=1e-6)
def test_reader_returns_none_before_first_frame(unused_tcp_port: int) -> None:
from data_only_viz.lidar_receiver import LidarTCPReader
reader = LidarTCPReader(host="127.0.0.1", port=unused_tcp_port, connect_timeout_s=0.05)
# Do not start it; latest() must be None.
assert reader.latest() is None
@@ -51,3 +51,16 @@ def test_state_mutations_are_all_under_lock():
f"line {lineno} mutates persons_smplx without a nearby `state.lock()` context:\n"
f"{lines[lineno - 1]}"
)
def test_predict_once_returns_none_when_coreml_unavailable(monkeypatch):
from data_only_viz.multi_hmr_worker import MultiHMRWorker
from data_only_viz.state import State
# Force CoreML loader to return None
state = State()
worker = MultiHMRWorker(state, num_persons=1)
monkeypatch.setattr(worker, "_get_or_load_coreml_backend", lambda: None)
import pytest, numpy as np
rgb = np.zeros((480, 640, 3), dtype=np.uint8)
with pytest.raises(NotImplementedError):
worker.predict_once(rgb)
@@ -0,0 +1,41 @@
"""arkit_pelvis_z_override : if ARKit pelvis z is fresh, replace
the Multi-HMR pred_cam_t.z so the SMPL-X mesh sits at the actual
distance instead of HaMeR's monocular guess.
"""
import time
import numpy as np
from data_only_viz.state import State
from data_only_viz.multi_hmr_worker import arkit_pelvis_z_override
def test_returns_arkit_z_when_fresh():
state = State()
arr = np.zeros((91, 3), dtype=np.float32)
arr[1] = (0.0, 0.0, 2.5) # ARKIT_PELVIS_IDX=1, z=2.5 m
with state.lock():
state.persons_arkit_joints[0] = arr
state.persons_arkit_last_t[0] = time.perf_counter()
z_pred = 5.0 # Multi-HMR ambiguous guess
z_out = arkit_pelvis_z_override(state, pid=0, z_pred=z_pred)
assert z_out == 2.5
def test_keeps_pred_when_stale():
state = State()
arr = np.zeros((91, 3), dtype=np.float32)
arr[1] = (0.0, 0.0, 2.5)
with state.lock():
state.persons_arkit_joints[0] = arr
state.persons_arkit_last_t[0] = time.perf_counter() - 5.0
z_pred = 5.0
z_out = arkit_pelvis_z_override(state, pid=0, z_pred=z_pred)
assert z_out == 5.0
def test_keeps_pred_when_pid_missing():
state = State()
z_pred = 4.2
z_out = arkit_pelvis_z_override(state, pid=99, z_pred=z_pred)
assert z_out == 4.2
+6 -2
View File
@@ -80,8 +80,12 @@ def test_infer_latency_under_target():
times.sort()
median_ms = times[n // 2]
print(f"median latency: {median_ms:.1f} ms (n={n})")
# Target 50ms = 20fps. M5 bench shows ~29ms. Generous margin.
assert median_ms < 80.0, f"median {median_ms:.1f}ms > 80ms target"
# Full Multi-HMR CoreML on M5: ~120-140 ms standalone (7-8 fps),
# see scripts/bench_multihmr_coreml.py and multihmr_coreml.py
# docstring. The earlier 80 ms target was a backbone-only probe
# estimate that does not hold for the full model. 250 ms gives
# headroom for thermal/contention without masking a regression.
assert median_ms < 250.0, f"median {median_ms:.1f}ms > 250ms target"
def test_filter_threshold():
+35
View File
@@ -0,0 +1,35 @@
"""Smoke test for the Open3D dependency used by ICP fusion."""
from __future__ import annotations
import numpy as np
import pytest
open3d = pytest.importorskip("open3d")
def test_open3d_pointcloud_roundtrip() -> None:
pts = np.random.RandomState(0).randn(100, 3).astype(np.float32)
pcd = open3d.geometry.PointCloud()
pcd.points = open3d.utility.Vector3dVector(pts)
out = np.asarray(pcd.points)
assert out.shape == (100, 3)
np.testing.assert_allclose(out, pts, atol=1e-5)
def test_open3d_icp_converges_on_translated_copy() -> None:
rng = np.random.RandomState(1)
src = rng.randn(500, 3).astype(np.float64)
translation = np.array([0.10, -0.05, 0.20])
tgt = src + translation
src_pcd = open3d.geometry.PointCloud()
src_pcd.points = open3d.utility.Vector3dVector(src)
tgt_pcd = open3d.geometry.PointCloud()
tgt_pcd.points = open3d.utility.Vector3dVector(tgt)
result = open3d.pipelines.registration.registration_icp(
src_pcd, tgt_pcd, max_correspondence_distance=0.5,
init=np.eye(4),
estimation_method=open3d.pipelines.registration.TransformationEstimationPointToPoint(),
)
np.testing.assert_allclose(result.transformation[:3, 3], translation, atol=1e-3)
+127
View File
@@ -0,0 +1,127 @@
"""Tests for the 3D pose filter chain."""
from __future__ import annotations
import math
import pytest
from data_only_viz.pose_filter import (
IKConstraints,
KalmanCV,
LookaheadPredictor,
MedianFilter,
PoseFilterChain,
L_ELBOW,
L_SHOULDER,
L_WRIST,
)
from data_only_viz.state import Kp3D
def _body(values: list[tuple[float, float, float]]) -> list[Kp3D]:
"""Build a 33-joint body, fill remaining with zeros."""
out = [Kp3D(x=v[0], y=v[1], z=v[2], c=1.0) for v in values]
while len(out) < 33:
out.append(Kp3D(x=0.0, y=0.0, z=0.0, c=1.0))
return out
def test_median_filter_kills_spike() -> None:
mf = MedianFilter(window=3)
pid, j = 0, 0
# Warm up
mf.apply(pid, j, 0.0, 0.0, 0.0)
mf.apply(pid, j, 0.01, 0.0, 0.0)
mf.apply(pid, j, 0.02, 0.0, 0.0)
# Spike (NaN)
x, y, z = mf.apply(pid, j, float("nan"), float("nan"), float("nan"))
assert math.isfinite(x) and math.isfinite(y) and math.isfinite(z)
assert abs(x) < 0.1
# Big outlier in x
x2, _, _ = mf.apply(pid, j, 10.0, 0.0, 0.0)
assert x2 < 1.0
def test_kalman_converges() -> None:
# Use a noisy constant-velocity signal : Kalman CV should converge.
import random
rng = random.Random(0)
kf = KalmanCV(q=1e-3, r=1e-2)
pid, j = 0, 0
t = 0.0
dt = 1.0 / 30.0
vel = 0.3 # m/s
errs: list[float] = []
for i in range(120):
t += dt
true_pos = vel * t
meas = true_pos + rng.gauss(0.0, 0.01) # 1 cm gaussian noise
out = kf.step(pid, j, meas, 0.0, 0.0, t)
if i > 30:
errs.append(abs(out[0] - true_pos))
mean_err = sum(errs) / len(errs)
assert mean_err < 0.01 # ±1 cm post warmup
def test_lookahead_extrapolates_constant_velocity() -> None:
pred = LookaheadPredictor(lookahead_ms=50.0, max_velocity=5.0)
x, y, z = pred.step(0.0, 0.0, 0.0, 1.0, 0.0, 0.0)
assert abs(x - 0.05) < 1e-6
assert abs(y) < 1e-9 and abs(z) < 1e-9
# Velocity cap
x2, _, _ = pred.step(0.0, 0.0, 0.0, 100.0, 0.0, 0.0)
assert abs(x2 - 5.0 * 0.050) < 1e-6
def test_ik_clamps_elbow_180_plus() -> None:
ik = IKConstraints()
# Shoulder at origin, elbow at (1,0,0), wrist BEHIND elbow at (2,0,0)
# -> shoulder-elbow-wrist angle is 180 deg, exceeds 175 deg limit.
coords: list[tuple[float, float, float]] = [(0.0, 0.0, 0.0)] * 33
coords[L_SHOULDER] = (0.0, 0.0, 0.0)
coords[L_ELBOW] = (1.0, 0.0, 0.0)
coords[L_WRIST] = (2.0, 0.0, 0.0)
body = _body(coords)
out = ik.apply(body)
p = (out[L_SHOULDER].x, out[L_SHOULDER].y, out[L_SHOULDER].z)
e = (out[L_ELBOW].x, out[L_ELBOW].y, out[L_ELBOW].z)
w = (out[L_WRIST].x, out[L_WRIST].y, out[L_WRIST].z)
v_pj = (p[0] - e[0], p[1] - e[1], p[2] - e[2])
v_cj = (w[0] - e[0], w[1] - e[1], w[2] - e[2])
n_pj = math.sqrt(sum(c * c for c in v_pj))
n_cj = math.sqrt(sum(c * c for c in v_cj))
cos_a = (v_pj[0] * v_cj[0] + v_pj[1] * v_cj[1] + v_pj[2] * v_cj[2]
) / (n_pj * n_cj)
cos_a = max(-1.0, min(1.0, cos_a))
ang_deg = math.degrees(math.acos(cos_a))
assert ang_deg <= 175.5
# Bone length preserved
assert abs(n_cj - 1.0) < 1e-6
def test_chain_no_op_when_disabled() -> None:
chain = PoseFilterChain(enabled_stages=())
body = _body([(0.1, 0.2, 0.3), (0.4, 0.5, 0.6)])
out = chain.apply([body], [0], t_now=0.0)
assert len(out) == 1
for i in range(len(body)):
assert out[0][i].x == body[i].x
assert out[0][i].y == body[i].y
assert out[0][i].z == body[i].z
def test_chain_latency_under_2ms() -> None:
chain = PoseFilterChain(
enabled_stages=("median", "kalman", "lookahead", "ik"))
body = _body([(i * 0.01, i * 0.02, i * 0.03) for i in range(33)])
# Warm up internal state
for k in range(5):
chain.apply([body, body], [0, 1], t_now=k * 0.033)
# Measure
times: list[float] = []
for k in range(30):
chain.apply([body, body], [0, 1], t_now=(k + 5) * 0.033)
times.append(chain.last_apply_ms)
avg = sum(times) / len(times)
# Generous bound for CI ; live target is <2 ms but allow 10 ms in tests.
assert avg < 10.0
+22
View File
@@ -0,0 +1,22 @@
"""State must expose persons_arkit_joints + persons_arkit_last_t."""
import numpy as np
from data_only_viz.state import State
def test_state_has_arkit_joint_fields():
s = State()
assert hasattr(s, "persons_arkit_joints")
assert hasattr(s, "persons_arkit_last_t")
assert isinstance(s.persons_arkit_joints, dict)
assert isinstance(s.persons_arkit_last_t, dict)
def test_state_arkit_joints_writable_under_lock():
s = State()
arr = np.zeros((91, 3), dtype=np.float32)
with s.lock():
s.persons_arkit_joints[0] = arr
s.persons_arkit_last_t[0] = 1.5
assert 0 in s.persons_arkit_joints
assert s.persons_arkit_last_t[0] == 1.5
+738 -5
View File
@@ -2,11 +2,15 @@ version = 1
revision = 3
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.12' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version < '3.12' and sys_platform == 'win32'",
"python_full_version >= '3.12' and sys_platform == 'darwin'",
"python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
"python_full_version >= '3.14' and sys_platform == 'darwin'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'darwin'",
"python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
"(python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
"python_full_version < '3.12' and sys_platform == 'darwin'",
"python_full_version < '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version < '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
@@ -21,6 +25,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" },
]
[[package]]
name = "addict"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/ef/fd7649da8af11d93979831e8f1f8097e85e82d5bfeabc8c68b39175d8e75/addict-2.4.0.tar.gz", hash = "sha256:b3b2210e0e067a281f5646c8c5db92e99b7231ea8b0eb5f74dbdf9e259d4e494", size = 9186, upload-time = "2020-11-21T16:21:31.416Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
@@ -87,6 +100,9 @@ detrpose = [
{ name = "transformers" },
{ name = "xtcocotools" },
]
lidar = [
{ name = "open3d" },
]
multihmr = [
{ name = "einops" },
{ name = "huggingface-hub" },
@@ -115,6 +131,21 @@ pose = [
{ name = "opencv-python" },
{ name = "ultralytics" },
]
smplerx = [
{ name = "einops" },
{ name = "mmcv-lite" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
{ name = "scipy" },
{ name = "smplx" },
{ name = "timm" },
{ name = "torch" },
{ name = "torchvision" },
{ name = "tqdm" },
{ name = "ultralytics" },
{ name = "yacs" },
]
[package.dev-dependencies]
dev = [
@@ -126,19 +157,25 @@ requires-dist = [
{ name = "cloudpickle", marker = "extra == 'detrpose'", specifier = ">=3.0" },
{ name = "coremltools", marker = "extra == 'pose'", specifier = ">=9.0" },
{ name = "einops", marker = "extra == 'multihmr'", specifier = ">=0.8" },
{ name = "einops", marker = "extra == 'smplerx'", specifier = ">=0.8" },
{ name = "huggingface-hub", marker = "extra == 'multihmr'", specifier = ">=0.24" },
{ name = "iopath", marker = "extra == 'detrpose'", specifier = ">=0.1.10" },
{ name = "iopath", marker = "extra == 'multihmr'", specifier = ">=0.1.10" },
{ name = "mediapipe", marker = "extra == 'pose'", specifier = ">=0.10.35" },
{ name = "mmcv-lite", marker = "extra == 'smplerx'", specifier = ">=2.1" },
{ name = "numpy", specifier = ">=1.26,<2" },
{ name = "numpy", marker = "extra == 'multihmr'", specifier = ">=1.26,<2" },
{ name = "numpy", marker = "extra == 'nlf'", specifier = ">=1.26" },
{ name = "numpy", marker = "extra == 'smplerx'", specifier = ">=1.26,<2" },
{ name = "omegaconf", marker = "extra == 'detrpose'", specifier = ">=2.3" },
{ name = "open3d", marker = "extra == 'lidar'", specifier = ">=0.18,<0.20" },
{ name = "opencv-python", marker = "extra == 'detrpose'", specifier = ">=4.10" },
{ name = "opencv-python", marker = "extra == 'multihmr'", specifier = ">=4.10" },
{ name = "opencv-python", marker = "extra == 'nlf'", specifier = ">=4.10" },
{ name = "opencv-python", marker = "extra == 'pose'", specifier = ">=4.10" },
{ name = "opencv-python", marker = "extra == 'smplerx'", specifier = ">=4.10" },
{ name = "pillow", marker = "extra == 'multihmr'", specifier = ">=10.0" },
{ name = "pillow", marker = "extra == 'smplerx'", specifier = ">=10.0" },
{ name = "pycocotools", marker = "extra == 'detrpose'", specifier = ">=2.0" },
{ name = "pyobjc-core", specifier = ">=10.3" },
{ name = "pyobjc-framework-avfoundation", specifier = ">=10.3" },
@@ -151,25 +188,42 @@ requires-dist = [
{ name = "scipy", specifier = ">=1.13" },
{ name = "scipy", marker = "extra == 'detrpose'", specifier = ">=1.13" },
{ name = "scipy", marker = "extra == 'multihmr'", specifier = ">=1.13" },
{ name = "scipy", marker = "extra == 'smplerx'", specifier = ">=1.13" },
{ name = "smplx", marker = "extra == 'multihmr'", specifier = ">=0.1.28" },
{ name = "smplx", marker = "extra == 'smplerx'", specifier = ">=0.1.28" },
{ name = "timm", marker = "extra == 'smplerx'", specifier = ">=1.0" },
{ name = "torch", marker = "extra == 'detrpose'", specifier = ">=2.4" },
{ name = "torch", marker = "extra == 'multihmr'", specifier = ">=2.4" },
{ name = "torch", marker = "extra == 'nlf'", specifier = ">=2.4" },
{ name = "torch", marker = "extra == 'smplerx'", specifier = ">=2.4" },
{ name = "torchgeometry", marker = "extra == 'multihmr'", specifier = ">=0.1.2" },
{ name = "torchvision", marker = "extra == 'detrpose'", specifier = ">=0.19" },
{ name = "torchvision", marker = "extra == 'multihmr'", specifier = ">=0.19" },
{ name = "torchvision", marker = "extra == 'nlf'", specifier = ">=0.19" },
{ name = "torchvision", marker = "extra == 'smplerx'", specifier = ">=0.19" },
{ name = "tqdm", marker = "extra == 'multihmr'", specifier = ">=4.65" },
{ name = "tqdm", marker = "extra == 'smplerx'", specifier = ">=4.65" },
{ name = "transformers", marker = "extra == 'detrpose'", specifier = ">=4.40" },
{ name = "trimesh", marker = "extra == 'multihmr'", specifier = ">=4.4" },
{ name = "ultralytics", marker = "extra == 'pose'", specifier = ">=8.3" },
{ name = "ultralytics", marker = "extra == 'smplerx'", specifier = ">=8.3" },
{ name = "xtcocotools", marker = "extra == 'detrpose'", specifier = ">=1.14" },
{ name = "yacs", marker = "extra == 'smplerx'", specifier = ">=0.1.8" },
]
provides-extras = ["pose", "detrpose", "nlf", "multihmr"]
provides-extras = ["pose", "detrpose", "lidar", "nlf", "multihmr", "smplerx"]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.3" }]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "cattrs"
version = "26.1.0"
@@ -381,6 +435,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "configargparse"
version = "1.7.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/0b/30328302903c55218ffc5199646d0e9d28348ff26c02ba77b2ffc58d294a/configargparse-1.7.5.tar.gz", hash = "sha256:e3f9a7bb6be34d66b2e3c4a2f58e3045f8dfae47b0dc039f87bcfaa0f193fb0f", size = 53548, upload-time = "2026-03-11T02:19:38.144Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/19/3ba5e1b0bcc7b91aeab6c258afd70e4907d220fed3972febe38feb40db30/configargparse-1.7.5-py3-none-any.whl", hash = "sha256:1e63fdffedf94da9cd435fc13a1cd24777e76879dd2343912c1f871d4ac8c592", size = 27692, upload-time = "2026-03-11T02:19:36.442Z" },
]
[[package]]
name = "contourpy"
version = "1.3.3"
@@ -604,6 +667,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/fa/d3c15189f7c52aaefbaea76fb012119b04b9013f4bf446cb4eb4c26c4e6b/cython-3.2.4-py3-none-any.whl", hash = "sha256:732fc93bc33ae4b14f6afaca663b916c2fdd5dcbfad7114e17fb2434eeaea45c", size = 1257078, upload-time = "2026-01-04T14:14:12.373Z" },
]
[[package]]
name = "dash"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "importlib-metadata" },
{ name = "nest-asyncio" },
{ name = "plotly" },
{ name = "requests" },
{ name = "retrying" },
{ name = "setuptools" },
{ name = "typing-extensions" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/da/a13ae3a6528bd51a6901461dbff4549c6009de203d6249a89b9a09ac5cfb/dash-4.1.0.tar.gz", hash = "sha256:17a92a87b0c1eacc025079a705e44e72cd4c5794629c0a2909942b611faeb595", size = 6927689, upload-time = "2026-03-23T20:39:47.578Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/00/10b1f8b3885fc4add1853e9603af15c593fa0be20d37c158c4d811e868dc/dash-4.1.0-py3-none-any.whl", hash = "sha256:1af9f302bc14061061012cdb129b7e370d3604b12a7f730b252ad8e4966f01f7", size = 7232489, upload-time = "2026-03-23T20:39:40.658Z" },
]
[[package]]
name = "einops"
version = "0.8.2"
@@ -613,6 +696,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638, upload-time = "2026-01-26T04:13:18.546Z" },
]
[[package]]
name = "fastjsonschema"
version = "2.21.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" },
]
[[package]]
name = "filelock"
version = "3.29.0"
@@ -622,6 +714,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "flatbuffers"
version = "25.12.19"
@@ -786,6 +895,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" },
]
[[package]]
name = "importlib-metadata"
version = "9.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -806,6 +927,15 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/72/73/b3d451dfc523756cf177d3ebb0af76dc7751b341c60e2a21871be400ae29/iopath-0.1.10.tar.gz", hash = "sha256:3311c16a4d9137223e20f141655759933e1eda24f8bff166af834af3c645ef01", size = 42226, upload-time = "2022-07-09T19:00:50.866Z" }
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -818,6 +948,55 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "joblib"
version = "1.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "jupyter-core"
version = "5.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
]
[[package]]
name = "kiwisolver"
version = "1.5.0"
@@ -1103,6 +1282,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/b3/5c7fa594c731e8dafab9f1a46ab6cef670fa62dbbfb6248cc70e42ec6fc5/mediapipe-0.10.35-py3-none-win_arm64.whl", hash = "sha256:46255326a6213118aaa518a7aa25e35f93337e82677960cc2a945f117bff8444", size = 9992331, upload-time = "2026-04-27T17:45:36.193Z" },
]
[[package]]
name = "mmcv-lite"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "addict" },
{ name = "mmengine" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "packaging" },
{ name = "pillow" },
{ name = "pyyaml" },
{ name = "regex", marker = "sys_platform == 'win32'" },
{ name = "yapf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8c/e7/075329ead4078d77b14e195f04b03162b99c5644d4186505113f62b7ca6c/mmcv-lite-2.2.0.tar.gz", hash = "sha256:62933ea165b2d9ad32e1b72ccd5ccba3cf71b5cd812c4c13c16cb2fcfc46a064", size = 479155, upload-time = "2024-04-24T14:24:38.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/bd/5d468c171f201d6169bec848d1116e95736854eec72307299bc68939fe45/mmcv_lite-2.2.0-py2.py3-none-any.whl", hash = "sha256:a24ee8dd3df7556dfced282dbfe8c3f87df6de2d4dcaf1207e83e9a2d58455a6", size = 732333, upload-time = "2024-04-24T14:24:28.555Z" },
]
[[package]]
name = "mmengine"
version = "0.10.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "addict" },
{ name = "matplotlib" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pyyaml" },
{ name = "regex", marker = "sys_platform == 'win32'" },
{ name = "rich" },
{ name = "termcolor" },
{ name = "yapf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/17/14/959360bbd8374e23fc1b720906999add16a3ac071a501636db12c5861ff5/mmengine-0.10.7.tar.gz", hash = "sha256:d20ffcc31127567e53dceff132612a87f0081de06cbb7ab2bdb7439125a69225", size = 378090, upload-time = "2025-03-04T12:23:09.568Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/8e/f98332248aad102511bea4ae19c0ddacd2f0a994f3ca4c82b7a369e0af8b/mmengine-0.10.7-py3-none-any.whl", hash = "sha256:262ac976a925562f78cd5fd14dd1bc9b680ed0aa81f0d85b723ef782f99c54ee", size = 452720, upload-time = "2025-03-04T12:23:06.339Z" },
]
[[package]]
name = "mpmath"
version = "1.3.0"
@@ -1112,6 +1331,39 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
]
[[package]]
name = "narwhals"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/0e/3ad61eb87088cc4932e0d851531fa82f845a6230b68b091a0e298cc7e537/narwhals-2.21.0.tar.gz", hash = "sha256:7c6e7f50528e62b7a967dd864d7e117d2955d38d4f730653ce46a9861358e2dc", size = 633083, upload-time = "2026-05-08T12:29:02.587Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/e1/68c2256b69a314eba133673377ba9118c356f6342a0c02b61de449cf2bf2/narwhals-2.21.0-py3-none-any.whl", hash = "sha256:1e6617d0fca68ae1fda29e5397c4eaacd3ffc9fffe6bcd6ded0c690475e853be", size = 451943, upload-time = "2026-05-08T12:29:01.058Z" },
]
[[package]]
name = "nbformat"
version = "5.10.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastjsonschema" },
{ name = "jsonschema" },
{ name = "jupyter-core" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" },
]
[[package]]
name = "nest-asyncio"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" },
]
[[package]]
name = "networkx"
version = "3.6.1"
@@ -1307,6 +1559,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500, upload-time = "2022-12-08T20:59:19.686Z" },
]
[[package]]
name = "open3d"
version = "0.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "addict" },
{ name = "configargparse" },
{ name = "dash" },
{ name = "flask" },
{ name = "matplotlib" },
{ name = "nbformat" },
{ name = "numpy" },
{ name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
{ name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
{ name = "pillow" },
{ name = "pyquaternion" },
{ name = "pyyaml" },
{ name = "scikit-learn" },
{ name = "tqdm" },
{ name = "werkzeug" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/37/8d1746fcb58c37a9bd868fdca9a36c25b3c277bd764b7146419d11d2a58d/open3d-0.19.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:117702467bfb1602e9ae0ee5e2c7bcf573ebcd227b36a26f9f08425b52c89929", size = 103098641, upload-time = "2025-01-08T07:26:12.371Z" },
{ url = "https://files.pythonhosted.org/packages/bc/50/339bae21d0078cc3d3735e8eaf493a353a17dcc95d76bcefaa8edcf723d3/open3d-0.19.0-cp311-cp311-manylinux_2_31_x86_64.whl", hash = "sha256:678017392f6cc64a19d83afeb5329ffe8196893de2432f4c258eaaa819421bb5", size = 447683616, upload-time = "2025-01-08T07:22:48.098Z" },
{ url = "https://files.pythonhosted.org/packages/a3/3c/358f1cc5b034dc6a785408b7aa7643e503229d890bcbc830cda9fce778b1/open3d-0.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:02091c309708f09da1167d2ea475e05d19f5e81dff025145f3afd9373cbba61f", size = 69151111, upload-time = "2025-01-08T07:27:22.662Z" },
{ url = "https://files.pythonhosted.org/packages/37/c5/286c605e087e72ad83eab130451ce13b768caa4374d926dc735edc20da5a/open3d-0.19.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:9e4a8d29443ba4c83010d199d56c96bf553dd970d3351692ab271759cbe2d7ac", size = 103202754, upload-time = "2025-01-08T07:26:27.169Z" },
{ url = "https://files.pythonhosted.org/packages/2b/95/3723e5ade77c234a1650db11cbe59fe25c4f5af6c224f8ea22ff088bb36a/open3d-0.19.0-cp312-cp312-manylinux_2_31_x86_64.whl", hash = "sha256:01e4590dc2209040292ebe509542fbf2bf869ea60bcd9be7a3fe77b65bad3192", size = 447665185, upload-time = "2025-01-08T07:23:39.769Z" },
{ url = "https://files.pythonhosted.org/packages/9f/c4/35a6e0a35aa72420e75dc28d54b24beaff79bcad150423e47c67d2ad8773/open3d-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:665839837e1d3a62524804c31031462c3b548a2b6ed55214e6deb91522844f97", size = 69169961, upload-time = "2025-01-08T07:27:35.392Z" },
]
[[package]]
name = "opencv-contrib-python"
version = "4.11.0.86"
@@ -1350,6 +1632,136 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pandas"
version = "2.3.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'darwin'",
"python_full_version >= '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version >= '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.14' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
]
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.14'" },
{ name = "python-dateutil", marker = "python_full_version >= '3.14'" },
{ name = "pytz", marker = "python_full_version >= '3.14'" },
{ name = "tzdata", marker = "python_full_version >= '3.14'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" },
{ url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" },
{ url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" },
{ url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" },
{ url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" },
{ url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" },
{ url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" },
{ url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" },
{ url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" },
{ url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" },
{ url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" },
{ url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" },
{ url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" },
{ url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" },
{ url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" },
{ url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" },
{ url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" },
{ url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" },
{ url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" },
{ url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" },
{ url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" },
{ url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" },
{ url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" },
{ url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" },
{ url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" },
{ url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" },
{ url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" },
{ url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" },
{ url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" },
{ url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" },
{ url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" },
{ url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" },
{ url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" },
{ url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" },
{ url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" },
{ url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" },
{ url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" },
{ url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" },
{ url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" },
]
[[package]]
name = "pandas"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version < '3.12' and sys_platform == 'win32'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'darwin'",
"python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
"python_full_version < '3.12' and sys_platform == 'darwin'",
"python_full_version < '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version < '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.12' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')",
]
dependencies = [
{ name = "numpy", marker = "python_full_version < '3.14'" },
{ name = "python-dateutil", marker = "python_full_version < '3.14'" },
{ name = "tzdata", marker = "(python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" },
{ url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" },
{ url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" },
{ url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" },
{ url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" },
{ url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" },
{ url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" },
{ url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" },
{ url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" },
{ url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" },
{ url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" },
{ url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" },
{ url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" },
{ url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" },
{ url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" },
{ url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" },
{ url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" },
{ url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" },
{ url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" },
{ url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" },
{ url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" },
{ url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" },
{ url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" },
{ url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" },
{ url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" },
{ url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" },
{ url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" },
{ url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" },
{ url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" },
{ url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" },
{ url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" },
{ url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" },
{ url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" },
{ url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" },
{ url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" },
{ url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" },
{ url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" },
{ url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" },
{ url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" },
{ url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" },
{ url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" },
{ url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" },
]
[[package]]
name = "pillow"
version = "12.2.0"
@@ -1437,6 +1849,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]]
name = "plotly"
version = "6.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "narwhals" },
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -1750,6 +2184,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]]
name = "pyquaternion"
version = "0.9.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/3d092aa20efaedacb89c3221a92c6491be5b28f618a2c36b52b53e7446c2/pyquaternion-0.9.9.tar.gz", hash = "sha256:b1f61af219cb2fe966b5fb79a192124f2e63a3f7a777ac3cadf2957b1a81bea8", size = 15530, upload-time = "2020-10-05T01:31:30.327Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/b3/d8482e8cacc8ea15a356efea13d22ce1c5914a9ee36622ba250523240bf2/pyquaternion-0.9.9-py3-none-any.whl", hash = "sha256:e65f6e3f7b1fdf1a9e23f82434334a1ae84f14223eee835190cd2e841f8172ec", size = 14361, upload-time = "2020-10-05T01:31:37.575Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
@@ -1787,6 +2233,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/a2/ce26e437e36707f0da839dd88b072413825ea8986b73ce7b5c4f3cd1b7f4/python_osc-1.10.2-py3-none-any.whl", hash = "sha256:018b28e1cc06427c2c3d695f4e8d87d0caecfe604ff889acc45235cfd94183a2", size = 45467, upload-time = "2026-04-02T20:46:03.718Z" },
]
[[package]]
name = "pytz"
version = "2026.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" },
]
[[package]]
name = "pywin32"
version = "311"
@@ -1861,6 +2316,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "regex"
version = "2026.5.9"
@@ -1980,6 +2449,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" },
]
[[package]]
name = "retrying"
version = "1.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
@@ -2002,6 +2480,114 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/55/bd/dd56edc2680df7658c7bdeeb58270ab35dfcdc0e2670899ce9771604b436/roma-1.5.6-py3-none-any.whl", hash = "sha256:ae21a4e095330428d60e1dbdcad4e550dbdc9a7b4597a0157aaf5d6fd2b46afb", size = 25327, upload-time = "2026-02-11T12:55:10.629Z" },
]
[[package]]
name = "rpds-py"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" },
{ url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" },
{ url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" },
{ url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" },
{ url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" },
{ url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" },
{ url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" },
{ url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" },
{ url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" },
{ url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" },
{ url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" },
{ url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" },
{ url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" },
{ url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
{ url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
{ url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
{ url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
{ url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
{ url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
{ url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
{ url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
{ url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
{ url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" },
{ url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" },
{ url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" },
{ url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" },
{ url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" },
{ url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" },
{ url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" },
{ url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" },
{ url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" },
{ url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" },
{ url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" },
]
[[package]]
name = "safetensors"
version = "0.7.0"
@@ -2024,6 +2610,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
]
[[package]]
name = "scikit-learn"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "joblib" },
{ name = "numpy" },
{ name = "scipy" },
{ name = "threadpoolctl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" },
{ url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" },
{ url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" },
{ url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" },
{ url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" },
{ url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" },
{ url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" },
{ url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" },
{ url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" },
{ url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" },
{ url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" },
{ url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" },
{ url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" },
{ url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
{ url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
{ url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
{ url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" },
{ url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
{ url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
{ url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
{ url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" },
{ url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" },
{ url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" },
{ url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" },
{ url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" },
{ url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" },
{ url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" },
{ url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" },
{ url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" },
{ url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" },
{ url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" },
{ url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" },
]
[[package]]
name = "scipy"
version = "1.17.1"
@@ -2163,6 +2799,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
[[package]]
name = "termcolor"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" },
]
[[package]]
name = "threadpoolctl"
version = "3.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
]
[[package]]
name = "timm"
version = "1.0.27"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
{ name = "pyyaml" },
{ name = "safetensors" },
{ name = "torch" },
{ name = "torchvision" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/54/ece85b0eef3700c90db8271a43669b05a0ebbe2edb1962329c34374a297e/timm-1.0.27.tar.gz", hash = "sha256:315dfe63186ca9fb7ff941268941231fd5be259f2b4bb4afa28560ae1015cb9a", size = 2439861, upload-time = "2026-05-08T19:38:36.844Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/2e/26bab7686ff4aed48f8f5f6c23e2aa37b7a37ddd9effe3aa61e908fd518f/timm-1.0.27-py3-none-any.whl", hash = "sha256:5ff07c9ddf53cbada88eab1c93ff175c64cab683b5a2fddf863bcee985926f89", size = 2589280, upload-time = "2026-05-08T19:38:35.034Z" },
]
[[package]]
name = "tokenizers"
version = "0.22.2"
@@ -2296,6 +2966,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
[[package]]
name = "traitlets"
version = "5.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" },
]
[[package]]
name = "transformers"
version = "5.8.0"
@@ -2371,6 +3050,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "tzdata"
version = "2026.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
]
[[package]]
name = "ultralytics"
version = "8.4.49"
@@ -2416,6 +3104,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
]
[[package]]
name = "xtcocotools"
version = "1.14.3"
@@ -2432,3 +3132,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/80/e0/01cf7f8b3f4229568b37de680d0eaadc651d5ee36bd483b83658f49dc6c2/xtcocotools-1.14.3-cp311-cp311-manylinux1_x86_64.whl", hash = "sha256:126ca596229b2016552bf27cad01f3a2f70a3ff7576a58305a00499cb9e0057d", size = 464351, upload-time = "2023-10-19T07:52:33.199Z" },
{ url = "https://files.pythonhosted.org/packages/a9/10/32bef0fcd29145dcda9bfaa9e11718f40acd444d6804cac870b0437fc7a8/xtcocotools-1.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:47cb5433903f30589343d54530e49abd6b61d0fd119857ba4948b8ce291dbee6", size = 88741, upload-time = "2023-10-19T07:53:51.151Z" },
]
[[package]]
name = "yacs"
version = "0.1.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/3e/4a45cb0738da6565f134c01d82ba291c746551b5bc82e781ec876eb20909/yacs-0.1.8.tar.gz", hash = "sha256:efc4c732942b3103bea904ee89af98bcd27d01f0ac12d8d4d369f1e7a2914384", size = 11100, upload-time = "2020-08-10T16:37:47.755Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/4f/fe9a4d472aa867878ce3bb7efb16654c5d63672b86dc0e6e953a67018433/yacs-0.1.8-py3-none-any.whl", hash = "sha256:99f893e30497a4b66842821bac316386f7bd5c4f47ad35c9073ef089aa33af32", size = 14747, upload-time = "2020-08-10T16:37:46.4Z" },
]
[[package]]
name = "yapf"
version = "0.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" },
]
[[package]]
name = "zipp"
version = "3.23.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" },
]
+69
View File
@@ -0,0 +1,69 @@
# ICP LiDAR ↔ SMPL-X Dense Fusion
Refines Multi-HMR SMPL-X meshes using live iPhone LiDAR via point-to-plane ICP.
## Env vars
| Var | Default | Effect |
|-----|---------|--------|
| `ICP_FUSION` | `0` | `1` enables LiDAR receiver + FusionWorker |
| `ICP_LIDAR_HOST` | _(required when on)_ | iPhone ARBodyTracker IP on the LAN |
| `ICP_LIDAR_PORT` | `5500` | TCP port the iOS app publishes ARMesh on |
| `ICP_LIDAR_EXTRINSIC` | `~/.config/av-live/lidar_extrinsic.json` | Path to persisted extrinsic JSON |
## Relation to ARKit joint fusion
ICP LiDAR fusion is **mesh-level** and complementary to the existing **joint-level** ARKit fusion (`iphone_osc_listener.py` + `pose_filter.py::ArkitFuse` + `multi_hmr_worker.arkit_pelvis_z_override`). The two run independently:
- **ARKit joints** (OSC :57128) — sparse (14 mapped joints), 60 Hz, fast, used to override MediaPipe pose joint slots and lock Multi-HMR pelvis Z.
- **ICP LiDAR mesh** (TCP :5500) — dense (~thousand points), 510 Hz, used to register Multi-HMR SMPL-X vertices onto the real-world geometry captured by the iPhone LiDAR.
They can be enabled together or separately. ICP runs only when `ICP_FUSION=1`.
## Calibration
1. Launch the iPhone ARBodyTracker app and note its LAN IP.
2. From `data_only_viz/`:
```bash
uv run --extra lidar python -m data_only_viz.scripts.calibrate_lidar \
--lidar-host <iPhone IP> --lidar-port 5500 --webcam-index 0
```
3. The script asks for 4 stances (front / left / right / back). Hold still each time and press ENTER.
4. The estimated extrinsic is written to `ICP_LIDAR_EXTRINSIC` (or the default path). Re-run any time the camera or iPhone moves.
> NOTE — as of the initial ICP MVP (Task 9), `multi_hmr_worker.predict_once` is a stub raising `NotImplementedError`. The calibration CLI runs the LiDAR reader and 4-stance loop scaffold but cannot capture the webcam pelvis side until a follow-up wires `predict_once` to the existing inference path. Track this in the next planning round.
## Runtime
```bash
ICP_FUSION=1 ICP_LIDAR_HOST=192.168.0.42 \
uv run --extra lidar python -m data_only_viz.main
```
## Architecture (summary)
```
iPhone ARBodyTracker app
├── OSC :57128 /body3d/kp → IphoneOSCListener (ARKit joint fusion)
└── TCP :5500 ARMeshAnchors → LidarTCPReader (ICP mesh fusion)
FusionWorker.run_once(state)
state.persons_smplx[*].vertices_3d
(replaced in place when ICP accepts)
```
ICP fusion runs in its own daemon thread (`IcpFusionThread`, target 8 Hz). It is opt-in (off by default) and a no-op if the LiDAR stream is absent.
## Troubleshooting
- **`open3d` missing** → `cd data_only_viz && uv sync --extra lidar`
- **No LiDAR frames** → check that the iPhone app is publishing on the expected port and that nothing else is bound to it. `nc -l 5500` from the Mac should not succeed while the app runs.
- **ICP always rejected (`fitness < 0.30`)** → the extrinsic is likely stale; re-run calibration. Verify the iPhone is facing the same scene as the webcam.
- **Mesh appears scaled wrong** → SMPL-X is in metres; the iPhone publishes metres. If you see a factor-1000 mismatch the iOS encoder is sending millimetres — patch the iOS app, not this code.
- **Bench shows `latency_ms_p95 > 100`** → reduce `IcpConfig.voxel_size_m` (e.g. 0.03 m) or `max_iterations` (e.g. 20).
- **Python `cp314` wheel failure on `uv sync --extra lidar`** → open3d does not ship cp313+ wheels yet. Use Python 3.12 (`uv venv --python 3.12`).
## Implementation note (Task 6 deviation)
`register_mesh_to_lidar` uses a two-stage coarse-to-fine ICP internally: a warm-start pass at `max(0.25 m, 5× threshold)` correspondence, then a strict pass at `IcpConfig.max_correspondence_m` (default 0.05 m). The accept/reject gate (`fitness ≥ 0.30`, `rmse ≤ 0.05 m`) is evaluated **only on the strict pass** so the contract is preserved. The warm-start makes ICP converge reliably when Multi-HMR's initial mesh sits more than 5 cm off the LiDAR surface (typical with a fresh calibration or pose change).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,921 @@
# iPhone LiDAR → Multi-HMR Fusion Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Wire the iOS ARBodyTracker app's `/body3d/kp` OSC stream into the Python pipeline so ARKit 91-joint LiDAR ground truth fixes Multi-HMR's scale ambiguity and reduces SMPL-X joint jitter via Kalman fusion.
**Architecture:** A new `iphone_osc_listener.py` worker subscribes to `/body3d/kp` on UDP `:57128` (port distinct from existing `:57126` body output) and writes per-pid 91-joint arrays into `state.persons_arkit_joints`. `pose_filter.py` gains an `arkit_fuse` stage that pulls the ARKit joints (low-noise ground truth) and overrides matching MediaPipe Pose 33 slots before the existing kalman/one_euro chain runs. `multi_hmr_worker.py` post-inference reads the ARKit pelvis world-z and rewrites `pred_cam_t` of the SMPL-X mesh so it lands at the actual depth instead of HaMeR's per-frame guess.
**Tech Stack:** Python 3.14, python-osc (OSCDispatcher), numpy, threading, pytest. Existing modules: `state.State`, `pose_filter.PoseFilterChain`, `multi_hmr_worker.MultiHMRWorker`.
---
## File Structure
| File | Responsibility |
|---|---|
| `data_only_viz/iphone_osc_listener.py` | **NEW**. ThreadingOSCUDPServer on `:57128`, routes `/body3d/kp pid joint_idx x y z``state.persons_arkit_joints[pid][joint_idx] = (x,y,z)`. GC entries older than 1.0s. |
| `data_only_viz/state.py` | **MODIFY**. Add fields `persons_arkit_joints: dict[int, np.ndarray]` (91×3 per pid) + `persons_arkit_last_t: dict[int, float]`. |
| `data_only_viz/arkit_joint_map.py` | **NEW**. Constant tuple `ARKIT91_TO_MP33` mapping ARKit joint indices → MediaPipe Pose 33 indices. |
| `data_only_viz/pose_filter.py` | **MODIFY**. Add `"arkit_fuse"` to `ALL_STAGES`, add `ArkitFuse` class, splice in `PoseFilterChain.apply` before kalman. |
| `data_only_viz/multi_hmr_worker.py` | **MODIFY**. After Multi-HMR inference, if `state.persons_arkit_joints[pid]` is fresh, override `pred_cam_t.z` with ARKit pelvis world-z. |
| `data_only_viz/main.py` | **MODIFY**. Start `IphoneOSCListener` in `_start_pose_worker` regardless of any --flag (always-on, harmless if no iPhone). |
| `data_only_viz/tests/test_iphone_osc_listener.py` | **NEW**. Unit tests: send fake OSC packets, assert state updated. |
| `data_only_viz/tests/test_arkit_fuse.py` | **NEW**. Unit tests: fake state, run PoseFilterChain.apply, assert MP33 slots overwritten. |
| `data_only_viz/tests/test_multihmr_arkit_z.py` | **NEW**. Unit test: fake ARKit pelvis z, assert pred_cam_t corrected. |
---
## Task 1: State fields for ARKit joints
**Files:**
- Modify: `data_only_viz/state.py` (add fields)
- Test: `data_only_viz/tests/test_state_arkit.py` (new)
- [ ] **Step 1: Write the failing test**
Create `data_only_viz/tests/test_state_arkit.py`:
```python
"""State must expose persons_arkit_joints + persons_arkit_last_t."""
import numpy as np
from data_only_viz.state import State
def test_state_has_arkit_joint_fields():
s = State()
assert hasattr(s, "persons_arkit_joints")
assert hasattr(s, "persons_arkit_last_t")
assert isinstance(s.persons_arkit_joints, dict)
assert isinstance(s.persons_arkit_last_t, dict)
def test_state_arkit_joints_writable_under_lock():
s = State()
arr = np.zeros((91, 3), dtype=np.float32)
with s.lock():
s.persons_arkit_joints[0] = arr
s.persons_arkit_last_t[0] = 1.5
assert 0 in s.persons_arkit_joints
assert s.persons_arkit_last_t[0] == 1.5
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_state_arkit.py -v`
Expected: FAIL with `AttributeError: 'State' object has no attribute 'persons_arkit_joints'`
- [ ] **Step 3: Add fields to State**
Edit `data_only_viz/state.py`. Find the existing line with `persons_hands_mesh_last_t: float = 0.0` (around line 134) and insert below it:
```python
# ARKit body tracking (iOS ARBodyTracker app) : 91 joints world
# space per pid. Same units as MediaPipe pose_world_landmarks
# (metres, hip-centered). Fresh = updated within < 1 s.
persons_arkit_joints: dict = field(default_factory=dict)
persons_arkit_last_t: dict = field(default_factory=dict)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_state_arkit.py -v`
Expected: PASS (2 passed)
- [ ] **Step 5: Commit**
```bash
cd /Users/electron/Documents/Projets/AV-Live
git add data_only_viz/state.py data_only_viz/tests/test_state_arkit.py
git commit -m "feat(state): add persons_arkit_joints + persons_arkit_last_t"
```
---
## Task 2: ARKit → MediaPipe joint index mapping
**Files:**
- Create: `data_only_viz/arkit_joint_map.py`
- Test: `data_only_viz/tests/test_arkit_joint_map.py` (new)
- [ ] **Step 1: Write the failing test**
Create `data_only_viz/tests/test_arkit_joint_map.py`:
```python
"""ARKit 91 joints → MediaPipe Pose 33 mapping integrity."""
from data_only_viz.arkit_joint_map import (
ARKIT91_TO_MP33, ARKIT_PELVIS_IDX, MP33_NUM_LANDMARKS,
)
def test_mapping_is_tuple_of_pairs():
assert isinstance(ARKIT91_TO_MP33, tuple)
assert len(ARKIT91_TO_MP33) > 0
for pair in ARKIT91_TO_MP33:
assert isinstance(pair, tuple)
assert len(pair) == 2
def test_mapping_indices_in_range():
for arkit_idx, mp33_idx in ARKIT91_TO_MP33:
assert 0 <= arkit_idx < 91, f"arkit idx out of range: {arkit_idx}"
assert 0 <= mp33_idx < MP33_NUM_LANDMARKS, \
f"mp33 idx out of range: {mp33_idx}"
def test_pelvis_index_valid():
assert 0 <= ARKIT_PELVIS_IDX < 91
def test_no_duplicate_mp33_targets():
"""Each MediaPipe slot must be written by at most one ARKit joint."""
mp33_seen = set()
for _, mp33_idx in ARKIT91_TO_MP33:
assert mp33_idx not in mp33_seen, \
f"mp33 slot {mp33_idx} mapped twice"
mp33_seen.add(mp33_idx)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_arkit_joint_map.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'data_only_viz.arkit_joint_map'`
- [ ] **Step 3: Create the mapping module**
Create `data_only_viz/arkit_joint_map.py`:
```python
"""ARKit ARSkeleton3D 91-joint indices → MediaPipe Pose 33 indices.
The ARKit ARSkeleton.JointName enum (Apple SDK) orders 91 joints
starting with the root, hips, spine chain, shoulders, etc. We pick
only the joints with a clear 1:1 anatomical correspondence to the
MediaPipe Pose 33 landmark set (which is what AVLiveBody renders).
Face/hand sub-joints (fingers, eyes) are skipped — those keep their
existing data sources (MediaPipe Face/Hand + HaMeR MANO).
Reference for ARKit joint order : Apple developer docs
"ARSkeleton.JointName" — the canonical 91-joint list runs from
root_joint=0 down to right_handThumbEndJoint=90.
The selection here mirrors `multi.py::SMPLX_TO_MP33` so the same 14
body slots are overridden by ARKit when fresh. Confidence comes
from ARKit's tracking state but is not currently fanned out — we
trust ARKit body tracking when its OSC frame is present.
"""
from __future__ import annotations
# MediaPipe Pose 33 cardinality (cf. mediapipe pose_world_landmarks).
MP33_NUM_LANDMARKS = 33
# Pelvis = ARKit hips_joint, slot 1 in the canonical enum order.
# Used by multi_hmr_worker for cam-translation z lock.
ARKIT_PELVIS_IDX = 1
# (arkit_joint_idx, mediapipe_pose_idx). Match the body slots used
# by the SMPL-X body fusion in multi.py.
ARKIT91_TO_MP33: tuple[tuple[int, int], ...] = (
(50, 11), # left_shoulder_1_joint -> L_SHOULDER
(32, 12), # right_shoulder_1_joint -> R_SHOULDER
(53, 13), # left_arm_joint -> L_ELBOW
(35, 14), # right_arm_joint -> R_ELBOW
(54, 15), # left_forearm_joint -> L_WRIST
(36, 16), # right_forearm_joint -> R_WRIST
(62, 23), # left_upLeg_joint -> L_HIP
(57, 24), # right_upLeg_joint -> R_HIP
(63, 25), # left_leg_joint -> L_KNEE
(58, 26), # right_leg_joint -> R_KNEE
(64, 27), # left_foot_joint -> L_ANKLE
(59, 28), # right_foot_joint -> R_ANKLE
(65, 31), # left_toes_joint -> L_FOOT_INDEX
(60, 32), # right_toes_joint -> R_FOOT_INDEX
)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_arkit_joint_map.py -v`
Expected: PASS (4 passed)
- [ ] **Step 5: Commit**
```bash
git add data_only_viz/arkit_joint_map.py data_only_viz/tests/test_arkit_joint_map.py
git commit -m "feat(viz): arkit 91-joint -> mediapipe 33 mapping"
```
---
## Task 3: iPhone OSC listener worker
**Files:**
- Create: `data_only_viz/iphone_osc_listener.py`
- Test: `data_only_viz/tests/test_iphone_osc_listener.py` (new)
- [ ] **Step 1: Write the failing test**
Create `data_only_viz/tests/test_iphone_osc_listener.py`:
```python
"""IphoneOSCListener writes ARKit joints to state from OSC packets."""
import time
import numpy as np
import pytest
from pythonosc.udp_client import SimpleUDPClient
from data_only_viz.state import State
from data_only_viz.iphone_osc_listener import (
IphoneOSCListener, IPHONE_OSC_PORT,
)
@pytest.fixture()
def listener():
state = State()
listener = IphoneOSCListener(state, port=IPHONE_OSC_PORT + 100)
listener.start()
yield state, listener
listener.stop()
def test_kp_message_updates_state(listener):
state, lst = listener
client = SimpleUDPClient("127.0.0.1", lst.port)
client.send_message("/body3d/kp", [0, 1, 0.1, 0.2, 0.3])
# Settle
deadline = time.monotonic() + 1.0
while time.monotonic() < deadline:
with state.lock():
if 0 in state.persons_arkit_joints:
arr = state.persons_arkit_joints[0]
if arr[1, 0] != 0.0:
break
time.sleep(0.02)
with state.lock():
arr = state.persons_arkit_joints[0]
assert arr.shape == (91, 3)
assert np.allclose(arr[1], [0.1, 0.2, 0.3])
def test_gc_drops_stale_pids(listener):
state, lst = listener
with state.lock():
state.persons_arkit_joints[7] = np.zeros((91, 3), dtype=np.float32)
state.persons_arkit_last_t[7] = time.perf_counter() - 5.0
lst._gc_stale()
with state.lock():
assert 7 not in state.persons_arkit_joints
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_iphone_osc_listener.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'data_only_viz.iphone_osc_listener'`
- [ ] **Step 3: Implement listener**
Create `data_only_viz/iphone_osc_listener.py`:
```python
"""OSC UDP listener for the iOS ARBodyTracker app.
Subscribes to /body3d/kp on UDP :57128 (distinct from MediaPipe
output :57126). Each /body3d/kp pid joint_idx x y z message stores
one joint of ARKit's 91-joint ARSkeleton3D into
state.persons_arkit_joints[pid] (np.ndarray shape (91, 3), float32).
A background GC drops pids whose last_t is older than 1.0 s.
Worker pattern mirrors osc_listener.OscListener.
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any
import numpy as np
from pythonosc import dispatcher, osc_server
from .state import State
LOG = logging.getLogger("iphone_osc")
IPHONE_OSC_PORT = 57128
ARKIT_NUM_JOINTS = 91
STALE_SEC = 1.0
class IphoneOSCListener:
def __init__(self, state: State, host: str = "0.0.0.0",
port: int = IPHONE_OSC_PORT) -> None:
self.state = state
self.host = host
self.port = port
self._server: osc_server.ThreadingOSCUDPServer | None = None
self._server_thread: threading.Thread | None = None
self._gc_thread: threading.Thread | None = None
self._stop = threading.Event()
def start(self) -> None:
d = dispatcher.Dispatcher()
d.map("/body3d/kp", self._on_kp)
d.map("/body3d/count", self._on_count)
self._server = osc_server.ThreadingOSCUDPServer(
(self.host, self.port), d)
self._server_thread = threading.Thread(
target=self._server.serve_forever,
name="iphone_osc", daemon=True)
self._server_thread.start()
self._gc_thread = threading.Thread(
target=self._gc_loop, name="iphone_gc", daemon=True)
self._gc_thread.start()
LOG.info("iphone OSC listening on %s:%d", self.host, self.port)
def stop(self) -> None:
self._stop.set()
if self._server is not None:
self._server.shutdown()
self._server.server_close()
self._server = None
def _on_kp(self, _addr: str, *args: Any) -> None:
if len(args) < 5:
return
try:
pid = int(args[0])
joint_idx = int(args[1])
x = float(args[2])
y = float(args[3])
z = float(args[4])
except (TypeError, ValueError):
return
if not (0 <= joint_idx < ARKIT_NUM_JOINTS):
return
with self.state.lock():
arr = self.state.persons_arkit_joints.get(pid)
if arr is None or arr.shape != (ARKIT_NUM_JOINTS, 3):
arr = np.zeros((ARKIT_NUM_JOINTS, 3), dtype=np.float32)
self.state.persons_arkit_joints[pid] = arr
arr[joint_idx] = (x, y, z)
self.state.persons_arkit_last_t[pid] = time.perf_counter()
def _on_count(self, _addr: str, *args: Any) -> None:
# Optional : we currently don't gate on count, but parse for log.
if not args:
return
try:
n = int(args[0])
except (TypeError, ValueError):
return
if not hasattr(self, "_last_hb") or \
time.monotonic() - self._last_hb > 5.0:
self._last_hb = time.monotonic()
LOG.info("hb: %d ARKit bodies live", n)
def _gc_stale(self) -> None:
cutoff = time.perf_counter() - STALE_SEC
with self.state.lock():
drop = [
pid for pid, t in self.state.persons_arkit_last_t.items()
if t < cutoff
]
for pid in drop:
self.state.persons_arkit_joints.pop(pid, None)
self.state.persons_arkit_last_t.pop(pid, None)
def _gc_loop(self) -> None:
while not self._stop.is_set():
self._gc_stale()
time.sleep(0.5)
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_iphone_osc_listener.py -v`
Expected: PASS (2 passed)
- [ ] **Step 5: Commit**
```bash
git add data_only_viz/iphone_osc_listener.py data_only_viz/tests/test_iphone_osc_listener.py
git commit -m "feat(viz): iphone OSC listener -> state.persons_arkit_joints"
```
---
## Task 4: ArkitFuse stage in PoseFilterChain
**Files:**
- Modify: `data_only_viz/pose_filter.py`
- Test: `data_only_viz/tests/test_arkit_fuse.py` (new)
- [ ] **Step 1: Write the failing test**
Create `data_only_viz/tests/test_arkit_fuse.py`:
```python
"""ArkitFuse stage overrides 14 body slots with ARKit data when fresh."""
import time
import numpy as np
from data_only_viz.state import Kp3D, State
from data_only_viz.pose_filter import PoseFilterChain
def _mp33_zero_body():
return [Kp3D(x=0.0, y=0.0, z=0.0, c=1.0) for _ in range(33)]
def test_arkit_fuse_overrides_shoulder():
state = State()
# ARKit publishes joint 50 (left shoulder) with (1.0, 2.0, 3.0)
arr = np.zeros((91, 3), dtype=np.float32)
arr[50] = (1.0, 2.0, 3.0)
with state.lock():
state.persons_arkit_joints[0] = arr
state.persons_arkit_last_t[0] = time.perf_counter()
chain = PoseFilterChain(state=state, enabled_stages=("arkit_fuse",))
bodies = [_mp33_zero_body()]
out = chain.apply(bodies, ids=[0], t_now=time.perf_counter())
# Slot 11 = L_SHOULDER (from ARKIT91_TO_MP33).
assert out[0][11].x == 1.0
assert out[0][11].y == 2.0
assert out[0][11].z == 3.0
def test_arkit_fuse_skips_stale():
state = State()
arr = np.zeros((91, 3), dtype=np.float32)
arr[50] = (9.0, 9.0, 9.0)
with state.lock():
state.persons_arkit_joints[0] = arr
state.persons_arkit_last_t[0] = time.perf_counter() - 5.0
chain = PoseFilterChain(state=state, enabled_stages=("arkit_fuse",))
bodies = [_mp33_zero_body()]
out = chain.apply(bodies, ids=[0], t_now=time.perf_counter())
# Stale -> not applied, MediaPipe zero left intact.
assert out[0][11].x == 0.0
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_arkit_fuse.py -v`
Expected: FAIL — `arkit_fuse` not in `ALL_STAGES` so chain.enabled is empty, no fuse happens.
- [ ] **Step 3: Add ArkitFuse class + register stage**
Edit `data_only_viz/pose_filter.py`. Find the line `ALL_STAGES = (...)` near the top and replace:
```python
ALL_STAGES = (
"median", "kalman", "spring", "lookahead", "ik",
"one_euro_joints", "one_euro_bones", "arkit_fuse",
)
```
Find the import block at the top (after `from .euro_filter import ...`) and add:
```python
from .arkit_joint_map import ARKIT91_TO_MP33
```
Find the `PoseFilterChain.__init__` method and after the line `self.one_euro_bones = BoneOneEuroFilter(...)` add:
```python
self.arkit_fuse = ArkitFuse()
```
In `PoseFilterChain.apply`, find the block defining `use_one_euro_joints = "one_euro_joints" in self.enabled` and add right after it:
```python
use_arkit_fuse = "arkit_fuse" in self.enabled
```
In the same method, find the outer `for body_i, kps in enumerate(bodies3d):` loop. The fuse happens BEFORE per-joint filtering (so kalman sees the fused signal). Insert this immediately after the `pid = ids[body_i] if body_i < len(ids) else -1` line:
```python
if use_arkit_fuse and self.state is not None:
kps = self.arkit_fuse.apply(self.state, pid, kps, t_now)
```
Then add the `ArkitFuse` class definition. Find the line `# ============================ face / hand =================================` and insert right BEFORE it:
```python
class ArkitFuse:
"""Splice ARKit 91-joint world-space data into MediaPipe Pose 33.
Reads ``state.persons_arkit_joints[pid]`` (shape (91, 3)) when fresh
(last_t within FRESH_SEC). Writes the 14 body slots covered by
ARKIT91_TO_MP33 ; everything else (face landmarks, finger tips)
stays MediaPipe-driven.
"""
FRESH_SEC: float = 1.0
def apply(self, state: "State", pid: int,
kps: list[Kp3D], t_now: float) -> list[Kp3D]:
with state.lock():
arr = state.persons_arkit_joints.get(pid)
last_t = state.persons_arkit_last_t.get(pid, 0.0)
if arr is None:
return kps
if t_now - last_t > self.FRESH_SEC:
return kps
out = list(kps)
n = len(out)
for arkit_idx, mp33_idx in ARKIT91_TO_MP33:
if mp33_idx >= n:
continue
x = float(arr[arkit_idx, 0])
y = float(arr[arkit_idx, 1])
z = float(arr[arkit_idx, 2])
old = out[mp33_idx]
out[mp33_idx] = Kp3D(x=x, y=y, z=z, c=getattr(old, "c", 1.0))
return out
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_arkit_fuse.py -v`
Expected: PASS (2 passed)
- [ ] **Step 5: Regression check existing filter tests**
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_pose_filter.py -v`
Expected: PASS (all existing pose_filter tests still green)
- [ ] **Step 6: Commit**
```bash
git add data_only_viz/pose_filter.py data_only_viz/tests/test_arkit_fuse.py
git commit -m "feat(viz): arkit_fuse stage in PoseFilterChain"
```
---
## Task 5: Cam-translation z lock in multi_hmr_worker
**Files:**
- Modify: `data_only_viz/multi_hmr_worker.py`
- Test: `data_only_viz/tests/test_multihmr_arkit_z.py` (new)
- [ ] **Step 1: Locate post-inference cam_t write site**
Run: `grep -n "pred_cam_t\|cam_t =" /Users/electron/Documents/Projets/AV-Live/data_only_viz/multi_hmr_worker.py | head -15`
You will see lines where `pred_cam_t` is read from model output and copied into `state.persons_smplx`. Look for the loop after model inference that assigns `transl=...` or `translation=...` per pid. Save the exact line numbers — Step 3 inserts the lock just AFTER the existing cam_t computation but BEFORE the state write.
- [ ] **Step 2: Write the failing test**
Create `data_only_viz/tests/test_multihmr_arkit_z.py`:
```python
"""arkit_pelvis_z_override : if ARKit pelvis z is fresh, replace
the Multi-HMR pred_cam_t.z so the SMPL-X mesh sits at the actual
distance instead of HaMeR's monocular guess.
"""
import time
import numpy as np
from data_only_viz.state import State
from data_only_viz.multi_hmr_worker import arkit_pelvis_z_override
def test_returns_arkit_z_when_fresh():
state = State()
arr = np.zeros((91, 3), dtype=np.float32)
arr[1] = (0.0, 0.0, 2.5) # ARKIT_PELVIS_IDX=1, z=2.5 m
with state.lock():
state.persons_arkit_joints[0] = arr
state.persons_arkit_last_t[0] = time.perf_counter()
z_pred = 5.0 # Multi-HMR ambiguous guess
z_out = arkit_pelvis_z_override(state, pid=0, z_pred=z_pred)
assert z_out == 2.5
def test_keeps_pred_when_stale():
state = State()
arr = np.zeros((91, 3), dtype=np.float32)
arr[1] = (0.0, 0.0, 2.5)
with state.lock():
state.persons_arkit_joints[0] = arr
state.persons_arkit_last_t[0] = time.perf_counter() - 5.0
z_pred = 5.0
z_out = arkit_pelvis_z_override(state, pid=0, z_pred=z_pred)
assert z_out == 5.0
def test_keeps_pred_when_pid_missing():
state = State()
z_pred = 4.2
z_out = arkit_pelvis_z_override(state, pid=99, z_pred=z_pred)
assert z_out == 4.2
```
- [ ] **Step 3: Run test to verify it fails**
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_multihmr_arkit_z.py -v`
Expected: FAIL — `arkit_pelvis_z_override` does not exist in multi_hmr_worker.
- [ ] **Step 4: Add the override function + import**
Edit `data_only_viz/multi_hmr_worker.py`. At the top of the file, add to the imports block:
```python
from .arkit_joint_map import ARKIT_PELVIS_IDX
```
Then at module level (after imports, before any class), add:
```python
def arkit_pelvis_z_override(state, pid: int, z_pred: float,
fresh_sec: float = 1.0) -> float:
"""Return ARKit pelvis world-z if a fresh ARKit frame exists for
this pid, otherwise return the Multi-HMR predicted z unchanged.
Used to resolve Multi-HMR's monocular scale ambiguity: ARKit's
LiDAR-anchored pelvis position is ground truth in the iPhone
world frame, which (after extrinsics calibration) is the same
metric scale as the SMPL-X cam-space output.
"""
import time as _time
with state.lock():
arr = state.persons_arkit_joints.get(pid)
last_t = state.persons_arkit_last_t.get(pid, 0.0)
if arr is None:
return float(z_pred)
if _time.perf_counter() - last_t > fresh_sec:
return float(z_pred)
return float(arr[ARKIT_PELVIS_IDX, 2])
```
- [ ] **Step 5: Run test to verify it passes**
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/test_multihmr_arkit_z.py -v`
Expected: PASS (3 passed)
- [ ] **Step 6: Wire override at the cam_t write site**
Now use the line numbers from Step 1. In `multi_hmr_worker.py`, find the per-person loop where `pred_cam_t` (or equivalent transl) is being written into the SMPL-X person record. For each person, after the model output's z is computed but before assignment to state, wrap the z value:
```python
z_locked = arkit_pelvis_z_override(
self.state, pid, float(transl[2]))
transl = np.array([transl[0], transl[1], z_locked],
dtype=transl.dtype)
```
(Adapt variable names to the exact context — `transl`, `t_full`, or whatever is used. The pattern is: take the existing z, replace via the override, repack.)
- [ ] **Step 7: Regression smoke**
Run: `cd /Users/electron/Documents/Projets/AV-Live && ~/avlive-venv/bin/python -m pytest data_only_viz/tests/ -q -x`
Expected: All tests pass (no regressions introduced).
- [ ] **Step 8: Commit**
```bash
git add data_only_viz/multi_hmr_worker.py data_only_viz/tests/test_multihmr_arkit_z.py
git commit -m "feat(viz): arkit pelvis z locks Multi-HMR cam translation"
```
---
## Task 6: Always-on listener in main.py
**Files:**
- Modify: `data_only_viz/main.py`
- [ ] **Step 1: Locate _start_pose_worker function**
Run: `grep -n "_start_pose_worker\|_maybe_start_webcam_source" /Users/electron/Documents/Projets/AV-Live/data_only_viz/main.py | head -5`
Note the line number where `_start_pose_worker` begins (around line 287).
- [ ] **Step 2: Insert listener startup**
Edit `data_only_viz/main.py`. In the body of `_start_pose_worker`, immediately after the call to `self._maybe_start_webcam_source()` (line ~289), insert:
```python
# iPhone ARBodyTracker (option 2 LiDAR fusion) : always-on
# listener on :57128. Harmless if no iPhone is broadcasting ;
# state.persons_arkit_joints stays empty and the arkit_fuse
# stage no-ops. Activated via POSE_FILTER=...+arkit_fuse.
try:
from .iphone_osc_listener import IphoneOSCListener
self._iphone_osc = IphoneOSCListener(self._state)
self._iphone_osc.start()
LOG.info("worker: + iPhone OSC listener :57128")
except Exception as e: # noqa: BLE001
LOG.warning("iphone OSC listener start failed (%s)", e)
```
- [ ] **Step 3: Smoke test the listener starts**
Run: `cd /Users/electron/Documents/Projets/AV-Live && AV_LIVE_INFERENCE_OFF=1 timeout 8 ~/avlive-venv/bin/python -u -c "
import os, sys, time
os.environ.setdefault('AV_SHARED_CAM', '0')
sys.path.insert(0, '/Users/electron/Documents/Projets/AV-Live')
import threading, logging
logging.basicConfig(level=logging.INFO)
from data_only_viz.state import State
from data_only_viz.iphone_osc_listener import IphoneOSCListener
s = State()
l = IphoneOSCListener(s)
l.start()
time.sleep(2)
print('listener up :', l.port)
l.stop()
print('stopped clean')
" 2>&1 | tail -5`
Expected output:
```
... iphone OSC listening on 0.0.0.0:57128
listener up : 57128
stopped clean
```
- [ ] **Step 4: Commit**
```bash
git add data_only_viz/main.py
git commit -m "feat(viz): start iphone OSC listener in main pose worker"
```
---
## Task 7: Documentation update
**Files:**
- Modify: `CLAUDE.md` (top-level env var table)
- Modify: `data_only_viz/CLAUDE.md` (POSE_FILTER stages table)
- [ ] **Step 1: Update top-level CLAUDE.md env var table**
Edit `/Users/electron/Documents/Projets/AV-Live/CLAUDE.md`. Find the row with `POSE_FILTER` and replace its description so it lists `arkit_fuse` as an available stage. Also append a new row for the iPhone OSC port. Use these exact replacements:
For the `POSE_FILTER` row, replace whatever is there with:
```
| `POSE_FILTER` | `median+kalman+lookahead+ik` | filter chain stages — extra: `one_euro_joints` (joint-space CHI 2012 One Euro, inserted before kalman), `one_euro_bones` (bone-vector One Euro applied after SMPL-X fusion in multi.py), `arkit_fuse` (overrides 14 body slots with ARKit ARSkeleton3D from the iOS app, expects /body3d/kp on :57128) |
```
Then below the `POSE_FILTER` row add:
```
| `IPHONE_OSC_PORT` | `57128` | UDP port the iPhone ARBodyTracker app pushes /body3d/kp to (always-on listener in data_only_viz) |
```
- [ ] **Step 2: Update data_only_viz/CLAUDE.md**
Edit `/Users/electron/Documents/Projets/AV-Live/data_only_viz/CLAUDE.md`. Find the "Conventions" section's filtering bullet (mentions `euro_filter.py`) and append after it:
```
- ARKit fusion : `iphone_osc_listener.py` consume /body3d/kp UDP :57128
→ `state.persons_arkit_joints`. `pose_filter.py::ArkitFuse` (stage
`arkit_fuse`) splices the 14 mapped body slots into MediaPipe pose
before kalman ; `multi_hmr_worker::arkit_pelvis_z_override` locks the
SMPL-X cam translation z to the ARKit pelvis. Mapping in
`arkit_joint_map.py`.
```
- [ ] **Step 3: Commit**
```bash
git add CLAUDE.md data_only_viz/CLAUDE.md
git commit -m "docs: iphone arkit fusion env + filter stage"
```
---
## Task 8: End-to-end live smoke
This is a manual verification step run once after the iOS app is
deployed to a real iPhone Pro and broadcasting on the LAN. No new
code ; just confirm wiring + telemetry.
- [ ] **Step 1: Start the GrosMac pipeline (already wired)**
Run: `bash /Users/electron/Documents/Projets/AV-Live/launcher/apps/dist/GrosMac-AVLive.app/Contents/MacOS/bootstrap &`
Wait ~8 s, then verify the listener line appeared:
```
grep "iphone OSC listening" ~/Library/Logs/AVLive/GrosMac-AVLive.python.log
```
Expected: a line `iphone OSC listening on 0.0.0.0:57128`.
- [ ] **Step 2: Start ARBodyTracker on iPhone**
In the iOS app (deployed via Xcode):
1. Host = your GrosMac LAN IP (`192.168.0.159`)
2. Port = `57128`
3. Tap **Start**
Stand 2 m in front of the iPhone with body fully visible. The app
status label should say "running (LiDAR depth, env mesh)".
- [ ] **Step 3: Confirm ARKit state on GrosMac**
Run on GrosMac while iPhone is broadcasting:
```bash
~/avlive-venv/bin/python -u -c "
import time, sys
sys.path.insert(0, '/Users/electron/Documents/Projets/AV-Live')
from data_only_viz.iphone_osc_listener import IphoneOSCListener
from data_only_viz.state import State
s = State()
l = IphoneOSCListener(s, port=57130) # alt port to avoid clash
l.start()
time.sleep(3)
with s.lock():
print('pids :', list(s.persons_arkit_joints.keys()))
if s.persons_arkit_joints:
pid = next(iter(s.persons_arkit_joints))
print('pelvis :', s.persons_arkit_joints[pid][1])
l.stop()
"
```
Expected: `pids : [0]` and `pelvis : [x, y, z]` with z > 0.
NOTE: this snippet uses port 57130 to avoid clashing with the live
listener already bound to 57128. To test against the live listener,
just open Activity Monitor's network panel for the Python process —
you should see UDP packets flowing in on :57128.
- [ ] **Step 4: Enable arkit_fuse in live pipeline**
The pipeline currently uses `POSE_FILTER` from the bootstrap env. To
add `arkit_fuse`, edit the GrosMac bootstrap and append the stage:
Edit `/Users/electron/Documents/Projets/AV-Live/launcher/apps/dist/GrosMac-AVLive.app/Contents/MacOS/bootstrap`. Find any existing `export POSE_FILTER=...` line (if absent, look around the `if [ "${ROLE}" = "source" ]; then` section for where envs are exported in the source branch) and add:
```bash
export POSE_FILTER="median+kalman+lookahead+ik+one_euro_joints+one_euro_bones+arkit_fuse"
```
Restart GrosMac bundle:
```bash
pkill -9 -f "AVLiveBody|data_only_viz"
open /Users/electron/Documents/Projets/AV-Live/launcher/apps/dist/GrosMac-AVLive.app
```
- [ ] **Step 5: Verify fusion in log**
After the live launch, tail the python log:
```bash
grep "PoseFilterChain stages" ~/Library/Logs/AVLive/GrosMac-AVLive.python.log
```
Expected: a line ending in `'arkit_fuse')`.
- [ ] **Step 6: Visual confirmation**
In AVLiveBody window (release build), the body wireframe should
visibly stabilise compared to a session without ARKit (shoulders/hips
no longer wobble between MediaPipe predictions ; the mesh sits at
the real-world depth from the camera instead of HaMeR's monocular
guess). If you see persistent jitter, double-check via Activity
Monitor that UDP :57128 traffic is non-zero, and that
`state.persons_arkit_joints` has fresh entries (Step 3 snippet).
---
## Self-Review
Spec coverage check :
- iphone_osc_listener.py ✅ Task 3
- state fields ✅ Task 1
- arkit_joint_map.py ✅ Task 2
- pose_filter arkit_fuse ✅ Task 4
- multi_hmr cam-z lock ✅ Task 5
- main.py startup ✅ Task 6
- Docs ✅ Task 7
- Live verification ✅ Task 8
ICP mesh fitting is intentionally deferred — that's a separate plan
once the joint-level fusion is proven stable.
Type consistency : `ARKIT91_TO_MP33` declared in Task 2, used in
Task 4 (pose_filter import) and Task 5 (multi_hmr import of
`ARKIT_PELVIS_IDX`). `IphoneOSCListener` defined Task 3, instantiated
Task 6. State fields `persons_arkit_joints` and
`persons_arkit_last_t` declared Task 1, consumed Tasks 3, 4, 5.
No placeholders, no TBD, every step is concrete with code or a
copy-pasteable command. The plan compiles a hot-loop story without
ICP, which keeps it bite-sized and shippable in a single working
session (~1 day for a fresh engineer).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,430 @@
# iPhone Capture Implementation Plan (Plan 2 of 3)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the iOS `ARBodyTracker` app stream the camera RGB video (HEVC) over the USB transport alongside the ARKit skeleton, and retire the legacy OSC/UDP sender — so the iPhone is a self-contained, network-free capture source.
**Architecture:** `ARBodySession` already captures the ARKit 91-joint skeleton and sends it as `AVLiveWire` `.skeleton` frames through `USBServer` (built in Plan 1). This plan adds a `VideoEncoder` (VideoToolbox hardware HEVC) that encodes each `ARFrame.capturedImage` and sends it as `.video` frames through the same `USBServer`. The OSC/UDP fanout (`/body3d/kp` to `host:57128/57129`) and its `ContentView` config fields are removed.
**Tech Stack:** Swift 5.10, ARKit, VideoToolbox, CoreMedia, `AVLiveWire` (local package), iOS 17. Build verification via `xcodebuild`.
**Companion spec:** `docs/superpowers/specs/2026-05-18-iphone-usb-body-link-design.md`
**Prerequisite:** Plan 1 (`docs/superpowers/plans/2026-05-18-iphone-usb-transport.md`) — merged.
---
## Verification note
The iOS app is an iOS-only target; it cannot be built with `swift build`
on a macOS host. The verification command for every task is:
```bash
cd iphone-arbody && xcodegen generate && \
xcodebuild -project ARBodyTracker.xcodeproj -scheme ARBodyTracker \
-sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
-configuration Debug build
```
Expected: `** BUILD SUCCEEDED **`. VideoToolbox HEVC encoding and ARKit
body tracking only run fully on a physical device — runtime behavior is
an owner on-device check, out of this plan's automated scope.
---
## File Structure
| File | Responsibility |
|------|----------------|
| `iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/VideoEncoder.swift` | NEW. VideoToolbox HEVC hardware encoder: `CVPixelBuffer``VideoPayload` via callback |
| `iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift` | MODIFY. Add video encoding in `session(_:didUpdate:)`; remove OSC fanout |
| `iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ContentView.swift` | MODIFY. Remove OSC host/port config UI |
---
## Task 1: VideoEncoder
`VideoEncoder` wraps a `VTCompressionSession` configured for HEVC. It
accepts `CVPixelBuffer`s and invokes `onPayload` with a `VideoPayload`
(keyframe flag + the access-unit bytes; for keyframes the HEVC
parameter sets are prepended so the Mac decoder is self-sufficient).
**Files:**
- Create: `iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/VideoEncoder.swift`
- [ ] **Step 1: Create the file**
```swift
import AVLiveWire
import CoreMedia
import CoreVideo
import Foundation
import VideoToolbox
/// Hardware HEVC encoder. Feed `CVPixelBuffer`s from ARKit frames in;
/// receive one `VideoPayload` per encoded access unit via `onPayload`.
/// Keyframe payloads carry the VPS/SPS/PPS parameter sets prepended,
/// each as a 4-byte-length-prefixed NAL unit, so the Mac decoder can
/// build its format description without a side channel.
final class VideoEncoder {
var onPayload: ((VideoPayload) -> Void)?
private var session: VTCompressionSession?
private let lock = NSLock()
/// Create the compression session for a given frame size.
func start(width: Int32, height: Int32) {
stop()
var s: VTCompressionSession?
let status = VTCompressionSessionCreate(
allocator: kCFAllocatorDefault,
width: width, height: height,
codecType: kCMVideoCodecType_HEVC,
encoderSpecification: nil,
imageBufferAttributes: nil,
compressedDataAllocator: nil,
outputCallback: nil,
refcon: nil,
compressionSessionOut: &s)
guard status == noErr, let s else {
NSLog("VideoEncoder: VTCompressionSessionCreate failed %d",
status)
return
}
VTSessionSetProperty(s, key: kVTCompressionPropertyKey_RealTime,
value: kCFBooleanTrue)
VTSessionSetProperty(s,
key: kVTCompressionPropertyKey_AllowFrameReordering,
value: kCFBooleanFalse)
VTSessionSetProperty(s,
key: kVTCompressionPropertyKey_MaxKeyFrameInterval,
value: 30 as CFNumber)
VTCompressionSessionPrepareToEncodeFrames(s)
session = s
}
/// Encode one frame. `pts` is the capture timestamp in seconds.
func encode(_ pixelBuffer: CVPixelBuffer, pts: Double) {
lock.lock(); let s = session; lock.unlock()
guard let s else { return }
let time = CMTime(seconds: pts, preferredTimescale: 1_000_000)
VTCompressionSessionEncodeFrame(
s, imageBuffer: pixelBuffer, presentationTimeStamp: time,
duration: .invalid, frameProperties: nil,
infoFlagsOut: nil) { [weak self] status, _, sample in
guard status == noErr, let sample else { return }
self?.handle(sample)
}
}
func stop() {
lock.lock(); let s = session; session = nil; lock.unlock()
if let s {
VTCompressionSessionInvalidate(s)
}
}
deinit { stop() }
// MARK: - Sample VideoPayload
private func handle(_ sample: CMSampleBuffer) {
let isKeyframe = !Self.notSync(sample)
var out = Data()
if isKeyframe, let fmt = CMSampleBufferGetFormatDescription(sample) {
out.append(Self.parameterSets(fmt))
}
if let block = CMSampleBufferGetDataBuffer(sample) {
var lengthOut = 0
var ptr: UnsafeMutablePointer<Int8>?
if CMBlockBufferGetDataPointer(
block, atOffset: 0, lengthAtOffsetOut: nil,
totalLengthOut: &lengthOut,
dataPointerOut: &ptr) == noErr, let ptr {
out.append(UnsafeBufferPointer(
start: UnsafeRawPointer(ptr)
.assumingMemoryBound(to: UInt8.self),
count: lengthOut))
}
}
guard !out.isEmpty else { return }
onPayload?(VideoPayload(isKeyframe: isKeyframe, data: out))
}
/// True if the sample is NOT a sync (key) frame.
private static func notSync(_ sample: CMSampleBuffer) -> Bool {
guard let arr = CMSampleBufferGetSampleAttachmentsArray(
sample, createIfNecessary: false),
CFArrayGetCount(arr) > 0 else { return false }
let dict = unsafeBitCast(CFArrayGetValueAtIndex(arr, 0),
to: CFDictionary.self)
let key = Unmanaged.passUnretained(
kCMSampleAttachmentKey_NotSync).toOpaque()
return CFDictionaryContainsKey(dict, key)
}
/// Concatenate the HEVC VPS/SPS/PPS parameter sets, each as a
/// 4-byte big-endian length prefix followed by the NAL bytes.
private static func parameterSets(
_ fmt: CMFormatDescription) -> Data {
var count = 0
CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(
fmt, parameterSetIndex: 0, parameterSetPointerOut: nil,
parameterSetSizeOut: nil, parameterSetCountOut: &count,
nalUnitHeaderLengthOut: nil)
var data = Data()
for i in 0..<count {
var ptr: UnsafePointer<UInt8>?
var size = 0
guard CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(
fmt, parameterSetIndex: i,
parameterSetPointerOut: &ptr,
parameterSetSizeOut: &size,
parameterSetCountOut: nil,
nalUnitHeaderLengthOut: nil) == noErr,
let ptr else { continue }
var be = UInt32(size).bigEndian
withUnsafeBytes(of: &be) { data.append(contentsOf: $0) }
data.append(UnsafeBufferPointer(start: ptr, count: size))
}
return data
}
}
```
- [ ] **Step 2: Verify it compiles**
Run the verification command from the "Verification note" section above.
Expected: `** BUILD SUCCEEDED **` (the new file compiles within the
target).
- [ ] **Step 3: Commit**
```bash
git add iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/VideoEncoder.swift
git commit -m "feat(ios): VideoToolbox HEVC encoder"
```
(subject ≤50 chars; add a short body — the commit hook rejects
subject-only messages; no AI attribution.)
---
## Task 2: Stream video from ARBodySession
Wire `VideoEncoder` into the ARKit frame loop. On each `didUpdate`
frame already processed for skeletons, also encode `capturedImage` and
send the resulting `VideoPayload` over the existing `USBServer`.
**Files:**
- Modify: `iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift`
- [ ] **Step 1: Add the encoder property and its payload wiring**
In `ARBodySession`, next to `private let usb = USBServer()` (currently
line 45), add:
```swift
private let videoEncoder = VideoEncoder()
private var videoStarted = false
```
In `init()`, after the `usb.onState = { ... }` block, add the encoder
output wiring:
```swift
videoEncoder.onPayload = { [weak self] payload in
Task { @MainActor in
guard let self, self.usbState == .connected else {
return
}
self.usb.send(tag: .video, pid: -1,
timestamp: self.lastFrameTime,
payload: payload.encoded())
}
}
```
- [ ] **Step 2: Encode the captured image in the frame loop**
In `session(_:didUpdate:)`, inside the `Task { @MainActor in ... }`
block, after `self.lastFrameTime = t` and before the anchor loop,
add video encoding:
```swift
// Start the encoder lazily once the first frame size is
// known, then encode every (throttled) frame.
let img = frame.capturedImage
let w = Int32(CVPixelBufferGetWidth(img))
let h = Int32(CVPixelBufferGetHeight(img))
if !self.videoStarted, w > 0, h > 0 {
self.videoEncoder.start(width: w, height: h)
self.videoStarted = true
}
if self.videoStarted {
self.videoEncoder.encode(img, pts: t)
}
```
- [ ] **Step 3: Stop the encoder on stop()**
In `stop()`, after `usb.stop()`, add:
```swift
videoEncoder.stop()
videoStarted = false
```
- [ ] **Step 4: Verify it compiles**
Run the verification command. Expected: `** BUILD SUCCEEDED **`.
- [ ] **Step 5: Commit**
```bash
git add iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift
git commit -m "feat(ios): stream HEVC video over USB"
```
---
## Task 3: Remove the legacy OSC sender
The OSC/UDP fanout is the network dependency the autonomous USB design
removes. Delete it from `ARBodySession`.
**Files:**
- Modify: `iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift`
- [ ] **Step 1: Delete OSC members and methods**
In `ARBodySession.swift`, delete:
- the stored properties `host`, `pythonPort`, `swiftPort` (currently
lines 39-41) and `conns` (line 44);
- the `configure(host:pythonPort:swiftPort:sendEnvMesh:)` method —
replace it with a parameterless `configure(sendEnvMesh:)`:
```swift
func configure(sendEnvMesh: Bool) {
self.sendEnvMesh = sendEnvMesh
}
```
- the call `openUDP()` in `start()`;
- in `stop()`, the lines `for c in conns { c.cancel() }` and
`conns.removeAll()`;
- the entire `// MARK: - UDP fanout` section: `openUDP()` and
`sendDatagram(_:)`;
- the `publishJoints(pid:body:)` method and its call site in
`session(_:didUpdate:)` (`self.publishJoints(pid: count, body: body)`);
- the `sendOSC(addr:args:)` call for `/body3d/count` in
`session(_:didUpdate:)`;
- the `// MARK: - OSC minimal encoder` section: the `OSCArg` enum,
`sendOSC(addr:args:)`, and `appendOSCString(_:into:)`.
After deletion, `Network` is still needed (`USBServer` uses it
indirectly — actually `USBServer` imports its own `Network`). Remove
`import Network` from `ARBodySession.swift` only if no symbol from it
remains; if `NWConnection`/`NWEndpoint` no longer appear in the file,
remove the import.
- [ ] **Step 2: Verify it compiles**
Run the verification command. Expected: `** BUILD SUCCEEDED **`. If the
build reports an unused `import` or an unresolved symbol, fix it
minimally (remove the dead import, or keep it if still referenced).
- [ ] **Step 3: Commit**
```bash
git add iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift
git commit -m "refactor(ios): drop legacy OSC sender"
```
---
## Task 4: Simplify ContentView
`ContentView` exposes OSC host/port text fields that no longer have a
backing. Remove them; keep the USB status indicator and Start/Stop.
**Files:**
- Modify: `iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ContentView.swift`
- [ ] **Step 1: Remove OSC state and UI**
In `ContentView`:
- delete the `@State` properties `host`, `pythonPort`, `swiftPort`
(currently lines 7-9);
- in `controlPanel`, delete the `HStack { Text("Host") ... }` block and
the `HStack { Text("Py") ... Text("Swift") ... }` block (the two
rows of OSC text fields, currently lines 85-102);
- in the Start/Stop button action, replace the `session.configure(
host:pythonPort:swiftPort:sendEnvMesh:)` call with
`session.configure(sendEnvMesh: sendEnvMesh)`.
Keep: `sendEnvMesh` toggle, Start/Stop button, status text, the USB
status dot/label, and the bodies/frames/jointsPerSec line.
- [ ] **Step 2: Verify it compiles**
Run the verification command. Expected: `** BUILD SUCCEEDED **`. The
three `#Preview` blocks at the end of the file construct
`ContentView(useMockBackground:useMockSkeleton:)` — those parameters
are unaffected; the previews must still compile.
- [ ] **Step 3: Commit**
```bash
git add iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ContentView.swift
git commit -m "refactor(ios): drop OSC config from ContentView"
```
---
## Task 5: Final build verification
- [ ] **Step 1: Full clean build**
```bash
cd iphone-arbody && xcodegen generate && \
xcodebuild -project ARBodyTracker.xcodeproj -scheme ARBodyTracker \
-sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
-configuration Debug clean build
```
Expected: `** BUILD SUCCEEDED **`, zero errors.
- [ ] **Step 2: Confirm no OSC references remain**
```bash
grep -rn -E "OSC|57128|57129|openUDP|sendDatagram" \
iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/
```
Expected: no matches in `ARBodySession.swift` or `ContentView.swift`.
(`USBServer.swift` and `VideoEncoder.swift` never had OSC.) Comments
mentioning history are acceptable; live OSC code is not.
- [ ] **Step 3: Commit any cleanup** (only if Step 2 found stragglers)
---
## Self-Review
- **Spec coverage:** This plan implements the spec's `VideoEncoder`
unit and the `ARBodySession` "exposes video frames / OSC sender
removed" requirement. `ARBodySession` already builds and sends
`SkeletonPayload` over `USBServer` (delivered via the recovery
branch + Plan 1), so no skeleton-path task is needed. `ContentView`
simplification follows from OSC removal.
- **Placeholders:** none — every step has concrete code or an exact
command and expected output.
- **Type consistency:** `VideoPayload`, `FrameTag.video`,
`USBServer.send(tag:pid:timestamp:payload:)` are used consistently
with their Plan 1 / `AVLiveWire` definitions. `VideoEncoder.start`
takes `Int32` width/height matching `CVPixelBufferGetWidth`'s `Int`
cast to `Int32`.
- **Known risk:** the `VideoEncoder` VideoToolbox code compiles on the
simulator but HEVC hardware encoding and the exact access-unit /
parameter-set byte layout can only be validated on a physical
device. Plan 3's `VideoDecoder` must agree with the framing chosen
here (length-prefixed parameter sets prepended to keyframe payloads);
this is the integration seam to verify when Plan 3 is built.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,549 @@
# macOS Multi-HMR Mesh Implementation Plan (Plan 3b of 3)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add the dense-mesh half of the macOS pipeline — run Multi-HMR (CoreML) on the USB video stream inside `AVLiveBody`, fuse the result with the ARKit skeleton, and render the SMPL-X body mesh.
**Architecture:** `VideoDecoder` (Plan 3a) already turns `.video` frames into `CVPixelBuffer`s. This plan adds `MultiHMRCoreML`, a Swift wrapper around the bundled `multihmr_full_672_s.mlpackage`: it preprocesses a pixel buffer into the model's two `MLMultiArray` inputs, runs inference, and parses up to 4 detected persons (10475-vertex SMPL-X meshes). `BodyFusion` associates each mesh with the ARKit skeleton from `USBSkeletonConsumer` and corrects pelvis depth. The existing `MeshRenderer` (which already renders 10475-vertex SMPL-X meshes from its OSC server) is fed from the fusion output.
**Tech Stack:** Swift 5, macOS 15, CoreML, CoreVideo/CoreImage, RealityKit, `AVLiveWire`, `XCTest`. Build verifies on the host with `swift build` / `swift test`.
**Companion spec:** `docs/superpowers/specs/2026-05-18-iphone-usb-body-link-design.md`
**Prerequisites:** Plan 1, 2, 3a (merged); the working CoreML model (voie 2).
---
## The model — exact I/O contract
The reference implementation is `data_only_viz/multihmr_coreml.py` (Python, validated). The Swift wrapper must mirror it:
- **File:** `~/.cache/av-live-multihmr/multihmr_full_672_s.mlpackage` (204 MB, FP32). Not in git (`*.mlpackage` is gitignored).
- **Load:** an `.mlpackage` must be compiled to `.mlmodelc` (`MLModel.compileModel(at:)`) before `MLModel(contentsOf:configuration:)`. Use `MLComputeUnits.cpuAndGPU` (benched best: ~139 ms standalone).
- **Inputs** (an `MLDictionaryFeatureProvider` with two `MLMultiArray`s):
- `"image"` — shape `[1, 3, 672, 672]`, Float32, RGB, **ImageNet-normalized**: `(v - mean) / std`, mean `[0.485, 0.456, 0.406]`, std `[0.229, 0.224, 0.225]` per channel. Feeding raw `[0,1]` collapses all scores (the "0 detections" bug).
- `"cam_K"` — shape `[1, 3, 3]`, Float32, camera intrinsics.
- **Outputs** (fixed K=4 persons):
- `var_2420` — v3d `[4, 10475, 3]` vertices
- `var_2423` — transl `[4, 1, 3]` pelvis translation
- `var_2436` — scores `[4]`
- `var_2439` — betas `[4, 10]`, `var_2442` — expression `[4, 10]` (unused here)
- **Detection:** keep person `k` when `scores[k] >= 0.3`.
---
## File Structure
| File | Responsibility |
|------|----------------|
| `launcher/AV-Live-Body/Sources/AVLiveBody/Resources/multihmr_full_672_s.mlpackage` | NEW (build input, gitignored). Copied from `~/.cache/av-live-multihmr/` by a setup step |
| `launcher/AV-Live-Body/Package.swift` | MODIFY. Declare the `.mlpackage` as a `.copy` resource |
| `launcher/AV-Live-Body/Sources/AVLiveBody/MultiHMRCoreML.swift` | NEW. Load the model; `CVPixelBuffer` → inputs → inference → `[MultiHMRPerson]` |
| `launcher/AV-Live-Body/Sources/AVLiveBody/BodyFusion.swift` | NEW. Associate ARKit skeleton ↔ Multi-HMR person; pelvis-depth correction |
| `launcher/AV-Live-Body/Tests/AVLiveBodyTests/BodyFusionTests.swift` | NEW. Pure association/correction logic tests |
| `launcher/AV-Live-Body/Sources/AVLiveBody/USBSkeletonConsumer.swift` | MODIFY. Drive `VideoDecoder``MultiHMRCoreML``BodyFusion``MeshRenderer` |
| `launcher/AV-Live-Body/Sources/AVLiveBody/MeshRenderer.swift` | REFERENCE — reuse its existing `updatePersons`-style entry point for 10475-vertex meshes |
---
## Task 1: Bundle the model + loader
**Files:**
- Create (copy): `launcher/AV-Live-Body/Sources/AVLiveBody/Resources/multihmr_full_672_s.mlpackage`
- Modify: `launcher/AV-Live-Body/Package.swift`
- [ ] **Step 1: Copy the model into the package resources**
The model is a build input that cannot live in git. Copy it:
```bash
mkdir -p launcher/AV-Live-Body/Sources/AVLiveBody/Resources
cp -R ~/.cache/av-live-multihmr/multihmr_full_672_s.mlpackage \
launcher/AV-Live-Body/Sources/AVLiveBody/Resources/
```
Verify it is gitignored (root `.gitignore` has `*.mlpackage`):
```bash
git check-ignore launcher/AV-Live-Body/Sources/AVLiveBody/Resources/multihmr_full_672_s.mlpackage
```
Expected: the path is printed (it is ignored — it must NOT be committed).
If the source file is absent, STOP — Plan 3b is blocked until voie 2's
`.mlpackage` is regenerated (`data_only_viz/scripts/coreml_full_probe.py`).
- [ ] **Step 2: Declare the resource in Package.swift**
In `launcher/AV-Live-Body/Package.swift`, add to the `AVLiveBody`
executable target's `resources:` array (next to the existing
`smplx_faces.bin` / `scene.metal` copies):
```swift
.copy("Resources/multihmr_full_672_s.mlpackage"),
```
- [ ] **Step 3: Verify the build still resolves resources**
Run: `cd launcher/AV-Live-Body && swift build`
Expected: build succeeds; the `.mlpackage` is copied into the bundle.
- [ ] **Step 4: Commit (Package.swift only — the model is gitignored)**
```bash
git add launcher/AV-Live-Body/Package.swift
git commit -m "build(av-live-body): bundle Multi-HMR mlpackage"
```
---
## Task 2: MultiHMRCoreML
`MultiHMRCoreML` loads the bundled model, preprocesses a `CVPixelBuffer`
into the two model inputs, runs inference, and returns detected persons.
**Files:**
- Create: `launcher/AV-Live-Body/Sources/AVLiveBody/MultiHMRCoreML.swift`
- [ ] **Step 1: Write the implementation**
`launcher/AV-Live-Body/Sources/AVLiveBody/MultiHMRCoreML.swift`:
```swift
import CoreML
import CoreVideo
import CoreImage
import Foundation
/// One detected SMPL-X body from Multi-HMR.
struct MultiHMRPerson {
var vertices: [SIMD3<Float>] // 10475 SMPL-X verts, model space
var translation: SIMD3<Float> // pelvis translation
var score: Float
}
/// CoreML wrapper around the bundled `multihmr_full_672_s.mlpackage`.
/// Mirrors `data_only_viz/multihmr_coreml.py`: two MLMultiArray inputs
/// (`image` 1x3x672x672 ImageNet-normalized, `cam_K` 1x3x3), fixed
/// K=4 person outputs.
final class MultiHMRCoreML {
static let inputSize = 672
static let vertexCount = 10475
static let maxPersons = 4
private static let detThreshold: Float = 0.3
private static let normMean: [Float] = [0.485, 0.456, 0.406]
private static let normStd: [Float] = [0.229, 0.224, 0.225]
private let model: MLModel
private let ciContext = CIContext()
/// Loads the bundled model. Returns nil if the resource or load
/// fails callers fall back to skeleton-only rendering.
init?() {
guard let url = Bundle.module.url(
forResource: "multihmr_full_672_s",
withExtension: "mlpackage") else {
NSLog("MultiHMRCoreML: mlpackage resource missing")
return nil
}
let cfg = MLModelConfiguration()
cfg.computeUnits = .cpuAndGPU
do {
let compiled = try MLModel.compileModel(at: url)
model = try MLModel(contentsOf: compiled, configuration: cfg)
} catch {
NSLog("MultiHMRCoreML: load failed %@",
String(describing: error))
return nil
}
}
/// Run inference on one camera frame. `cameraK` is the 3x3 camera
/// intrinsics row-major.
func infer(_ pixelBuffer: CVPixelBuffer,
cameraK: [Float]) -> [MultiHMRPerson] {
guard let image = makeImageInput(pixelBuffer),
let k = makeKInput(cameraK) else { return [] }
let inputs: [String: MLFeatureValue] = [
"image": MLFeatureValue(multiArray: image),
"cam_K": MLFeatureValue(multiArray: k),
]
guard let provider = try? MLDictionaryFeatureProvider(
dictionary: inputs),
let out = try? model.prediction(from: provider) else {
return []
}
return parse(out)
}
// MARK: - Input preprocessing
/// `CVPixelBuffer` -> [1,3,672,672] Float32, RGB, ImageNet-normed.
private func makeImageInput(_ pb: CVPixelBuffer) -> MLMultiArray? {
let n = Self.inputSize
// Resize to n x n BGRA via CoreImage.
let ci = CIImage(cvPixelBuffer: pb)
let sx = CGFloat(n) / ci.extent.width
let sy = CGFloat(n) / ci.extent.height
let scaled = ci.transformed(
by: CGAffineTransform(scaleX: sx, y: sy))
var dst: CVPixelBuffer?
CVPixelBufferCreate(kCFAllocatorDefault, n, n,
kCVPixelFormatType_32BGRA, nil, &dst)
guard let dst else { return nil }
ciContext.render(scaled, to: dst)
CVPixelBufferLockBaseAddress(dst, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(dst, .readOnly) }
guard let base = CVPixelBufferGetBaseAddress(dst) else {
return nil
}
let rowBytes = CVPixelBufferGetBytesPerRow(dst)
let px = base.assumingMemoryBound(to: UInt8.self)
guard let arr = try? MLMultiArray(
shape: [1, 3, NSNumber(value: n), NSNumber(value: n)],
dataType: .float32) else { return nil }
let ptr = arr.dataPointer.assumingMemoryBound(to: Float.self)
let plane = n * n
for y in 0..<n {
for x in 0..<n {
let p = y * rowBytes + x * 4 // BGRA
let b = Float(px[p]) / 255.0
let g = Float(px[p + 1]) / 255.0
let r = Float(px[p + 2]) / 255.0
let idx = y * n + x
ptr[idx] =
(r - Self.normMean[0]) / Self.normStd[0]
ptr[plane + idx] =
(g - Self.normMean[1]) / Self.normStd[1]
ptr[2 * plane + idx] =
(b - Self.normMean[2]) / Self.normStd[2]
}
}
return arr
}
/// 9 row-major intrinsics -> [1,3,3] Float32.
private func makeKInput(_ k: [Float]) -> MLMultiArray? {
guard k.count == 9,
let arr = try? MLMultiArray(
shape: [1, 3, 3], dataType: .float32) else { return nil }
let ptr = arr.dataPointer.assumingMemoryBound(to: Float.self)
for i in 0..<9 { ptr[i] = k[i] }
return arr
}
// MARK: - Output parsing
private func parse(_ out: MLFeatureProvider) -> [MultiHMRPerson] {
guard let v3d = out.featureValue(for: "var_2420")?
.multiArrayValue,
let transl = out.featureValue(for: "var_2423")?
.multiArrayValue,
let scores = out.featureValue(for: "var_2436")?
.multiArrayValue else { return [] }
var persons: [MultiHMRPerson] = []
let vc = Self.vertexCount
for k in 0..<Self.maxPersons {
let score = scores[k].floatValue
if score < Self.detThreshold { continue }
var verts = [SIMD3<Float>](
repeating: .zero, count: vc)
let base = k * vc * 3
for i in 0..<vc {
let o = base + i * 3
verts[i] = SIMD3(v3d[o].floatValue,
v3d[o + 1].floatValue,
v3d[o + 2].floatValue)
}
let tb = k * 3
persons.append(MultiHMRPerson(
vertices: verts,
translation: SIMD3(transl[tb].floatValue,
transl[tb + 1].floatValue,
transl[tb + 2].floatValue),
score: score))
}
return persons
}
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd launcher/AV-Live-Body && swift build`
Expected: build succeeds. `Bundle.module` exists because the target
has resources. If a CoreML signature differs on this SDK, fix
minimally; the I/O contract (two named MLMultiArray inputs, the three
named outputs) must be preserved.
- [ ] **Step 3: Commit**
```bash
git add launcher/AV-Live-Body/Sources/AVLiveBody/MultiHMRCoreML.swift
git commit -m "feat(av-live-body): Multi-HMR CoreML wrapper"
```
---
## Task 3: BodyFusion
`BodyFusion` is pure logic: given the ARKit 91-joint skeleton frames
(from `USBSkeletonConsumer`) and the Multi-HMR persons, associate each
mesh with the nearest skeleton and lock the mesh pelvis depth to the
ARKit pelvis Z (the LiDAR-anchored, metrically-correct depth).
**Files:**
- Create: `launcher/AV-Live-Body/Sources/AVLiveBody/BodyFusion.swift`
- Test: `launcher/AV-Live-Body/Tests/AVLiveBodyTests/BodyFusionTests.swift`
- [ ] **Step 1: Write the failing test**
`launcher/AV-Live-Body/Tests/AVLiveBodyTests/BodyFusionTests.swift`:
```swift
import XCTest
import AVLiveWire
@testable import AVLiveBody
final class BodyFusionTests: XCTestCase {
private func skeleton(pelvisZ: Float)
-> ArkitOSCListener.ArkitBodyFrame {
var f = ArkitOSCListener.ArkitBodyFrame()
f.pid = 0
// ARKit body skeleton joint 0 is the hips/pelvis root.
f.joints[0] = SIMD3(0, 0, pelvisZ)
f.hasJoint[0] = true
return f
}
func testPelvisDepthOverride() {
let mesh = MultiHMRPerson(
vertices: [SIMD3<Float>](repeating: .zero, count: 1),
translation: SIMD3(0, 0, -1.0), score: 0.9)
let fused = BodyFusion.fuse(
persons: [mesh], skeletons: [0: skeleton(pelvisZ: -2.5)])
XCTAssertEqual(fused.count, 1)
XCTAssertEqual(fused[0].translation.z, -2.5, accuracy: 1e-4)
}
func testPassthroughWhenNoSkeleton() {
let mesh = MultiHMRPerson(
vertices: [SIMD3<Float>](repeating: .zero, count: 1),
translation: SIMD3(0, 0, -1.0), score: 0.9)
let fused = BodyFusion.fuse(persons: [mesh], skeletons: [:])
XCTAssertEqual(fused[0].translation.z, -1.0, accuracy: 1e-4)
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cd launcher/AV-Live-Body && swift test --filter BodyFusionTests`
Expected: FAIL — `BodyFusion` undefined.
- [ ] **Step 3: Write the implementation**
`launcher/AV-Live-Body/Sources/AVLiveBody/BodyFusion.swift`:
```swift
import AVLiveWire
import Foundation
import simd
/// Associates Multi-HMR meshes with ARKit skeletons and corrects the
/// mesh pelvis depth. Pure, stateless unit-testable.
enum BodyFusion {
/// ARKit body skeleton root (hips) joint index.
static let pelvisJoint = 0
/// Returns the persons with `translation.z` of each replaced by
/// the matching ARKit skeleton's pelvis Z when one is available.
/// Association is nearest-translation; with a single skeleton and
/// a single dominant person this is exact.
static func fuse(persons: [MultiHMRPerson],
skeletons: [Int: ArkitOSCListener.ArkitBodyFrame])
-> [MultiHMRPerson] {
// Collect candidate ARKit pelvis depths.
let pelvisZs: [Float] = skeletons.values.compactMap { s in
guard pelvisJoint < s.hasJoint.count,
s.hasJoint[pelvisJoint] else { return nil }
return s.joints[pelvisJoint].z
}
guard !pelvisZs.isEmpty else { return persons }
// Highest-scoring person is the primary; lock its depth to the
// single ARKit skeleton (ARKit tracks one body). Others pass
// through unchanged.
guard let primaryIdx = persons.indices.max(by: {
persons[$0].score < persons[$1].score
}) else { return persons }
var out = persons
out[primaryIdx].translation.z = pelvisZs[0]
return out
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `cd launcher/AV-Live-Body && swift test --filter BodyFusionTests`
Expected: PASS, 2 tests.
- [ ] **Step 5: Run the full suite + commit**
Run: `cd launcher/AV-Live-Body && swift test` — Expected: all pass
(9: prior 7 + 2).
```bash
git add launcher/AV-Live-Body/Sources/AVLiveBody/BodyFusion.swift launcher/AV-Live-Body/Tests/AVLiveBodyTests/BodyFusionTests.swift
git commit -m "feat(av-live-body): ARKit-to-mesh body fusion"
```
---
## Task 4: Wire the mesh pipeline
Drive the chain: `USBSkeletonConsumer.onVideo``VideoDecoder`
`MultiHMRCoreML``BodyFusion``MeshRenderer`.
**Files:**
- Modify: `launcher/AV-Live-Body/Sources/AVLiveBody/USBSkeletonConsumer.swift`
- Reference: `launcher/AV-Live-Body/Sources/AVLiveBody/MeshRenderer.swift`
- [ ] **Step 1: Read `MeshRenderer.swift`**
Identify the method that ingests SMPL-X persons (the OSC `SMPX` server
path calls it — likely `updatePersons(_:)` taking per-person 10475
vertex arrays). Note its exact signature and the vertex/coordinate
convention it expects.
- [ ] **Step 2: Add the mesh pipeline to `USBSkeletonConsumer`**
Give `USBSkeletonConsumer` an optional mesh pipeline. Add stored
properties:
```swift
private let videoDecoder = VideoDecoder()
private let multiHMR = MultiHMRCoreML()
/// Set by the app to receive fused mesh persons on the main queue.
var onMeshPersons: (([MultiHMRPerson]) -> Void)?
/// Camera intrinsics (row-major 3x3) for Multi-HMR; a sane default
/// is the iPhone main-camera focal at 672 px until a `.meta` frame
/// supplies the real values.
private var cameraK: [Float] = [
672, 0, 336,
0, 672, 336,
0, 0, 1,
]
```
In `init()` (or `start()`), wire the decoder to the model:
```swift
videoDecoder.onFrame = { [weak self] pixelBuffer in
guard let self else { return }
guard let hmr = self.multiHMR else { return }
let raw = hmr.infer(pixelBuffer, cameraK: self.cameraK)
let latestSkeletons = self.bodies
let fused = BodyFusion.fuse(
persons: raw, skeletons: latestSkeletons)
DispatchQueue.main.async {
self.onMeshPersons?(fused)
}
}
```
Change the `.video` branch of `route(_:)` so it feeds the decoder
instead of only forwarding the payload:
```swift
case .video:
guard let payload =
VideoPayload(decoding: frame.payload) else { return }
videoDecoder.decode(payload)
```
(`onVideo` may be kept for diagnostics or removed — keeping it is
harmless; if removed, delete its declaration too.)
- [ ] **Step 3: Feed `MeshRenderer` from the app**
In `AVLiveBodyApp.swift`'s `ContentView` `.onAppear` (or where the
renderers are wired), set `usbConsumer.onMeshPersons` to call the
`MeshRenderer` ingest method identified in Step 1, converting
`[MultiHMRPerson]` (vertices + fused translation) into whatever shape
that method expects. The translation from `BodyFusion` positions each
mesh; the 10475 vertices are the SMPL-X surface.
If `MeshRenderer`'s ingest method is not reachable from `ContentView`
(it may be owned by `BodyView`), thread an `onMeshPersons` closure the
same way `usbConsumer` itself was threaded in Plan 3a Task 4.
- [ ] **Step 4: Verify build + tests**
Run: `cd launcher/AV-Live-Body && swift build && swift test`
Expected: build succeeds; all tests pass (9).
- [ ] **Step 5: Commit**
```bash
git add launcher/AV-Live-Body/Sources/AVLiveBody/USBSkeletonConsumer.swift launcher/AV-Live-Body/Sources/AVLiveBody/AVLiveBodyApp.swift
git commit -m "feat(av-live-body): wire Multi-HMR mesh pipeline"
```
(Include `BodyView.swift` in the commit if Step 3 threaded a closure
through it.)
---
## Task 5: Final verification
- [ ] **Step 1: Clean build + full test suite**
```bash
cd launcher/AV-Live-Body && swift build && swift test
```
Expected: build succeeds; all 9 tests pass.
- [ ] **Step 2: Confirm the model is bundled, not committed**
```bash
git status --porcelain | grep mlpackage || echo "model not staged — correct"
ls -d launcher/AV-Live-Body/Sources/AVLiveBody/Resources/multihmr_full_672_s.mlpackage
```
Expected: the model directory exists on disk but is NOT staged in git.
---
## Self-Review
- **Spec coverage:** This plan implements the spec's `MultiHMRCoreML`,
`BodyFusion`, and the mesh-render wiring — the dense-mesh half
deferred from Plan 3a. With Plan 3b done, the full spec
(`USBClient`/`StreamDemuxer`/`VideoDecoder`/`MultiHMRCoreML`/
`BodyFusion` + renderers) is covered.
- **Placeholders:** none — new files carry complete code; modify tasks
cite exact files and instruct reading `MeshRenderer.swift` for the
one signature this plan cannot reproduce blind.
- **Type consistency:** `MultiHMRPerson` is produced by
`MultiHMRCoreML.infer` and consumed by `BodyFusion.fuse` and
`onMeshPersons`. The model I/O names (`image`, `cam_K`, `var_2420`,
`var_2423`, `var_2436`) match `multihmr_coreml.py` exactly.
- **Known risks:**
1. **Bundling 204 MB**`swift build` copies the `.mlpackage` into
the app bundle; build is slower and the app is large. Acceptable
per the owner's decision (FP32, validated).
2. **`CVPixelBuffer` → tensor** — the CoreImage resize + manual
BGRA→normalized-CHW packing is the most error-prone code here and
needs on-device validation against `multihmr_coreml.py`'s output
on the same frame. It also runs per-frame on the CPU — a perf
hotspot; revisit with `vImage`/Metal if frame rate suffers.
3. **~7.6 fps** — Multi-HMR is far below 30 fps; the mesh layer is
slow while the skeleton (Plan 3a) stays real-time. `MeshRenderer`
already interpolates meshes to ~60 fps between worker frames —
reuse that, do not block the USB read loop on inference (the
`videoDecoder.onFrame` callback already runs off the main queue).
4. **`cameraK`** — a placeholder intrinsics matrix is used until a
`.meta` frame carries the real values; absolute depth scale will
be approximate until then. A future iteration should send camera
intrinsics from the iPhone in a `.meta` frame.
@@ -0,0 +1,655 @@
# macOS USB Consumer Implementation Plan (Plan 3a of 3)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the macOS `AVLiveBody` app consume the iPhone's USB stream — connect via `usbmuxd`, demux `AVLiveWire` frames, render the 91-joint skeleton on screen, and HEVC-decode the video — without the Multi-HMR dense-mesh step (deferred to Plan 3b).
**Architecture:** A new `USBSkeletonConsumer` runs the blocking `UnixMuxTransport`/`USBClient` read loop on a dedicated background thread, feeds bytes through `StreamDemuxer`, and republishes `.skeleton` frames as `@Published` ARKit-shaped body frames plus a `.video` callback. `Skeleton3DRenderer`'s long-standing `// TODO: render yellow ARKit markers` (line 138) is completed so the 91-joint USB skeleton actually draws. A new `VideoDecoder` turns `.video` `VideoPayload`s into `CVPixelBuffer`s via `VTDecompressionSession`.
**Tech Stack:** Swift 5 (language mode v5), macOS 15, RealityKit, VideoToolbox, `AVLiveWire` (already a dependency of `AV-Live-Body`), `XCTest`.
**Companion spec:** `docs/superpowers/specs/2026-05-18-iphone-usb-body-link-design.md`
**Prerequisites:** Plan 1 (transport, merged), Plan 2 (iOS capture, merged).
**Out of scope:** `MultiHMRCoreML`, `BodyFusion`, dense-mesh rendering — Plan 3b, gated on a confirmed CoreML Multi-HMR `.mlpackage`.
---
## Verification
`AV-Live-Body` is a macOS target — it builds on the host:
```bash
cd launcher/AV-Live-Body && swift build
cd launcher/AV-Live-Body && swift test
```
Each task ends with `swift build` (and `swift test` where a test was
added) succeeding.
---
## File Structure
| File | Responsibility |
|------|----------------|
| `launcher/AV-Live-Body/Sources/AVLiveBody/USBSkeletonConsumer.swift` | NEW. Background USB read loop → `StreamDemuxer``@Published` body frames + video callback |
| `launcher/AV-Live-Body/Sources/AVLiveBody/VideoDecoder.swift` | NEW. `VTDecompressionSession` HEVC decode: `VideoPayload``CVPixelBuffer` |
| `launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBSkeletonConsumerTests.swift` | NEW. Unit test for the `SkeletonPayload``ArkitBodyFrame` mapping |
| `launcher/AV-Live-Body/Sources/AVLiveBody/Skeleton3DRenderer.swift` | MODIFY. Complete the line-138 TODO: draw 91 USB-skeleton joint markers |
| `launcher/AV-Live-Body/Sources/AVLiveBody/ArkitOSCListener.swift` | REFERENCE only — reuse its nested `ArkitBodyFrame` type |
| `launcher/AV-Live-Body/Sources/AVLiveBody/AVLiveBodyApp.swift` | MODIFY. Own a `USBSkeletonConsumer`, start it in `.onAppear` |
| `launcher/AV-Live-Body/Sources/AVLiveBody/BodyView.swift` | MODIFY. Thread the consumer into `Skeleton3DRenderer.attach` |
---
## Task 1: USBSkeletonConsumer
`USBSkeletonConsumer` owns the blocking USB read loop on a background
`Thread`. It reconnects on drop. It republishes `.skeleton` frames as
`ArkitOSCListener.ArkitBodyFrame` (the existing 91-joint body type, so
`Skeleton3DRenderer` can consume them with no new type) and forwards
`.video` payloads via a callback. It is **not** `@MainActor`: the loop
runs off-main and hops to main only for `@Published` writes — the same
pattern as `ArkitOSCListener`.
**Files:**
- Create: `launcher/AV-Live-Body/Sources/AVLiveBody/USBSkeletonConsumer.swift`
- Test: `launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBSkeletonConsumerTests.swift`
- [ ] **Step 1: Write the failing test**
`launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBSkeletonConsumerTests.swift`:
```swift
import XCTest
import AVLiveWire
@testable import AVLiveBody
final class USBSkeletonConsumerTests: XCTestCase {
func testSkeletonPayloadMapsToBodyFrame() {
var p = SkeletonPayload()
p.joints[0] = SIMD3(1, 2, 3)
p.valid[0] = true
p.joints[90] = SIMD3(-4, 5, -6)
p.valid[90] = true
let frame = USBSkeletonConsumer.bodyFrame(pid: 7, from: p)
XCTAssertEqual(frame.pid, 7)
XCTAssertEqual(frame.joints.count, 91)
XCTAssertEqual(frame.hasJoint.count, 91)
XCTAssertEqual(frame.joints[0], SIMD3(1, 2, 3))
XCTAssertTrue(frame.hasJoint[0])
XCTAssertEqual(frame.joints[90], SIMD3(-4, 5, -6))
XCTAssertFalse(frame.hasJoint[1])
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cd launcher/AV-Live-Body && swift test --filter USBSkeletonConsumerTests`
Expected: FAIL — `USBSkeletonConsumer` undefined.
- [ ] **Step 3: Write the implementation**
`launcher/AV-Live-Body/Sources/AVLiveBody/USBSkeletonConsumer.swift`:
```swift
import AVLiveWire
import Combine
import Foundation
/// Connects to the tethered iPhone over USB (usbmuxd), demuxes the
/// AVLiveWire stream, and republishes skeleton frames (as the existing
/// 91-joint `ArkitOSCListener.ArkitBodyFrame`) plus video payloads.
/// The blocking transport runs on a dedicated background thread; only
/// `@Published` writes hop to the main queue.
final class USBSkeletonConsumer: ObservableObject {
/// 91-joint body frames keyed by pid same shape `Skeleton3DRenderer`
/// already consumes from `ArkitOSCListener`.
@Published var bodies: [Int: ArkitOSCListener.ArkitBodyFrame] = [:]
@Published var connected = false
/// Called (on the main queue) for every decoded `.video` frame.
var onVideo: ((VideoPayload) -> Void)?
/// TCP port the iPhone `USBServer` listens on (must match the iOS
/// app's `USBServer.port`).
static let devicePort: UInt16 = 7000
private let stateLock = NSLock()
private var running = false
private var thread: Thread?
private var isRunning: Bool {
stateLock.lock(); defer { stateLock.unlock() }
return running
}
func start() {
stateLock.lock()
if running { stateLock.unlock(); return }
running = true
stateLock.unlock()
let t = Thread { [weak self] in self?.loop() }
t.name = "cc.avlive.usbconsumer"
t.start()
thread = t
}
func stop() {
stateLock.lock(); running = false; stateLock.unlock()
}
/// Pure mapping `SkeletonPayload` -> `ArkitBodyFrame`. Static so it
/// is unit-testable without a transport.
static func bodyFrame(pid: Int, from p: SkeletonPayload)
-> ArkitOSCListener.ArkitBodyFrame {
var f = ArkitOSCListener.ArkitBodyFrame()
f.pid = pid
f.joints = p.joints
f.hasJoint = p.valid
f.seenAt = CFAbsoluteTimeGetCurrent()
return f
}
// MARK: - Background read loop
private func loop() {
while isRunning {
guard let transport = UnixMuxTransport() else {
Thread.sleep(forTimeInterval: 1.0); continue
}
let client = USBClient(transport: transport)
guard let dev = client.listDevices().first,
client.connect(deviceID: dev,
port: Self.devicePort) else {
transport.close()
Thread.sleep(forTimeInterval: 1.0); continue
}
publishConnected(true)
var demux = StreamDemuxer()
while isRunning {
guard let chunk = transport.readStream(),
!chunk.isEmpty else { break }
for frame in demux.feed(chunk) { route(frame) }
}
transport.close()
publishConnected(false)
if isRunning { Thread.sleep(forTimeInterval: 1.0) }
}
}
private func route(_ frame: StreamDemuxer.Frame) {
switch frame.header.tag {
case .skeleton:
guard let payload =
SkeletonPayload(decoding: frame.payload) else { return }
let pid = Int(frame.header.pid)
let body = Self.bodyFrame(pid: pid, from: payload)
DispatchQueue.main.async { [weak self] in
self?.bodies[pid] = body
}
case .video:
guard let payload =
VideoPayload(decoding: frame.payload) else { return }
DispatchQueue.main.async { [weak self] in
self?.onVideo?(payload)
}
case .meta:
break
}
}
private func publishConnected(_ value: Bool) {
DispatchQueue.main.async { [weak self] in
self?.connected = value
}
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `cd launcher/AV-Live-Body && swift test --filter USBSkeletonConsumerTests`
Expected: PASS, 1 test.
If `ArkitOSCListener.ArkitBodyFrame` has no memberwise mutability or a
different field set than `pid`/`joints`/`hasJoint`/`seenAt`, read
`ArkitOSCListener.swift` and adjust `bodyFrame` to match the actual
struct (it is a `struct ArkitBodyFrame: Equatable` with `var pid`,
`var joints: [SIMD3<Float>]`, `var hasJoint: [Bool]`, `var seenAt`).
- [ ] **Step 5: Run the full suite + commit**
Run: `cd launcher/AV-Live-Body && swift test`
Expected: PASS, all tests (7: prior 6 + this 1).
```bash
git add launcher/AV-Live-Body/Sources/AVLiveBody/USBSkeletonConsumer.swift launcher/AV-Live-Body/Tests/AVLiveBodyTests/USBSkeletonConsumerTests.swift
git commit -m "feat(av-live-body): USB skeleton consumer"
```
(subject ≤50 chars; add a short body — the hook rejects subject-only.)
---
## Task 2: VideoDecoder
`VideoDecoder` turns `.video` `VideoPayload`s into `CVPixelBuffer`s. A
keyframe payload carries the HEVC parameter sets prepended (each as a
4-byte big-endian length prefix + NAL bytes — the format Plan 2's iOS
`VideoEncoder` produces); the decoder builds its
`CMVideoFormatDescription` from those, then decodes subsequent access
units.
**Files:**
- Create: `launcher/AV-Live-Body/Sources/AVLiveBody/VideoDecoder.swift`
- [ ] **Step 1: Write the implementation**
`launcher/AV-Live-Body/Sources/AVLiveBody/VideoDecoder.swift`:
```swift
import AVLiveWire
import CoreMedia
import CoreVideo
import Foundation
import VideoToolbox
/// HEVC decoder. Feed `VideoPayload`s in; receive `CVPixelBuffer`s via
/// `onFrame`. Keyframe payloads must carry the VPS/SPS/PPS parameter
/// sets prepended as 4-byte-length-prefixed NAL units (the layout the
/// iOS `VideoEncoder` emits); the decoder (re)builds its format
/// description from those.
final class VideoDecoder {
var onFrame: ((CVPixelBuffer) -> Void)?
private var session: VTDecompressionSession?
private var formatDesc: CMVideoFormatDescription?
/// Decode one access unit.
func decode(_ payload: VideoPayload) {
var au = payload.data
if payload.isKeyframe {
// Split the prepended parameter sets from the frame data.
let (params, rest) = Self.splitParameterSets(au)
if !params.isEmpty {
rebuildFormat(params)
}
au = rest
}
guard let fmt = formatDesc, !au.isEmpty else { return }
if session == nil { makeSession(fmt) }
guard let session else { return }
guard let block = Self.blockBuffer(au) else { return }
var sample: CMSampleBuffer?
var sampleSize = au.count
guard CMSampleBufferCreateReady(
allocator: kCFAllocatorDefault, dataBuffer: block,
formatDescription: fmt, sampleCount: 1, sampleTimingEntryCount: 0,
sampleTimingArray: nil, sampleSizeEntryCount: 1,
sampleSizeArray: &sampleSize,
sampleBufferOut: &sample) == noErr, let sample else { return }
VTDecompressionSessionDecodeFrame(
session, sampleBuffer: sample, flags: [],
infoFlagsOut: nil) { [weak self] status, _, image, _, _ in
guard status == noErr, let image else { return }
self?.onFrame?(image)
}
}
func stop() {
if let session { VTDecompressionSessionInvalidate(session) }
session = nil
formatDesc = nil
}
deinit { stop() }
// MARK: - Helpers
/// Parameter sets are 4-byte-length-prefixed NAL units at the head
/// of a keyframe payload. The first NAL whose type is a VCL slice
/// marks the start of frame data but to stay simple and robust,
/// we treat every leading NAL as a parameter set until the running
/// concatenation can build a valid HEVC format description; the
/// remainder is the frame. Returns (parameterSetData, frameData).
private static func splitParameterSets(_ data: Data)
-> (Data, Data) {
// Parameter set NALs for HEVC: VPS=32, SPS=33, PPS=34
// (nal_unit_type = (firstByte >> 1) & 0x3F).
var offset = 0
let bytes = [UInt8](data)
var paramEnd = 0
while offset + 4 <= bytes.count {
let len = (Int(bytes[offset]) << 24)
| (Int(bytes[offset + 1]) << 16)
| (Int(bytes[offset + 2]) << 8)
| Int(bytes[offset + 3])
let nalStart = offset + 4
guard len > 0, nalStart + len <= bytes.count else { break }
let nalType = (Int(bytes[nalStart]) >> 1) & 0x3F
if nalType == 32 || nalType == 33 || nalType == 34 {
offset = nalStart + len
paramEnd = offset
} else {
break
}
}
return (data.prefix(paramEnd),
data.suffix(from: data.startIndex
.advanced(by: paramEnd)))
}
private func rebuildFormat(_ paramData: Data) {
var sets: [[UInt8]] = []
let bytes = [UInt8](paramData)
var offset = 0
while offset + 4 <= bytes.count {
let len = (Int(bytes[offset]) << 24)
| (Int(bytes[offset + 1]) << 16)
| (Int(bytes[offset + 2]) << 8)
| Int(bytes[offset + 3])
let start = offset + 4
guard len > 0, start + len <= bytes.count else { break }
sets.append(Array(bytes[start..<start + len]))
offset = start + len
}
guard sets.count >= 3 else { return }
let pointers = sets.map { UnsafePointer<UInt8>($0) }
let sizes = sets.map { $0.count }
var fmt: CMFormatDescription?
let status = pointers.withUnsafeBufferPointer { pBuf in
sizes.withUnsafeBufferPointer { sBuf in
CMVideoFormatDescriptionCreateFromHEVCParameterSets(
allocator: kCFAllocatorDefault,
parameterSetCount: sets.count,
parameterSetPointers: pBuf.baseAddress!,
parameterSetSizes: sBuf.baseAddress!,
nalUnitHeaderLength: 4, extensions: nil,
formatDescriptionOut: &fmt)
}
}
if status == noErr, let fmt {
formatDesc = fmt
if let session { VTDecompressionSessionInvalidate(session) }
session = nil
}
}
private func makeSession(_ fmt: CMVideoFormatDescription) {
let attrs: [CFString: Any] = [
kCVPixelBufferPixelFormatTypeKey:
kCVPixelFormatType_32BGRA,
]
VTDecompressionSessionCreate(
allocator: kCFAllocatorDefault, formatDescription: fmt,
decoderSpecification: nil,
imageBufferAttributes: attrs as CFDictionary,
outputCallback: nil, decompressionSessionOut: &session)
}
private static func blockBuffer(_ data: Data) -> CMBlockBuffer? {
var block: CMBlockBuffer?
guard CMBlockBufferCreateWithMemoryBlock(
allocator: kCFAllocatorDefault, memoryBlock: nil,
blockLength: data.count, blockAllocator: kCFAllocatorDefault,
customBlockSource: nil, offsetToData: 0,
dataLength: data.count, flags: 0,
blockBufferOut: &block) == noErr, let block else {
return nil
}
var ok = false
data.withUnsafeBytes { raw in
if CMBlockBufferReplaceDataBytes(
with: raw.baseAddress!, blockBuffer: block,
offsetIntoDestination: 0,
dataLength: data.count) == noErr { ok = true }
}
return ok ? block : nil
}
}
```
- [ ] **Step 2: Verify it compiles**
Run: `cd launcher/AV-Live-Body && swift build`
Expected: build succeeds. If a VideoToolbox/CoreMedia signature differs
on this SDK, fix minimally — the behavior (build a format description
from the prepended parameter sets, decode the rest) must be preserved.
- [ ] **Step 3: Commit**
```bash
git add launcher/AV-Live-Body/Sources/AVLiveBody/VideoDecoder.swift
git commit -m "feat(av-live-body): HEVC video decoder"
```
---
## Task 3: Render the 91-joint USB skeleton
`Skeleton3DRenderer` already subscribes to a 91-joint ARKit body
publisher into `lastArkit` but never draws it — `Skeleton3DRenderer.swift:138`
is `// TODO: render yellow ARKit markers from lastArkit in update()`.
Complete it: draw the 91 joints as small yellow spheres.
**Files:**
- Modify: `launcher/AV-Live-Body/Sources/AVLiveBody/Skeleton3DRenderer.swift`
- [ ] **Step 1: Read the renderer**
Read `Skeleton3DRenderer.swift` fully. Note: `PersonEntities` (the
per-pid entity struct), `lastArkit: [Int: ArkitOSCListener.ArkitBodyFrame]`,
`makePerson(pid:parent:)`, the `update(frames:)` 30 fps tick, and the
RealityKit space conversion used for MediaPipe joints
(`SIMD3(k.x, -k.y, -k.z)`).
- [ ] **Step 2: Add 91 ARKit marker entities to `PersonEntities`**
In the `PersonEntities` struct, add a field:
```swift
var arkitMarkers: [ModelEntity] // 91 yellow ARKit joint spheres
```
In `makePerson(pid:parent:)`, after the hand spheres are built, create
91 yellow marker spheres (reuse the `jointRadius`-sized sphere mesh, a
yellow `SimpleMaterial`), parent them to `root`, start them disabled,
and include `arkitMarkers:` in the returned `PersonEntities(...)`:
```swift
let arkitMat = SimpleMaterial(
color: .systemYellow, roughness: 0.6, isMetallic: false)
var arkitMarkers: [ModelEntity] = []
arkitMarkers.reserveCapacity(91)
for _ in 0..<91 {
let e = ModelEntity(mesh: sphereMesh, materials: [arkitMat])
e.isEnabled = false
root.addChild(e)
arkitMarkers.append(e)
}
```
- [ ] **Step 3: Draw the ARKit markers each tick**
Replace the line `// TODO: render yellow ARKit markers from lastArkit in update()`
(`Skeleton3DRenderer.swift:138`) — leave the comment removed — and add,
at the end of `update(frames:)` (after the existing per-pid loop), a
call to a new private method `applyArkit()`. Then add the method:
```swift
/// Draw the 91-joint ARKit/USB skeletons as yellow joint markers.
/// ARKit joints are world-space metric; convert to RealityKit
/// space (x, y, z) -> (x, -y, -z) like the MediaPipe path.
private func applyArkit() {
for (pid, entities) in persons {
guard let frame = lastArkit[pid] else {
for m in entities.arkitMarkers { m.isEnabled = false }
continue
}
let n = min(91, entities.arkitMarkers.count,
frame.joints.count)
for i in 0..<n {
let marker = entities.arkitMarkers[i]
if frame.hasJoint[i] {
let j = frame.joints[i]
marker.transform.translation =
SIMD3<Float>(j.x, -j.y, -j.z)
marker.isEnabled = true
} else {
marker.isEnabled = false
}
}
for i in n..<entities.arkitMarkers.count {
entities.arkitMarkers[i].isEnabled = false
}
}
}
```
Note: `applyArkit()` iterates `persons`, which is only populated for
pids seen in the MediaPipe `frames`. If the USB skeleton must show
when there is no MediaPipe pose, also create a `PersonEntities` for
each pid present in `lastArkit`. To keep Task 3 minimal, in
`update(frames:)` before `applyArkit()`, ensure entities exist for
ARKit-only pids:
```swift
for pid in lastArkit.keys where persons[pid] == nil {
persons[pid] = makePerson(pid: pid, parent: anchor)
lastSeenAt[pid] = now
}
```
- [ ] **Step 4: Verify build + tests**
Run: `cd launcher/AV-Live-Body && swift build` — Expected: succeeds.
Run: `cd launcher/AV-Live-Body && swift test` — Expected: all tests
still pass (no regression).
- [ ] **Step 5: Commit**
```bash
git add launcher/AV-Live-Body/Sources/AVLiveBody/Skeleton3DRenderer.swift
git commit -m "feat(av-live-body): render 91-joint USB skeleton"
```
---
## Task 4: Wire the consumer into the app
Construct `USBSkeletonConsumer` in the app, start/stop it with the
scene, and feed it into `Skeleton3DRenderer` in place of (or alongside)
`ArkitOSCListener`.
**Files:**
- Modify: `launcher/AV-Live-Body/Sources/AVLiveBody/AVLiveBodyApp.swift`
- Modify: `launcher/AV-Live-Body/Sources/AVLiveBody/BodyView.swift`
- [ ] **Step 1: Read the two files**
Read `AVLiveBodyApp.swift` and `BodyView.swift`. Identify: where the
`@StateObject` listeners are declared in `ContentView`, where `.onAppear`
starts them, how `ArkitOSCListener` is passed into `BodyView`, and where
`BodyView.makeNSView` calls `skel3d.attach(to:listener:arkitListener:)`.
- [ ] **Step 2: Own and start the consumer**
In `AVLiveBodyApp.swift`'s `ContentView`, add a `@StateObject`:
```swift
@StateObject private var usbConsumer = USBSkeletonConsumer()
```
In `.onAppear`, alongside the existing listener `.start()` calls, add
`usbConsumer.start()`. If there is an `.onDisappear`, add
`usbConsumer.stop()`.
- [ ] **Step 3: Thread the consumer to the renderer**
`Skeleton3DRenderer.attach` currently takes
`arkitListener: ArkitOSCListener?`. The simplest correct change: give
`USBSkeletonConsumer` the same role. Add an overload / extra parameter
so `attach` can subscribe to `usbConsumer.$bodies` exactly as it
subscribes to `arkitListener.$bodies` (both publish
`[Int: ArkitOSCListener.ArkitBodyFrame]`). Concretely, in
`Skeleton3DRenderer.attach`, accept `usbConsumer: USBSkeletonConsumer?`
and, if non-nil, subscribe its `$bodies` into `lastArkit` with the same
sink already used for `arkitListener` (the `arkitSub` Combine
subscription). Pass `usbConsumer` from `ContentView``BodyView`
`makeNSView``skel3d.attach(...)`, mirroring how `arkitListener` is
already threaded.
If `arkitListener` (the OSC one) is now redundant, it may be passed as
`nil`; do not delete `ArkitOSCListener` in this plan (other code or
Plan 3b cleanup may still reference it).
- [ ] **Step 4: Verify build**
Run: `cd launcher/AV-Live-Body && swift build` — Expected: succeeds.
Run: `cd launcher/AV-Live-Body && swift test` — Expected: no regression.
- [ ] **Step 5: Commit**
```bash
git add launcher/AV-Live-Body/Sources/AVLiveBody/AVLiveBodyApp.swift launcher/AV-Live-Body/Sources/AVLiveBody/BodyView.swift
git commit -m "feat(av-live-body): wire USB consumer to renderer"
```
---
## Task 5: Final verification
- [ ] **Step 1: Clean build + full test suite**
```bash
cd launcher/AV-Live-Body && swift build && swift test
```
Expected: build succeeds; all tests pass (7: prior 6 + Task 1's).
- [ ] **Step 2: Confirm the integration seam**
`USBSkeletonConsumer.devicePort` (7000) must equal the iOS app's
`USBServer.port`. Verify:
```bash
grep -rn "port.*7000\|devicePort" \
launcher/AV-Live-Body/Sources/AVLiveBody/USBSkeletonConsumer.swift \
iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/USBServer.swift
```
Expected: both sides use `7000`.
- [ ] **Step 3: Commit any fix** (only if Step 2 found a mismatch).
---
## Self-Review
- **Spec coverage:** This plan implements the spec's `USBClient`
consumption inside `AVLiveBody`, the `VideoDecoder` unit, and the
skeleton render path. `MultiHMRCoreML`, `BodyFusion`, and dense-mesh
rendering are explicitly Plan 3b (gated on a confirmed CoreML
Multi-HMR `.mlpackage`).
- **Placeholders:** none — new files have complete code; modify tasks
cite exact files and the line-138 TODO, and instruct the implementer
to read exact context for `AVLiveBodyApp.swift`/`BodyView.swift`
(whose current line numbers are not reproduced here).
- **Type consistency:** `USBSkeletonConsumer.bodyFrame` returns
`ArkitOSCListener.ArkitBodyFrame`; `Skeleton3DRenderer` already
stores `lastArkit: [Int: ArkitOSCListener.ArkitBodyFrame]`, so the
consumer is type-compatible with the existing `arkitSub` path.
`VideoDecoder` consumes `VideoPayload` exactly as Plan 2's
`VideoEncoder` produces it (parameter sets prepended, 4-byte
big-endian length prefixes).
- **Known risks:** (1) `BodyView` owns `Skeleton3DRenderer`, so Task 4
threads a new object through `ContentView``BodyView``attach`
multi-file, follow the existing `arkitListener` threading exactly.
(2) `StreamDemuxer.findMagic` copies the whole buffer per `feed()`;
for HEVC video this is a perf risk — acceptable for Plan 3a, revisit
if frame rate suffers. (3) The HEVC parameter-set split in
`VideoDecoder` assumes the iOS encoder's exact prepend layout —
this is the Plan 2 ↔ Plan 3a integration seam; validate on real
device data.
@@ -0,0 +1,171 @@
# AVLiveBody macOS — Clean Rewrite Design
> **Status:** design approved (brainstorming), pending implementation plan.
> **Date:** 2026-05-18
## Goal
Rebuild the macOS `AVLiveBody` app from scratch as a clean, native
Xcode application focused solely on the iPhone-USB body pipeline:
display the iPhone camera video and the tracked body (91-joint
skeleton + SMPL-X mesh) in a single RealityKit 3D scene. Drop all the
legacy components that have made incremental work fragile.
## Motivation
The existing `launcher/AV-Live-Body` is a SwiftPM package carrying
years of unrelated functionality — MediaPipe OSC listeners, openFrame-
works-style Metal "viz mode" scenes, a data-feeds HUD, a 33-joint
MediaPipe skeleton renderer, Mac-webcam capture, viz-mode hotkeys, a
multi-layer `BodyView`. Bolting the iPhone-USB pipeline onto it caused
recurring friction: the skeleton render tick was coupled to the
MediaPipe publisher, the app could not take keyboard focus when run as
a bare SwiftPM executable, the camera defaulted to the Mac webcam. A
clean, purpose-built app removes that whole class of problems.
## Decisions (brainstorming outcomes)
1. **Fresh native macOS app, Xcode project**, xcodegen-managed
(`project.yml``.xcodeproj`, matching the `iphone-arbody` iOS
app). New directory `avlivebody-mac/` in the AV-Live monorepo. The
old `launcher/AV-Live-Body/` is archived.
2. **Reuse the clean USB pipeline** built previously — the
`AVLiveWire` package plus `USBMuxProtocol`, `USBClient`,
`UnixMuxTransport`, `VideoDecoder`, `USBSkeletonConsumer`,
`MultiHMRCoreML`, `BodyFusion`. These migrate into the new app
unchanged (they are tested and reviewed).
3. **Rendering: a single RealityKit 3D scene** — the iPhone video is a
texture on a quad at the back of the scene; the body (skeleton +
mesh) is in front; an orbitable camera.
4. **Drop all legacy** — MediaPipe OSC listeners, the `SceneRenderer`
Metal viz modes, `DataFeedsOSCListener` + HUD, the 33-joint
`Skeleton3DRenderer`, Mac-webcam capture, viz-mode hotkeys, the
layered `BodyView`, `PoseOSCListener`.
5. Built as a proper `.app` via Xcode, which resolves the keyboard-
focus problem that affected the `swift run` executable.
## Architecture
A SwiftUI `@main App` with one window. An `AppDelegate` sets
`NSApplication` activation policy to `.regular`. The window hosts one
RealityKit `ARView` (used purely as a general 3D view on macOS — no
ARKit). The `ARView` holds a single scene containing:
- a **video quad** — a flat plane entity at the back, its material
texture replaced from each decoded iPhone `CVPixelBuffer`;
- the **body** — 91 skeleton joint markers and the dense SMPL-X mesh,
positioned in front of the video quad;
- an **orbitable camera**.
The USB pipeline (reused components) feeds the scene. The app is a
strict consumer: no network, the only input is the USB cable.
## Components
### Reused — the USB pipeline (migrated unchanged)
| Unit | Responsibility |
|------|----------------|
| `AVLiveWire` (SwiftPM package, stays in `shared/`) | 19-byte frame format, `FrameHeader`/`FrameTag`, `SkeletonPayload`/`VideoPayload`, `StreamDemuxer` |
| `USBMuxProtocol` | usbmux 16-byte-header + plist codec |
| `USBClient` / `MuxTransport` / `UnixMuxTransport` | usbmux device discovery, connect, `AF_UNIX` socket |
| `VideoDecoder` | HEVC `VideoPayload``CVPixelBuffer` (`VTDecompressionSession`) |
| `USBSkeletonConsumer` | background USB read loop → `StreamDemuxer`; republishes `.skeleton` body frames + decoded `.video` pixel buffers; auto-reconnect |
| `MultiHMRCoreML` | bundled CoreML model → N SMPL-X persons |
| `BodyFusion` | associate ARKit skeleton ↔ Multi-HMR person, pelvis-depth correction |
These move from `launcher/AV-Live-Body/Sources/AVLiveBody/` into the
new app's source tree. `AVLiveWire` stays in `shared/AVLiveWire`; the
new app declares it as a local package dependency.
### New — rendering (clean, zero legacy)
| Unit | Responsibility |
|------|----------------|
| `AVLiveBodyApp` | `@main` SwiftUI `App`; `AppDelegate` forces `.regular` activation; one window |
| `SceneView` | `NSViewRepresentable` wrapping the RealityKit `ARView` |
| `SceneController` | owns the scene, the orbital camera, assembles the entities; exposes `updateSkeleton`, `updateMesh`, `updateVideo` |
| `VideoQuad` | the back plane entity; updates its `TextureResource` from a `CVPixelBuffer` per frame |
| `SkeletonEntity` | 91 joint marker entities (native 91-joint, no MediaPipe 33-joint schema) |
| `MeshEntity` | the SMPL-X mesh entity (10475 vertices); mesh-building logic cleanly adapted from the old `MeshRenderer` |
| `StatusBar` | a small SwiftUI overlay showing connection state from `USBSkeletonConsumer.connected` |
## Data flow
```
iPhone ──USB── UnixMuxTransport → USBClient → StreamDemuxer → USBSkeletonConsumer
├─ .skeleton → SceneController.updateSkeleton → SkeletonEntity
└─ .video → VideoDecoder → CVPixelBuffer ─┬─ SceneController.updateVideo → VideoQuad
└─ MultiHMRCoreML → BodyFusion → SceneController.updateMesh → MeshEntity
```
Two rates: the skeleton streams at ~30 fps (smooth markers); video and
Multi-HMR run slower (~7 fps for the mesh). The video quad texture
refreshes at the video frame rate.
## Error handling
- **USB disconnect / no iPhone** — `USBSkeletonConsumer` retries every
second; `StatusBar` shows "waiting for iPhone".
- **CoreML model absent or failing** — the app runs skeleton-only (no
mesh); not a fatal error.
- **Video decode failure** — the frame is skipped.
- **Reconnect** — handled by the consumer's loop; entities holding
stale data are cleared after a timeout.
## Testing
- The reused USB components keep their existing unit tests
(`AVLiveWireTests`, `USBMuxProtocolTests`, `USBClientTests`,
`BodyFusionTests`, `USBSkeletonConsumerTests`) — carried into the new
app's test target.
- New rendering units (`VideoQuad`, `SkeletonEntity`, `MeshEntity`,
`SceneController`) depend on RealityKit/CoreML/VideoToolbox —
verified by build + on-device/manual run. Any extractable pure logic
(coordinate mapping, mesh index construction) gets unit tests.
- Build verification: a real Xcode project —
`xcodebuild -scheme AVLiveBody -destination 'platform=macOS' build`.
## Scope
**In scope**
- New `avlivebody-mac/` Xcode app (xcodegen `project.yml`).
- Migrate the USB pipeline components into the new app.
- The RealityKit scene: `VideoQuad`, `SkeletonEntity`, `MeshEntity`,
`SceneController`, orbital camera.
- Connection-status UI.
- Archive `launcher/AV-Live-Body/`.
**Out of scope**
- All legacy AVLiveBody functionality (MediaPipe pose, Metal viz
modes, data-feeds HUD, Mac webcam, viz-mode hotkeys) — deliberately
dropped, not migrated.
- Changes to the iOS `ARBodyTracker` app or to `AVLiveWire`.
## Migration notes
- The USB component files currently live in
`launcher/AV-Live-Body/Sources/AVLiveBody/`. They are copied into the
new app's source tree; the old directory is then archived (moved
aside / removed from the active build), not deleted from git history.
- `AVLiveWire` is untouched in `shared/AVLiveWire`.
- The new app's `project.yml` declares the local `AVLiveWire` package
dependency and bundles the Multi-HMR `.mlpackage` as a resource
(per the earlier owner decision: bundle the validated FP32 model).
## Risks
- **Video-as-texture in RealityKit** — RealityKit has no direct
"stream of `CVPixelBuffer` → texture" path (`VideoMaterial` is
driven by an `AVPlayer`, not a decoded buffer stream). `VideoQuad`
must replace a `TextureResource` (or use `LowLevelTexture`) per
frame. This is the app's hardest technical point; the implementation
plan isolates it in `VideoQuad` so it can be iterated independently.
- **macOS RealityKit camera control** — `ARView` on macOS is a general
3D view; an orbital camera must be set up explicitly (RealityKit
does not provide macOS orbit controls out of the box).
- **Multi-HMR throughput** — ~7 fps; the mesh layer is slow while the
skeleton stays real-time. Acceptable; mesh interpolation can be
added later if needed.
@@ -0,0 +1,201 @@
# iPhone USB Body-Tracking Link — Design
> **Status:** design approved (brainstorming), pending implementation plan.
> **Date:** 2026-05-18
## Goal
Replace the network (OSC/UDP over WiFi) link between the iOS
`ARBodyTracker` app and the macOS `AVLiveBody` app with a **wired USB
link**, so the body-tracking pipeline runs autonomously on one
iPhone + one Mac with no WiFi, no router, no hotspot, no remote
worker.
## Motivation
AV-Live's body pipeline is currently distributed: the Mac camera
feeds Multi-HMR (on a remote host), and the iPhone ARKit data only
*corrects* it over OSC/UDP. This depends on the network. The owner
wants a self-contained, network-free system.
## Decisions (brainstorming outcomes)
1. **iPhone is the source.** ARKit body tracking + LiDAR + RGB video
all originate on the iPhone. The Mac no longer uses its own camera.
2. **iPhone streams video.** Multi-HMR is an image-to-SMPL-X model, so
the iPhone sends the RGB video (not just the skeleton); the Mac runs
Multi-HMR on that video. The ARKit skeleton + LiDAR correct scale
and depth.
3. **Transport is USB.** Bluetooth cannot carry video bandwidth; WiFi
is a network. The cable is the only network-free, high-bandwidth,
low-latency option.
4. **Single native macOS app.** `AVLiveBody` becomes one Swift app:
receives USB, runs Multi-HMR in CoreML, renders the mesh. No Python
in the iPhone-USB path.
5. **Multi-person.** Multi-HMR yields N meshes from the video; the
single ARKit skeleton corrects the *primary* body only; others are
Multi-HMR raw. Skeleton-to-mesh association logic is required.
6. **USB transport mechanism:** native Swift `usbmux` client (no
`peertalk` dependency).
## Architecture
Two apps, one cable.
- **ARBodyTracker (iOS)** — extends the existing
`iphone-arbody/ARBodyTracker.swiftpm`. Captures the ARKit 91-joint
skeleton (LiDAR-anchored) and the `ARFrame` RGB image, HEVC-encodes
the video, frames skeleton + video into one stream, and serves it on
a local TCP port that the Mac reaches through `usbmuxd`.
- **AVLiveBody (macOS)** — extends the existing
`launcher/AV-Live-Body` Swift app. Connects to the iPhone over USB,
demuxes the stream, HEVC-decodes the video, runs CoreML Multi-HMR
(N meshes), fuses with the ARKit skeleton, renders the meshes, and
keeps feeding SuperCollider via localhost OSC.
usbmuxd is Apple's USB device-multiplexing daemon (the channel Xcode
uses for a tethered device). The iOS app's TCP listener is never
exposed to any network; the Mac connects to it through the cable via
`/var/run/usbmuxd`.
## Components
### iOS — ARBodyTracker
| Unit | Responsibility | Depends on |
|------|----------------|------------|
| `ARBodySession` | `ARBodyTrackingConfiguration` → 91-joint skeleton + `ARFrame.capturedImage` | ARKit (exists, extend) |
| `VideoEncoder` | hardware HEVC encode (VideoToolbox): pixel buffer → compressed access unit | VideoToolbox |
| `WireFormat` | binary framing `[tag, pid, timestamp, length, payload]`; pure, testable | — |
| `USBServer` | TCP `NWListener` on a fixed local port; usbmuxd exposes it to the tethered Mac | Network, WireFormat |
| `ContentView` | UI: AR preview, connection status, start/stop | SwiftUI (exists, extend) |
The existing OSC sender in ARBodyTracker is removed.
### macOS — AVLiveBody
| Unit | Responsibility | Depends on |
|------|----------------|------------|
| `USBClient` | native Swift usbmux client: `/var/run/usbmuxd` socket, device list, connect-to-port, attach/detach events, byte stream | — (Unix socket, mockable) |
| `StreamDemuxer` | parse `WireFormat` frames → skeleton frames / video frames; resync on partial buffers | WireFormat |
| `VideoDecoder` | hardware HEVC decode → `CVPixelBuffer` | VideoToolbox |
| `MultiHMRCoreML` | run the CoreML Multi-HMR model on a frame → N SMPL-X meshes | CoreML `.mlpackage` |
| `BodyFusion` | associate the ARKit skeleton with the matching Multi-HMR person; LiDAR scale/depth correction on the primary; others pass through; pure, testable | — |
| `MeshRenderer` / `Skeleton3DRenderer` | RealityKit rendering of meshes/skeletons | RealityKit (exist) |
| `PoseOSCBridge` | emit pose to SuperCollider `:57121` on localhost — preserves AV-Live's audio half | Network (localhost only) |
`ArkitOSCListener` (network) is retired; `USBClient` takes its role
over USB.
## Data flow
```
iPhone ARKit ──┬─ skeleton 91 joints ─────────────┐
└─ ARFrame RGB → VideoEncoder HEVC ─┤
WireFormat ┤→ USBServer (local TCP port)
═══ USB cable / usbmuxd ═══
Mac USBClient → StreamDemuxer ─┬─ video → VideoDecoder → MultiHMRCoreML → N meshes ┐
└─ skeleton ───────────────────────────────────────┤
BodyFusion ┤
┌──────────────────────────────────────────────────────┘
├→ MeshRenderer (N meshes) + Skeleton3DRenderer
└→ PoseOSCBridge → SuperCollider :57121 (localhost)
```
`BodyFusion` associates the ARKit skeleton with the nearest Multi-HMR
person (by 2D projection / position) and corrects that person's scale
and depth (`pred_cam_t.z`) from the LiDAR-anchored joints. Other
bodies remain Multi-HMR raw.
**Two rates.** The skeleton streams at ~30 fps (cheap, always fresh).
Video / Multi-HMR runs slower (CoreML throughput, ~2-5 fps on Apple
Silicon). Every frame carries a timestamp; fusion matches a mesh to
the nearest-in-time skeleton. The skeleton is the smooth real-time
layer; the dense mesh is a slower layer, bridged to 30 fps by the
existing mesh interpolation in `AVLiveBody` (commit `0293cde`),
driven by the 30 fps skeleton.
## Wire format
Each frame: a fixed header followed by a payload.
| Field | Type | Notes |
|-------|------|-------|
| `tag` | `u8` | 1 = skeleton, 2 = video, 3 = meta |
| `pid` | `i16` | body id (skeleton/meta); `-1` for video |
| `timestamp` | `f64` | capture time, seconds |
| `length` | `u32` BE | payload byte count |
| `payload` | bytes | per-tag, below |
- **skeleton** — 91 × `(x, y, z)` `f32` world-space + a 91-bit
validity mask.
- **video** — one HEVC access unit; a flag marks keyframes and
carries parameter sets (VPS/SPS/PPS) when present.
- **meta** — video dimensions, camera intrinsics, body count.
Exact byte layout is finalized in the implementation plan.
## Error handling
- **USB attach/detach** — `USBClient` subscribes to usbmuxd device
events and auto-reconnects. Renderers GC stale persons (existing
`retainSec`).
- **Backpressure** — if Multi-HMR is slower than capture, latest frame
wins: intermediate video frames are dropped, never queued. The
skeleton stream stays fresh independently.
- **HEVC decode failure** — frame skipped.
- **CoreML model absent or failing** — fall back to skeleton-only
rendering (`Skeleton3DRenderer` draws the ARKit skeleton): degraded
but alive.
- **Frame sync** — timestamp-based nearest match in `BodyFusion`.
## Testing
| Unit | Test |
|------|------|
| `WireFormat` | pure unit: encode→decode roundtrip, all tags, truncated/corrupt frames |
| `USBClient` | unit: usbmux protocol against a mocked Unix socket (canned plist replies), device-list parse, connect handshake, attach/detach events |
| `StreamDemuxer` | roundtrip + resync on partial (non-frame-aligned) buffers |
| `VideoDecoder` | decode a known HEVC sample → expected dimensions |
| `BodyFusion` | pure logic: synthetic skeleton + synthetic Multi-HMR persons → assert association + scale/depth correction |
| `MultiHMRCoreML` | integration: known frame → mesh, sanity bounds |
| iOS (`VideoEncoder`, `ARBodySession`, `USBServer`) | framing unit-tested; ARKit/VideoToolbox need a device — manual/integration |
| End-to-end | iPhone tethered, both apps, N meshes render + latency budget + USB reconnect |
## Scope
**In scope**
- Extend `iphone-arbody/ARBodyTracker.swiftpm`: `VideoEncoder`,
`WireFormat`, `USBServer`; `ARBodySession` exposes video frames; the
OSC sender is removed.
- Extend `launcher/AV-Live-Body`: `USBClient`, `StreamDemuxer`,
`VideoDecoder`, `MultiHMRCoreML` wiring, `BodyFusion`;
`ArkitOSCListener` retired.
- Keep `PoseOSCBridge` → SuperCollider on localhost.
**Out of scope**
- The Python `data_only_viz` pipeline — untouched; it remains the
Mac-camera mode. This project is the iPhone-USB path only.
- CoreML Multi-HMR model *conversion* — assumed already done
(`multihmr_coreml.py` + existing conversion plans). This project
*consumes* the `.mlpackage`.
- LiDAR scene mesh / ICP fusion (separate plan).
- iOS app signing and deployment — owner action.
## Risks & dependencies
- **CoreML Multi-HMR readiness** — the dense-mesh half depends on a
working, fast-enough `.mlpackage`. If not ready, that half is
blocked, but the skeleton-only fallback keeps the project useful —
not all-or-nothing.
- **Multi-HMR throughput** — ~2-5 fps measured on Apple Silicon. The
dense mesh updates slowly; the 30 fps skeleton + existing mesh
interpolation cover the gap.
- **Device pairing** — the iPhone must be trusted/paired with the Mac
for usbmuxd to expose it.
- **iOS deployment** — building/signing/installing the iOS app is a
manual owner step.
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,23 @@
// swift-tools-version:5.10
import PackageDescription
let package = Package(
name: "ARBodyTracker",
defaultLocalization: "en",
platforms: [.iOS(.v17)],
products: [
.executable(name: "ARBodyTracker", targets: ["ARBodyTracker"]),
],
dependencies: [
.package(path: "../../shared/AVLiveWire"),
],
targets: [
.executableTarget(
name: "ARBodyTracker",
dependencies: [
.product(name: "AVLiveWire", package: "AVLiveWire"),
],
path: "Sources/ARBodyTracker"
),
]
)
@@ -0,0 +1,217 @@
import ARKit
import AVLiveWire
import Combine
import Foundation
import RealityKit
import SwiftUI
/// Drives the ARKit body-tracking session and streams the 91-joint
/// skeleton plus HEVC-encoded camera video to the tethered Mac over
/// USB (AVLiveWire frames via usbmuxd). No network involved.
///
/// Lightweight 2D snapshot of the tracked skeleton, ready for SwiftUI
/// Canvas. Joint indices follow `ARSkeletonDefinition.defaultBody3D`.
struct SkeletonSnapshot: Equatable {
/// Projected joint positions in viewport coordinates, or nil if the
/// joint falls outside the view / is not finite.
let points: [CGPoint?]
/// Per-joint tracking flag (`ARSkeleton.isJointTracked`).
let tracked: [Bool]
}
@MainActor
final class ARBodySession: NSObject, ObservableObject, ARSessionDelegate {
@Published var running: Bool = false
@Published var status: String = "idle"
@Published var framesSent: Int = 0
@Published var jointsPerSec: Double = 0
@Published var bodyCount: Int = 0
@Published var skeleton2D: SkeletonSnapshot?
@Published var usbState: USBServer.State = .idle
/// Set by the SwiftUI view via GeometryReader so the projection
/// matches the on-screen ARView size.
var viewportSize: CGSize = .zero
private var sendEnvMesh: Bool = false
private let session = ARSession()
private let usb = USBServer()
private let videoEncoder = VideoEncoder()
private var videoStarted = false
private var lastFrameTime: TimeInterval = 0
private var jointsInSecond: Int = 0
private var lastSecond: TimeInterval = 0
private let bodyParents: [Int] =
ARSkeletonDefinition.defaultBody3D.parentIndices
let arView = ARView(frame: .zero)
override init() {
super.init()
arView.session = session
arView.session.delegate = self
arView.environment.background = .color(.black)
arView.debugOptions = []
usb.onState = { [weak self] s in
Task { @MainActor in self?.usbState = s }
}
videoEncoder.onPayload = { [weak self] payload in
Task { @MainActor in
guard let self, self.usbState == .connected else {
return
}
self.usb.send(tag: .video, pid: -1,
timestamp: self.lastFrameTime,
payload: payload.encoded())
}
}
}
func configure(sendEnvMesh: Bool) {
self.sendEnvMesh = sendEnvMesh
}
func start() {
guard ARBodyTrackingConfiguration.isSupported else {
status = "ARBodyTracking unsupported (need A12+, iPhone XR/XS+)"
return
}
let cfg = ARBodyTrackingConfiguration()
var feats: [String] = []
// No extra frame semantics: `.sceneDepth` is reserved to
// ARWorldTracking, and `.personSegmentationWithDepth` is
// rejected per-frame by ABPKPersonIDTracker in this config
// (spams the console without producing usable depth).
// NOTE: ARBodyTrackingConfiguration does not expose
// sceneReconstruction (that's ARWorldTrackingConfiguration
// territory). Env mesh capture requires a separate ARSession
// with body tracking off out of scope for this scaffold.
if sendEnvMesh {
feats.append("env-mesh: requires separate session (TODO)")
}
cfg.automaticImageScaleEstimationEnabled = true
usb.start()
session.run(cfg, options: [.resetTracking, .removeExistingAnchors])
status = feats.isEmpty
? "running (RGB only)"
: "running (\(feats.joined(separator: ", ")))"
running = true
}
func stop() {
session.pause()
usb.stop()
videoEncoder.stop()
videoStarted = false
running = false
status = "stopped"
}
// MARK: - ARSessionDelegate
nonisolated func session(_ s: ARSession, didUpdate frame: ARFrame) {
let t = frame.timestamp
Task { @MainActor in
// Throttle to 30 fps max.
if t - self.lastFrameTime < 1.0 / 30.0 { return }
self.lastFrameTime = t
// Encode the camera frame to HEVC and stream it over USB.
let img = frame.capturedImage
let w = Int32(CVPixelBufferGetWidth(img))
let h = Int32(CVPixelBufferGetHeight(img))
if !self.videoStarted, w > 0, h > 0 {
self.videoEncoder.start(width: w, height: h)
self.videoStarted = true
}
if self.videoStarted {
self.videoEncoder.encode(img, pts: t)
}
var count: Int = 0
var firstBody: ARBodyAnchor?
for anchor in frame.anchors {
guard let body = anchor as? ARBodyAnchor else { continue }
self.publishUSB(pid: count, timestamp: t, body: body)
if count == 0 { firstBody = body }
count += 1
}
self.framesSent &+= 1
self.bodyCount = count
self.updateSkeleton2D(body: firstBody, camera: frame.camera)
let now = Date().timeIntervalSinceReferenceDate
self.jointsInSecond &+= count * 91
if now - self.lastSecond >= 1.0 {
self.jointsPerSec = Double(self.jointsInSecond)
/ max(0.001, now - self.lastSecond)
self.jointsInSecond = 0
self.lastSecond = now
}
}
}
private func currentInterfaceOrientation() -> UIInterfaceOrientation {
for scene in UIApplication.shared.connectedScenes {
if let ws = scene as? UIWindowScene {
return ws.interfaceOrientation
}
}
return .portrait
}
private func updateSkeleton2D(body: ARBodyAnchor?, camera: ARCamera) {
guard let body, viewportSize.width > 1, viewportSize.height > 1
else {
if skeleton2D != nil { skeleton2D = nil }
return
}
let xforms = body.skeleton.jointModelTransforms
let root = body.transform
let orient = currentInterfaceOrientation()
var pts: [CGPoint?] = Array(repeating: nil, count: xforms.count)
var tracked: [Bool] = Array(repeating: false, count: xforms.count)
for (i, m) in xforms.enumerated() {
let w = root * m
let p3 = simd_make_float3(w.columns.3.x,
w.columns.3.y,
w.columns.3.z)
let p2 = camera.projectPoint(p3,
orientation: orient,
viewportSize: viewportSize)
if p2.x.isFinite && p2.y.isFinite { pts[i] = p2 }
tracked[i] = body.skeleton.isJointTracked(i)
}
skeleton2D = SkeletonSnapshot(points: pts, tracked: tracked)
}
/// Exposed for SwiftUI overlays that need to wire bone parent
/// indices without re-reading the ARKit skeleton definition.
var bodyParentIndices: [Int] { bodyParents }
private func publishUSB(pid: Int, timestamp: TimeInterval,
body: ARBodyAnchor) {
guard usbState == .connected else { return }
let skeleton = body.skeleton
let transforms = skeleton.jointModelTransforms
let root = body.transform
var payload = SkeletonPayload()
let n = min(SkeletonPayload.jointCount, transforms.count)
for i in 0..<n {
let w = root * transforms[i]
payload.joints[i] = SIMD3(w.columns.3.x,
w.columns.3.y,
w.columns.3.z)
payload.valid[i] = skeleton.isJointTracked(i)
}
usb.send(tag: .skeleton,
pid: Int16(clamping: pid),
timestamp: timestamp,
payload: payload.encoded())
}
}
struct ARViewContainer: UIViewRepresentable {
@ObservedObject var session: ARBodySession
func makeUIView(context: Context) -> ARView { session.arView }
func updateUIView(_ uiView: ARView, context: Context) {}
}
@@ -0,0 +1,10 @@
import SwiftUI
@main
struct ARBodyTrackerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
@@ -0,0 +1,237 @@
import SwiftUI
import ARKit
import RealityKit
import UIKit
struct ContentView: View {
@StateObject private var session = ARBodySession()
@State private var sendEnvMesh: Bool = false
/// Replace the live ARView with a gradient placeholder. Camera is
/// unavailable inside Xcode previews; enable this flag there so the
/// panel + skeleton overlay can still be laid out.
var useMockBackground: Bool = false
/// Overlay a synthetic ARKit T-pose so the skeleton renderer can be
/// tuned without running on a device.
var useMockSkeleton: Bool = false
var body: some View {
GeometryReader { geo in
ZStack(alignment: .topLeading) {
cameraBackground
.ignoresSafeArea()
SkeletonOverlay(
snapshot: useMockSkeleton
? SkeletonSnapshot.mockTPose(in: geo.size)
: session.skeleton2D,
parents: useMockSkeleton
? SkeletonSnapshot.mockParents
: session.bodyParentIndices)
.ignoresSafeArea()
.allowsHitTesting(false)
controlPanel
}
.onAppear {
session.viewportSize = geo.size
// Keep the screen awake during streaming sessions; iOS
// would otherwise lock and tear down the USBServer TCP
// listener within seconds of inactivity.
UIApplication.shared.isIdleTimerDisabled = true
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
}
.onChange(of: geo.size) { _, newSize in
session.viewportSize = newSize
}
}
}
private var usbDotColor: Color {
switch session.usbState {
case .idle: return .gray
case .listening: return .yellow
case .connected: return .green
}
}
private var usbStateLabel: String {
switch session.usbState {
case .idle: return "idle"
case .listening: return "listening :\(USBServer.port)"
case .connected: return "connected"
}
}
@ViewBuilder
private var cameraBackground: some View {
if useMockBackground {
ZStack {
LinearGradient(
colors: [
Color(red: 0.18, green: 0.20, blue: 0.24),
Color(red: 0.05, green: 0.05, blue: 0.08),
],
startPoint: .top,
endPoint: .bottom)
Text("camera preview\n(unavailable in Xcode canvas)")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.white.opacity(0.35))
}
} else {
ARViewContainer(session: session)
}
}
private var controlPanel: some View {
VStack(alignment: .leading, spacing: 8) {
Text("AR Body → AV-Live")
.font(.headline)
.foregroundColor(.white)
Toggle(isOn: $sendEnvMesh) {
Text("Env mesh (LiDAR)").foregroundColor(.white)
}
HStack {
Button(session.running ? "Stop" : "Start") {
if session.running {
session.stop()
} else {
session.configure(sendEnvMesh: sendEnvMesh)
session.start()
}
}
.buttonStyle(.borderedProminent)
Spacer()
Text(session.status)
.font(.caption)
.foregroundColor(.white)
.padding(6)
.background(.black.opacity(0.5))
.cornerRadius(6)
}
HStack(spacing: 6) {
Circle()
.fill(usbDotColor)
.frame(width: 8, height: 8)
Text("USB \(usbStateLabel)")
.font(.caption2)
.foregroundColor(.white.opacity(0.8))
Spacer(minLength: 8)
Text("bodies: \(session.bodyCount) frames: \(session.framesSent) j/s: \(Int(session.jointsPerSec))")
.font(.caption2)
.foregroundColor(.white)
}
}
.padding(12)
.background(.black.opacity(0.5))
.cornerRadius(10)
.padding()
}
}
extension SkeletonSnapshot {
/// Parent indices for the 16-joint preview stick figure. Each entry
/// is the parent joint index, or -1 for the root (head).
static let mockParents: [Int] = [
-1, // 0 head
0, // 1 neck
1, // 2 lShoulder
1, // 3 rShoulder
2, // 4 lElbow
3, // 5 rElbow
4, // 6 lWrist
5, // 7 rWrist
1, // 8 spine
8, // 9 pelvis
9, // 10 lHip
9, // 11 rHip
10, // 12 lKnee
11, // 13 rKnee
12, // 14 lAnkle
13, // 15 rAnkle
]
/// Synthetic 16-joint stick figure used by Xcode previews. ARKit
/// is not available in the preview canvas, so we cannot rely on
/// `ARSkeletonDefinition.neutralBodySkeleton3D` (returns nil).
static func mockTPose(in size: CGSize) -> SkeletonSnapshot {
// Normalized layout: origin at body center, +y down, ±1 spans
// roughly the full body height.
let layout: [CGPoint] = [
CGPoint(x: 0.00, y: -0.45),
CGPoint(x: 0.00, y: -0.32),
CGPoint(x: -0.18, y: -0.30),
CGPoint(x: 0.18, y: -0.30),
CGPoint(x: -0.30, y: -0.12),
CGPoint(x: 0.30, y: -0.12),
CGPoint(x: -0.36, y: 0.08),
CGPoint(x: 0.36, y: 0.08),
CGPoint(x: 0.00, y: -0.10),
CGPoint(x: 0.00, y: 0.06),
CGPoint(x: -0.10, y: 0.09),
CGPoint(x: 0.10, y: 0.09),
CGPoint(x: -0.12, y: 0.30),
CGPoint(x: 0.12, y: 0.30),
CGPoint(x: -0.13, y: 0.46),
CGPoint(x: 0.13, y: 0.46),
]
let scale = min(size.width * 0.9, size.height * 0.8)
let cx = size.width * 0.5
let cy = size.height * 0.5
let pts: [CGPoint?] = layout.map {
CGPoint(x: cx + $0.x * scale,
y: cy + $0.y * scale)
}
return SkeletonSnapshot(
points: pts,
tracked: Array(repeating: true, count: pts.count))
}
}
/// Draws ARKit body joints + bones over the camera view. Bones are
/// derived from `ARSkeletonDefinition.defaultBody3D` parent indices.
struct SkeletonOverlay: View {
let snapshot: SkeletonSnapshot?
let parents: [Int]
var body: some View {
Canvas { ctx, _ in
guard let snap = snapshot else { return }
for (i, parent) in parents.enumerated() where parent >= 0 {
guard i < snap.points.count, parent < snap.points.count,
let a = snap.points[i], let b = snap.points[parent]
else { continue }
var path = Path()
path.move(to: a)
path.addLine(to: b)
let solid = snap.tracked[i] && snap.tracked[parent]
ctx.stroke(
path,
with: .color(solid ? .green : .yellow.opacity(0.5)),
lineWidth: solid ? 2 : 1.2)
}
for (i, pt) in snap.points.enumerated() {
guard let pt else { continue }
let r: CGFloat = snap.tracked[i] ? 4 : 2.5
let rect = CGRect(x: pt.x - r, y: pt.y - r,
width: r * 2, height: r * 2)
ctx.fill(Path(ellipseIn: rect),
with: .color(snap.tracked[i]
? .cyan : .yellow.opacity(0.8)))
}
}
}
}
#Preview("iPhone 15 Pro — portrait") {
ContentView(useMockBackground: true, useMockSkeleton: true)
}
#Preview("iPhone 15 Pro — landscape", traits: .landscapeLeft) {
ContentView(useMockBackground: true, useMockSkeleton: true)
}
#Preview("Empty camera (no body)") {
ContentView(useMockBackground: true, useMockSkeleton: false)
}
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key><string>en</string>
<key>CFBundleDisplayName</key><string>ARBody Tracker</string>
<key>CFBundleExecutable</key><string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>ARBodyTracker</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>NSCameraUsageDescription</key>
<string>Required for ARKit body tracking and LiDAR depth capture.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Streams ARKit body tracking and camera video to a tethered Mac over USB.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key><false/>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
<string>arkit</string>
</array>
<key>UIDeviceFamily</key>
<array><integer>1</integer></array>
<key>UIRequiresFullScreen</key><true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UILaunchScreen</key><dict/>
</dict>
</plist>
@@ -0,0 +1,62 @@
import Foundation
import Network
import AVLiveWire
/// TCP listener on a fixed local port. usbmuxd tunnels it to the
/// tethered Mac the port is never advertised on any network.
final class USBServer {
static let port: UInt16 = 7000
enum State { case idle, listening, connected }
var onState: ((State) -> Void)?
private var listener: NWListener?
private var connection: NWConnection?
private let queue = DispatchQueue(label: "cc.avlive.usbserver")
func start() {
let params = NWParameters.tcp
params.allowLocalEndpointReuse = true
guard let l = try? NWListener(using: params,
on: NWEndpoint.Port(rawValue: Self.port)!) else {
onState?(.idle)
return
}
listener = l
l.newConnectionHandler = { [weak self] conn in
self?.adopt(conn)
}
l.start(queue: queue)
onState?(.listening)
}
private func adopt(_ conn: NWConnection) {
connection?.cancel()
connection = conn
conn.stateUpdateHandler = { [weak self] st in
switch st {
case .ready: self?.onState?(.connected)
case .failed, .cancelled: self?.onState?(.listening)
default: break
}
}
conn.start(queue: queue)
}
/// Send one framed message. Drops silently if no peer.
func send(tag: FrameTag, pid: Int16, timestamp: Double,
payload: Data) {
guard let conn = connection else { return }
guard payload.count <= Int(StreamDemuxer.maxPayloadLength)
else { return }
let header = FrameHeader(tag: tag, pid: pid,
timestamp: timestamp, length: UInt32(payload.count))
conn.send(content: header.encoded() + payload,
completion: .contentProcessed { _ in })
}
func stop() {
connection?.cancel(); listener?.cancel()
onState?(.idle)
}
}
@@ -0,0 +1,137 @@
import AVLiveWire
import CoreMedia
import CoreVideo
import Foundation
import VideoToolbox
/// Hardware HEVC encoder. Feed `CVPixelBuffer`s from ARKit frames in;
/// receive one `VideoPayload` per encoded access unit via `onPayload`.
/// Keyframe payloads carry the VPS/SPS/PPS parameter sets prepended,
/// each as a 4-byte big-endian length prefix followed by the NAL
/// bytes, so the Mac decoder can build its format description without
/// a side channel.
final class VideoEncoder {
var onPayload: ((VideoPayload) -> Void)?
private var session: VTCompressionSession?
private let lock = NSLock()
/// Create the compression session for a given frame size.
func start(width: Int32, height: Int32) {
stop()
var s: VTCompressionSession?
let status = VTCompressionSessionCreate(
allocator: kCFAllocatorDefault,
width: width, height: height,
codecType: kCMVideoCodecType_HEVC,
encoderSpecification: nil,
imageBufferAttributes: nil,
compressedDataAllocator: nil,
outputCallback: nil,
refcon: nil,
compressionSessionOut: &s)
guard status == noErr, let s else {
NSLog("VideoEncoder: VTCompressionSessionCreate failed %d",
status)
return
}
VTSessionSetProperty(s, key: kVTCompressionPropertyKey_RealTime,
value: kCFBooleanTrue)
VTSessionSetProperty(s,
key: kVTCompressionPropertyKey_AllowFrameReordering,
value: kCFBooleanFalse)
VTSessionSetProperty(s,
key: kVTCompressionPropertyKey_MaxKeyFrameInterval,
value: 30 as CFNumber)
VTCompressionSessionPrepareToEncodeFrames(s)
lock.lock(); session = s; lock.unlock()
}
/// Encode one frame. `pts` is the capture timestamp in seconds.
func encode(_ pixelBuffer: CVPixelBuffer, pts: Double) {
lock.lock(); let s = session; lock.unlock()
guard let s else { return }
let time = CMTime(seconds: pts, preferredTimescale: 1_000_000)
VTCompressionSessionEncodeFrame(
s, imageBuffer: pixelBuffer, presentationTimeStamp: time,
duration: .invalid, frameProperties: nil,
infoFlagsOut: nil) { [weak self] status, _, sample in
guard status == noErr, let sample else { return }
self?.handle(sample)
}
}
func stop() {
lock.lock(); let s = session; session = nil; lock.unlock()
if let s {
VTCompressionSessionInvalidate(s)
}
}
deinit { stop() }
// MARK: - Sample -> VideoPayload
private func handle(_ sample: CMSampleBuffer) {
let isKeyframe = !Self.notSync(sample)
var out = Data()
if isKeyframe,
let fmt = CMSampleBufferGetFormatDescription(sample) {
out.append(Self.parameterSets(fmt))
}
if let block = CMSampleBufferGetDataBuffer(sample) {
var lengthOut = 0
var ptr: UnsafeMutablePointer<Int8>?
if CMBlockBufferGetDataPointer(
block, atOffset: 0, lengthAtOffsetOut: nil,
totalLengthOut: &lengthOut,
dataPointerOut: &ptr) == noErr, let ptr {
out.append(UnsafeBufferPointer(
start: UnsafeRawPointer(ptr)
.assumingMemoryBound(to: UInt8.self),
count: lengthOut))
}
}
guard !out.isEmpty else { return }
onPayload?(VideoPayload(isKeyframe: isKeyframe, data: out))
}
/// True if the sample is NOT a sync (key) frame.
private static func notSync(_ sample: CMSampleBuffer) -> Bool {
guard let arr = CMSampleBufferGetSampleAttachmentsArray(
sample, createIfNecessary: false),
CFArrayGetCount(arr) > 0 else { return false }
let dict = unsafeBitCast(CFArrayGetValueAtIndex(arr, 0),
to: CFDictionary.self)
let key = Unmanaged.passUnretained(
kCMSampleAttachmentKey_NotSync).toOpaque()
return CFDictionaryContainsKey(dict, key)
}
/// Concatenate the HEVC VPS/SPS/PPS parameter sets, each as a
/// 4-byte big-endian length prefix followed by the NAL bytes.
private static func parameterSets(
_ fmt: CMFormatDescription) -> Data {
var count = 0
CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(
fmt, parameterSetIndex: 0, parameterSetPointerOut: nil,
parameterSetSizeOut: nil, parameterSetCountOut: &count,
nalUnitHeaderLengthOut: nil)
var data = Data()
for i in 0..<count {
var ptr: UnsafePointer<UInt8>?
var size = 0
guard CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(
fmt, parameterSetIndex: i,
parameterSetPointerOut: &ptr,
parameterSetSizeOut: &size,
parameterSetCountOut: nil,
nalUnitHeaderLengthOut: nil) == noErr,
let ptr else { continue }
var be = UInt32(size).bigEndian
withUnsafeBytes(of: &be) { data.append(contentsOf: $0) }
data.append(UnsafeBufferPointer(start: ptr, count: size))
}
return data
}
}
+94
View File
@@ -0,0 +1,94 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
iPhone-only app that streams ARKit `ARBodyAnchor` joints (91 per body) to
the AV-Live stack on a tethered Mac. Part of the parent `AV-Live/`
monorepo — read `../CLAUDE.md` for global conventions (commit style,
French/English split, no emoji, etc.).
## Two build forms, one source tree
Both flavours compile the same files under
`ARBodyTracker.swiftpm/Sources/ARBodyTracker/`:
| Form | When to use | How |
|------|-------------|-----|
| `ARBodyTracker.swiftpm/` (Swift Package) | Quick local iteration; previews | open `Package.swift` in Xcode |
| `ARBodyTracker.xcodeproj` (generated) | Device deploy with reproducible signing | `xcodegen generate` from `project.yml` |
The `.xcodeproj` is **gitignored** — regenerate after every change to
`project.yml` or after adding sources. `Config/Local.xcconfig` (also
gitignored) carries `DEVELOPMENT_TEAM`; copy from
`Config/Local.xcconfig.example` on a fresh clone.
## Common commands
```bash
brew install xcodegen # one-time
cp Config/Local.xcconfig.example Config/Local.xcconfig # set DEVELOPMENT_TEAM
xcodegen generate # writes ARBodyTracker.xcodeproj
open ARBodyTracker.xcodeproj # then ⌘R on a connected iPhone
# AVLiveWire — shared wire-format package (consumed by both the iOS
# app and launcher/AV-Live-Body on macOS). Tests live with it.
cd ../shared/AVLiveWire && swift test
cd ../shared/AVLiveWire && swift test --filter LoopbackTests
```
`xcodegen generate` is the one command to remember: any time `project.yml`
or the dep on `../shared/AVLiveWire` changes, the generated project must
be rebuilt before opening Xcode.
## Architecture
```
ARBodyTrackerApp ── ContentView ── ARViewContainer (ARView)
│ │
│ └─ ARBodySession (ARSessionDelegate)
│ ├─ OSC fanout (UDP) ─► host:57128 (Python data_only_viz)
│ │ host:57129 (Swift AV-Live-Body)
│ ├─ USBServer (TCP :7000, AVLiveWire frames)
│ └─ @Published skeleton2D ──┐
└─ SkeletonOverlay (SwiftUI Canvas) ◄─────────┘
```
- **`ARBodySession`** owns the `ARSession` configured with
`ARBodyTrackingConfiguration` (RGB-only; LiDAR `sceneDepth` is not
available on this config, do not re-add it — it crashes the session).
Throttles to 30 fps. Projects joints to 2D via
`ARCamera.projectPoint(_:orientation:viewportSize:)`; the viewport
size is fed back from the SwiftUI `GeometryReader`.
- **Transport** is dual: legacy OSC/UDP (still default), and a new
USB/TCP path using `AVLiveWire` framed messages. The Mac side bridges
via `usbmuxd`; the iOS side just listens on a fixed local port. No
WiFi involved on the USB path.
- **`AVLiveWire`** (in `../shared/AVLiveWire`) defines a fixed 19-byte
big-endian header (`AVL1` magic + tag + pid + timestamp + length) and
an incremental `StreamDemuxer`. Both the iOS `USBServer` and the
macOS consumer link against it so the wire format lives in exactly
one place.
- **`SkeletonOverlay`** draws joints and bones from a published
`SkeletonSnapshot`; bone parent indices come from
`ARSkeletonDefinition.defaultBody3D.parentIndices` at runtime. For
Xcode previews the snapshot/parents are replaced by a 16-joint
stick-figure mock (ARKit's `neutralBodySkeleton3D` returns nil off
device).
## Gotchas
- **Frame semantics:** `ARBodyTrackingConfiguration` rejects
`.sceneDepth` (world-tracking only) and spams `ABPKPersonIDTracker:
Portrait image is not supported` per-frame if
`.personSegmentationWithDepth` is added. Keep `feats` empty unless a
newly-needed semantic is verified against
`ARBodyTrackingConfiguration.supportsFrameSemantics(_:)`.
- **Signing without an Apple ID logged into Xcode:** with
`CODE_SIGN_STYLE = Automatic`, Xcode needs the account in
Settings → Accounts to download a profile. If only the keychain
cert is present, the build fails with "No Account for Team …".
Reconnect the account, or fall back to a manually installed profile
via `CODE_SIGN_STYLE = Manual` + `PROVISIONING_PROFILE_SPECIFIER`.
- **`UIDeviceFamily` warning:** `Info.plist` and `TARGETED_DEVICE_FAMILY`
duplicate the device family. The build setting wins; the Info.plist
key only generates a warning and can be removed if desired.

Some files were not shown because too many files have changed in this diff Show More