# Prompt #2 — D2 Vision (Caméra + MediaPipe + Motion Score)

**Rôle :**
Tu es _Développeur Vision_ du projet **Skull Pi**. Tu livres un service Python temps réel qui capte la caméra, détecte/tracke 1..N visages avec MediaPipe, calcule la pose cible (yaw/pitch/dwell), un score de mouvement, et publie les messages MQTT requis. Le service peut (optionnel) émettre des commandes _eyes/neck_ (mapping h) avec lissages et retard. Langue : FR. Cible : Raspberry Pi Zero 2 W.

**Contexte & contraintes :**

- Dossier : `/opt/Skull/apps/vision`, exécution `python -m vision.main`.
- Cam : V4L2, résolution/fps par `.env` (`VISION_RES=320x240`, `VISION_FPS=15`).
- Bus : MQTT local `127.0.0.1:1883`.
- Publications requises (cf. cahier) :

  - `vision/targets`, `vision/pose`, `vision/motion`, `vision/capabilities` (retained).

- Paramètres via config retained et `.env`.
- Option mapping (activable) : publier `eyes/cmd` immédiat et `neck/cmd` avec `lag_ms`.
- Perf cible : **≥ 12 FPS** constants à 320×240 sur Pi Zero 2 W ; latence _capture→MQTT_ médiane < **100 ms**.

---

## Objectif fonctionnel

1. Détection multi-visages, extraction par visage :

   - `id` stable (tracking), `conf`, `bbox (cx, cy, w, h)`, angles `yaw, pitch, roll` (estimés via landmarks / orientation tête).

2. Sélection d’une **cible courante** selon politique : `closest | largest | round_robin`.
3. Calcul d’un **score de mouvement** `[0..1]` basé sur variations temporelles des features MediaPipe (positions, aire, orientation).
4. Publication MQTT temps réel : `vision/targets`, `vision/pose`, `vision/motion` (+ capabilities retained).
5. **Lissage** configurable : `EYES_ALPHA`, `NECK_ALPHA`, délai `NECK_LAG_MS`.
6. **(Option)** Mapping horizontal h = `(cx - 0.5) * 2` → `eyes/cmd` immédiat, puis `neck/cmd` avec retard.
7. **Robustesse :** repli “no camera / no model” (capabilities adaptées), watchdog interne, logs structurés.

---

## Spécification MQTT

### Sorties

- `vision/targets` (JSON)

  ```json
  {
    "ts_ms": 0,
    "targets": [
      {
        "id": 1,
        "conf": 0.92,
        "bbox": { "cx": 0.52, "cy": 0.41, "w": 0.22, "h": 0.28 },
        "yaw": -0.18,
        "pitch": 0.05,
        "roll": 0.02
      }
    ]
  }
  ```

- `vision/pose` (JSON) — **cible courante**

  ```json
  { "ts_ms": 0, "yaw": -0.18, "pitch": 0.05, "conf": 0.92, "target_id": 1 }
  ```

- `vision/motion` (JSON) — **score mouvement**

  ```json
  { "ts_ms": 0, "active": true, "score": 0.67, "reason": "face_motion" }
  ```

- `vision/capabilities` (retained)

  ```json
  { "mediapipe": true, "fps": 15, "res": "320x240", "multi_face": true }
  ```

### (Option) Commandes Motion

- `eyes/cmd`
  `{"h": float_deg, "spd": 0..1}` où `h = gain_eyes * ((cx-0.5)*2)*H_MAX_DEG` (H*MAX_DEG laissé côté motion via clamp ; ici seulement \_gain_eyes*).
- `neck/cmd`
  `{"pan": float_deg, "spd": 0..1, "lag_ms": NECK_LAG_MS}` avec `pan = gain_neck * ((cx-0.5)*2)*PAN_MAX_DEG`.

> L’activation se fait via retained `skull/config/gaze` et/ou `skull/config/vision` (flags `publish_motion_cmds: true|false`). Par défaut **true**.

---

## Paramètres (UI + .env)

- `.env` lus au boot :

  ```
  VISION_RES=320x240
  VISION_FPS=15
  VISION_MIN_DET_CONF=0.6
  VISION_MIN_TRACK_CONF=0.5
  ```

- Retained configurables (abonnements) :

  - `skull/config/vision`
    `{"min_det_conf":0.6,"min_track_conf":0.5,"motion_threshold":0.35,"eyes_alpha":0.5,"neck_alpha":0.2,"neck_lag_ms":120,"publish_motion_cmds":true}`
  - `skull/config/gaze`
    `{"policy":"round_robin","dwell_ms":1200,"max_targets":3,"g_eyes":0.9,"g_neck":0.6}`

---

## Détails d’implémentation

1. **Structure package** (`/opt/Skull/apps/vision`)

```
vision/
  __init__.py
  main.py           # boucle capture, scheduling, pub MQTT
  camera.py         # V4L2/OpenCV initialisation, timing
  mp.py             # MediaPipe wrapper (détection + landmarks)
  tracker.py        # IDs stables, politiques de sélection
  motion_score.py   # calcul score de mouvement
  smoothing.py      # EMA / IIR pour eyes/neck
  mapper.py         # h/pan mapping et émission MQTT (option)
  mqtt.py
  config.py
  health.py         # métriques fps/latence
  tests/
```

2. **MediaPipe**

- Choisir solution _Face Detection_ + _Face Mesh_ légère (si dispo) pour yaw/pitch.
- Extraction `cx, cy, w, h` normalisés \[0..1].
- Estimation `yaw/pitch/roll` : régression simple via paires de landmarks (nez/yeux/oreilles) ; renvoyer `None` si non fiable (< conf).
- _Backoff_ : si Face Mesh indisponible, publie uniquement bbox + `yaw/pitch/roll = 0` et `conf` réduite.

3. **Tracking & politiques**

- Association inter-frame par IoU + distance centre + heuristique taille (Hungarian optionnel).
- IDs persistants max `GAZE_MAX_TARGETS`.
- Politique `closest` (plus grand bbox), `largest` (aire), `round_robin` (tourne toutes `dwell_ms`).
- `vision/pose` met la cible **après lissage** (EMA par `eyes_alpha`/`neck_alpha` appliqués séparément).

4. **Score de mouvement**

- `score = clamp01( a*Δbbox + b*Δcentres + c*Δangles )` lissé par EMA 0.4.
- `active = score >= VISION_MOTION_THRESHOLD`.
- `reason`: `"face_motion" | "no_face" | "static"`.

5. **Timing & santé**

- Boucle capture indépendante de la publication (queues).
- Publier métriques dans logs JSON : `fps_capture`, `fps_pipeline`, `latency_ms`.
- Healthcheck périodique (2 s) : `{"ok":true,"fps":..., "latency_p50":..., "ts_ms":...}` (peut aller dans `vision/health` si utile).

6. **Logs**

- JSON lignes vers `/opt/Skull/logs/vision.log`, niveaux info/warn/error.

7. **Démarrage gracieux**

- Publier `vision/capabilities` (retained) < 2 s après boot.
- Si caméra indisponible, `mediapipe:false`, `fps:0`, `res:"unknown"` et logs d’erreur explicites.

---

## Déploiement / systemd

- Service `skull-vision.service` (modèle du cahier), `WorkingDirectory=/opt/Skull/apps/vision`, `ExecStart=/opt/Skull/bin/skull-vision.sh`.
- Wrapper :

  ```bash
  #!/usr/bin/env bash
  set -euo pipefail
  source /opt/Skull/venv/bin/activate
  exec python -m vision.main
  ```

---

## Livrables

- Code Python propre (type hints), `ruff` + `mypy` OK.
- Tests `pytest` avec _frames synthétiques_ (numpy) + _fake MediaPipe_ (mocks) ⇒ reproductibles sans caméra.
- `README.md` (installation, perf attendues, topics, params UI).
- Script de bench rapide (`python -m vision.main --bench`) affichant FPS/latence.

---

## Critères d’acceptation

- **Perf** : ≥ 12 FPS pipeline, latence médiane ≤ 100 ms @ 320×240/15 FPS.
- **MQTT** : schémas JSON conformes, `vision/capabilities` retained correct.
- **Multi-cibles** : au moins 2 visages simulés → IDs stables ≥ 2 s.
- **Politiques** : `closest`, `largest`, `round_robin` fonctionnelles (rotation \~ `dwell_ms`).
- **Motion score** : varie cohérent (immobile < seuil, mouvement > seuil) avec hystérésis faible.
- **Option mapping** activée : publication `eyes/cmd` immédiate puis `neck/cmd` avec `lag_ms` (tolérance +/− 30 ms).
- **Robustesse** : perte caméra → service reste vivant, capabilities mises à jour, reprise dès retour caméra.

---

## Plan de tests unitaires (rapides & isolés)

1. **Formats MQTT**

   - Vérifier `vision/targets|pose|motion` vs schéma (clés/typos, ranges \[0..1]).

2. **Tracking ID**

   - Série de bbox légèrement déplacées (bruit) ⇒ ID constant, jitter < 2 px normalisés.

3. **Politiques cible**

   - Synthèse 2 visages (aire différente) ⇒ `largest` choisit le plus grand ; `closest` idem si défini comme plus proche centre.
   - `round_robin` : alterne toutes `dwell_ms` ±10%.

4. **Yaw/Pitch estimation** (mock landmarks)

   - Perturber positions yeux/nez ⇒ yaw signe et ordre corrects.

5. **Score mouvement**

   - Série statique ⇒ score < 0.1 ; pas de “active”.
   - Micro-saccades ⇒ score pic 0.3–0.5 ; franchit `motion_threshold=0.35`.

6. **Lissage EMA**

   - Entrée step sur `cx` ⇒ sortie suit EMA avec erreur initiale conforme `alpha`.

7. **Mapping & retard**

   - `cx` ↗ vers 0.7 ⇒ `eyes/cmd.h` émis en < 1 frame, `neck/cmd` émis après `NECK_LAG_MS` ±30 ms.

8. **Capabilities retained**

   - Redémarrage ⇒ `vision/capabilities` présent et correct.

9. **Dégradations**

   - MediaPipe indisponible (mock exception) ⇒ service publie `mediapipe:false`, reste stable 10 s.

---

## Procédure de test manuel (rapide)

1. Démarrer `skull-vision.service`; vérifier `vision/capabilities` :

   ```
   mosquitto_sub -t vision/capabilities -v
   ```

2. Visualiser sorties :

   ```
   mosquitto_sub -t 'vision/targets' -v
   mosquitto_sub -t 'vision/pose' -v
   mosquitto_sub -t 'vision/motion' -v
   ```

3. Bouger devant la caméra : `vision/motion.score` doit franchir le seuil.
4. Tester politiques : publier

   ```
   mosquitto_pub -t skull/config/gaze -m '{"policy":"round_robin","dwell_ms":800,"max_targets":3,"g_eyes":0.9,"g_neck":0.6}'
   ```

   Vérifier alternance des `target_id`.

5. Activer mapping et observer commandes :

   ```
   mosquitto_pub -t skull/config/vision -m '{"publish_motion_cmds":true,"eyes_alpha":0.5,"neck_alpha":0.2,"neck_lag_ms":150}'
   mosquitto_sub -t 'eyes/cmd' -v
   mosquitto_sub -t 'neck/cmd' -v
   ```

6. Couvrir la caméra → `vision/motion.active=false`, `targets=[]`.

---

## Conseils d’implémentation

- Capture : OpenCV `cv2.VideoCapture` avec `CAP_V4L2`, _buffer_ drop frames (lecture non bloquante).
- Optimisations Pi :

  - Conversion BGR→RGB _in-place_, redimensionnement en entrée 320×240.
  - Utiliser _region-of-interest_ (si tracking) pour réduire calcul.

- MediaPipe : initialiser graph une seule fois ; réutiliser buffers.
- Séparer _compute_ (purs numpy) de _IO_ (cam/MQTT) pour tests.
- Toujours _clamp01_ les normalisés ; timestamps en `ts_ms = int(time.time()*1000)`.

---

**À livrer** : MR “D2 Vision — MVP temps réel” avec code, tests, bench, service, README.
