docs(iphone): SOTA 2026 refonte design spec

This commit is contained in:
clement
2026-06-30 21:33:45 +02:00
parent 40c21a246c
commit 5adb8abdc8
@@ -0,0 +1,325 @@
# iPhone ↔ AV-Live — refonte technique SOTA 2026 (design)
Date: 2026-06-30
Statut: design validé, en attente de relecture utilisateur avant plan d'implémentation
Approche retenue: **C — fidélité maximale** (corps 3D + mains liftées en 3D)
## Contexte & problème
Le système capture un squelette corps + mains sur iPhone 16 Pro (iOS 26.5) et
le streame vers un Mac pour piloter une performance audio-visuelle (matrix
SuperCollider). L'utilisateur veut « vraiment la vision du squelette de pose
sur data_only_viz et dans le lien de données », et une refonte technique
« SOTA 2026 » sur quatre axes : **fidélité du squelette, latence/perf,
modernisation de la stack Apple, robustesse/maintenabilité**.
Signaux retenus pour la performance : **corps 3D + mains/doigts**.
Hors scope : visage/bouche, profondeur/mesh LiDAR.
### Diagnostic de l'existant (factuel)
Le **lien de données est déjà bon**. L'iPhone envoie sur TCP :7000 (usbmuxd,
filaire, pas de réseau) en frames AVLiveWire (header 19 o big-endian) :
- `skeleton` (tag 1) : 91 joints 3D world-space + validité, ~30 fps
- `skeleton2D` (tag 6) : 91 joints 2D projetés normalisés, ~30 fps
- `hands` (tag 4) : 21 pts × 2 mains (Vision), ~15 fps
- `video` (tag 2) : HEVC, keyframe / 30 frames
La dégradation est **côté consommation/rendu data_only_viz** :
- Les 91 joints sont décodés intacts dans `persons_arkit_joints`, **puis 76
sont jetés** : `arkit_joint_map.py` (`ARKIT91_TO_MP33`) ne mappe que **15
joints** dans le conteneur MediaPipe MP33.
- `renderer.py` ne connaît que MP33 (35 os MediaPipe) → seuls **14 segments**
sont dessinés (le « 14 segs, 17 tris » observé). Aucun chemin vers
`persons_arkit_joints`.
- Les index du mapping sont **non vérifiés** (`ARKIT_HEAD_IDX = 51` annoté
« VERIFY LIVE »).
- Le log `body3d: n=1 (pose_world_landmarks)` est trompeur (la source est
ARKit sous `--iphone-usb`, pas MediaPipe).
- Code mort : chemin OSC/UDP supprimé côté iOS mais doc périmée
(`iphone-arbody/CLAUDE.md` montre encore un fanout OSC) ; tag `face` (5)
défini mais jamais émis ; tag `meta` (3) réservé inutilisé.
À noter : l'armature 91 joints complète **est** rendue correctement ailleurs,
par `AVLiveBody` (Swift, `Skeleton3DRenderer.swift`), consommateur distinct du
même flux. Les deux consommateurs divergent.
### État de l'art Apple iOS 26 (recherche, sources datées)
- **Corps 3D** : ARKit `ARBodyTrackingConfiguration` / `ARSkeleton3D` (~91
joints hiérarchiques world-space, A12+, 60 fps) reste le plus riche sur
iPhone. Vision `VNDetectHumanBodyPose3DRequest` (iOS 17+) ne donne que 17
joints → trop pauvre. Rien de matériellement nouveau en iOS 26 pour le body
3D iPhone (surtout une API Vision Swift async).
- **Mains** : pas de finger tracking ARKit natif sur iPhone (visionOS only).
Voie native = Vision `VNDetectHumanHandPoseRequest` (21 joints). iOS 26
apporte un **nouveau modèle hand pose** plus petit/précis/basse latence —
**mais les positions de joints ont bougé** (re-tuning des classifieurs
requis). Holistic `detectsHands` (iOS 18) existe mais est **2D**.
- **Fusion corps+mains 3D** : pas d'unifié natif sur iPhone → **fusion
manuelle obligatoire** (état de l'art).
- **Transport** : usbmuxd + TCP sur câble reste recommandé pour le live
filaire ; `NWConnection` (Network framework) est l'API moderne ;
peer-to-peer Wi-Fi à éviter (latence).
- **Concurrence** : `CVPixelBuffer` / `ARFrame` non `Sendable` → pipeline en
`actor`, copie en structs `Sendable`, `AsyncStream` + `sending`, cible Swift
6.2 « approachable concurrency ».
Conclusion : la stack de base (ARKit body + Vision hands + AVLiveWire/usbmux)
est la bonne ; la refonte **exploite le riche existant** et **modernise les
fondations**, elle ne remplace pas ARKit.
## Contrainte structurante
**Le lift 3D des mains par LiDAR n'est pas faisable.**
`ARBodyTrackingConfiguration` rejette `.sceneDepth` (crashe la session) ;
`.personSegmentationWithDepth` spamme « Portrait image not supported ». Le
TrueDepth est sur la caméra avant, l'ARBodyTracking sur l'arrière → pas
simultanés. La profondeur capteur n'est donc pas disponible pendant le body
tracking.
→ Le 3D des mains est obtenu par **lift géométrique / IK ancré sur le poignet
ARKit 3D** (cf. `HandLiftSolver`), sans LiDAR.
## Principe directeur
> Le téléphone est la **source de vérité 3D auto-descriptive** : il envoie
> l'armature complète (corps + mains liftées) + sa topologie, horodatée, dans
> un frame unifié. Les consommateurs (data_only_viz Python, AVLiveBody Swift)
> **dessinent ce qu'on leur décrit** — zéro index hardcodé, zéro
> reconstruction dégradée.
## Architecture & composants
### iPhone (`iphone-arbody/`, Swift 6.2)
```
ARSession (ARBodyTrackingConfiguration, caméra arrière)
│ ARFrame @60fps (MainActor : extrait UNIQUEMENT des valeurs Sendable)
CaptureActor (actor, hors main thread)
├─ extrait 91 joints 3D + validité
├─ HandPoseService (nouveau modèle Vision iOS 26)
├─ HandLiftSolver (2D → 3D ancré poignet)
└─ copie en structs Sendable
│ PoseSnapshot (Sendable, 1 timestamp)
PoseEncoder → AsyncStream<Frame> TopologyProvider (1× au connect)
WireServer (NWConnection, TCP :7000, usbmux) [+ VideoEncoder optionnel]
```
Nouveaux composants : `CaptureActor` (isolation concurrence),
`HandPoseService`, `HandLiftSolver`, `TopologyProvider`, `PoseEncoder`.
`VideoEncoder` devient optionnel (`SEND_VIDEO`).
### Protocole partagé (`shared/AVLiveWire/`, v2)
- `FrameHeader` v2 : magic `AVL2`, octet **version** négocié.
- `PosePayload` (tag `pose`) : corps 91 + N mains, **un seul frame horodaté**.
- `TopologyPayload` (tag `topology`) : noms de joints + parentIndices corps &
mains, émis 1× au connect (+ resend à chaque reconnexion).
- Tags `skeleton` / `skeleton2D` / `hands` / `face` dépréciés puis retirés.
### Mac
- `data_only_viz` : `IphonePoseSource` (décode v2 → état unifié) +
`SkeletonRenderer` refondu (corps + mains 3D, topologie-driven, plus de
MP33). Détection gestes (`hand_features.py`) consomme les joints **main 3D**.
- `AVLiveBody` (Swift) : aligné sur v2 (même `pose` + topologie).
- Suppressions : `arkit_joint_map.py`, chemin OSC mort, log trompeur.
## Flux de données & schéma v2
### Header v2 (20 octets, big-endian)
| Champ | Taille | Note |
|-------|--------|------|
| magic | 4 o | `AVL2` |
| version | 1 o | `2` (négociation explicite) |
| tag | 1 o | `pose=1`, `topology=2`, `video=3` |
| pid | 2 o (i16) | id corps, `-1` global |
| timestamp | 8 o (f64) | horloge ARFrame, partagé corps+mains |
| length | 4 o (u32) | longueur payload |
### `PosePayload` (tag `pose`), ~30 fps
```
u8 bodyJointCount (= 91)
[91] float32 x,y,z joints corps 3D world-space (m)
[91] u8 valid
u8 handCount (0..2)
└─ par main:
u8 chirality (0=left, 1=right)
u8 lifted (1 = 3D résolu, 0 = fallback planaire)
[21] float32 x,y,z joints main, MÊME repère monde que le corps
[21] u8 valid
```
### `TopologyPayload` (tag `topology`), 1× au connect
```
u16 bodyJointCount, puis bodyJointCount × [u8 nameLen + utf8 name]
[bodyJointCount] i16 parentIndex (-1 = racine)
u16 handJointCount (=21), idem noms + parents (modèle Vision iOS 26)
```
Le renderer construit ses os depuis `parentIndex` : zéro index hardcodé. Si
l'ordre des joints change (nouveau modèle mains iOS 26), le rendu suit.
### Synchro & cadence
- Body @30 fps (throttle conservé) ; le dernier hand snapshot dispo est
agrégé dans le frame body courant (timestamp partagé) → fin du désync
15/30 fps.
- Vidéo : tag séparé, optionnelle (`SEND_VIDEO=0` par défaut pour la visu).
## HandLiftSolver (cœur de l'approche C)
Sur l'iPhone. Entrées : 21 joints 2D Vision + intrinsèques caméra `ARFrame` +
poignet ARKit 3D (déjà dans les 91 joints). Sortie : 21 joints main
world-space, même repère que le corps.
1. **Ancrage** : poignet de la main Vision forcé sur le poignet ARKit 3D
(profondeur `d₀` connue, non ambiguë).
2. **Orientation initiale** : axe avant-bras ARKit (coude→poignet) + up caméra
→ repère de paume (lève l'ambiguïté dos/paume + roll).
3. **Back-projection** : chaque joint 2D → un rayon caméra (via intrinsèques).
4. **Résolution par contrainte de longueur d'os** : propagation de la chaîne
cinématique, profondeur par joint choisie pour respecter les longueurs d'os
anthropométriques (a priori main ~18 cm, proportions fixes). Ambiguïté à 2
solutions → branche cohérente avec la frame précédente.
5. **Lissage** : One Euro sur les 21 joints 3D, anti-flip de branche.
### Précision (honnêteté)
Le 3D monoculaire d'une main est intrinsèquement ambigu en profondeur. Le lift
contraint par longueurs d'os donne un 3D **plausible et stable pour la
visualisation et les effets spatiaux**, pas de la métrologie. C'est le but de
l'approche C (fidélité visuelle).
### Gestes en 3D (décision utilisateur)
Les 8 slots pinch + fist sont calculés sur le **3D lifté** (pas le 2D) :
- Pinch = distance 3D métrique pouce↔doigt, **normalisée par la taille de main
3D** → invariant à la rotation de la main. Fist = courbure 3D de la chaîne.
- **Re-tuning obligatoire** : `PINCH_RATIO_ON=0.45` / `OFF=0.65` deviennent des
ratios 3D, à re-dériver sur device (re-tuning de toute façon imposé par le
nouveau modèle mains iOS 26).
- **Robustesse** : seuils normalisés par taille de main (annule l'erreur
d'échelle), hystérésis ON/OFF conservée, réfractaire conservé, lissage One
Euro avant calcul du geste.
- **Fallback `lifted=0`** : 3D planaire à la profondeur du poignet → le pinch
reste l'équivalent du 2D actuel ; les gestes continuent de marcher.
- **Calcul côté Mac** (`hand_features.py` + routing OSC matrix) sur les joints
main 3D du frame. iPhone = capture+lift pur, Mac = sens+routing.
## Concurrence, latence & perf
### Pipeline iPhone en isolation stricte (Swift 6.2)
- `ARSessionDelegate` (MainActor) extrait uniquement des valeurs `Sendable`
(transforms, intrinsèques) ; aucun `CVPixelBuffer`/`ARFrame` ne franchit une
frontière d'isolation (supprime le `nonisolated(unsafe)` patch).
- `CaptureActor` sérialise l'état mutable (fps, compteurs, dernier hand) → fin
des data races sans lock manuel.
- Structs `Sendable` (`BodySnapshot`, `HandSnapshot`, `PoseFrame`) copiées
avant toute frontière. `CVPixelBuffer``VideoEncoder` uniquement, via
`sending`.
- `AsyncStream` pour pousser au réseau (back-pressure naturelle).
- Build en strict concurrency (Swift 6.2 approachable concurrency).
### Budget latence (geste → son), chemin filaire
| Étage | ~ms |
|-------|-----|
| ARKit body + Vision hand (A18 Pro) | 1620 |
| HandLiftSolver | < 2 |
| Encode frame v2 + usbmux TCP (câble) | 25 |
| Décode Mac + filtre pose + calcul geste | 58 |
| OSC → SuperCollider | ~2 |
| **Total estimé** | **~3040 ms** |
### Leviers perf
- Vidéo off par défaut (`SEND_VIDEO=0`) : supprime encode HEVC + décode PyAV +
bande passante.
- Frame `pose` unifié : 1 message / 30 fps au lieu de 3 flux désynchronisés.
- Throttle body 30 fps conservé (60 fps dispo si besoin futur).
## Gestion d'erreurs & dégradation
Principe : dégrader gracieusement, ne jamais crasher, toujours signaler
l'état.
- **Reconnexion** : back-off auto (iPhone `WireServer`, Mac `IphonePoseSource`).
La topologie est renvoyée à chaque (re)connexion ; le Mac bufferise les
`pose` jusqu'à réception de la topologie. Flux coupé → dernier squelette
gelé avec indicateur « stale », pas de disparition brutale.
- **Perte de tracking** : aucun corps → « no body », modulation gelée sur
dernière valeur ; joints partiels → flag `valid` par joint, os non dessiné,
IK comble les trous courts ; main absente → gestes désarmés proprement ;
main non liftée → `lifted=0`, 3D planaire, geste calculé, affichage distinct.
- **Mismatch version** : `AVL2` + octet version → un consommateur d'une autre
version rejette explicitement avec log clair (pas de décodage silencieux
faux). `StreamDemuxer` : resync magic, rejet `length > maxPayload`, compteur
de frames corrompues exposé.
- **Garde-fous gestes** : hystérésis + réfractaire ; confiance < seuil sur N
frames → geste forcé OFF ; armement sur front uniquement.
- **Observabilité** : frame de métriques léger (fps réel, % frames liftées,
drops Vision, frames corrompues, latence aller) affichable dans data_only_viz
/ web control.
## Tests & validation
- **N1 — Unitaires déterministes** (`shared/AVLiveWire`) : round-trip
encode/decode `PosePayload` + `TopologyPayload` ; robustesse `StreamDemuxer`
(tronqué, magic partiel, frame géante, resync, rejet de version) ;
`HandLiftSolver` (poignet synthétique + rayons + longueurs d'os → coïncidence
poignet 3D, reprojection < ε px, stabilité de branche).
- **N2 — Index ARKit sur device** : mode debug logue les noms
`ARSkeletonDefinition`, le renderer étiquette chaque joint → confirmation
visuelle une fois pour toutes (tue le « VERIFY LIVE » ; la topologie
auto-descriptive empêche la re-dérive).
- **N3 — Harnais comparatif gestes 2D→3D** : double détection 2D‖3D en
parallèle sur le même flux device, log des divergences ; bascule quand 3D ≥
2D en fiabilité et 0 faux positif ajouté (pinch lent/rapide, main
tournée/de profil, fist) ; re-dérivation des seuils 3D ici. 2D reste source
live tant que le critère n'est pas atteint (flag réversible).
- **N4 — Visuel bout-en-bout** : data_only_viz affiche l'armature 91 joints +
mains 3D (vs 14 segments actuels). Capture d'écran : `screencapture` est
bloqué (permission Enregistrement de l'écran absente) → soit accorder la
permission, soit valider via métriques + logs structurés (à décider en
implémentation).
- **Latence** : mesurée en continu via le timestamp partagé du frame.
## Phasage & migration
Le live garde un chemin fonctionnel à chaque étape ; chaque phase est livrable,
validée, réversible par flag. Valeur visible d'abord, risque ensuite.
| Phase | Contenu | Driver | Risque | Réversible |
|-------|---------|--------|--------|------------|
| 0. Protocole | AVLiveWire v2 (header versionné, `PosePayload`, `TopologyPayload`) + tests | robustesse | faible | n/a |
| 1. Squelette fidèle | iPhone émet la topologie ; data_only_viz nouveau renderer sur les 91 joints déjà décodés + mains, topologie-driven. **Gain visible : vrai squelette.** | fidélité | faible | flag (rendu MP33 en repli) |
| 2. iPhone modernisé | Refacto capture `actor` Swift 6.2 + Sendable, frame `pose` unifié horodaté, vidéo optionnelle | latence + Apple | moyen | dual-emit v1/v2 |
| 3. Mains 3D | `HandLiftSolver` → mains world-space dans le frame ; validation solver | fidélité | moyen | `lifted=0` repli planaire |
| 4. Gestes 3D | Harnais double-détection, re-tuning seuils, bascule quand 3D ≥ 2D | fidélité | moyen | flag (2D source tant que non validé) |
| 5. Hand model iOS 26 | Swap nouveau modèle Vision + re-tuning | Apple | faible | flag |
| 6. Nettoyage | Retrait tags v1/face, OSC mort, `arkit_joint_map` ; alignement AVLiveBody sur v2 ; mise à jour des CLAUDE.md périmés | robustesse | faible | — |
Point de bascule sûr : dès la fin de Phase 1, le besoin n°1 (vraie vision du
squelette dans data_only_viz) est livré, sans toucher l'iPhone en profondeur
ni risquer les gestes.
## Décisions ouvertes pour l'implémentation
- Permission « Enregistrement de l'écran » pour les captures de validation
visuelle (N4) — à accorder ou contourner par métriques.
- Indices anatomiques ARKit exacts (poignet, avant-bras) à confirmer sur device
en Phase N2 avant de câbler le `HandLiftSolver`.
- Re-vérifier en Phase 5 l'impact du nouveau modèle mains iOS 26 sur les noms /
ordre de joints de la topologie.