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

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

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

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

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

203 lines
6.9 KiB
Python

#!/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())