File size: 12,610 Bytes
8a4a092 e641eac 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 1c9c5b2 8a4a092 e641eac 8a4a092 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 | import argparse
import csv
import math
import os
import subprocess
import sys
import urllib.request
import cv2
import mediapipe as mp
import numpy as np
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision as mp_vision
from mediapipe.tasks.python.vision import FaceLandmarkerOptions, FaceLandmarker
# MediaPipe
MODEL_URL = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task"
MODEL_PATH = "face_landmarker.task"
def ensure_model():
if not os.path.exists(MODEL_PATH):
print(f"Téléchargement du modèle MediaPipe -> {MODEL_PATH} ...")
urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)
print(" Modele telecharge.")
# Indices visage
LEFT_EYE = [362, 385, 387, 263, 373, 380] # point qui contoure l oeil
RIGHT_EYE = [33, 160, 158, 133, 153, 144]
MOUTH_OUTER = [61, 291, 0, 17, 37, 267]
MOUTH_L, MOUTH_R = 61, 291
LEFT_PUP, RIGHT_PUP = 468, 473
EAR_THRESHOLD = 0.20 # en dessous de ce seuil l'oeil considéré fermé
#EAR = eye aspect ratio
HEAD_POSE_IDX = [1, 152, 226, 446, 57, 287]
MODEL_3D = np.array([# genere un model 3D d'une tete d'humain
[ 0.0, 0.0, 0.0 ],
[ 0.0, -63.6, -12.5],
[-43.3, 32.7, -26.0],
[ 43.3, 32.7, -26.0],
[-28.9, -28.9, -24.1],
[ 28.9, -28.9, -24.1],
], dtype=np.float64)#float 64 oblige a etre tres orecis
# Calculs
def _pts(lms, indices, w, h): # convertit les coordonnées en pixel
return np.array([[lms[i].x * w, lms[i].y * h] for i in indices])
def ear(lms, indices, w, h): # ca calule le degres d'ouverture de l'oeil
p = _pts(lms, indices, w, h)# recupere les pixels de l oeil
A = np.linalg.norm(p[1]-p[5]); B = np.linalg.norm(p[2]-p[4]) # distance verticale des paupiere
return (A+B) / (2*np.linalg.norm(p[0]-p[3])+1e-6)
def mar(lms, indices, w, h):# calcul le degres d'ouverture de la bouche
p = _pts(lms, indices, w, h)
A = np.linalg.norm(p[1]-p[5]); B = np.linalg.norm(p[2]-p[4])
return (A+B) / (2*np.linalg.norm(p[0]-p[3])+1e-6)
def smile_index(lms, w, h):# calcul a quel point la personne sourit
ml = np.array([lms[MOUTH_L].x*w, lms[MOUTH_L].y*h])# coordonne coin de bouche droit et gauche
mr = np.array([lms[MOUTH_R].x*w, lms[MOUTH_R].y*h])
pl = np.array([lms[LEFT_PUP].x*w, lms[LEFT_PUP].y*h])#coordonne pupilles
pr = np.array([lms[RIGHT_PUP].x*w, lms[RIGHT_PUP].y*h])
return np.linalg.norm(mr-ml) / (np.linalg.norm(pr-pl)+1e-6)# divise largeur bouche par largeur pupille car elle se dilate en souriant
def head_pose(lms, w, h): # fonction qui estime l'orientation de la tete
img_pts = np.array([[lms[i].x*w, lms[i].y*h] for i in HEAD_POSE_IDX], dtype=np.float64)# extrait les coordonnées des points de references
cam = np.array([[w,0,w/2],[0,w,h/2],[0,0,1]], dtype=np.float64)
dist = np.zeros((4,1))
ok, rvec, _ = cv2.solvePnP(MODEL_3D, img_pts, cam, dist, flags=cv2.SOLVEPNP_ITERATIVE)
if not ok:
return 0.0, 0.0, 0.0
R, _ = cv2.Rodrigues(rvec)
sy = math.sqrt(R[0,0]**2 + R[1,0]**2)
if sy > 1e-6:
roll = math.degrees(math.atan2( R[2,1], R[2,2]))
pitch = math.degrees(math.atan2(-R[2,0], sy))
yaw = math.degrees(math.atan2( R[1,0], R[0,0]))
else:
roll = math.degrees(math.atan2(-R[1,2], R[1,1]))
pitch = math.degrees(math.atan2(-R[2,0], sy))
yaw = 0.0
return yaw, pitch, roll
def face_bbox(lms, w, h): # focntion pour calculer une boite qui enveloppe le visage
xs = [lm.x for lm in lms]; ys = [lm.y for lm in lms]# ca creer une liste de tout les X et Y du visage
xmn,xmx,ymn,ymx = min(xs),max(xs),min(ys),max(ys)
return (xmn+xmx)/2,(ymn+ymx)/2,xmx-xmn,ymx-ymn,xmn,ymn,xmx,ymx # retourne tout les infos sur la boites
# Annotation
def annotate_frame(frame, lms, sig, w, h): # fonction qui va dessiner sur l video
x1,y1 = int(sig["bbox_xmin"]*w), int(sig["bbox_ymin"]*h)
x2,y2 = int(sig["bbox_xmax"]*w), int(sig["bbox_ymax"]*h)
cv2.rectangle(frame,(x1,y1),(x2,y2),(0,255,0),2) # fait un carre vert autour du visage
for idx in LEFT_EYE+RIGHT_EYE+MOUTH_OUTER: # dessine des points sur les differents endroit du visage
cv2.circle(frame,(int(lms[idx].x*w),int(lms[idx].y*h)),2,(255,0,255),-1)
for idx in [LEFT_PUP, RIGHT_PUP]:
cv2.circle(frame,(int(lms[idx].x*w),int(lms[idx].y*h)),3,(0,200,255),-1)
lines = [ # affiche les lignes texte qui contient les differents
f"Yaw:{sig['yaw']:+.1f} Pitch:{sig['pitch']:+.1f} Roll:{sig['roll']:+.1f}",
f"EAR L:{sig['ear_left']:.3f} R:{sig['ear_right']:.3f}",
f"MAR:{sig['mar']:.3f} Smile:{sig['smile']:.3f}",
f"T:{sig['timestamp_s']:.2f}s",
]
for i, line in enumerate(lines):# ecrit les ligne de texte au dessus de la boite
cv2.putText(frame, line, (x1, max(y1-10-i*18, 14)),
cv2.FONT_HERSHEY_SIMPLEX, 0.48, (0,255,0), 1, cv2.LINE_AA)
nose = (int(lms[1].x*w), int(lms[1].y*h))
tip = (int(nose[0]+60*math.sin(math.radians(sig["yaw"]))),
int(nose[1]-60*math.sin(math.radians(sig["pitch"]))))
cv2.arrowedLine(frame, nose, tip, (0,100,255), 2, tipLength=0.3)# dessine feche partant du nez montrant orientation tete
return frame
# Fusion audio via FFmpeg
def merge_audio(original_video, silent_video, output_path):# fonction qui reassemble le son de la video avec la video
try:
subprocess.run(
["ffmpeg", "-version"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
)
except (subprocess.CalledProcessError, FileNotFoundError):
print("\n [AVERTISSEMENT] ffmpeg introuvable dans le PATH.")
print(" -> L'audio n'a pas ete recollé.")
print(" Installez ffmpeg puis lancez manuellement :")
print(f' ffmpeg -i "{silent_video}" -i "{original_video}" '
f'-c copy -map 0:v:0 -map 1:a:0 -shortest "{output_path}"')
return False
cmd = [
"ffmpeg", "-y",
"-i", silent_video, # vidéo annotée (sans audio)
"-i", original_video, # vidéo originale (source audio)
"-c", "copy", # pas de ré-encodage
"-map", "0:v:0", # flux vidéo de la vidéo annotée
"-map", "1:a:0", # flux audio de l'originale
"-shortest", # aligne sur la durée la plus courte
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print(f" -> Video finale avec audio : {output_path}")
return True
else:
print(f" [ERREUR FFmpeg] {result.stderr[-300:]}")
return False
# Pipeline principal
def process_video(video_path, output_dir):
os.makedirs(output_dir, exist_ok=True)
ensure_model() # telecharge le modele si il n'est pas deja present
cap = cv2.VideoCapture(video_path) # ouvre le fichier video pour le lire image par image
if not cap.isOpened():
sys.exit(f"[ERREUR] Impossible d'ouvrir : {video_path}")
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 # nombre d'image par seconde
W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) # dimension de la video
H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # nombre total d'image dans la video
print(f"Video : {W}x{H} {fps:.1f} fps {total} frames")
base_opts = mp_python.BaseOptions(model_asset_path=MODEL_PATH) # charge MediaPipe et configure les options du modele
options = FaceLandmarkerOptions(
base_options=base_opts,
running_mode=mp_vision.RunningMode.VIDEO,
num_faces=1, # cherche qu'un seul visage
min_face_detection_confidence=0.5,
min_face_presence_confidence=0.5,
min_tracking_confidence=0.5,
output_face_blendshapes=False,
output_facial_transformation_matrixes=False,
)
landmarker = FaceLandmarker.create_from_options(options) # cree le detecteur de visage
# Fichier intermédiaire sans audio
silent_path = os.path.join(output_dir, "_annotated_silent.mp4")
final_path = os.path.join(output_dir, "output_annotated.mp4")
writer = cv2.VideoWriter(
silent_path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (W, H) # ouvre un fichier de sortie pour ecrire les frames annotées sans audio
)
records = [] # liste pour stocker les données extraites de chaque frame
frame_idx = 0 # index de la frame actuelle
while True:
ret, frame = cap.read() # lit la frame suivante de la video
if not ret:
break
ts_ms = int(frame_idx * 1000 / fps) # calcule le timestamp en millisecondes pour la frame actuelle
ts_s = frame_idx / fps # calcule le timestamp en secondes pour la frame actuelle
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb) # convertit la frame en format RGB et crée une image MediaPipe à partir de celle-ci
result = landmarker.detect_for_video(mp_image, ts_ms) # applique le modèle de détection de visage à l'image et obtient les résultats pour la frame actuelle
if result.face_landmarks: # si un visage est détecté, extraire les informations et les stocker dans un dictionnaire
lms = result.face_landmarks[0]
cx,cy,bw,bh,xmn,ymn,xmx,ymx = face_bbox(lms, W, H)
yaw,pitch,roll = head_pose(lms, W, H)
el = ear(lms, LEFT_EYE, W, H)
er = ear(lms, RIGHT_EYE, W, H)
m = mar(lms, MOUTH_OUTER, W, H)
sm = smile_index(lms, W, H)
rec = {
"frame":frame_idx, "timestamp_s":round(ts_s,4), "face_detected":True,
"face_cx":round(cx,4), "face_cy":round(cy,4),
"face_w":round(bw,4), "face_h":round(bh,4),
"bbox_xmin":round(xmn,4), "bbox_ymin":round(ymn,4),
"bbox_xmax":round(xmx,4), "bbox_ymax":round(ymx,4),
"yaw":round(yaw,2), "pitch":round(pitch,2), "roll":round(roll,2),
"ear_left":round(el,4), "ear_right":round(er,4),
"eye_left_open": bool(el >= EAR_THRESHOLD),
"eye_right_open": bool(er >= EAR_THRESHOLD),
"mar":round(m,4), "smile":round(sm,4),
}
records.append(rec)
frame = annotate_frame(frame, lms, rec, W, H)
else:
records.append({"frame":frame_idx,"timestamp_s":round(ts_s,4),"face_detected":False})
cv2.putText(frame,"Visage non detecte",(20,40),
cv2.FONT_HERSHEY_SIMPLEX,0.8,(0,0,255),2)
writer.write(frame)
frame_idx += 1
if frame_idx % 100 == 0:
pct = 100*frame_idx//total if total else 0
print(f" Frame {frame_idx}/{total} ({pct}%)")
cap.release(); writer.release(); landmarker.close() # libère les ressources utilisées pour la lecture de la vidéo, l'écriture de la vidéo annotée et le modèle de détection de visage
print(f" -> Video sans audio : {silent_path}")
# ── Recoller l'audio avec FFmpeg ──────────────
audio_ok = merge_audio(video_path, silent_path, final_path)
if audio_ok:
os.remove(silent_path) # supprime le fichier intermédiaire
else:
# FFmpeg absent : renommer le silencieux en sortie finale
os.replace(silent_path, final_path)
# ── CSV ──────────────────────────────────────
csv_path = os.path.join(output_dir, "signals.csv")
fields = ["frame","timestamp_s","face_detected","face_cx","face_cy","face_w","face_h",
"bbox_xmin","bbox_ymin","bbox_xmax","bbox_ymax",
"yaw","pitch","roll",
"ear_left","ear_right","eye_left_open","eye_right_open",
"mar","smile"]
with open(csv_path,"w",newline="",encoding="utf-8") as f:
wc = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
wc.writeheader(); wc.writerows(records)
print(f" -> CSV : {csv_path}")
print("\n Traitement termine.")
print(f" Fichiers dans : {os.path.abspath(output_dir)}/")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--video", required=True)
parser.add_argument("--output_dir", default="output")
args = parser.parse_args()
process_video(args.video, args.output_dir) |