LESTAL / coquille /main.py
Lucatastik's picture
Problème url
cf95356 verified
Raw
History Blame Contribute Delete
16.1 kB
import threading # Lancement du thread de lecture CSV sans bloquer le serveur
import subprocess # Exécution de Jalon2OPTI.py comme sous-processus Python
import sys
import os
import time # Gestion précise du timing pour la synchronisation
import csv
from fastapi import Request # Objet requête HTTP
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse, Response # Différents types de réponses
from fastapi.staticfiles import StaticFiles # Fichiers statiques
from reachy_mini import ReachyMini, ReachyMiniApp
from reachy_mini.utils import create_head_pose
import numpy as np # Conversion degrés → radians pour les antennes
import soundfile as sf # Lecture de fichiers audio WAV
import scipy.signal
import yt_dlp # Téléchargement de vidéos YouTube
### CHEMINS DE SAUVEGARDE ###
DOSSIER = os.path.dirname(os.path.abspath(__file__))
STATIC = os.path.join(DOSSIER, "static")
DOSSIER_CSV = os.path.join(DOSSIER, "liste", "csv")
DOSSIER_LANDMARKS = os.path.join(DOSSIER, "liste", "landmarks")
DOSSIER_AUDIO = os.path.join(DOSSIER, "liste", "audio")
# Crée les dossiers si jamais ils sont absents
os.makedirs(DOSSIER_CSV, exist_ok=True)
os.makedirs(DOSSIER_LANDMARKS, exist_ok=True)
os.makedirs(DOSSIER_AUDIO, exist_ok=True)
### CLASSE PRINCIPALE POUR L'APP ###
class Coquille(ReachyMiniApp):
custom_app_url: str | None = "http://localhost:8042" # URL interne ouverte en local
request_media_backend: str | None = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.robot: ReachyMini | None = None # Instance du robot
# Le robot et la lecture de la vidéo sont prets à démarrer
self.robot_ready = threading.Event()
self.playback_ready = threading.Event()
self.playback_paused = False # Lecture en pause
self.playback_stop = False # Arret
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
self.robot = reachy_mini
self.robot_ready.set()
print("[ROBOT] Connecté et prêt")
stop_event.wait() # Bloque jusqu'à l'arret de l'app
### FONCTIONS UTILITAIRES ###
def interruptible_sleep(seconds: float, app_instance: Coquille):
deadline = time.perf_counter() + seconds
while time.perf_counter() < deadline:
if app_instance.playback_stop:
return
if app_instance.playback_paused:
deadline += 0.01
time.sleep(0.01)
### PERMET D'ATTENDRE QUE LE ROBOT SOIT LA 2PUIS EXECUTE LE REJEU DE LA LOGIQUE DU JALON 3 ###
def execute_jalon3_thread(csv_path: str, wav_path: str, app_instance: Coquille):
# Attente que le robot robot soit là
if not app_instance.robot_ready.wait(timeout=15):
print("[JALON3] Timeout : robot non connecté")
app_instance.playback_ready.set()
return
robot = app_instance.robot
if robot is None:
print("[JALON3] Robot None — abandon")
app_instance.playback_ready.set()
return
try:
# Chargement du csv
with open(csv_path, newline='') as f:
rows = list(csv.DictReader(f))
# Préparer l'audio
audio_ok = False
if os.path.exists(wav_path):
try:
# Lecture du .wav
audio, samplerate_in = sf.read(wav_path, dtype="float32")
# Conversion stéréo mono
if audio.ndim > 1:
audio = np.mean(audio, axis=1)
output_sr = robot.media.get_output_audio_samplerate()
if samplerate_in != output_sr:
audio = scipy.signal.resample(
audio,
int(len(audio) * (output_sr / samplerate_in))
)
audio_ok = True
print(f"[JALON3] Audio chargé ({len(audio)} samples)")
except Exception as e:
print(f"[JALON3] Erreur chargement audio : {e}")
else:
print(f"[JALON3] Pas de fichier WAV : {wav_path}")
print(f"[JALON3] {len(rows)} frames — signal prêt envoyé")
# Signaler au JS que tout est prêt
app_instance.playback_ready.set()
# Démarrer l'audio
if audio_ok:
try:
robot.media.start_playing()
robot.media.push_audio_sample(audio)
print("[JALON3] Audio lancé")
except Exception as e:
print(f"[JALON3] Erreur démarrage audio : {e}")
# Boucle de lecture image par image
start_time = time.perf_counter() # T0 de référence pour la synchronisation
for idx, row in enumerate(rows):
# Vérification de l'arrêt demandé
if app_instance.playback_stop:
print(f"[JALON3] Stop à frame {idx}")
break
# Attente de la reprise en pause
while app_instance.playback_paused and not app_instance.playback_stop:
time.sleep(0.01)
if app_instance.playback_stop:
break
try:
# Construction de la pose de la tête
# x = ouverture de bouche (lèvre du robot)
# y = position horizontale du visage
# z = position verticale du visage
# pitch = inclinaison avant/arrière
# yaw = rotation gauche/droite
# roll = inclinaison latérale
head_pose = create_head_pose(
x=float(row["bouche"]),
y=float(row["norm_x"]),
z=float(row["norm_y"]),
pitch=float(row["pitch"]),
yaw=float(row["yaw"]),
roll=float(row["roll"]),
)
# Angles des antennes
# oeil g (antenne gauche) et d (antenne droite) en degrés puis conversion en radian pour Reachy
antennas_rad = np.deg2rad(np.array([float(row["oeil_g"]), float(row["oeil_d"])]))
# Envoi de la commande au robot
robot.set_target(head=head_pose, antennas=antennas_rad)
# Syncrhonisation temporelle
# Calcul en fonction du tps réel et du timestamp csv avant d'envoyer la prochaine frame
ts = float(row["timestamp"])
elapsed = time.perf_counter() - start_time
sleep_time = ts - elapsed
if sleep_time > 0.001:
interruptible_sleep(sleep_time, app_instance)
except Exception as e:
print(f"[JALON3] Erreur frame {idx}: {e}")
# Arrêter l'audio proprement
if audio_ok:
try:
robot.media.stop_playing()
except Exception as e:
print(f"[JALON3] Erreur arrêt audio : {e}")
print("[JALON3] Terminé")
except Exception as e:
print(f"[JALON3] Erreur générale: {e}")
app_instance.playback_ready.set() # Evite le blocage du JS
### APP ###
app = Coquille()
# Appelle le dossier static pour le navigateur
app.settings_app.mount("/static", StaticFiles(directory=STATIC), name="static")
@app.settings_app.get("/")
def index():
# Appelle la page index.html du dossier static
return FileResponse(os.path.join(STATIC, "index.html"))
@app.settings_app.get("/favicon.ico")
def favicon():
# Evite les erreurs 404
return Response(status_code=204)
@app.settings_app.get("/liste")
def liste():
# Retourne la liste des enregistrements en scannant le dossier csv pour avoir les noms de chaque enregistrement
noms = [f[:-4] for f in os.listdir(DOSSIER_CSV) if f.endswith(".csv")]
return JSONResponse({"noms": noms})
@app.settings_app.post("/charger")
async def charger(request: Request):
# Recoit une video locale pour lancer le pipeline ensuite
# Sauvegarde la vidéo sur le disque en vdieo_temp.mp4
# Lance le Jalon2OPTI puis retourne succès ou erreur en fonction
form = await request.form()
video_file = form.get("video")
nom = (form.get("nom") or "").strip()
if not video_file or not nom:
return JSONResponse({"succes": False, "message": "Vidéo ou nom manquant."}, status_code=400)
# Sauvegarde temporaire de la vidéo chargée
video_path = os.path.join(DOSSIER, "video_temp.mp4")
with open(video_path, "wb") as f:
f.write(await video_file.read())
try:
subprocess.run(
[sys.executable, os.path.join(DOSSIER, "Jalon2OPTI.py"), video_path, nom],
cwd=DOSSIER, capture_output=True, text=True, check=True, timeout=300
)
print(f"[PIPELINE] OK pour {nom}")
except subprocess.CalledProcessError:
return JSONResponse({"succes": False, "message": "Pipeline échoué"}, status_code=500)
except subprocess.TimeoutExpired:
return JSONResponse({"succes": False, "message": "Pipeline timeout"}, status_code=500)
return JSONResponse({"succes": True, "nom": nom})
@app.settings_app.post("/supprimer")
async def supprimer(request: Request):
# Supprime les fichiers wav video landmarkée et csv d'un enregistrement
# Si une vidéo n'a par exemple pas d'audio on l'ignore
data = await request.json()
nom = data.get("nom")
if not nom:
return JSONResponse({"succes": False, "message": "Nom manquant"}, status_code=400)
# Suppression de chaque fichier
for path in [
os.path.join(DOSSIER_CSV, f"{nom}.csv"),
os.path.join(DOSSIER_LANDMARKS, f"{nom}(landmarks).mp4"),
os.path.join(DOSSIER_AUDIO, f"{nom}.wav"),
]:
if os.path.exists(path):
os.remove(path)
print(f"[DELETE] {nom} supprimé")
return JSONResponse({"succes": True})
@app.settings_app.post("/play")
async def play(request: Request):
# Lance un enregistrement sur le robot
# Dans un premier tps, vérifie sur le csv est présent
# Réinitialise les play et pause
# Remet à 0 l'évent du robot pret
# Lance le rejeu dans un thread pour reachy mini
# Le JS interroge le /is-ready pour savoir quand commencer la vidéo
data = await request.json()
nom = data.get("nom", "").strip()
if not nom:
return JSONResponse({"succes": False, "message": "Nom manquant"}, status_code=400)
csv_path = os.path.join(DOSSIER_CSV, f"{nom}.csv")
wav_path = os.path.join(DOSSIER_AUDIO, f"{nom}.wav")
if not os.path.exists(csv_path):
return JSONResponse({"succes": False, "message": f"CSV introuvable : {nom}"}, status_code=404)
# Réinitialisation de l'état de lecture
app.playback_paused = False
app.playback_stop = False
app.playback_ready.clear() # Reset de l'évent pour pouvoir relancer
thread = threading.Thread(target=execute_jalon3_thread, args=(csv_path, wav_path, app), daemon=True)
thread.start()
print(f"[PLAY] Thread lancé pour {nom}")
return JSONResponse({"succes": True})
@app.settings_app.get("/is-ready")
async def is_ready():
# Vérifie si on peut lancer la lecture
# Si ready=True le JS lance la vidéo en même tps que le rejeu de reachy
return JSONResponse({"ready": app.playback_ready.is_set()})
@app.settings_app.post("/pause")
async def pause(request: Request):
# Met la lecture en pause
app.playback_paused = True
return JSONResponse({"succes": True})
@app.settings_app.post("/resume")
async def resume(request: Request):
# Reprend la lecture après une pause
app.playback_paused = False
return JSONResponse({"succes": True})
@app.settings_app.post("/stop")
async def stop(request: Request):
# Arret complet de la lecture
app.playback_stop = True
return JSONResponse({"succes": True})
@app.settings_app.get("/video/{nom}")
def video(nom: str, request: Request):
# lis la vidéo landmarkée
path = os.path.join(DOSSIER_LANDMARKS, f"{nom}(landmarks).mp4")
if not os.path.exists(path):
return JSONResponse({"succes": False, "message": f"Vidéo introuvable : {nom}"}, status_code=404)
file_size = os.path.getsize(path)
range_header = request.headers.get("range")
if range_header:
start, _, end_str = range_header.replace("bytes=", "").partition("-")
start = int(start)
end = int(end_str) if end_str else file_size - 1
end = min(end, file_size - 1)
chunk = end - start + 1
def stream():
with open(path, "rb") as f:
f.seek(start)
rem = chunk
while rem > 0:
data = f.read(min(65536, rem))
if not data:
break
rem -= len(data)
yield data
return StreamingResponse(stream(), status_code=206, media_type="video/mp4", headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(chunk),
})
return FileResponse(path, media_type="video/mp4", headers={"Accept-Ranges": "bytes"})
@app.settings_app.post("/telecharger")
async def telecharger(request: Request):
# Télécharge une vidéo YouTube dans video_temp.mp4 via yt_dlp
# la vidéo est sauvegardée en vidéo_temp qu'on utilisera pour la lancer dans le navigateur
data = await request.json()
url = data.get("url", "").strip()
if not url:
return JSONResponse({"succes": False, "message": "URL manquante."}, status_code=400)
video_path = os.path.join(DOSSIER, "video_temp.mp4")
if os.path.exists(video_path):
os.remove(video_path) # Supprime une ancienne vidéo temp pour qu'elle soit bien temporaire
try:
ydl_opts = {
"outtmpl": video_path, # Chemin de sortie
# Fusion de l'audio et de la vidéo et choix des meilleurs qualités de vidéo
"format": "mp4/bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]",
"merge_output_format": "mp4",
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
except Exception as e:
return JSONResponse({"succes": False, "message": str(e)}, status_code=500)
return JSONResponse({"succes": True})
@app.settings_app.post("/lancer-youtube")
async def lancer_youtube(request: Request):
# Lance Jalon2OPTI sur la video_temp.mp4 déjà téléchargée
# Séparé de /telecharger pour permettre au JS d'attendre la fin du téléchargement avant de demander le traitement (qui peut être long).
data = await request.json()
nom = (data.get("nom") or "").strip()
if not nom:
return JSONResponse({"succes": False, "message": "Nom manquant."}, status_code=400)
video_path = os.path.join(DOSSIER, "video_temp.mp4")
if not os.path.exists(video_path):
return JSONResponse({"succes": False, "message": "Aucune vidéo téléchargée."}, status_code=404)
try:
subprocess.run(
[sys.executable, os.path.join(DOSSIER, "Jalon2OPTI.py"), video_path, nom],
cwd=DOSSIER, capture_output=True, text=True,
encoding="utf-8", errors="replace", check=True
)
print(f"[PIPELINE] OK pour {nom}")
except subprocess.CalledProcessError as e:
return JSONResponse({
"succes": False,
"message": e.stderr or e.stdout or "erreur inconnue"
}, status_code=500)
return JSONResponse({"succes": True, "nom": nom})
if __name__ == "__main__":
import uvicorn
# Lance le thread de connexion robot en arrière-plan (daemon=True : s'arrête automatiquement à la fermeture du processus principal)
threading.Thread(target=app.wrapped_run, daemon=True).start()
# Démarre le serveur HTTP FastAPI sur le port 8042
uvicorn.run(app.settings_app, host="127.0.0.1", port=8042)