Raffaton commited on
Commit
597be31
·
1 Parent(s): bdd12c2

commentaire main.py

Browse files
Files changed (1) hide show
  1. reachy_mini_hello_world/main.py +85 -24
reachy_mini_hello_world/main.py CHANGED
@@ -14,16 +14,22 @@ from fastapi import UploadFile, File
14
  from fastapi.responses import FileResponse
15
  import yt_dlp
16
  from reachy_mini import ReachyMini, ReachyMiniApp
 
 
17
  from scripts.play_json import analyser_video, rejouer_mouvements, extraire_donnees_visage
18
  from scripts.generation_mouvement import trouver_emotion, calculer_cibles_antennes, adoucir_mouvement, calculer_danse_antennes
19
 
 
20
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
21
  sys.path.append(str(PROJECT_ROOT))
22
 
23
- DATA_DIR = PROJECT_ROOT / "data"
24
- MODELS_DIR = PROJECT_ROOT / "models"
25
  MODEL = MODELS_DIR / "face_landmarker_v2_with_blendshapes.task"
26
 
 
 
 
27
  if not MODEL.exists():
28
  import urllib.request
29
  try:
@@ -37,6 +43,7 @@ if not MODEL.exists():
37
  except Exception as e:
38
  print(f"Erreur lors du téléchargement automatique du modèle : {e}")
39
 
 
40
  def est_prete(filename):
41
  stem = Path(filename).stem
42
  return (
@@ -45,32 +52,36 @@ def est_prete(filename):
45
  )
46
 
47
  class ReachyMiniHelloWorld(ReachyMiniApp):
48
- custom_app_url: str | None = "http://0.0.0.0:8080"
49
- request_media_backend: str | None = "no_media"
 
50
 
51
- def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
 
52
  status = {
53
- "robot_busy": False,
54
- "analysis_busy": False,
55
- "analysis_file": None,
56
- "analysis_progress": 0,
57
- "selected_video": None,
58
- "playback_state": "playing",
59
- "playback_time_sync": 0.0,
60
- "playback_sync_flag": False,
61
- "stop_requested": False
62
  }
63
 
 
64
  live = {
65
- "active": False,
66
- "detector": None,
67
- "emotion": "Neutre",
68
- "emotion_validee": "Neutre",
69
- "emotion_en_cours": "Neutre",
70
- "confirmations": 0,
71
- "chronometre_emotion": 0.0,
72
- "dernier_temps_visage": 0.0,
73
- "temps_debut": 0.0,
 
74
  "etat_robot": {
75
  "tete_p": 0.0, "tete_y": 0.0, "tete_r": 0.0,
76
  "ant_g_p": 0.0, "ant_g_r": 0.0,
@@ -78,6 +89,9 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
78
  }
79
  }
80
 
 
 
 
81
  def pipeline_analyse(filename, delai_danse=2.0):
82
  status["stop_requested"] = False
83
  status["analysis_busy"] = True
@@ -86,6 +100,7 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
86
 
87
  try:
88
  video_cible = DATA_DIR / filename
 
89
  analyser_video(str(video_cible), str(MODEL), status=status, delai_danse=delai_danse)
90
  except Exception as e:
91
  pass
@@ -95,6 +110,7 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
95
  status["analysis_progress"] = 0
96
  status["stop_requested"] = False
97
 
 
98
  def pipeline_replay():
99
  status["stop_requested"] = False
100
  status["robot_busy"] = True
@@ -104,6 +120,7 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
104
  try:
105
  stem = Path(status["selected_video"]).stem
106
  movement_path = DATA_DIR / f"{stem}_movement.json"
 
107
  rejouer_mouvements(str(movement_path), reachy_mini, status=status)
108
  except Exception as e:
109
  pass
@@ -111,6 +128,9 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
111
  status["robot_busy"] = False
112
  status["stop_requested"] = False
113
 
 
 
 
114
  @self.settings_app.get("/status")
115
  async def get_status():
116
  is_ready = est_prete(status["selected_video"]) if status["selected_video"] else False
@@ -124,6 +144,7 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
124
  "live_active": live["active"]
125
  }
126
 
 
127
  @self.settings_app.get("/videos")
128
  async def lister_videos():
129
  if not DATA_DIR.exists():
@@ -134,6 +155,7 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
134
  if "_processed" not in f.name
135
  ]
136
 
 
137
  @self.settings_app.post("/select_video")
138
  async def selectionner_video(filename: str):
139
  if not (DATA_DIR / filename).exists():
@@ -141,6 +163,7 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
141
  status["selected_video"] = filename
142
  return {"selected": filename, "ready": est_prete(filename)}
143
 
 
144
  @self.settings_app.post("/analyser")
145
  async def lancer_analyse(delai_danse_secondes: float = 2.0):
146
  if status["analysis_busy"]:
@@ -150,9 +173,11 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
150
  if est_prete(status["selected_video"]):
151
  return {"error": "Vidéo déjà analysée"}
152
 
 
153
  threading.Thread(target=pipeline_analyse, args=(status["selected_video"], delai_danse_secondes), daemon=True).start()
154
  return {"started": True, "delai_danse": delai_danse_secondes}
155
 
 
156
  @self.settings_app.post("/run")
157
  async def run_pipeline():
158
  if status["robot_busy"]:
@@ -160,13 +185,16 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
160
  if not est_prete(status["selected_video"]):
161
  return {"error": "Vidéo non prête"}
162
 
 
163
  reachy_mini.wake_up()
164
  reachy_mini.enable_motors()
165
  await asyncio.sleep(0.5)
166
 
 
167
  threading.Thread(target=pipeline_replay, daemon=True).start()
168
  return {"started": True}
169
 
 
170
  @self.settings_app.post("/sync_playback")
171
  async def sync_playback(state: str, current_time: float):
172
  status["playback_state"] = state
@@ -174,12 +202,14 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
174
  status["playback_sync_flag"] = True
175
  return {"synced": True}
176
 
 
177
  @self.settings_app.post("/stop")
178
  async def stop_pipeline():
179
  if status["robot_busy"]:
180
  status["stop_requested"] = True
181
  return {"stopped": True}
182
 
 
183
  @self.settings_app.post("/upload_video")
184
  async def upload_video(file: UploadFile = File(...), delai_danse_secondes: float = 2.0):
185
  if not file.filename.lower().endswith(".mp4"):
@@ -198,12 +228,14 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
198
 
199
  status["selected_video"] = file.filename
200
 
 
201
  if not est_prete(file.filename) and not status["analysis_busy"]:
202
  threading.Thread(target=pipeline_analyse, args=(file.filename, delai_danse_secondes), daemon=True).start()
203
  return {"uploaded": file.filename, "analyse_demarree": True, "delai_danse": delai_danse_secondes}
204
 
205
  return {"uploaded": file.filename, "analyse_demarree": False}
206
 
 
207
  @self.settings_app.get("/processed_video")
208
  async def get_processed_video():
209
  if not status["selected_video"]:
@@ -213,6 +245,7 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
213
  return FileResponse(str(video_path), media_type="video/mp4")
214
  return {"error": "Vidéo traitée non disponible"}
215
 
 
216
  @self.settings_app.post("/download_youtube")
217
  async def download_youtube(data: dict):
218
  url = data.get('url')
@@ -235,6 +268,9 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
235
  except Exception as e:
236
  return {"success": False, "error": str(e)}
237
 
 
 
 
238
  @self.settings_app.post("/live/start")
239
  async def live_start():
240
  if status["robot_busy"]:
@@ -242,10 +278,11 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
242
  if live["active"]:
243
  return {"error": "Mode live déjà actif"}
244
 
 
245
  base_options = python.BaseOptions(model_asset_path=str(MODEL))
246
  options = vision.FaceLandmarkerOptions(
247
  base_options=base_options,
248
- running_mode=vision.RunningMode.VIDEO,
249
  output_face_blendshapes=True,
250
  output_facial_transformation_matrixes=True,
251
  num_faces=1,
@@ -255,6 +292,7 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
255
  )
256
  live["detector"] = vision.FaceLandmarker.create_from_options(options)
257
 
 
258
  live.update({
259
  "active": True,
260
  "emotion": "Neutre",
@@ -278,34 +316,41 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
278
  await asyncio.sleep(0.5)
279
  return {"started": True}
280
 
 
281
  @self.settings_app.post("/live/stop")
282
  async def live_stop():
283
  live["active"] = False
284
  live["detector"] = None
285
  status["robot_busy"] = False
286
  try:
 
287
  reachy_mini.goto_sleep()
288
  reachy_mini.disable_motors()
289
  except Exception:
290
  pass
291
  return {"stopped": True}
292
 
 
 
293
  @self.settings_app.post("/live/frame")
294
  async def live_frame(file: UploadFile = File(...)):
295
  if not live["active"] or live["detector"] is None:
296
  return {"error": "Mode live non actif"}
297
 
 
298
  contents = await file.read()
299
  nparr = np.frombuffer(contents, np.uint8)
300
  frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
301
  if frame is None:
302
  return {"error": "Image invalide"}
303
 
 
304
  h, w = frame.shape[:2]
305
  if w > 960:
306
  ratio = 960 / w
307
  frame = cv2.resize(frame, (960, int(h * ratio)), interpolation=cv2.INTER_AREA)
308
 
 
309
  lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB)
310
  l_ch, a_ch, b_ch = cv2.split(lab)
311
  clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
@@ -314,6 +359,7 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
314
 
315
  image_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
316
 
 
317
  live["frame_timestamp_ms"] += 66
318
  resultat = live["detector"].detect_for_video(
319
  mp.Image(image_format=mp.ImageFormat.SRGB, data=image_rgb),
@@ -325,14 +371,17 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
325
  cible_tete_p = cible_tete_y = cible_tete_r = 0.0
326
  cible_ag_p = cible_ag_r = cible_ad_p = cible_ad_r = 0.0
327
 
 
328
  if resultat.face_landmarks:
329
  live["dernier_temps_visage"] = temps_actuel
330
 
 
331
  matrice = resultat.facial_transformation_matrixes[0]
332
  scores = {b.category_name: b.score for b in resultat.face_blendshapes[0]}
333
  traits = extraire_donnees_visage(matrice, scores)
334
  live["last_blendshapes"] = {k: round(v, 3) for k, v in traits.items()}
335
 
 
336
  emotion_detectee = trouver_emotion(traits)
337
  if emotion_detectee == live["emotion_en_cours"]:
338
  live["confirmations"] += 1
@@ -340,24 +389,32 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
340
  live["emotion_en_cours"] = emotion_detectee
341
  live["confirmations"] = 1
342
 
 
343
  if live["confirmations"] >= 3 and live["emotion_en_cours"] != live["emotion_validee"]:
344
  live["emotion_validee"] = live["emotion_en_cours"]
345
  live["chronometre_emotion"] = temps_actuel
346
 
 
347
  temps_animation = temps_actuel - live["chronometre_emotion"]
348
  cible_ag_p, cible_ag_r, cible_ad_p, cible_ad_r = calculer_cibles_antennes(
349
  live["emotion_validee"], temps_animation
350
  )
 
 
351
  cible_tete_p = traits["pitch"]
352
  cible_tete_y = traits["yaw"]
353
  cible_tete_r = traits["roll"]
354
  live["emotion"] = live["emotion_validee"]
355
 
 
356
  else:
357
  delai = temps_actuel - live["dernier_temps_visage"]
 
358
  if delai >= 2.0:
359
  cible_ag_p, cible_ag_r, cible_ad_p, cible_ad_r = calculer_danse_antennes(delai - 2.0)
360
 
 
 
361
  er["tete_p"] = adoucir_mouvement(er["tete_p"], cible_tete_p, 0.6)
362
  er["tete_y"] = adoucir_mouvement(er["tete_y"], cible_tete_y, 0.6)
363
  er["tete_r"] = adoucir_mouvement(er["tete_r"], cible_tete_r, 0.6)
@@ -366,6 +423,7 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
366
  er["ant_d_p"] = adoucir_mouvement(er["ant_d_p"], cible_ad_p, 0.5)
367
  er["ant_d_r"] = adoucir_mouvement(er["ant_d_r"], cible_ad_r, 0.5)
368
 
 
369
  pose_tete = create_head_pose(
370
  pitch=er["tete_p"], yaw=er["tete_y"], roll=er["tete_r"], degrees=False
371
  )
@@ -379,13 +437,16 @@ class ReachyMiniHelloWorld(ReachyMiniApp):
379
 
380
  return {"emotion": live["emotion"]}
381
 
 
382
  @self.settings_app.get("/live/debug")
383
  async def live_debug():
384
  """Retourne les dernières valeurs brutes des blendshapes pour calibration."""
385
  return live.get("last_blendshapes", {})
386
 
 
387
  stop_event.wait()
388
 
 
389
  if __name__ == "__main__":
390
  app = ReachyMiniHelloWorld()
391
  try:
 
14
  from fastapi.responses import FileResponse
15
  import yt_dlp
16
  from reachy_mini import ReachyMini, ReachyMiniApp
17
+
18
+ # Import des scripts internes pour l'analyse, le rejeu et la génération de mouvements
19
  from scripts.play_json import analyser_video, rejouer_mouvements, extraire_donnees_visage
20
  from scripts.generation_mouvement import trouver_emotion, calculer_cibles_antennes, adoucir_mouvement, calculer_danse_antennes
21
 
22
+ # CONFIGURATION DES CHEMINS ET DOSSIERS DU PROJET
23
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
24
  sys.path.append(str(PROJECT_ROOT))
25
 
26
+ DATA_DIR = PROJECT_ROOT / "data" # Dossier de stockage des vidéos et JSON de mouvements
27
+ MODELS_DIR = PROJECT_ROOT / "models" # Dossier pour les modèles d'IA (MediaPipe)
28
  MODEL = MODELS_DIR / "face_landmarker_v2_with_blendshapes.task"
29
 
30
+ # TÉLÉCHARGEMENT AUTOMATIQUE DU MODÈLE DE VISION (Sécurité / Zéro Configuration)
31
+ # Si le fichier de détection de visage n'est pas présent localement,
32
+ # l'application le télécharge automatiquement depuis les serveurs de Google.
33
  if not MODEL.exists():
34
  import urllib.request
35
  try:
 
43
  except Exception as e:
44
  print(f"Erreur lors du téléchargement automatique du modèle : {e}")
45
 
46
+ # Fonction utilitaire pour vérifier si une vidéo a déjà été analysée (fichiers prêts)
47
  def est_prete(filename):
48
  stem = Path(filename).stem
49
  return (
 
52
  )
53
 
54
  class ReachyMiniHelloWorld(ReachyMiniApp):
55
+ # Configuration du serveur web embarqué
56
+ custom_app_url: str | None = "http://0.0.0.0:8080" # URL de notre dashboard de contrôle
57
+ request_media_backend: str | None = "no_media" # Indique à Reachy qu'on gère nous-mêmes la caméra
58
 
59
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
60
+ # État du pipeline de traitement de fichiers vidéo locaux/YouTube
61
  status = {
62
+ "robot_busy": False, # True si le robot est en train de rejouer des mouvements
63
+ "analysis_busy": False, # True si MediaPipe est en train d'analyser une vidéo
64
+ "analysis_file": None, # Nom de la vidéo en cours d'analyse
65
+ "analysis_progress": 0, # Barre de progression de l'analyse (0-100 %)
66
+ "selected_video": None, # Vidéo actuellement sélectionnée
67
+ "playback_state": "playing", # État de lecture (playing / paused)
68
+ "playback_time_sync": 0.0, # Temps de lecture synchronisé avec le navigateur
69
+ "playback_sync_flag": False, # Flag pour forcer la resynchronisation temporelle
70
+ "stop_requested": False # True si l'utilisateur a cliqué sur "Stop"
71
  }
72
 
73
+ # État du pipeline de suivi en temps réel (Webcam Live)
74
  live = {
75
+ "active": False, # True si le mode live par webcam est actif
76
+ "detector": None, # Instance active de l'IA FaceLandmarker de MediaPipe
77
+ "emotion": "Neutre", # Émotion actuellement affichée par le robot
78
+ "emotion_validee": "Neutre", # Émotion stable après confirmation temporelle
79
+ "emotion_en_cours": "Neutre", # Émotion détectée sur la frame actuelle
80
+ "confirmations": 0, # Compteur de frames pour stabiliser l'émotion détectée
81
+ "chronometre_emotion": 0.0, # Timestamp du début de l'émotion actuelle (pour l'animation)
82
+ "dernier_temps_visage": 0.0, # Dernier instant où un visage a été détecté
83
+ "temps_debut": 0.0, # Heure système au lancement du mode live
84
+ # État lissé des articulations (pitch, yaw, roll pour la tête et les antennes)
85
  "etat_robot": {
86
  "tete_p": 0.0, "tete_y": 0.0, "tete_r": 0.0,
87
  "ant_g_p": 0.0, "ant_g_r": 0.0,
 
89
  }
90
  }
91
 
92
+ # PIPELINES DE TRAITEMENT EN ARRIÈRE-PLAN
93
+
94
+ # Pipeline 1 : Analyse complète d'un fichier vidéo en tâche de fond
95
  def pipeline_analyse(filename, delai_danse=2.0):
96
  status["stop_requested"] = False
97
  status["analysis_busy"] = True
 
100
 
101
  try:
102
  video_cible = DATA_DIR / filename
103
+ # Appelle notre fonction de traitement d'image et d'extraction de blendshapes
104
  analyser_video(str(video_cible), str(MODEL), status=status, delai_danse=delai_danse)
105
  except Exception as e:
106
  pass
 
110
  status["analysis_progress"] = 0
111
  status["stop_requested"] = False
112
 
113
+ # Pipeline 2 : Rejeu physique des mouvements sur le robot
114
  def pipeline_replay():
115
  status["stop_requested"] = False
116
  status["robot_busy"] = True
 
120
  try:
121
  stem = Path(status["selected_video"]).stem
122
  movement_path = DATA_DIR / f"{stem}_movement.json"
123
+ # Envoie les consignes angulaires lissées et synchronisées au robot en temps réel
124
  rejouer_mouvements(str(movement_path), reachy_mini, status=status)
125
  except Exception as e:
126
  pass
 
128
  status["robot_busy"] = False
129
  status["stop_requested"] = False
130
 
131
+ # POINTS D'ENTRÉE API REST (FastAPI)
132
+
133
+ # Route 1 : Retourne les états globaux au Dashboard (barre de chargement, état d'occupation...)
134
  @self.settings_app.get("/status")
135
  async def get_status():
136
  is_ready = est_prete(status["selected_video"]) if status["selected_video"] else False
 
144
  "live_active": live["active"]
145
  }
146
 
147
+ # Route 2 : Liste toutes les vidéos importées prêtes à l'analyse ou au rejeu
148
  @self.settings_app.get("/videos")
149
  async def lister_videos():
150
  if not DATA_DIR.exists():
 
155
  if "_processed" not in f.name
156
  ]
157
 
158
+ # Route 3 : Sélectionne une vidéo spécifique dans l'interface
159
  @self.settings_app.post("/select_video")
160
  async def selectionner_video(filename: str):
161
  if not (DATA_DIR / filename).exists():
 
163
  status["selected_video"] = filename
164
  return {"selected": filename, "ready": est_prete(filename)}
165
 
166
+ # Route 4 : Lance l'analyse par IA (MediaPipe) en arrière-plan sans bloquer l'API
167
  @self.settings_app.post("/analyser")
168
  async def lancer_analyse(delai_danse_secondes: float = 2.0):
169
  if status["analysis_busy"]:
 
173
  if est_prete(status["selected_video"]):
174
  return {"error": "Vidéo déjà analysée"}
175
 
176
+ # Démarrage de l'analyse dans un thread séparé
177
  threading.Thread(target=pipeline_analyse, args=(status["selected_video"], delai_danse_secondes), daemon=True).start()
178
  return {"started": True, "delai_danse": delai_danse_secondes}
179
 
180
+ # Route 5 : Active les moteurs et lance le rejeu physique des expressions de la vidéo
181
  @self.settings_app.post("/run")
182
  async def run_pipeline():
183
  if status["robot_busy"]:
 
185
  if not est_prete(status["selected_video"]):
186
  return {"error": "Vidéo non prête"}
187
 
188
+ # Phase d'initialisation du robot
189
  reachy_mini.wake_up()
190
  reachy_mini.enable_motors()
191
  await asyncio.sleep(0.5)
192
 
193
+ # Démarrage du rejeu synchrone dans un thread dédié
194
  threading.Thread(target=pipeline_replay, daemon=True).start()
195
  return {"started": True}
196
 
197
+ # Route 6 : Resynchronise la timeline vidéo du navigateur avec les moteurs du robot
198
  @self.settings_app.post("/sync_playback")
199
  async def sync_playback(state: str, current_time: float):
200
  status["playback_state"] = state
 
202
  status["playback_sync_flag"] = True
203
  return {"synced": True}
204
 
205
+ # Route 7 : Arrête immédiatement tous les mouvements du robot
206
  @self.settings_app.post("/stop")
207
  async def stop_pipeline():
208
  if status["robot_busy"]:
209
  status["stop_requested"] = True
210
  return {"stopped": True}
211
 
212
+ # Route 8 : Gère le téléversement (upload) de vidéos MP4 et lance l'analyse automatique
213
  @self.settings_app.post("/upload_video")
214
  async def upload_video(file: UploadFile = File(...), delai_danse_secondes: float = 2.0):
215
  if not file.filename.lower().endswith(".mp4"):
 
228
 
229
  status["selected_video"] = file.filename
230
 
231
+ # Si la vidéo est nouvelle, on lance l'analyse immédiatement
232
  if not est_prete(file.filename) and not status["analysis_busy"]:
233
  threading.Thread(target=pipeline_analyse, args=(file.filename, delai_danse_secondes), daemon=True).start()
234
  return {"uploaded": file.filename, "analyse_demarree": True, "delai_danse": delai_danse_secondes}
235
 
236
  return {"uploaded": file.filename, "analyse_demarree": False}
237
 
238
+ # Route 9 : Récupère le flux vidéo MP4 traité et annoté par notre IA pour l'affichage web
239
  @self.settings_app.get("/processed_video")
240
  async def get_processed_video():
241
  if not status["selected_video"]:
 
245
  return FileResponse(str(video_path), media_type="video/mp4")
246
  return {"error": "Vidéo traitée non disponible"}
247
 
248
+ # Route 10 : Télécharge automatiquement une vidéo depuis YouTube via yt-dlp
249
  @self.settings_app.post("/download_youtube")
250
  async def download_youtube(data: dict):
251
  url = data.get('url')
 
268
  except Exception as e:
269
  return {"success": False, "error": str(e)}
270
 
271
+ # MODULE WEBCAM EN DIRECT (Webcam Live Pipelines & Processing)
272
+
273
+ # Route 11 : Initialise l'IA de suivi de visage MediaPipe en mode dynamique et démarre le mode Live
274
  @self.settings_app.post("/live/start")
275
  async def live_start():
276
  if status["robot_busy"]:
 
278
  if live["active"]:
279
  return {"error": "Mode live déjà actif"}
280
 
281
+ # Configuration avancée des options de suivi
282
  base_options = python.BaseOptions(model_asset_path=str(MODEL))
283
  options = vision.FaceLandmarkerOptions(
284
  base_options=base_options,
285
+ running_mode=vision.RunningMode.VIDEO, # Optimisé pour un flux d'images continu
286
  output_face_blendshapes=True,
287
  output_facial_transformation_matrixes=True,
288
  num_faces=1,
 
292
  )
293
  live["detector"] = vision.FaceLandmarker.create_from_options(options)
294
 
295
+ # Réinitialisation de l'état temporel du suivi
296
  live.update({
297
  "active": True,
298
  "emotion": "Neutre",
 
316
  await asyncio.sleep(0.5)
317
  return {"started": True}
318
 
319
+ # Route 12 : Arrête le suivi par webcam et désactive les moteurs en toute sécurité
320
  @self.settings_app.post("/live/stop")
321
  async def live_stop():
322
  live["active"] = False
323
  live["detector"] = None
324
  status["robot_busy"] = False
325
  try:
326
+ # Retour au repos
327
  reachy_mini.goto_sleep()
328
  reachy_mini.disable_motors()
329
  except Exception:
330
  pass
331
  return {"stopped": True}
332
 
333
+ # Route 13 : Reçoit chaque frame de la webcam capturée dans le navigateur,
334
+ # l'analyse par IA, calcule la pose 3D de la tête et anime le robot en direct.
335
  @self.settings_app.post("/live/frame")
336
  async def live_frame(file: UploadFile = File(...)):
337
  if not live["active"] or live["detector"] is None:
338
  return {"error": "Mode live non actif"}
339
 
340
+ # 1. Décodage de l'image brute reçue en binaire
341
  contents = await file.read()
342
  nparr = np.frombuffer(contents, np.uint8)
343
  frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
344
  if frame is None:
345
  return {"error": "Image invalide"}
346
 
347
+ # Réduction de la résolution de traitement pour minimiser la latence
348
  h, w = frame.shape[:2]
349
  if w > 960:
350
  ratio = 960 / w
351
  frame = cv2.resize(frame, (960, int(h * ratio)), interpolation=cv2.INTER_AREA)
352
 
353
+ # Amélioration du contraste en cas de faible éclairage
354
  lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB)
355
  l_ch, a_ch, b_ch = cv2.split(lab)
356
  clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
 
359
 
360
  image_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
361
 
362
+ # 2. Détection IA de visage avec MediaPipe
363
  live["frame_timestamp_ms"] += 66
364
  resultat = live["detector"].detect_for_video(
365
  mp.Image(image_format=mp.ImageFormat.SRGB, data=image_rgb),
 
371
  cible_tete_p = cible_tete_y = cible_tete_r = 0.0
372
  cible_ag_p = cible_ag_r = cible_ad_p = cible_ad_r = 0.0
373
 
374
+ # Cas A : Un visage est détecté
375
  if resultat.face_landmarks:
376
  live["dernier_temps_visage"] = temps_actuel
377
 
378
+ # Extraction de la matrice de transformation 3D et des coefficients musculaires (blendshapes)
379
  matrice = resultat.facial_transformation_matrixes[0]
380
  scores = {b.category_name: b.score for b in resultat.face_blendshapes[0]}
381
  traits = extraire_donnees_visage(matrice, scores)
382
  live["last_blendshapes"] = {k: round(v, 3) for k, v in traits.items()}
383
 
384
+ # Détermination de l'émotion courante
385
  emotion_detectee = trouver_emotion(traits)
386
  if emotion_detectee == live["emotion_en_cours"]:
387
  live["confirmations"] += 1
 
389
  live["emotion_en_cours"] = emotion_detectee
390
  live["confirmations"] = 1
391
 
392
+ # Stabilisation de l'émotion (doit persister 3 frames pour être validée)
393
  if live["confirmations"] >= 3 and live["emotion_en_cours"] != live["emotion_validee"]:
394
  live["emotion_validee"] = live["emotion_en_cours"]
395
  live["chronometre_emotion"] = temps_actuel
396
 
397
+ # Calcul des consigne pour les antennes selon l'émotion validée
398
  temps_animation = temps_actuel - live["chronometre_emotion"]
399
  cible_ag_p, cible_ag_r, cible_ad_p, cible_ad_r = calculer_cibles_antennes(
400
  live["emotion_validee"], temps_animation
401
  )
402
+
403
+ # Consignes de pose 3D de la tête (angles de Pitch, Yaw et Roll)
404
  cible_tete_p = traits["pitch"]
405
  cible_tete_y = traits["yaw"]
406
  cible_tete_r = traits["roll"]
407
  live["emotion"] = live["emotion_validee"]
408
 
409
+ # Cas B : Aucun visage n'est détecté
410
  else:
411
  delai = temps_actuel - live["dernier_temps_visage"]
412
+ # Après 2 secondes d'absence, le robot commence à "danser" avec ses antennes
413
  if delai >= 2.0:
414
  cible_ag_p, cible_ag_r, cible_ad_p, cible_ad_r = calculer_danse_antennes(delai - 2.0)
415
 
416
+ # 3. Lissage des mouvements (Interpolation linéaire / Lissage exponentiel)
417
+ # Permet d'éviter les saccades brusques du robot physique (préserve la mécanique)
418
  er["tete_p"] = adoucir_mouvement(er["tete_p"], cible_tete_p, 0.6)
419
  er["tete_y"] = adoucir_mouvement(er["tete_y"], cible_tete_y, 0.6)
420
  er["tete_r"] = adoucir_mouvement(er["tete_r"], cible_tete_r, 0.6)
 
423
  er["ant_d_p"] = adoucir_mouvement(er["ant_d_p"], cible_ad_p, 0.5)
424
  er["ant_d_r"] = adoucir_mouvement(er["ant_d_r"], cible_ad_r, 0.5)
425
 
426
+ # 4. Envoi des angles à Reachy Mini
427
  pose_tete = create_head_pose(
428
  pitch=er["tete_p"], yaw=er["tete_y"], roll=er["tete_r"], degrees=False
429
  )
 
437
 
438
  return {"emotion": live["emotion"]}
439
 
440
+ # Route 14 : Route utilitaire pour la calibration des muscles faciaux (Blendshapes)
441
  @self.settings_app.get("/live/debug")
442
  async def live_debug():
443
  """Retourne les dernières valeurs brutes des blendshapes pour calibration."""
444
  return live.get("last_blendshapes", {})
445
 
446
+ # Attente bloquante sur le thread principal pour maintenir les API FastAPI ouvertes
447
  stop_event.wait()
448
 
449
+ # 4. SCRIPT D'EXÉCUTION
450
  if __name__ == "__main__":
451
  app = ReachyMiniHelloWorld()
452
  try: