Hugo.V commited on
Commit
1f31743
·
1 Parent(s): d0a4224

commit pour mettre en pause video et gere video sans visage

Browse files
jericho/emotion.py CHANGED
@@ -12,6 +12,8 @@ from mediapipe.tasks import python as mp_python
12
  from mediapipe.tasks.python import vision as mp_vision # Importe les outils de vision par ordinateur de MediaPipe
13
  from mediapipe.tasks.python.vision import FaceLandmarkerOptions, FaceLandmarker # Importe spécifiquement le détecteur de visage
14
 
 
 
15
  MODEL_URL = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task" #
16
  MODEL_PATH = "face_landmarker.task" # IA save sur le pc
17
 
@@ -37,6 +39,7 @@ RIGHT_BROW_INNER = 55
37
  EAR_THRESHOLD = 0.20 # valeur limite d'ouverture de l'oeil
38
 
39
 
 
40
  class EMAFilter: # classe pour lisser les mouvements afin d eviter les micromouvements
41
 
42
  def __init__(self, alpha=0.5):
@@ -51,6 +54,7 @@ class EMAFilter: # classe pour lisser les mouvements afin d eviter les micromouv
51
  return self.state[key]
52
 
53
 
 
54
  def _pts(lms, indices, w, h): # convertit point en px
55
  return np.array([[lms[i].x * w, lms[i].y * h] for i in indices])
56
 
@@ -112,6 +116,7 @@ def brow_furrow(lms, w, h): #froncement des sourcils
112
  return np.linalg.norm(ri - li) / (np.linalg.norm(pr - pl) + 1e-6) # distance entre sourcils divisée par écart des yeux
113
 
114
 
 
115
  def emotions_records(records): # fonction analyse la video pour trouver les émotions
116
 
117
  valid_recs = [r["rec"] for r in records if r["rec"].get("face_detected", False)] # garde image avec un visage detecté
@@ -157,21 +162,29 @@ def emotions_records(records): # fonction analyse la video pour trouver les émo
157
  n_brow_raise = (b_raise - b_raise_m) / b_raise_s
158
  n_brow_furrow = (b_furrow - b_furrow_m) / b_furrow_s
159
 
 
 
 
160
  if n_brow_raise > 0.8 and n_ear > 0.8:
161
  emotion = "SURPRISE"
162
 
 
163
  elif n_brow_raise < -0.5 and n_smile < -0.5:
164
  emotion = "COLERE"
165
 
 
166
  elif n_brow_raise < -0.3 and n_ear < -0.5:
167
  emotion = "TRISTESSE"
168
 
 
169
  elif n_smile > 0.8:
170
  emotion = "JOIE"
171
 
 
172
  elif n_ear > 0.8:
173
  emotion = "PEUR"
174
 
 
175
  elif n_smile > 0.3:
176
  emotion = "CONTENT"
177
 
@@ -193,7 +206,7 @@ def annotate_frame(frame, lms, sig, w, h): # fonction pour annoter la video
193
  emotion = sig.get("emotion", "NEUTRE")
194
  cv2.putText(frame, f"EMOTION: {emotion}", (x1, max(y1-15, 30)),
195
  cv2.FONT_HERSHEY_DUPLEX, 1.0, (0, 255, 255), 2)
196
- # dessine des points sur chaque marqueur
197
  for idx in LEFT_EYE + RIGHT_EYE + MOUTH_OUTER:
198
  cv2.circle(frame,(int(lms[idx].x*w), int(lms[idx].y*h)), 2, (255,0,255), -1)
199
 
@@ -226,30 +239,83 @@ def annotate_frame(frame, lms, sig, w, h): # fonction pour annoter la video
226
  return frame # retourne l image annoté
227
 
228
 
229
- def merge_audio(original_video, silent_video, output_path): # remet son de la video original
230
  try:
231
- subprocess.run( # exécute une commande système pour vérifier FFmpeg
232
- ["ffmpeg", "-version"],
233
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
234
  )
235
- except (subprocess.CalledProcessError, FileNotFoundError): # Si FFmpeg n'est pas installé ou plante
236
- print("\n ffmpeg introuvable. téléchargé le")
237
- return False
238
-
239
- # Correction de la commande pour forcer le codec H.264 (libx264)
240
- cmd = [ # combine son et video
241
- "ffmpeg", "-y",
242
- "-i", silent_video,
243
- "-i", original_video,
244
- "-c:v", "libx264", # Encodage en H.264 compatible navigateurs web
245
- "-pix_fmt", "yuv420p", # Format de pixels standard pour le web
246
- "-c:a", "aac", # Encodage audio standard
247
- "-map", "0:v:0",
248
- "-map", "1:a:0",
249
- "-shortest",
250
- output_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  ]
252
- result = subprocess.run(cmd, capture_output=True, text=True) # exécute la ligne de commande
 
 
253
  return result.returncode == 0
254
 
255
 
@@ -329,76 +395,104 @@ def process_video(video_path, output_dir):
329
  "roll": round(ema('roll', roll_raw), 2), # penchement
330
  "ear_left": round(ema('el', el_raw), 4), # oeil gauche
331
  "ear_right": round(ema('er', er_raw), 4), # oeil droit
332
- "mar": round(ema('m', m_raw), 4), # bouche lissée
333
- "smile": round(ema('sm', sm_raw), 4), # sourire lissé
334
- "brow_raise_left": round(ema('br_l', br_left_raw), 4), # sourcil gauche lissé
335
- "brow_raise_right": round(ema('br_r', br_right_raw), 4), # sourcil droit lissé
336
- "brow_furrow": round(ema('bf', bf_raw), 4), # froncement lissé
337
  }
338
- records.append({"rec": rec, "lms": lms}) # ajoute dico, et les points originaux 'lms', à la liste globale
339
- else: # si l'IA n'a pas trouvé de visage
340
- records.append({ # ajoute un petit dicto indiquant que la tete n a pas été detecte
341
  "rec": {"frame": frame_idx, "timestamp_s": round(ts_s, 4), "face_detected": False},
342
  "lms": None # Et pas de points
343
  })
344
 
345
- frame_idx += 1 # passe à l'image suivante
346
- if frame_idx % 100 == 0: # toutes les 100 images...
347
- print(f" Frame {frame_idx}/{total} ({100*frame_idx//total}%)") # affiche la progression dans la console
348
 
349
- cap.release() # relâche le fichier vidéo pour libérer la mémoire de l'ordinateur
350
  landmarker.close() # Ferme l'IA MediaPipe proprement
351
 
352
- # calcul des émotions globales
353
- emotions_records(records) # appelle la fonction FACS pour déduire les emotions de tous les dico d'un coup
354
 
355
-
356
- print("\nCréation de la vidéo avec affichage des émotions...") # informe du début de la passe graphique
357
- cap = cv2.VideoCapture(video_path) # reouvre la vidéo originale depuis le tout début
358
- silent_path = os.path.join(output_dir, "_annotated_silent.mp4") # prépare le nom d'un fichier vidéo temporaire (sans son)
359
- final_path = os.path.join(output_dir, "output_annotated.mp4") # prépare le nom du fichier vidéo final (avec son)
360
-
361
-
362
- fourcc = cv2.VideoWriter_fourcc(*"avc1") # pour la compatibilité avec du web
363
- writer = cv2.VideoWriter(silent_path, fourcc, fps, (W, H)) # écrit la nouvelle vidéo image par image
364
-
365
- idx = 0 # compteur d'images remis à 0
366
- while True: # boucle infinie
367
- ret, frame = cap.read() #lit l'image originale
368
- if not ret: break # stop à la fin de la vidéo
369
-
370
- if idx < len(records): # sécurité pour ne pas dépasser la taille de notre liste de données
371
- item = records[idx] # récupère les données de l'image actuelle
372
- if item["rec"].get("face_detected", False): # Si un visage est présent
373
- frame = annotate_frame(frame, item["lms"], item["rec"], W, H) # on dessine tout par-dessus
374
- else:
375
- cv2.putText(frame, "Visage non detecte", (20,40), #
376
- cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0,0,255), 2)
377
-
378
- writer.write(frame) # enregistre l'image modifiée dans la nouvelle vidéo
379
- idx += 1
380
 
381
- cap.release()
382
- writer.release()
383
 
384
-
385
- if merge_audio(video_path, silent_path, final_path): # recolle son originel
386
- os.remove(silent_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  else:
388
- os.replace(silent_path, final_path)
389
-
390
- json_path = os.path.join(output_dir, "signals.json") # save fichier json
391
- with open(json_path, "w", encoding="utf-8") as f:
392
- json_records = [item["rec"] for item in records] # extrait uniquement les dicos rec
393
- json.dump( # exporte le tout en texte formaté JSON
394
- {"video": os.path.abspath(video_path), "fps": round(fps, 4), "width": W, "height": H, "total_frames": total, "frames": json_records},
395
- f, ensure_ascii=False, indent=2
 
 
 
 
 
 
 
 
 
 
396
  )
397
- print(f"\n fait, fichiers dans : {os.path.abspath(output_dir)}/")
398
 
399
- if __name__ == "__main__": # cette ligne spéciale vérifie si on a lancé ce fichier directement depuis le terminal
400
  parser = argparse.ArgumentParser() # initialise analyseur de ligne de commande
401
  parser.add_argument("--video", required=True) # on doit fournir un chemin avec --video
402
  parser.add_argument("--output_dir", default="output") #pour le odssier où on place les docs
403
  args = parser.parse_args() # lit ce que l'utilisateur a tapé
404
- process_video(args.video, args.output_dir)
 
12
  from mediapipe.tasks.python import vision as mp_vision # Importe les outils de vision par ordinateur de MediaPipe
13
  from mediapipe.tasks.python.vision import FaceLandmarkerOptions, FaceLandmarker # Importe spécifiquement le détecteur de visage
14
 
15
+
16
+
17
  MODEL_URL = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task" #
18
  MODEL_PATH = "face_landmarker.task" # IA save sur le pc
19
 
 
39
  EAR_THRESHOLD = 0.20 # valeur limite d'ouverture de l'oeil
40
 
41
 
42
+
43
  class EMAFilter: # classe pour lisser les mouvements afin d eviter les micromouvements
44
 
45
  def __init__(self, alpha=0.5):
 
54
  return self.state[key]
55
 
56
 
57
+
58
  def _pts(lms, indices, w, h): # convertit point en px
59
  return np.array([[lms[i].x * w, lms[i].y * h] for i in indices])
60
 
 
116
  return np.linalg.norm(ri - li) / (np.linalg.norm(pr - pl) + 1e-6) # distance entre sourcils divisée par écart des yeux
117
 
118
 
119
+
120
  def emotions_records(records): # fonction analyse la video pour trouver les émotions
121
 
122
  valid_recs = [r["rec"] for r in records if r["rec"].get("face_detected", False)] # garde image avec un visage detecté
 
162
  n_brow_raise = (b_raise - b_raise_m) / b_raise_s
163
  n_brow_furrow = (b_furrow - b_furrow_m) / b_furrow_s
164
 
165
+
166
+
167
+
168
  if n_brow_raise > 0.8 and n_ear > 0.8:
169
  emotion = "SURPRISE"
170
 
171
+
172
  elif n_brow_raise < -0.5 and n_smile < -0.5:
173
  emotion = "COLERE"
174
 
175
+
176
  elif n_brow_raise < -0.3 and n_ear < -0.5:
177
  emotion = "TRISTESSE"
178
 
179
+
180
  elif n_smile > 0.8:
181
  emotion = "JOIE"
182
 
183
+
184
  elif n_ear > 0.8:
185
  emotion = "PEUR"
186
 
187
+
188
  elif n_smile > 0.3:
189
  emotion = "CONTENT"
190
 
 
206
  emotion = sig.get("emotion", "NEUTRE")
207
  cv2.putText(frame, f"EMOTION: {emotion}", (x1, max(y1-15, 30)),
208
  cv2.FONT_HERSHEY_DUPLEX, 1.0, (0, 255, 255), 2)
209
+ # dessine des points sur chaque marqueur
210
  for idx in LEFT_EYE + RIGHT_EYE + MOUTH_OUTER:
211
  cv2.circle(frame,(int(lms[idx].x*w), int(lms[idx].y*h)), 2, (255,0,255), -1)
212
 
 
239
  return frame # retourne l image annoté
240
 
241
 
242
+ def _ffmpeg_available():
243
  try:
244
+ subprocess.run(
245
+ ["ffmpeg", "-version"],
246
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
247
  )
248
+ return True
249
+ except (subprocess.CalledProcessError, FileNotFoundError):
250
+ print("\n ffmpeg introuvable.")
251
+ return False
252
+
253
+
254
+ def _face_frame_runs(records):
255
+ #Retourne les blocs continus de frames où un visage est détecté
256
+ runs = []
257
+ start = None
258
+ previous = None
259
+
260
+ for item in records:
261
+ rec = item["rec"]
262
+ frame = int(rec.get("frame", 0))
263
+ if rec.get("face_detected", False):
264
+ if start is None:
265
+ start = frame
266
+ previous = frame
267
+ elif start is not None:
268
+ runs.append((start, previous))
269
+ start = None
270
+ previous = None
271
+
272
+ if start is not None:
273
+ runs.append((start, previous))
274
+ return runs
275
+
276
+
277
+ def merge_filtered_audio(original_video, silent_video, output_path, face_runs, fps):
278
+
279
+ #Recolle l'audio en supprimant les mêmes intervalles que les frames sans visage.
280
+ #Si l'audio n'est pas disponible, la vidéo muette reste utilisée.
281
+
282
+ if not _ffmpeg_available():
283
+ return False
284
+
285
+ if not face_runs:
286
+ return False
287
+
288
+ # Trop de micro-coupures peut créer une commande ffmpeg très longue.
289
+ # Dans ce cas, on garde la vidéo synchronisée robot/visuel sans audio.
290
+ if len(face_runs) > 300:
291
+ print(" Trop de segments audio à concaténer : export vidéo muette synchronisée.")
292
+ return False
293
+
294
+ # aselect garde uniquement les morceaux où un visage existait, puis asetpts compacte la timeline audio.
295
+ expr_parts = []
296
+ for start, end in face_runs:
297
+ t0 = start / fps
298
+ t1 = (end + 1) / fps
299
+ expr_parts.append(rf"between(t\,{t0:.6f}\,{t1:.6f})")
300
+ select_expr = "+".join(expr_parts)
301
+ filter_complex = f"[1:a]aselect={select_expr},asetpts=N/SR/TB[a]"
302
+
303
+ cmd = [
304
+ "ffmpeg", "-y",
305
+ "-i", silent_video,
306
+ "-i", original_video,
307
+ "-filter_complex", filter_complex,
308
+ "-map", "0:v:0",
309
+ "-map", "[a]",
310
+ "-c:v", "libx264",
311
+ "-pix_fmt", "yuv420p",
312
+ "-c:a", "aac",
313
+ "-shortest",
314
+ output_path,
315
  ]
316
+ result = subprocess.run(cmd, capture_output=True, text=True)
317
+ if result.returncode != 0:
318
+ print(" Audio filtré impossible, export vidéo muette synchronisée.")
319
  return result.returncode == 0
320
 
321
 
 
395
  "roll": round(ema('roll', roll_raw), 2), # penchement
396
  "ear_left": round(ema('el', el_raw), 4), # oeil gauche
397
  "ear_right": round(ema('er', er_raw), 4), # oeil droit
398
+ "mar": round(ema('m', m_raw), 4), # Bouche lissée
399
+ "smile": round(ema('sm', sm_raw), 4), # Sourire lissé
400
+ "brow_raise_left": round(ema('br_l', br_left_raw), 4), # Sourcil gauche lissé
401
+ "brow_raise_right": round(ema('br_r', br_right_raw), 4), # Sourcil droit lissé
402
+ "brow_furrow": round(ema('bf', bf_raw), 4), # Froncement lissé
403
  }
404
+ records.append({"rec": rec, "lms": lms}) # Ajoute ce gros dictionnaire, et les points originaux 'lms', à la liste globale
405
+ else: # Si l'IA n'a pas trouvé de visage
406
+ records.append({ # Ajoute un petit dictionnaire disant "Face Not Detected"
407
  "rec": {"frame": frame_idx, "timestamp_s": round(ts_s, 4), "face_detected": False},
408
  "lms": None # Et pas de points
409
  })
410
 
411
+ frame_idx += 1 # Passe à l'image suivante
412
+ if frame_idx % 100 == 0: # Toutes les 100 images...
413
+ print(f" Frame {frame_idx}/{total} ({100*frame_idx//total}%)") # ... Affiche la progression dans la console
414
 
415
+ cap.release() # Relâche le fichier vidéo pour libérer la mémoire de l'ordinateur
416
  landmarker.close() # Ferme l'IA MediaPipe proprement
417
 
418
+ # Calcul des émotions globales
419
+ emotions_records(records) # Appelle la grosse fonction FACS pour déduire la ligne "emotion" de tous les dictionnaires d'un coup
420
 
421
+ face_runs = _face_frame_runs(records)
422
+ valid_count = sum(1 for item in records if item["rec"].get("face_detected", False))
423
+ skipped_count = len(records) - valid_count
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
 
425
+ if valid_count == 0:
426
+ sys.exit("Aucun visage détecté : impossible de créer une timeline synchronisée.")
427
 
428
+
429
+ print("\n Création de la vidéo synchronisée avec les frames sans visage supprimées...")
430
+ cap = cv2.VideoCapture(video_path)
431
+ silent_path = os.path.join(output_dir, "_annotated_silent.mp4")
432
+ final_path = os.path.join(output_dir, "output_annotated.mp4")
433
+
434
+ # Codec H.264 pour le Web ; fallback mp4v si OpenCV n'a pas avc1.
435
+ fourcc = cv2.VideoWriter_fourcc(*"avc1")
436
+ writer = cv2.VideoWriter(silent_path, fourcc, fps, (W, H))
437
+ if not writer.isOpened():
438
+ fourcc = cv2.VideoWriter_fourcc(*"mp4v")
439
+ writer = cv2.VideoWriter(silent_path, fourcc, fps, (W, H))
440
+
441
+ compact_records = []
442
+ idx = 0 # Compteur d'images d'origine
443
+ out_idx = 0 # Compteur d'images de la vidéo finale compacte
444
+
445
+ while True: # Boucle infinie
446
+ ret, frame = cap.read() # Lit l'image originale
447
+ if not ret:
448
+ break # Stop à la fin de la vidéo
449
+
450
+ if idx < len(records): # Sécurité pour ne pas dépasser la taille de notre liste de données
451
+ item = records[idx] # Récupère les données de l'image actuelle
452
+ if item["rec"].get("face_detected", False): # Si un visage était présent...
453
+ rec = dict(item["rec"])
454
+ rec["original_frame"] = rec["frame"]
455
+ rec["original_timestamp_s"] = rec["timestamp_s"]
456
+ rec["frame"] = out_idx
457
+ rec["timestamp_s"] = round(out_idx / fps, 4)
458
+
459
+ frame = annotate_frame(frame, item["lms"], rec, W, H) # On dessine tout par-dessus
460
+ writer.write(frame) # Enregistre uniquement les frames valides
461
+ compact_records.append(rec)
462
+ out_idx += 1
463
+
464
+ idx += 1 # Passe à la suivante
465
+
466
+ cap.release() # Relâche la vidéo originale
467
+ writer.release() # Clôture le fichier vidéo annoté
468
+
469
+ if merge_filtered_audio(video_path, silent_path, final_path, face_runs, fps):
470
+ os.remove(silent_path)
471
  else:
472
+ os.replace(silent_path, final_path) # Vidéo muette, mais timeline visuelle/robot synchronisée
473
+
474
+ json_path = os.path.join(output_dir, "signals.json") # Prépare le chemin pour sauvegarder le JSON (signals.json)
475
+ with open(json_path, "w", encoding="utf-8") as f: # Ouvre le fichier en mode écriture ("w")
476
+ json.dump( # Exporte le tout en texte formaté JSON
477
+ {
478
+ "video": os.path.abspath(video_path),
479
+ "output_video": os.path.abspath(final_path),
480
+ "fps": round(fps, 4),
481
+ "width": W,
482
+ "height": H,
483
+ "total_frames": total,
484
+ "output_total_frames": len(compact_records),
485
+ "skipped_no_face_frames": skipped_count,
486
+ "timeline": "compact_face_only",
487
+ "frames": compact_records,
488
+ },
489
+ f, ensure_ascii=False, indent=2 # Avec une jolie indentation (2 espaces) pour que ça soit lisible
490
  )
491
+ print(f"\n fait, {skipped_count} frames sans visage supprimées. Fichiers dans : {os.path.abspath(output_dir)}/")
492
 
493
+ if __name__ == "__main__": # Cette ligne spéciale vérifie si on a lancé ce fichier directement depuis le terminal
494
  parser = argparse.ArgumentParser() # initialise analyseur de ligne de commande
495
  parser.add_argument("--video", required=True) # on doit fournir un chemin avec --video
496
  parser.add_argument("--output_dir", default="output") #pour le odssier où on place les docs
497
  args = parser.parse_args() # lit ce que l'utilisateur a tapé
498
+ process_video(args.video, args.output_dir) #
jericho/main.py CHANGED
@@ -7,18 +7,19 @@ import base64
7
  import numpy as np
8
  import sys
9
  import subprocess
10
- import shutil
 
11
  from pydantic import BaseModel
12
 
13
- # FastAPI pour le site web
14
  from fastapi.staticfiles import StaticFiles
15
  from fastapi.responses import FileResponse
16
  from fastapi import HTTPException, BackgroundTasks
17
 
 
18
  from reachy_mini import ReachyMini, ReachyMiniApp
19
  from reachy_mini.utils import create_head_pose
20
 
21
- # Chargement signals.json en ne conservant que les frames avec un visage détecté
22
 
23
  def load_signals(path: str) -> tuple[dict, list[dict]]:
24
  with open(path, "r", encoding="utf-8") as f:
@@ -28,9 +29,6 @@ def load_signals(path: str) -> tuple[dict, list[dict]]:
28
  print(f"[Chargement] {len(frames)} frames avec visage.")
29
  return meta, frames
30
 
31
-
32
- # Filtre pour lisser les mouvements pour ne pas les avoir saccadés
33
-
34
  def moving_average(values: list[float], window: int = 7) -> np.ndarray:
35
  arr = np.asarray(values, dtype=float)
36
  pad = window // 2
@@ -39,24 +37,18 @@ def moving_average(values: list[float], window: int = 7) -> np.ndarray:
39
  smoothed = np.convolve(padded, kernel, mode="valid")
40
  return smoothed[: len(arr)]
41
 
42
-
43
- # Traduit les signaux faciaux en commandes pour le robot
44
-
45
  class SignalMapper:
46
-
47
- # Limites de mouvements du robot en rad/m
48
- HEAD_YAW_MAX = np.deg2rad(5) # rotation horizontale tête : ±5°
49
- HEAD_PITCH_MAX = np.deg2rad(15) # inclinaison haut/bas tête : ±15°
50
- HEAD_ROLL_MAX = np.deg2rad(15) # inclinaison latérale tête : ±15°
51
- HEAD_Y_MAX = 0.03 # déplacement latéral tête : ±3 cm
52
- HEAD_Z_MAX = 0.03 # déplacement vertical tête : ±3 cm
53
- BODY_YAW_MAX = np.deg2rad(15) # rotation corps : ±15°
54
- ANTENNA_MAX = np.deg2rad(120) # débattement antennes : ±120°
55
  ANTENNA_MAX_alt = np.deg2rad(45)
56
  ANTENNA_MAX_col = np.deg2rad(110)
57
- COMBINED_YAW_LIMIT = np.deg2rad(60) # contrainte combinée corps + tête en yaw
58
 
59
- # Valeurs d'amplification des mouvements
60
  YAW_AMP = 8.0
61
  PITCH_AMP = 5.0
62
  ROLL_AMP = 3.0
@@ -64,8 +56,8 @@ class SignalMapper:
64
  CX_TO_BODY_YAW = 0.8
65
  CY_TO_Z_SCALE = -0.10
66
 
67
- SMOOTH_WINDOW_HEAD = 7 # Lissage pour la pose tête pour réduire le "bruit" (fluidifie) et avoir une meilleure réactivité
68
- SMOOTH_WINDOW_EMOTION = 11 # Lissage pour les émotions pour éviter les détections sur des clignements
69
 
70
  def __init__(self, frames: list[dict]):
71
  self.frames = frames
@@ -73,45 +65,29 @@ class SignalMapper:
73
  self.commands = self._build_commands()
74
 
75
  def _compute_baselines(self):
76
-
77
- # Moyenne
78
  def mean(key, calc_func=None):
79
  if calc_func: return float(np.mean([calc_func(f) for f in self.frames]))
80
  return float(np.mean([f[key] for f in self.frames]))
81
-
82
- # Écart type à 0.000001 pour éviter la division par 0 pour le z score
83
  def std(key, calc_func=None):
84
  if calc_func: return max(float(np.std([calc_func(f) for f in self.frames])), 1e-6)
85
  return max(float(np.std([f[key] for f in self.frames])), 1e-6)
86
 
87
- self.yaw_mean = mean("yaw") # rotation gauche/droite moyenne (degrés)
88
- self.pitch_mean = mean("pitch") # inclinaison haut/bas moyenne
89
- self.roll_mean = mean("roll") # roulis moyen
90
- self.cx_mean = mean("face_cx") # position X moyenne du visage dans le cadre
91
- self.cy_mean = mean("face_cy") # position Y moyenne
92
-
93
- # Étirement de la bouche
94
  self.smile_mean = mean("smile")
95
  self.smile_std = std("smile")
96
-
97
- # Position des sourcils (hauteur) avec les deux sourcils au cas où un n'est pas détecté/visible
98
  self.brow_raise_mean = mean(None, lambda f: (f["brow_raise_left"] + f["brow_raise_right"]) / 2)
99
  self.brow_raise_std = std(None, lambda f: (f["brow_raise_left"] + f["brow_raise_right"]) / 2)
100
-
101
- # Écartement des sourcils
102
  self.brow_furrow_mean = mean("brow_furrow")
103
  self.brow_furrow_std = std("brow_furrow")
104
-
105
  self.ear_mean = mean(None, lambda f: (f["ear_left"] + f["ear_right"]) / 2)
106
  self.ear_std = std(None, lambda f: (f["ear_left"] + f["ear_right"]) / 2)
107
-
108
- # Même chose pour la bouche
109
  self.mar_mean = mean("mar")
110
  self.mar_std = std("mar")
111
 
112
-
113
- # Définit l'émotion selon les valeurs en entrée
114
-
115
  def _detect_emotion(self, smile, ear, mar, brow_raise, brow_furrow):
116
  n_smile = (smile - self.smile_mean) / self.smile_std
117
  n_ear = (ear - self.ear_mean) / self.ear_std
@@ -119,23 +95,14 @@ class SignalMapper:
119
  n_brow_raise = (brow_raise - self.brow_raise_mean) / self.brow_raise_std
120
  n_brow_furrow = (brow_furrow - self.brow_furrow_mean) / self.brow_furrow_std
121
 
122
- # 1. SURPRISE : Écart yeux/sourcil élevé ET yeux grand ouverts
123
  if n_brow_raise > 0.8 and n_ear > 0.8: return "SURPRISE"
124
- # 2. COLÈRE : Écart yeux/sourcil diminué ET largeur bouche diminuée
125
  if n_brow_raise < -0.5 and n_smile < -0.5: return "COLERE"
126
- # 3. TRISTESSE : Écart yeux/sourcil réduit ET petite ouverture des yeux
127
  if n_brow_raise < -0.3 and n_ear < -0.5: return "TRISTESSE"
128
- # 4. JOIE : Grand sourire
129
  if n_smile > 0.8: return "JOIE"
130
- # 5. PEUR : Yeux écarquillés
131
  if n_ear > 0.8: return "PEUR"
132
- # 6. CONTENT : Léger sourire
133
  if n_smile > 0.3: return "CONTENT"
134
  return "NEUTRE"
135
 
136
-
137
- # Mouvements des antennes selon l'émotion détectée
138
-
139
  def _compute_antennas(self, emotion, t_state):
140
  left_antenna, right_antenna = 0.0, 0.0
141
  if emotion == "TRISTESSE":
@@ -177,17 +144,6 @@ class SignalMapper:
177
  right_antenna = -(angle - 0.2)
178
  return left_antenna, right_antenna
179
 
180
- """
181
- 1. Extraction des signaux bruts en tableaux
182
- 2. Lissage
183
- 3. Pour chaque frame :
184
- a. Conversion des angles de tête (degrés en radians, amplification, clamp)
185
- b. Calcul des translations de la tête (y, z) depuis la position du visage
186
- c. Calcul du body_yaw avec contrainte combinée avec head_yaw
187
- d. Détection de l'émotion
188
- e. Mouvements des antennes
189
- """
190
-
191
  def _build_commands(self) -> list[dict]:
192
  n = len(self.frames)
193
  raw_yaw = np.array([f["yaw"] - self.yaw_mean for f in self.frames])
@@ -269,97 +225,168 @@ class SignalMapper:
269
  return float(max(-limit, min(limit, value)))
270
 
271
 
272
- # Modèle de données pour la réception de la vidéo via l'API
273
-
274
  class VideoPayload(BaseModel):
275
- name: str # Nom du projet
276
- file_data: str # Contenu de la vidéo encodé en base64 pour la methode POST /api/upload car la video est en binaire et ne peut pas être envoyée directement
277
 
278
-
279
- # Application principale : contrôle du robot et serveur web
 
 
 
280
 
281
  class Jericho(ReachyMiniApp):
282
-
283
  custom_app_url: str | None = "http://0.0.0.0:8042"
284
  request_media_backend: str | None = None
285
 
286
- # Refait les mouvements sur le robot en synchronisation avec le timestamp vidéo
287
- def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
 
289
- # --- 1. DÉCLARATION DES VARIABLES D'ÉTAT SANS __init__ ---
290
- antennas_enabled = True
291
- sound_play_requested = False
292
- play_robot_requested = False
293
- current_commands = []
294
-
295
- # Chemins vers les dossiers
296
- current_dir = os.path.dirname(os.path.abspath(__file__))
297
- projects_dir = os.path.join(current_dir, "projects")
298
- os.makedirs(projects_dir, exist_ok=True)
299
- static_dir = os.path.join(current_dir, "static")
300
-
301
- # --- 2. CONFIGURATION DES ROUTES WEB ---
302
- if os.path.exists(static_dir):
303
- self.settings_app.mount("/static", StaticFiles(directory=static_dir), name="static")
304
- self.settings_app.mount("/projects", StaticFiles(directory=projects_dir), name="projects")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
 
306
  @self.settings_app.get("/")
307
  def serve_index():
308
- return FileResponse(os.path.join(static_dir, "index.html"))
309
 
310
  def process_in_background(video_path: str, proj_path: str, proj_name: str):
311
  status_file = os.path.join(proj_path, "status.txt")
312
  try:
313
  with open(status_file, "w") as f: f.write("PROCESSING")
314
- print(f"[{proj_name}] Lancement de l'ia.")
315
-
316
- emotion_script = os.path.join(current_dir, "emotion.py")
317
-
318
  process = subprocess.run(
319
  [sys.executable, emotion_script, "--video", video_path, "--output_dir", proj_path],
320
  capture_output=True, text=True
321
  )
322
-
323
  if process.returncode == 0:
324
- with open(status_file, "w") as f: f.write("DONE")
325
- print(f"[{proj_name}] Analyse terminée avec succès !")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  else:
327
  err = process.stderr.strip()
328
  clean_err = err.split('\n')[-1] if err else "Crash silencieux de Mediapipe"
329
  err_msg = f"ERROR: L'IA a planté. Détail: {clean_err}"
330
  with open(status_file, "w") as f: f.write(err_msg)
331
  print(f"[{proj_name}] Erreur : {err_msg}\n-- Log Terminal :\n{err}")
332
-
333
  except Exception as e:
334
  error_msg = f"ERROR: {type(e).__name__} - {str(e)}"
335
  with open(status_file, "w") as f: f.write(error_msg)
336
  print(f"[{proj_name}] Exception système : {error_msg}")
337
-
338
  @self.settings_app.post("/api/upload")
339
  def upload_video(payload: VideoPayload, bg_tasks: BackgroundTasks):
340
- project_path = os.path.join(projects_dir, payload.name)
341
  os.makedirs(project_path, exist_ok=True)
342
  input_video_path = os.path.join(project_path, "input.mp4")
343
-
344
  header, encoded = payload.file_data.split(",", 1) if "," in payload.file_data else ("", payload.file_data)
345
  with open(input_video_path, "wb") as f:
346
  f.write(base64.b64decode(encoded))
347
-
348
  bg_tasks.add_task(process_in_background, input_video_path, project_path, payload.name)
349
  return {"status": "accepted"}
350
 
351
  @self.settings_app.get("/api/library")
352
  def get_library():
353
  projects = []
354
- for folder in os.listdir(projects_dir):
355
- folder_path = os.path.join(projects_dir, folder)
356
  if os.path.isdir(folder_path):
357
  status_path = os.path.join(folder_path, "status.txt")
358
  status = "UNKNOWN"
359
  if os.path.exists(status_path):
360
  with open(status_path, "r") as f:
361
  status = f.read().strip()
362
-
363
  if os.path.exists(os.path.join(folder_path, "output_annotated.mp4")):
364
  projects.append({
365
  "name": folder,
@@ -374,9 +401,11 @@ class Jericho(ReachyMiniApp):
374
  })
375
  return projects
376
 
 
377
  @self.settings_app.delete("/api/projects/{project_name}")
378
  def delete_project(project_name: str):
379
- project_path = os.path.join(projects_dir, project_name)
 
380
  if os.path.exists(project_path) and os.path.isdir(project_path):
381
  try:
382
  shutil.rmtree(project_path)
@@ -386,75 +415,122 @@ class Jericho(ReachyMiniApp):
386
  raise HTTPException(status_code=500, detail=f"Erreur de suppression: {str(e)}")
387
  raise HTTPException(status_code=404, detail="Projet introuvable.")
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  @self.settings_app.post("/api/play_movement/{project_name}")
390
  def trigger_movement(project_name: str):
391
- nonlocal current_commands, play_robot_requested
392
- signals_path = os.path.join(projects_dir, project_name, "signals.json")
393
- if not os.path.exists(signals_path):
394
- raise HTTPException(status_code=404, detail="Fichier signals.json introuvable.")
395
-
396
  try:
397
- meta, frames = load_signals(signals_path)
398
- mapper = SignalMapper(frames)
399
- current_commands = mapper.commands
400
- play_robot_requested = True
401
- return {"status": "ok", "commands_loaded": len(current_commands)}
402
  except Exception as e:
403
  raise HTTPException(status_code=500, detail=str(e))
404
 
405
- # --- 3. BOUCLE DE MOUVEMENT DU ROBOT ---
406
  reachy_mini.goto_target(create_head_pose(), antennas=[0.0, 0.0], duration=1.0)
407
  time.sleep(1.2)
408
 
409
  while not stop_event.is_set():
410
- if not play_robot_requested or not current_commands:
411
- time.sleep(0.1)
412
- continue
413
-
414
- play_robot_requested = False
415
- commands_to_play = list(current_commands)
416
-
417
- start_wall = time.time()
418
- video_offset = commands_to_play[0]["timestamp_s"]
419
- idx = 0
420
- n = len(commands_to_play)
421
-
422
- while not stop_event.is_set() and idx < n:
423
 
424
- if play_robot_requested:
425
- break
 
 
 
 
 
426
 
427
- elapsed = time.time() - start_wall
428
- target_t = video_offset + elapsed
 
429
 
430
- while idx < n - 1 and commands_to_play[idx]["timestamp_s"] < target_t:
431
- idx += 1
 
 
 
432
 
433
- if idx >= n: break
 
 
 
 
434
 
435
- cmd = commands_to_play[idx]
436
- head_dict = cmd["head"]
 
437
 
438
- head_pose = create_head_pose(
439
- x=head_dict["x"], y=head_dict["y"], z=head_dict["z"],
440
- roll=head_dict["roll"], pitch=head_dict["pitch"], yaw=head_dict["yaw"],
441
- degrees=False, mm=False,
442
- )
443
 
444
- antennas_target = cmd["antennas"] if antennas_enabled else [0.0, 0.0]
 
 
 
 
 
 
445
 
446
- try:
447
- reachy_mini.set_target(head=head_pose, antennas=antennas_target, body_yaw=cmd["body_yaw"])
448
- except TypeError:
449
- reachy_mini.set_target(head=head_pose, antennas=antennas_target)
450
 
451
- time.sleep(0.01)
 
 
 
452
 
453
- if not play_robot_requested:
454
- reachy_mini.goto_target(create_head_pose(), antennas=[0.0, 0.0], duration=1.0)
 
 
 
455
 
 
456
 
457
- # Lancement de l'application
458
  if __name__ == "__main__":
459
  app = Jericho()
460
  try:
 
7
  import numpy as np
8
  import sys
9
  import subprocess
10
+ import shutil
11
+ import bisect
12
  from pydantic import BaseModel
13
 
14
+
15
  from fastapi.staticfiles import StaticFiles
16
  from fastapi.responses import FileResponse
17
  from fastapi import HTTPException, BackgroundTasks
18
 
19
+
20
  from reachy_mini import ReachyMini, ReachyMiniApp
21
  from reachy_mini.utils import create_head_pose
22
 
 
23
 
24
  def load_signals(path: str) -> tuple[dict, list[dict]]:
25
  with open(path, "r", encoding="utf-8") as f:
 
29
  print(f"[Chargement] {len(frames)} frames avec visage.")
30
  return meta, frames
31
 
 
 
 
32
  def moving_average(values: list[float], window: int = 7) -> np.ndarray:
33
  arr = np.asarray(values, dtype=float)
34
  pad = window // 2
 
37
  smoothed = np.convolve(padded, kernel, mode="valid")
38
  return smoothed[: len(arr)]
39
 
 
 
 
40
  class SignalMapper:
41
+ HEAD_YAW_MAX = np.deg2rad(5)
42
+ HEAD_PITCH_MAX = np.deg2rad(15)
43
+ HEAD_ROLL_MAX = np.deg2rad(15)
44
+ HEAD_Y_MAX = 0.03
45
+ HEAD_Z_MAX = 0.03
46
+ BODY_YAW_MAX = np.deg2rad(15)
47
+ ANTENNA_MAX = np.deg2rad(120)
 
 
48
  ANTENNA_MAX_alt = np.deg2rad(45)
49
  ANTENNA_MAX_col = np.deg2rad(110)
50
+ COMBINED_YAW_LIMIT = np.deg2rad(60)
51
 
 
52
  YAW_AMP = 8.0
53
  PITCH_AMP = 5.0
54
  ROLL_AMP = 3.0
 
56
  CX_TO_BODY_YAW = 0.8
57
  CY_TO_Z_SCALE = -0.10
58
 
59
+ SMOOTH_WINDOW_HEAD = 7
60
+ SMOOTH_WINDOW_EMOTION = 11
61
 
62
  def __init__(self, frames: list[dict]):
63
  self.frames = frames
 
65
  self.commands = self._build_commands()
66
 
67
  def _compute_baselines(self):
 
 
68
  def mean(key, calc_func=None):
69
  if calc_func: return float(np.mean([calc_func(f) for f in self.frames]))
70
  return float(np.mean([f[key] for f in self.frames]))
 
 
71
  def std(key, calc_func=None):
72
  if calc_func: return max(float(np.std([calc_func(f) for f in self.frames])), 1e-6)
73
  return max(float(np.std([f[key] for f in self.frames])), 1e-6)
74
 
75
+ self.yaw_mean = mean("yaw")
76
+ self.pitch_mean = mean("pitch")
77
+ self.roll_mean = mean("roll")
78
+ self.cx_mean = mean("face_cx")
79
+ self.cy_mean = mean("face_cy")
 
 
80
  self.smile_mean = mean("smile")
81
  self.smile_std = std("smile")
 
 
82
  self.brow_raise_mean = mean(None, lambda f: (f["brow_raise_left"] + f["brow_raise_right"]) / 2)
83
  self.brow_raise_std = std(None, lambda f: (f["brow_raise_left"] + f["brow_raise_right"]) / 2)
 
 
84
  self.brow_furrow_mean = mean("brow_furrow")
85
  self.brow_furrow_std = std("brow_furrow")
 
86
  self.ear_mean = mean(None, lambda f: (f["ear_left"] + f["ear_right"]) / 2)
87
  self.ear_std = std(None, lambda f: (f["ear_left"] + f["ear_right"]) / 2)
 
 
88
  self.mar_mean = mean("mar")
89
  self.mar_std = std("mar")
90
 
 
 
 
91
  def _detect_emotion(self, smile, ear, mar, brow_raise, brow_furrow):
92
  n_smile = (smile - self.smile_mean) / self.smile_std
93
  n_ear = (ear - self.ear_mean) / self.ear_std
 
95
  n_brow_raise = (brow_raise - self.brow_raise_mean) / self.brow_raise_std
96
  n_brow_furrow = (brow_furrow - self.brow_furrow_mean) / self.brow_furrow_std
97
 
 
98
  if n_brow_raise > 0.8 and n_ear > 0.8: return "SURPRISE"
 
99
  if n_brow_raise < -0.5 and n_smile < -0.5: return "COLERE"
 
100
  if n_brow_raise < -0.3 and n_ear < -0.5: return "TRISTESSE"
 
101
  if n_smile > 0.8: return "JOIE"
 
102
  if n_ear > 0.8: return "PEUR"
 
103
  if n_smile > 0.3: return "CONTENT"
104
  return "NEUTRE"
105
 
 
 
 
106
  def _compute_antennas(self, emotion, t_state):
107
  left_antenna, right_antenna = 0.0, 0.0
108
  if emotion == "TRISTESSE":
 
144
  right_antenna = -(angle - 0.2)
145
  return left_antenna, right_antenna
146
 
 
 
 
 
 
 
 
 
 
 
 
147
  def _build_commands(self) -> list[dict]:
148
  n = len(self.frames)
149
  raw_yaw = np.array([f["yaw"] - self.yaw_mean for f in self.frames])
 
225
  return float(max(-limit, min(limit, value)))
226
 
227
 
 
 
228
  class VideoPayload(BaseModel):
229
+ name: str
230
+ file_data: str
231
 
232
+ class PlaybackPayload(BaseModel):
233
+ project_name: str | None = None
234
+ current_time: float = 0.0
235
+ playing: bool | None = None
236
+ action: str = "sync"
237
 
238
  class Jericho(ReachyMiniApp):
 
239
  custom_app_url: str | None = "http://0.0.0.0:8042"
240
  request_media_backend: str | None = None
241
 
242
+ def __init__(self, **kwargs):
243
+ super().__init__(**kwargs)
244
+
245
+ self.antennas_enabled = True
246
+ self.sound_play_requested = False
247
+ self.play_robot_requested = False
248
+ self.current_commands = []
249
+ self.current_timestamps = []
250
+ self.current_project_name = None
251
+ self.playback_lock = threading.Lock()
252
+ self.playback_state = {
253
+ "project_name": None,
254
+ "playing": False,
255
+ "current_time": 0.0,
256
+ "updated_at": time.time(),
257
+ "dirty": False,
258
+ "stop_requested": False,
259
+ }
260
 
261
+ self.current_dir = os.path.dirname(os.path.abspath(__file__))
262
+ self.projects_dir = os.path.join(self.current_dir, "projects")
263
+ os.makedirs(self.projects_dir, exist_ok=True)
264
+ self.static_dir = os.path.join(self.current_dir, "static")
265
+
266
+ self._setup_routes()
267
+
268
+ def _load_project_commands(self, project_name: str) -> int:
269
+
270
+ with self.playback_lock:
271
+ if project_name == self.current_project_name and self.current_commands:
272
+ return len(self.current_commands)
273
+
274
+ signals_path = os.path.join(self.projects_dir, project_name, "signals.json")
275
+ if not os.path.exists(signals_path):
276
+ raise HTTPException(status_code=404, detail="Fichier signals.json introuvable.")
277
+
278
+ meta, frames = load_signals(signals_path)
279
+ if not frames:
280
+ raise HTTPException(status_code=400, detail="Aucune frame avec visage dans signals.json.")
281
+
282
+ mapper = SignalMapper(frames)
283
+ commands = mapper.commands
284
+ timestamps = [cmd["timestamp_s"] for cmd in commands]
285
+
286
+ with self.playback_lock:
287
+ self.current_project_name = project_name
288
+ self.current_commands = commands
289
+ self.current_timestamps = timestamps
290
+
291
+ return len(commands)
292
+
293
+ def _set_playback_state(self, project_name: str | None, current_time: float, playing: bool, *, dirty: bool = True, stop_requested: bool = False):
294
+ with self.playback_lock:
295
+ self.playback_state.update({
296
+ "project_name": project_name,
297
+ "playing": playing,
298
+ "current_time": max(0.0, float(current_time)),
299
+ "updated_at": time.time(),
300
+ "dirty": dirty,
301
+ "stop_requested": stop_requested,
302
+ })
303
+
304
+ def _setup_routes(self):
305
+ if os.path.exists(self.static_dir):
306
+ self.settings_app.mount("/static", StaticFiles(directory=self.static_dir), name="static")
307
+ self.settings_app.mount("/projects", StaticFiles(directory=self.projects_dir), name="projects")
308
 
309
  @self.settings_app.get("/")
310
  def serve_index():
311
+ return FileResponse(os.path.join(self.static_dir, "index.html"))
312
 
313
  def process_in_background(video_path: str, proj_path: str, proj_name: str):
314
  status_file = os.path.join(proj_path, "status.txt")
315
  try:
316
  with open(status_file, "w") as f: f.write("PROCESSING")
317
+ print(f"[{proj_name}] Lancement de l'IA dans un processus isolé...")
318
+
319
+ emotion_script = os.path.join(self.current_dir, "emotion.py")
320
+
321
  process = subprocess.run(
322
  [sys.executable, emotion_script, "--video", video_path, "--output_dir", proj_path],
323
  capture_output=True, text=True
324
  )
325
+
326
  if process.returncode == 0:
327
+
328
+ signals_path = os.path.join(proj_path, "signals.json")
329
+ valid_project = False
330
+
331
+ if os.path.exists(signals_path):
332
+ try:
333
+ with open(signals_path, "r", encoding="utf-8") as f:
334
+ data = json.load(f)
335
+ frames = [fr for fr in data.get("frames", []) if fr.get("face_detected", False)]
336
+
337
+
338
+ if frames and (frames[-1]["timestamp_s"] - frames[0]["timestamp_s"] >= 1.0):
339
+ valid_project = True
340
+ except Exception:
341
+ valid_project = False
342
+
343
+ if valid_project:
344
+ with open(status_file, "w") as f: f.write("DONE")
345
+ print(f"[{proj_name}] Analyse terminée avec succès !")
346
+ else:
347
+
348
+ output_video = os.path.join(proj_path, "output_annotated.mp4")
349
+ if os.path.exists(output_video):
350
+ os.remove(output_video)
351
+
352
+ with open(status_file, "w") as f: f.write("ERROR: NO_FACE_DETECTED")
353
+ print(f"[{proj_name}] Rejeté : pas de visage detecté")
354
  else:
355
  err = process.stderr.strip()
356
  clean_err = err.split('\n')[-1] if err else "Crash silencieux de Mediapipe"
357
  err_msg = f"ERROR: L'IA a planté. Détail: {clean_err}"
358
  with open(status_file, "w") as f: f.write(err_msg)
359
  print(f"[{proj_name}] Erreur : {err_msg}\n-- Log Terminal :\n{err}")
360
+
361
  except Exception as e:
362
  error_msg = f"ERROR: {type(e).__name__} - {str(e)}"
363
  with open(status_file, "w") as f: f.write(error_msg)
364
  print(f"[{proj_name}] Exception système : {error_msg}")
 
365
  @self.settings_app.post("/api/upload")
366
  def upload_video(payload: VideoPayload, bg_tasks: BackgroundTasks):
367
+ project_path = os.path.join(self.projects_dir, payload.name)
368
  os.makedirs(project_path, exist_ok=True)
369
  input_video_path = os.path.join(project_path, "input.mp4")
370
+
371
  header, encoded = payload.file_data.split(",", 1) if "," in payload.file_data else ("", payload.file_data)
372
  with open(input_video_path, "wb") as f:
373
  f.write(base64.b64decode(encoded))
374
+
375
  bg_tasks.add_task(process_in_background, input_video_path, project_path, payload.name)
376
  return {"status": "accepted"}
377
 
378
  @self.settings_app.get("/api/library")
379
  def get_library():
380
  projects = []
381
+ for folder in os.listdir(self.projects_dir):
382
+ folder_path = os.path.join(self.projects_dir, folder)
383
  if os.path.isdir(folder_path):
384
  status_path = os.path.join(folder_path, "status.txt")
385
  status = "UNKNOWN"
386
  if os.path.exists(status_path):
387
  with open(status_path, "r") as f:
388
  status = f.read().strip()
389
+
390
  if os.path.exists(os.path.join(folder_path, "output_annotated.mp4")):
391
  projects.append({
392
  "name": folder,
 
401
  })
402
  return projects
403
 
404
+
405
  @self.settings_app.delete("/api/projects/{project_name}")
406
  def delete_project(project_name: str):
407
+
408
+ project_path = os.path.join(self.projects_dir, project_name)
409
  if os.path.exists(project_path) and os.path.isdir(project_path):
410
  try:
411
  shutil.rmtree(project_path)
 
415
  raise HTTPException(status_code=500, detail=f"Erreur de suppression: {str(e)}")
416
  raise HTTPException(status_code=404, detail="Projet introuvable.")
417
 
418
+ @self.settings_app.post("/api/playback")
419
+ def sync_playback(payload: PlaybackPayload):
420
+
421
+ action = (payload.action or "sync").lower()
422
+ project_name = payload.project_name or self.current_project_name
423
+ current_time = max(0.0, float(payload.current_time or 0.0))
424
+
425
+ if action == "stop":
426
+ self._set_playback_state(project_name, current_time, False, dirty=False, stop_requested=True)
427
+ return {"status": "ok", "action": action}
428
+
429
+ if not project_name:
430
+ raise HTTPException(status_code=400, detail="Aucun projet sélectionné.")
431
+
432
+ try:
433
+ commands_loaded = self._load_project_commands(project_name)
434
+ except HTTPException:
435
+ raise
436
+ except Exception as e:
437
+ raise HTTPException(status_code=500, detail=str(e))
438
+
439
+ if action == "pause":
440
+ playing = False
441
+ dirty = True
442
+ elif action == "seek":
443
+ with self.playback_lock:
444
+ current_playing = bool(self.playback_state.get("playing", False))
445
+ playing = current_playing if payload.playing is None else bool(payload.playing)
446
+ dirty = True
447
+ elif action in {"play", "start"}:
448
+ playing = True
449
+ dirty = True
450
+ else:
451
+ playing = bool(payload.playing) if payload.playing is not None else True
452
+ dirty = False
453
+
454
+ self._set_playback_state(project_name, current_time, playing, dirty=dirty, stop_requested=False)
455
+ return {"status": "ok", "action": action, "commands_loaded": commands_loaded}
456
+
457
  @self.settings_app.post("/api/play_movement/{project_name}")
458
  def trigger_movement(project_name: str):
459
+
 
 
 
 
460
  try:
461
+ commands_loaded = self._load_project_commands(project_name)
462
+ self._set_playback_state(project_name, 0.0, True, dirty=True, stop_requested=False)
463
+ return {"status": "ok", "commands_loaded": commands_loaded}
464
+ except HTTPException:
465
+ raise
466
  except Exception as e:
467
  raise HTTPException(status_code=500, detail=str(e))
468
 
469
+ def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
470
  reachy_mini.goto_target(create_head_pose(), antennas=[0.0, 0.0], duration=1.0)
471
  time.sleep(1.2)
472
 
473
  while not stop_event.is_set():
474
+ with self.playback_lock:
475
+ commands_to_play = list(self.current_commands)
476
+ timestamps = list(self.current_timestamps)
477
+ state = dict(self.playback_state)
 
 
 
 
 
 
 
 
 
478
 
479
+ if state.get("stop_requested"):
480
+ reachy_mini.goto_target(create_head_pose(), antennas=[0.0, 0.0], duration=1.0)
481
+ with self.playback_lock:
482
+ self.playback_state["stop_requested"] = False
483
+ self.playback_state["dirty"] = False
484
+ time.sleep(0.05)
485
+ continue
486
 
487
+ if not commands_to_play or not timestamps:
488
+ time.sleep(0.05)
489
+ continue
490
 
491
+ playing = bool(state.get("playing", False))
492
+ dirty = bool(state.get("dirty", False))
493
+ if not playing and not dirty:
494
+ time.sleep(0.03)
495
+ continue
496
 
497
+ now = time.time()
498
+ if playing:
499
+ target_t = float(state.get("current_time", 0.0)) + (now - float(state.get("updated_at", now)))
500
+ else:
501
+ target_t = float(state.get("current_time", 0.0))
502
 
503
+ if target_t > timestamps[-1] + 0.20:
504
+ self._set_playback_state(state.get("project_name"), timestamps[-1], False, dirty=False, stop_requested=True)
505
+ continue
506
 
507
+ idx = bisect.bisect_left(timestamps, max(0.0, target_t))
508
+ if idx >= len(commands_to_play):
509
+ idx = len(commands_to_play) - 1
 
 
510
 
511
+ cmd = commands_to_play[idx]
512
+ head_dict = cmd["head"]
513
+ head_pose = create_head_pose(
514
+ x=head_dict["x"], y=head_dict["y"], z=head_dict["z"],
515
+ roll=head_dict["roll"], pitch=head_dict["pitch"], yaw=head_dict["yaw"],
516
+ degrees=False, mm=False,
517
+ )
518
 
519
+ antennas_target = cmd["antennas"] if self.antennas_enabled else [0.0, 0.0]
 
 
 
520
 
521
+ try:
522
+ reachy_mini.set_target(head=head_pose, antennas=antennas_target, body_yaw=cmd["body_yaw"])
523
+ except TypeError:
524
+ reachy_mini.set_target(head=head_pose, antennas=antennas_target)
525
 
526
+ if dirty:
527
+ with self.playback_lock:
528
+
529
+ if self.playback_state.get("updated_at") == state.get("updated_at"):
530
+ self.playback_state["dirty"] = False
531
 
532
+ time.sleep(0.01 if playing else 0.03)
533
 
 
534
  if __name__ == "__main__":
535
  app = Jericho()
536
  try:
jericho/projects/fish/signals.json ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "video": "C:\\Users\\hugov\\reachy_mini\\my_apps\\jericho\\jericho\\projects\\fish\\input.mp4",
3
+ "output_video": "C:\\Users\\hugov\\reachy_mini\\my_apps\\jericho\\jericho\\projects\\fish\\output_annotated.mp4",
4
+ "fps": 29.97,
5
+ "width": 256,
6
+ "height": 144,
7
+ "total_frames": 6812,
8
+ "output_total_frames": 7,
9
+ "skipped_no_face_frames": 6800,
10
+ "timeline": "compact_face_only",
11
+ "frames": [
12
+ {
13
+ "frame": 0,
14
+ "timestamp_s": 0.0,
15
+ "face_detected": true,
16
+ "face_cx": 0.5041,
17
+ "face_cy": 0.5993,
18
+ "face_w": 0.1426,
19
+ "face_h": 0.2724,
20
+ "bbox_xmin": 0.4328,
21
+ "bbox_ymin": 0.4631,
22
+ "bbox_xmax": 0.5754,
23
+ "bbox_ymax": 0.7355,
24
+ "yaw": 20.61,
25
+ "pitch": -5.88,
26
+ "roll": -24.4,
27
+ "ear_left": 0.3552,
28
+ "ear_right": 0.3423,
29
+ "mar": 0.4183,
30
+ "smile": 0.9454,
31
+ "brow_raise_left": 0.0973,
32
+ "brow_raise_right": 0.1283,
33
+ "brow_furrow": 0.3575,
34
+ "emotion": "CONTENT",
35
+ "original_frame": 362,
36
+ "original_timestamp_s": 12.0787
37
+ },
38
+ {
39
+ "frame": 1,
40
+ "timestamp_s": 0.0334,
41
+ "face_detected": true,
42
+ "face_cx": 0.4867,
43
+ "face_cy": 0.5864,
44
+ "face_w": 0.1723,
45
+ "face_h": 0.2947,
46
+ "bbox_xmin": 0.4005,
47
+ "bbox_ymin": 0.439,
48
+ "bbox_xmax": 0.5729,
49
+ "bbox_ymax": 0.7337,
50
+ "yaw": 19.71,
51
+ "pitch": -2.08,
52
+ "roll": -39.22,
53
+ "ear_left": 0.3216,
54
+ "ear_right": 0.3117,
55
+ "mar": 0.3816,
56
+ "smile": 0.7823,
57
+ "brow_raise_left": 0.0676,
58
+ "brow_raise_right": 0.1177,
59
+ "brow_furrow": 0.3827,
60
+ "emotion": "NEUTRE",
61
+ "original_frame": 2441,
62
+ "original_timestamp_s": 81.448
63
+ },
64
+ {
65
+ "frame": 2,
66
+ "timestamp_s": 0.0667,
67
+ "face_detected": true,
68
+ "face_cx": 0.4762,
69
+ "face_cy": 0.5905,
70
+ "face_w": 0.1745,
71
+ "face_h": 0.2462,
72
+ "bbox_xmin": 0.3889,
73
+ "bbox_ymin": 0.4674,
74
+ "bbox_xmax": 0.5634,
75
+ "bbox_ymax": 0.7136,
76
+ "yaw": 43.6,
77
+ "pitch": 9.84,
78
+ "roll": -60.06,
79
+ "ear_left": 0.3956,
80
+ "ear_right": 0.3608,
81
+ "mar": 0.3857,
82
+ "smile": 1.0639,
83
+ "brow_raise_left": 0.0065,
84
+ "brow_raise_right": 0.0484,
85
+ "brow_furrow": 0.5031,
86
+ "emotion": "JOIE",
87
+ "original_frame": 2501,
88
+ "original_timestamp_s": 83.45
89
+ },
90
+ {
91
+ "frame": 3,
92
+ "timestamp_s": 0.1001,
93
+ "face_detected": true,
94
+ "face_cx": 0.4605,
95
+ "face_cy": 0.5879,
96
+ "face_w": 0.1804,
97
+ "face_h": 0.2179,
98
+ "bbox_xmin": 0.3703,
99
+ "bbox_ymin": 0.4789,
100
+ "bbox_xmax": 0.5508,
101
+ "bbox_ymax": 0.6969,
102
+ "yaw": 51.33,
103
+ "pitch": 1.17,
104
+ "roll": -72.7,
105
+ "ear_left": 0.4808,
106
+ "ear_right": 0.5752,
107
+ "mar": 0.3477,
108
+ "smile": 0.8995,
109
+ "brow_raise_left": 0.0225,
110
+ "brow_raise_right": 0.0464,
111
+ "brow_furrow": 0.4005,
112
+ "emotion": "PEUR",
113
+ "original_frame": 5331,
114
+ "original_timestamp_s": 177.8777
115
+ },
116
+ {
117
+ "frame": 4,
118
+ "timestamp_s": 0.1335,
119
+ "face_detected": true,
120
+ "face_cx": 0.4809,
121
+ "face_cy": 0.604,
122
+ "face_w": 0.1627,
123
+ "face_h": 0.2473,
124
+ "bbox_xmin": 0.3996,
125
+ "bbox_ymin": 0.4803,
126
+ "bbox_xmax": 0.5622,
127
+ "bbox_ymax": 0.7276,
128
+ "yaw": 28.46,
129
+ "pitch": -2.9,
130
+ "roll": -43.45,
131
+ "ear_left": 0.3534,
132
+ "ear_right": 0.4038,
133
+ "mar": 0.4107,
134
+ "smile": 0.8494,
135
+ "brow_raise_left": 0.0586,
136
+ "brow_raise_right": 0.0748,
137
+ "brow_furrow": 0.4035,
138
+ "emotion": "NEUTRE",
139
+ "original_frame": 5662,
140
+ "original_timestamp_s": 188.9221
141
+ },
142
+ {
143
+ "frame": 5,
144
+ "timestamp_s": 0.1668,
145
+ "face_detected": true,
146
+ "face_cx": 0.4915,
147
+ "face_cy": 0.6195,
148
+ "face_w": 0.1447,
149
+ "face_h": 0.2324,
150
+ "bbox_xmin": 0.4192,
151
+ "bbox_ymin": 0.5033,
152
+ "bbox_xmax": 0.5639,
153
+ "bbox_ymax": 0.7357,
154
+ "yaw": 27.66,
155
+ "pitch": -3.79,
156
+ "roll": -36.34,
157
+ "ear_left": 0.3412,
158
+ "ear_right": 0.448,
159
+ "mar": 0.3767,
160
+ "smile": 0.764,
161
+ "brow_raise_left": 0.0665,
162
+ "brow_raise_right": 0.099,
163
+ "brow_furrow": 0.3344,
164
+ "emotion": "NEUTRE",
165
+ "original_frame": 6053,
166
+ "original_timestamp_s": 201.9684
167
+ },
168
+ {
169
+ "frame": 6,
170
+ "timestamp_s": 0.2002,
171
+ "face_detected": true,
172
+ "face_cx": 0.4813,
173
+ "face_cy": 0.5952,
174
+ "face_w": 0.1594,
175
+ "face_h": 0.2209,
176
+ "bbox_xmin": 0.4016,
177
+ "bbox_ymin": 0.4848,
178
+ "bbox_xmax": 0.561,
179
+ "bbox_ymax": 0.7057,
180
+ "yaw": 45.55,
181
+ "pitch": 5.15,
182
+ "roll": -64.75,
183
+ "ear_left": 0.3412,
184
+ "ear_right": 0.4439,
185
+ "mar": 0.3823,
186
+ "smile": 0.8951,
187
+ "brow_raise_left": 0.0063,
188
+ "brow_raise_right": 0.0339,
189
+ "brow_furrow": 0.4389,
190
+ "emotion": "NEUTRE",
191
+ "original_frame": 6777,
192
+ "original_timestamp_s": 226.1259
193
+ }
194
+ ]
195
+ }
jericho/projects/fish/status.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ERROR: NO_FACE_DETECTED
jericho/projects/{hugotest → hugo}/signals.json RENAMED
The diff for this file is too large to render. See raw diff
 
jericho/projects/{hugotest → hugo}/status.txt RENAMED
File without changes
jericho/projects/vid_test/signals.json DELETED
The diff for this file is too large to render. See raw diff
 
jericho/projects/vid_test/status.txt DELETED
@@ -1 +0,0 @@
1
- DONE
 
 
jericho/static/index.html CHANGED
@@ -8,10 +8,10 @@
8
  </head>
9
  <body>
10
  <header>
11
- <h1> Jericho projet : Reachy Mini </h1>
12
  <div id="conteneurHeader">
13
- <img src="./image/logoReachyMini.png" alt="Photo de reachy" width="10%"/>
14
- <div id="robot-status"> Prêt</div>
15
  </div>
16
  </header>
17
 
@@ -20,23 +20,13 @@
20
  <h2>[↓] Importer votre Vidéo</h2>
21
  <div class="form-group">
22
  <label>Nom du projet :</label>
23
- <input
24
- type="text"
25
- id="video-name"
26
- placeholder="ex: session_joie_01"
27
- />
28
  </div>
29
  <div class="form-group">
30
  <label for="video-file">Fichier Vidéo :</label>
31
- <input
32
- type="file"
33
- id="video-file"
34
- accept="video/mp4,video/quicktime"
35
- />
36
  </div>
37
- <button id="import-btn" class="btn-primary">
38
- Analyser et Enregistrer
39
- </button>
40
  <p id="import-msg"></p>
41
  </section>
42
 
@@ -47,22 +37,22 @@
47
  </div>
48
  </section>
49
 
50
- <section id="player-section" class="card">
51
  <h2 id="current-video-title">Lecture</h2>
52
  <video id="video-player" controls width="100%"></video>
53
 
54
  <div class="sync-controls">
55
  <button id="launch-sync-btn" class="btn-success">
56
- [->] Lancer Vidéo Annotée + Robot
57
- </button>
58
- </div>
59
- <button id="launch-sync-btn" class="btn-success">
60
- [->] Lancer Vidéo + Robot
61
  </button>
 
 
 
 
62
  </div>
63
  </section>
64
  </main>
65
 
66
  <script src="/static/main.js"></script>
67
  </body>
68
- </html>
 
8
  </head>
9
  <body>
10
  <header>
11
+ <h1>Jericho projet : Reachy Mini</h1>
12
  <div id="conteneurHeader">
13
+ <img src="./image/logoReachyMini.png" alt="Photo de Reachy" width="10%" />
14
+ <div id="robot-status">Prêt</div>
15
  </div>
16
  </header>
17
 
 
20
  <h2>[↓] Importer votre Vidéo</h2>
21
  <div class="form-group">
22
  <label>Nom du projet :</label>
23
+ <input type="text" id="video-name" placeholder="ex: session_joie_01" />
 
 
 
 
24
  </div>
25
  <div class="form-group">
26
  <label for="video-file">Fichier Vidéo :</label>
27
+ <input type="file" id="video-file" accept="video/mp4,video/quicktime" />
 
 
 
 
28
  </div>
29
+ <button id="import-btn" class="btn-primary">Analyser et Enregistrer</button>
 
 
30
  <p id="import-msg"></p>
31
  </section>
32
 
 
37
  </div>
38
  </section>
39
 
40
+ <section id="player-section" class="card hidden">
41
  <h2 id="current-video-title">Lecture</h2>
42
  <video id="video-player" controls width="100%"></video>
43
 
44
  <div class="sync-controls">
45
  <button id="launch-sync-btn" class="btn-success">
46
+ [->] Lire vidéo + robot synchronisés
 
 
 
 
47
  </button>
48
+ <p class="sync-hint">
49
+ Les pauses, reprises et déplacements dans la vidéo sont envoyés au robot.
50
+ Les frames sans visage sont supprimées de la vidéo traitée et des signaux.
51
+ </p>
52
  </div>
53
  </section>
54
  </main>
55
 
56
  <script src="/static/main.js"></script>
57
  </body>
58
+ </html>
jericho/static/main.js CHANGED
@@ -1,159 +1,257 @@
1
  let currentLoadedProject = null; // stocke le nom du projet sélectionné
2
- const robotStatus = document.getElementById('robot-status');
3
- const videoPlayer = document.getElementById('video-player');
4
-
5
- document.addEventListener("DOMContentLoaded", () => {
6
- loadLibrary(); // charge la liste des vidéos
7
- setInterval(loadLibrary, 3000); // relance le chargement toutes les 3 secondes pour eviter d avoir a recharger la page a chaque importation
8
-
9
- const btn = document.getElementById('launch-sync-btn'); // cherche le bouton de lancement
10
- if (btn) btn.style.display = 'none'; // cache le bouton s'il est trouvé sur la page
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  });
12
 
13
- document.getElementById('import-btn').onclick = function() { //on recupere les infos quand on lance
14
- const name = document.getElementById('video-name').value;
15
- const file = document.getElementById('video-file').files[0];
16
- const msg = document.getElementById('import-msg');
17
 
18
- if (!name || !file) { // vérifie si y a un nom et un fichier sinon message d erreur
19
- msg.textContent = "Erreur : Nom et fichier requis.";
20
- msg.style.color = "red";
21
- return;
22
  }
23
 
24
- msg.textContent = " envoie vid au serveur";
25
- msg.style.color = "#d35400";
26
- document.getElementById('import-btn').disabled = true; // désactive le bouton pour éviter un double clic
27
-
28
- const reader = new FileReader(); // prépare un outil pour lire la vidéo
29
- reader.readAsDataURL(file); // transforme la vidéo en texte
30
-
31
- reader.onload = async function () { // quand la transformation en texte est terminée
32
- const base64data = reader.result; // stocke le résultat texte
33
-
34
- try { // essaie d'envoyer au serveur
35
- const response = await fetch('/api/upload', { // envoie les données à python
36
- method: 'POST',
37
- headers: { 'Content-Type': 'application/json' },
38
- body: JSON.stringify({ name: name, file_data: base64data })
39
  });
40
 
41
- if (response.ok) { // si python le recoit bien affiche le
42
- msg.textContent = ` Vidéo en traitement`;
43
- msg.style.color = "#8269ae";
44
- document.getElementById('video-name').value = '';
45
- document.getElementById('video-file').value = '';
46
- } else {// sinon ca affiche l erreur
47
- const result = await response.json();
48
- msg.textContent = ` Erreur du serveur : ${result.detail}`;
49
- msg.style.color = "red";
 
50
  }
51
- } catch (error) { // si le serveur est injoignable
52
- msg.textContent = "Erreur de connexion au serveur."; // affiche une erreur de réseau
53
- msg.style.color = "red"; // en rouge
54
- } finally { // dans tous les cas à la fin
55
- document.getElementById('import-btn').disabled = false; // réactive le bouton importer
56
  }
57
  };
58
  };
59
 
 
 
60
 
61
- window.deleteProject = async function(event, name) { // fonction pour supp un projet
62
- event.stopPropagation(); // empêche de lancer la vidéo en même temps
63
-
64
- if (!confirm(`Voulez-vous vraiment supprimer la video "${name}" ?`)) {
65
- return;
66
  }
67
 
68
- try {
69
- const response = await fetch(`/api/projects/${name}`, { method: 'DELETE' }); // envoie l'ordre de suppression à python
70
- if (response.ok) {
71
- // si on supprime la vidéo qu'on est en train de lire, on cache le lecteur
72
- if (currentLoadedProject === name) {
 
 
 
73
  document.getElementById('player-section').classList.add('hidden');
74
- currentLoadedProject = null; // efface la mémoire du projet
75
- videoPlayer.pause();
76
- videoPlayer.src = ""; // retire la source de la vidéo
 
 
77
  }
78
- loadLibrary(); // rafraichit l'affichage
79
- } else {
80
- alert(" Erreur lors de la suppression."); // affiche une alerte d'erreur
81
  }
82
- } catch (error) {
83
- alert(" Erreur de connexion au serveur.");
84
  }
85
  };
86
 
87
- async function loadLibrary() { // fonction qui charge la liste des vidéos
88
- try {
89
- const response = await fetch('/api/library'); // demande les projets à python
90
- const projects = await response.json();
91
- const container = document.getElementById('video-library'); // cible la zone d'affichage de la bibliothèque
92
-
93
- let newHTML = "";
94
- if (projects.length === 0) {
95
- newHTML = '<p class="empty-text">Aucune vidéo traitée.</p>'; // affiche un message par défaut
96
- } else {
97
- projects.forEach(video => { // boucle sur chaque vidéo
98
- // La poubelle
99
- const deleteBtn = `<span onclick="deleteProject(event, '${video.name}')" style="position:absolute; top:2px; right:4px; font-size:16px; cursor:pointer;" title="Supprimer définitivement">x</span>`;
100
-
101
- if (video.status === "DONE") { // si l'analyse est terminée avec succès
102
- newHTML += `<div class="video-item" onclick="loadVideoPlayer('${video.name}', '${video.url}')" style="position:relative; border: 2px solid green;">
 
 
103
  ${deleteBtn}
104
- <br><strong>${video.name}</strong><br><span style="color:green; font-size:12px;"> Prêt</span>
105
- </div>`; // crée un bloc vert cliquable
106
- } else if (video.status === "PROCESSING") { // si l'analyse est en cours
107
  newHTML += `<div class="video-item" style="position:relative; opacity: 0.7; cursor: wait; border: 2px dashed orange;">
108
  ${deleteBtn}
109
- <br><strong>${video.name}</strong><br><span style="color:#d35400; font-size:12px;">Calcul en cours</span>
110
- </div>`; // crée un bloc orange en pointillé
111
- } else if (video.status && video.status.startsWith("ERROR")) { // si une erreur s'est produite côté python
112
- newHTML += `<div class="video-item" style="position:relative; border: 2px solid red; cursor: not-allowed;" title="${video.status}">
 
 
 
 
 
113
  ${deleteBtn}
114
- <br><strong>${video.name}</strong><br><span style="color:red; font-size:11px;">Échec (Survolez)</span>
115
- </div>`; // crée un bloc rouge avec erreur au survol
116
- } else { // si l'état est indéterminé
117
  newHTML += `<div class="video-item" style="position:relative; opacity: 0.5; cursor: wait;">
118
  ${deleteBtn}
119
- <br><strong>${video.name}</strong><br><span style="color:gray; font-size:12px;">En attente</span>
120
- </div>`; // crée un bloc grisé
121
  }
122
  });
123
  }
124
-
125
- if (container.innerHTML !== newHTML) {
126
- container.innerHTML = newHTML;
127
  }
128
- } catch (error) {
129
- console.error("Erreur de bibliothèque.");
130
  }
131
  }
132
 
133
- window.loadVideoPlayer = function(name, url) { // charge une vidéo dans le lecteur
134
- currentLoadedProject = name; // mémorise le nom du projet sélectionné
135
- document.getElementById('player-section').classList.remove('hidden'); // rend le lecteur visible
136
- document.getElementById('current-video-title').textContent = "Projet : " + name;
137
- videoPlayer.src = url; // donne le chemin du fichier au lecteur
138
- videoPlayer.load(); // charge la video
139
-
140
-
141
- };
142
-
143
- videoPlayer.onplay = async function() { // quand la lecture de la vidéo commence
144
- if (!currentLoadedProject) return; // annule si aucun projet n'est chargé
145
-
146
- try { // tente d'envoyer l'ordre
147
- await fetch(`/api/play_movement/${currentLoadedProject}`, { method: 'POST' }); // demande à python de jouer le mouvement
148
- } catch (error) { // si la communication échoue
149
- robotStatus.textContent = "Erreur de connexion."; // affiche l'erreur
150
  }
151
- };
152
 
153
- videoPlayer.onpause = function() { // quand la vidéo est mise sur pause
154
- robotStatus.textContent = " En pause"; // met à jour le texte
 
 
 
 
155
  };
156
 
157
- videoPlayer.onended = function() { // quand la vidéo est terminée
158
- robotStatus.textContent = " Terminé "; // met à jour le texte
159
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  let currentLoadedProject = null; // stocke le nom du projet sélectionné
2
+ let syncTimer = null;
3
+ let lastSyncSent = 0;
4
+ const SYNC_INTERVAL_MS = 250;
5
+
6
+ const robotStatus = document.getElementById('robot-status');
7
+ const videoPlayer = document.getElementById('video-player');
8
+
9
+ function escapeHtml(value) {
10
+ return String(value)
11
+ .replaceAll('&', '&amp;')
12
+ .replaceAll('<', '&lt;')
13
+ .replaceAll('>', '&gt;')
14
+ .replaceAll('"', '&quot;')
15
+ .replaceAll("'", '&#039;');
16
+ }
17
+
18
+ async function sendPlayback(action, options = {}) {
19
+ const projectName = options.projectName || currentLoadedProject;
20
+ if (!projectName) return;
21
+
22
+ const payload = {
23
+ project_name: projectName,
24
+ current_time: Number(videoPlayer.currentTime || 0),
25
+ playing: !videoPlayer.paused && !videoPlayer.ended,
26
+ action: action,
27
+ ...options
28
+ };
29
+ delete payload.projectName;
30
+
31
+ try {
32
+ const response = await fetch('/api/playback', {
33
+ method: 'POST',
34
+ headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify(payload)
36
+ });
37
+
38
+ if (!response.ok) {
39
+ const result = await response.json().catch(() => ({}));
40
+ robotStatus.textContent = `Erreur robot : ${result.detail || response.status}`;
41
+ }
42
+ } catch (error) {
43
+ robotStatus.textContent = 'Erreur de connexion.';
44
+ }
45
+ }
46
+
47
+ function startSyncLoop() {
48
+ stopSyncLoop();
49
+ syncTimer = setInterval(() => {
50
+ if (!videoPlayer.paused && !videoPlayer.ended) {
51
+ sendPlayback('sync', { playing: true });
52
+ }
53
+ }, SYNC_INTERVAL_MS);
54
+ }
55
+
56
+ function stopSyncLoop() {
57
+ if (syncTimer) {
58
+ clearInterval(syncTimer);
59
+ syncTimer = null;
60
+ }
61
+ }
62
+
63
+ document.addEventListener('DOMContentLoaded', () => {
64
+ loadLibrary();
65
+ setInterval(loadLibrary, 3000);
66
+
67
+ const btn = document.getElementById('launch-sync-btn');
68
+ if (btn) {
69
+ btn.onclick = () => {
70
+ if (currentLoadedProject) videoPlayer.play();
71
+ };
72
+ }
73
  });
74
 
75
+ document.getElementById('import-btn').onclick = function() {
76
+ const name = document.getElementById('video-name').value.trim();
77
+ const file = document.getElementById('video-file').files[0];
78
+ const msg = document.getElementById('import-msg');
79
 
80
+ if (!name || !file) {
81
+ msg.textContent = 'Erreur : Nom et fichier requis.';
82
+ msg.style.color = 'red';
83
+ return;
84
  }
85
 
86
+ msg.textContent = 'Envoi vidéo au serveur';
87
+ msg.style.color = '#d35400';
88
+ document.getElementById('import-btn').disabled = true;
89
+
90
+ const reader = new FileReader();
91
+ reader.readAsDataURL(file);
92
+
93
+ reader.onload = async function () {
94
+ const base64data = reader.result;
95
+
96
+ try {
97
+ const response = await fetch('/api/upload', {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({ name: name, file_data: base64data })
101
  });
102
 
103
+ if (response.ok) {
104
+ msg.textContent = 'Vidéo en traitement';
105
+ msg.style.color = '#8269ae';
106
+ document.getElementById('video-name').value = '';
107
+ document.getElementById('video-file').value = '';
108
+ loadLibrary();
109
+ } else {
110
+ const result = await response.json();
111
+ msg.textContent = `Erreur du serveur : ${result.detail}`;
112
+ msg.style.color = 'red';
113
  }
114
+ } catch (error) {
115
+ msg.textContent = 'Erreur de connexion au serveur.';
116
+ msg.style.color = 'red';
117
+ } finally {
118
+ document.getElementById('import-btn').disabled = false;
119
  }
120
  };
121
  };
122
 
123
+ window.deleteProject = async function(event, name) {
124
+ event.stopPropagation();
125
 
126
+ if (!confirm(`Voulez-vous vraiment supprimer la vidéo "${name}" ?`)) {
127
+ return;
 
 
 
128
  }
129
 
130
+ try {
131
+ if (currentLoadedProject === name) {
132
+ await sendPlayback('stop', { projectName: name, playing: false });
133
+ }
134
+
135
+ const response = await fetch(`/api/projects/${encodeURIComponent(name)}`, { method: 'DELETE' });
136
+ if (response.ok) {
137
+ if (currentLoadedProject === name) {
138
  document.getElementById('player-section').classList.add('hidden');
139
+ currentLoadedProject = null;
140
+ videoPlayer.pause();
141
+ videoPlayer.removeAttribute('src');
142
+ videoPlayer.load();
143
+ stopSyncLoop();
144
  }
145
+ loadLibrary();
146
+ } else {
147
+ alert('Erreur lors de la suppression.');
148
  }
149
+ } catch (error) {
150
+ alert('Erreur de connexion au serveur.');
151
  }
152
  };
153
 
154
+ async function loadLibrary() {
155
+ try {
156
+ const response = await fetch('/api/library');
157
+ const projects = await response.json();
158
+ const container = document.getElementById('video-library');
159
+
160
+ let newHTML = '';
161
+ if (projects.length === 0) {
162
+ newHTML = '<p class="empty-text">Aucune vidéo traitée.</p>';
163
+ } else {
164
+ projects.forEach(video => {
165
+ const safeName = escapeHtml(video.name);
166
+ const deleteBtn = `<span onclick="deleteProject(event, ${JSON.stringify(video.name).replaceAll('"', '&quot;')})" style="position:absolute; top:2px; right:4px; font-size:16px; cursor:pointer;" title="Supprimer définitivement">x</span>`;
167
+
168
+ if (video.status === 'DONE') {
169
+ const nameArg = JSON.stringify(video.name).replaceAll('"', '&quot;');
170
+ const urlArg = JSON.stringify(video.url).replaceAll('"', '&quot;');
171
+ newHTML += `<div class="video-item" onclick="loadVideoPlayer(${nameArg}, ${urlArg})" style="position:relative; border: 2px solid green;">
172
  ${deleteBtn}
173
+ <br><strong>${safeName}</strong><br><span style="color:green; font-size:12px;">Prêt</span>
174
+ </div>`;
175
+ } else if (video.status === 'PROCESSING') {
176
  newHTML += `<div class="video-item" style="position:relative; opacity: 0.7; cursor: wait; border: 2px dashed orange;">
177
  ${deleteBtn}
178
+ <br><strong>${safeName}</strong><br><span style="color:#d35400; font-size:12px;">Calcul en cours</span>
179
+ </div>`;
180
+ } else if (video.status === 'ERROR: NO_FACE_DETECTED') {
181
+ newHTML += `<div class="video-item" style="position:relative; border: 2px solid red; cursor: not-allowed;" title="La vidéo avec visage détecté doit faire au moins 2 secondes.">
182
+ ${deleteBtn}
183
+ <br><strong>${safeName}</strong><br><span style="color:red; font-size:11px;">Aucun visage détecté</span>
184
+ </div>`;
185
+ } else if (video.status && video.status.startsWith('ERROR')) {
186
+ newHTML += `<div class="video-item" style="position:relative; border: 2px solid red; cursor: not-allowed;" title="${escapeHtml(video.status)}">
187
  ${deleteBtn}
188
+ <br><strong>${safeName}</strong><br><span style="color:red; font-size:11px;">Échec (survolez)</span>
189
+ </div>`;
 
190
  newHTML += `<div class="video-item" style="position:relative; opacity: 0.5; cursor: wait;">
191
  ${deleteBtn}
192
+ <br><strong>${safeName}</strong><br><span style="color:gray; font-size:12px;">En attente</span>
193
+ </div>`;
194
  }
195
  });
196
  }
197
+
198
+ if (container.innerHTML !== newHTML) {
199
+ container.innerHTML = newHTML;
200
  }
201
+ } catch (error) {
202
+ console.error('Erreur de bibliothèque.', error);
203
  }
204
  }
205
 
206
+ window.loadVideoPlayer = async function(name, url) {
207
+ if (currentLoadedProject && currentLoadedProject !== name) {
208
+ await sendPlayback('stop', { projectName: currentLoadedProject, playing: false });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  }
 
210
 
211
+ currentLoadedProject = name;
212
+ document.getElementById('player-section').classList.remove('hidden');
213
+ document.getElementById('current-video-title').textContent = 'Projet : ' + name;
214
+ videoPlayer.src = url;
215
+ videoPlayer.load();
216
+ robotStatus.textContent = 'Vidéo chargée';
217
  };
218
 
219
+ videoPlayer.addEventListener('play', () => {
220
+ if (!currentLoadedProject) return;
221
+ robotStatus.textContent = 'Lecture synchronisée';
222
+ sendPlayback('play', { playing: true });
223
+ startSyncLoop();
224
+ });
225
+
226
+ videoPlayer.addEventListener('pause', () => {
227
+ if (!currentLoadedProject || videoPlayer.ended) return;
228
+ robotStatus.textContent = 'En pause';
229
+ sendPlayback('pause', { playing: false });
230
+ stopSyncLoop();
231
+ });
232
+
233
+ videoPlayer.addEventListener('seeking', () => {
234
+ if (!currentLoadedProject) return;
235
+ sendPlayback('seek', { playing: !videoPlayer.paused && !videoPlayer.ended });
236
+ });
237
+
238
+ videoPlayer.addEventListener('seeked', () => {
239
+ if (!currentLoadedProject) return;
240
+ sendPlayback('seek', { playing: !videoPlayer.paused && !videoPlayer.ended });
241
+ });
242
+
243
+ videoPlayer.addEventListener('timeupdate', () => {
244
+ if (!currentLoadedProject || videoPlayer.paused || videoPlayer.ended) return;
245
+
246
+ const now = Date.now();
247
+ if (now - lastSyncSent >= SYNC_INTERVAL_MS) {
248
+ lastSyncSent = now;
249
+ sendPlayback('sync', { playing: true });
250
+ }
251
+ });
252
+
253
+ videoPlayer.addEventListener('ended', () => {
254
+ robotStatus.textContent = 'Terminé';
255
+ sendPlayback('stop', { playing: false });
256
+ stopSyncLoop();
257
+ });
jericho/static/style.css CHANGED
@@ -116,4 +116,9 @@ input[type="text"], input[type="number"] {
116
 
117
  #video-file{
118
  color:#a34952;
119
- }
 
 
 
 
 
 
116
 
117
  #video-file{
118
  color:#a34952;
119
+ }
120
+ .sync-hint {
121
+ font-size: 0.9rem;
122
+ color: #74424a;
123
+ margin-bottom: 0;
124
+ }