Spaces:
Running
Running
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 +171 -77
- jericho/main.py +228 -152
- jericho/projects/fish/signals.json +195 -0
- jericho/projects/fish/status.txt +1 -0
- jericho/projects/{hugotest → hugo}/signals.json +0 -0
- jericho/projects/{hugotest → hugo}/status.txt +0 -0
- jericho/projects/vid_test/signals.json +0 -0
- jericho/projects/vid_test/status.txt +0 -1
- jericho/static/index.html +13 -23
- jericho/static/main.js +218 -120
- jericho/static/style.css +6 -1
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 |
-
|
| 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
|
| 230 |
try:
|
| 231 |
-
subprocess.run(
|
| 232 |
-
["ffmpeg", "-version"],
|
| 233 |
-
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
|
| 234 |
)
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
"
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
]
|
| 252 |
-
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
|
|
|
|
|
| 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), #
|
| 333 |
-
"smile": round(ema('sm', sm_raw), 4), #
|
| 334 |
-
"brow_raise_left": round(ema('br_l', br_left_raw), 4), #
|
| 335 |
-
"brow_raise_right": round(ema('br_r', br_right_raw), 4), #
|
| 336 |
-
"brow_furrow": round(ema('bf', bf_raw), 4), #
|
| 337 |
}
|
| 338 |
-
records.append({"rec": rec, "lms": lms}) #
|
| 339 |
-
else: #
|
| 340 |
-
records.append({ #
|
| 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 #
|
| 346 |
-
if frame_idx % 100 == 0: #
|
| 347 |
-
print(f" Frame {frame_idx}/{total} ({100*frame_idx//total}%)") #
|
| 348 |
|
| 349 |
-
cap.release() #
|
| 350 |
landmarker.close() # Ferme l'IA MediaPipe proprement
|
| 351 |
|
| 352 |
-
#
|
| 353 |
-
emotions_records(records) #
|
| 354 |
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 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 |
-
|
| 382 |
-
|
| 383 |
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
else:
|
| 388 |
-
os.replace(silent_path, final_path)
|
| 389 |
-
|
| 390 |
-
json_path = os.path.join(output_dir, "signals.json") #
|
| 391 |
-
with open(json_path, "w", encoding="utf-8") as f:
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
)
|
| 397 |
-
print(f"\n fait,
|
| 398 |
|
| 399 |
-
if __name__ == "__main__": #
|
| 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 |
-
|
| 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 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 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)
|
| 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
|
| 68 |
-
SMOOTH_WINDOW_EMOTION = 11
|
| 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")
|
| 88 |
-
self.pitch_mean = mean("pitch")
|
| 89 |
-
self.roll_mean = mean("roll")
|
| 90 |
-
self.cx_mean = mean("face_cx")
|
| 91 |
-
self.cy_mean = mean("face_cy")
|
| 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
|
| 276 |
-
file_data: str
|
| 277 |
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
| 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 |
-
|
| 325 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
except Exception as e:
|
| 403 |
raise HTTPException(status_code=500, detail=str(e))
|
| 404 |
|
| 405 |
-
|
| 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 |
-
|
| 411 |
-
|
| 412 |
-
|
| 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 |
-
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
|
| 427 |
-
|
| 428 |
-
|
|
|
|
| 429 |
|
| 430 |
-
|
| 431 |
-
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
|
| 435 |
-
|
| 436 |
-
|
|
|
|
| 437 |
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
degrees=False, mm=False,
|
| 442 |
-
)
|
| 443 |
|
| 444 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
|
| 446 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 452 |
|
| 453 |
-
if
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
| 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>
|
| 12 |
<div id="conteneurHeader">
|
| 13 |
-
<img src="./image/logoReachyMini.png" alt="Photo de
|
| 14 |
-
<div id="robot-status">
|
| 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 |
-
[->]
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
});
|
| 12 |
|
| 13 |
-
document.getElementById('import-btn').onclick = function() {
|
| 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) {
|
| 19 |
-
msg.textContent =
|
| 20 |
-
msg.style.color =
|
| 21 |
-
return;
|
| 22 |
}
|
| 23 |
|
| 24 |
-
msg.textContent =
|
| 25 |
-
msg.style.color =
|
| 26 |
-
document.getElementById('import-btn').disabled = true;
|
| 27 |
-
|
| 28 |
-
const reader = new FileReader();
|
| 29 |
-
reader.readAsDataURL(file);
|
| 30 |
-
|
| 31 |
-
reader.onload = async function () {
|
| 32 |
-
const base64data = reader.result;
|
| 33 |
-
|
| 34 |
-
try {
|
| 35 |
-
const response = await fetch('/api/upload', {
|
| 36 |
-
method: 'POST',
|
| 37 |
-
headers: { 'Content-Type': 'application/json' },
|
| 38 |
-
body: JSON.stringify({ name: name, file_data: base64data })
|
| 39 |
});
|
| 40 |
|
| 41 |
-
if (response.ok) {
|
| 42 |
-
msg.textContent =
|
| 43 |
-
msg.style.color =
|
| 44 |
-
document.getElementById('video-name').value = '';
|
| 45 |
-
document.getElementById('video-file').value = '';
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
msg.
|
|
|
|
| 50 |
}
|
| 51 |
-
} catch (error) {
|
| 52 |
-
msg.textContent =
|
| 53 |
-
msg.style.color =
|
| 54 |
-
} finally {
|
| 55 |
-
document.getElementById('import-btn').disabled = false;
|
| 56 |
}
|
| 57 |
};
|
| 58 |
};
|
| 59 |
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
if (!confirm(`Voulez-vous vraiment supprimer la video "${name}" ?`)) {
|
| 65 |
-
return;
|
| 66 |
}
|
| 67 |
|
| 68 |
-
try {
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
| 73 |
document.getElementById('player-section').classList.add('hidden');
|
| 74 |
-
currentLoadedProject = null;
|
| 75 |
-
videoPlayer.pause();
|
| 76 |
-
videoPlayer.src
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
-
loadLibrary();
|
| 79 |
-
} else {
|
| 80 |
-
alert(
|
| 81 |
}
|
| 82 |
-
} catch (error) {
|
| 83 |
-
alert(
|
| 84 |
}
|
| 85 |
};
|
| 86 |
|
| 87 |
-
async function loadLibrary() {
|
| 88 |
-
try {
|
| 89 |
-
const response = await fetch('/api/library');
|
| 90 |
-
const projects = await response.json();
|
| 91 |
-
const container = document.getElementById('video-library');
|
| 92 |
-
|
| 93 |
-
let newHTML =
|
| 94 |
-
if (projects.length === 0) {
|
| 95 |
-
newHTML = '<p class="empty-text">Aucune vidéo traitée.</p>';
|
| 96 |
-
} else {
|
| 97 |
-
projects.forEach(video => {
|
| 98 |
-
|
| 99 |
-
const deleteBtn = `<span onclick="deleteProject(event,
|
| 100 |
-
|
| 101 |
-
if (video.status ===
|
| 102 |
-
|
|
|
|
|
|
|
| 103 |
${deleteBtn}
|
| 104 |
-
<br><strong>${
|
| 105 |
-
</div>`;
|
| 106 |
-
} else if (video.status ===
|
| 107 |
newHTML += `<div class="video-item" style="position:relative; opacity: 0.7; cursor: wait; border: 2px dashed orange;">
|
| 108 |
${deleteBtn}
|
| 109 |
-
<br><strong>${
|
| 110 |
-
</div>`;
|
| 111 |
-
} else if (video.status
|
| 112 |
-
newHTML += `<div class="video-item" style="position:relative; border: 2px solid red; cursor: not-allowed;" title="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
${deleteBtn}
|
| 114 |
-
<br><strong>${
|
| 115 |
-
</div>`;
|
| 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>${
|
| 120 |
-
</div>`;
|
| 121 |
}
|
| 122 |
});
|
| 123 |
}
|
| 124 |
-
|
| 125 |
-
if (container.innerHTML !== newHTML) {
|
| 126 |
-
container.innerHTML = newHTML;
|
| 127 |
}
|
| 128 |
-
} catch (error) {
|
| 129 |
-
console.error(
|
| 130 |
}
|
| 131 |
}
|
| 132 |
|
| 133 |
-
window.loadVideoPlayer = function(name, url) {
|
| 134 |
-
currentLoadedProject = name
|
| 135 |
-
|
| 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 |
-
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
};
|
| 156 |
|
| 157 |
-
videoPlayer.
|
| 158 |
-
|
| 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('&', '&')
|
| 12 |
+
.replaceAll('<', '<')
|
| 13 |
+
.replaceAll('>', '>')
|
| 14 |
+
.replaceAll('"', '"')
|
| 15 |
+
.replaceAll("'", ''');
|
| 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('"', '"')})" 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('"', '"');
|
| 170 |
+
const urlArg = JSON.stringify(video.url).replaceAll('"', '"');
|
| 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 |
+
}
|