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