Celianv2 commited on
Commit
8a4a092
Β·
1 Parent(s): b1d4989

face detection

Browse files
Files changed (1) hide show
  1. jericho/main.py +332 -131
jericho/main.py CHANGED
@@ -1,135 +1,336 @@
1
- import time
2
- import threading
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import numpy as np
4
-
5
- from reachy_mini import ReachyMini, ReachyMiniApp
6
- from reachy_mini.utils import create_head_pose
7
-
8
-
9
- class TestDance1(ReachyMiniApp):
10
-
11
- def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
12
-
13
- reachy_mini.goto_target(create_head_pose(), antennas=[0.0, 0.0], duration=1.0)
14
-
15
- start_time = time.time()
16
-
17
- while not stop_event.is_set():
18
- t = time.time()
19
- elapsed_time = t - start_time
20
-
21
- compteur = int(elapsed_time / 2)
22
-
23
- antennas_offset = np.deg2rad(20 * np.sin(2 * np.pi * 0.5 * t))
24
-
25
- sinus_rot = np.deg2rad(20 * np.sin(2 * np.pi * 0.5 * t))
26
- sinus_trans = 0.03 * np.sin(2 * np.pi * 0.5 * t)
27
-
28
- current_roll = 0.0
29
- current_pitch = 0.0
30
- current_yaw = 0.0
31
- current_x = 0.0
32
- current_y = 0.0
33
- current_z = 0.0
34
-
35
- if compteur < 3:
36
- current_roll = sinus_rot
37
- elif compteur < 6:
38
- current_pitch = sinus_rot
39
- elif compteur < 9:
40
- current_yaw = sinus_rot
41
- elif compteur < 12:
42
- current_x = sinus_trans
43
- elif compteur < 15:
44
- current_y = sinus_trans
45
- elif compteur < 18:
46
- current_z = sinus_trans
47
- else:
48
- start_time = time.time()
49
- continue
50
-
51
- head_pose = create_head_pose(
52
- x=current_x,
53
- y=current_y,
54
- z=current_z,
55
- roll=current_roll,
56
- pitch=current_pitch,
57
- yaw=current_yaw,
58
- degrees=False,
59
- mm=False,
60
- )
61
-
62
- reachy_mini.set_target(head=head_pose, antennas=[antennas_offset, antennas_offset])
63
-
64
- time.sleep(0.01)
65
-
66
-
67
- class TestDance2(ReachyMiniApp):
68
-
69
- def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
70
-
71
- reachy_mini.goto_target(create_head_pose(), antennas=[0.0, 0.0], duration=1.0)
72
-
73
- start_time = time.time()
74
-
75
- while not stop_event.is_set():
76
- t = time.time()
77
- elapsed_time = t - start_time
78
-
79
- compteur = int(elapsed_time / 2)
80
-
81
- antennas_offset = np.deg2rad(20 * np.sin(2 * np.pi * 0.5 * t))
82
-
83
- sinus_rot = np.deg2rad(20 * np.sin(2 * np.pi * 0.5 * 4 * t))
84
- sinus_trans = 0.03 * np.sin(2 * np.pi * 0.5 * t)
85
- cosinus_trans = 0.03 * np.cos(2 * np.pi * 0.5 * t)
86
-
87
- current_roll = 0.0
88
- current_pitch = 0.0
89
- current_yaw = 0.0
90
- current_x = 0.0
91
- current_y = 0.0
92
- current_z = 0.0
93
-
94
- if compteur < 3:
95
- current_pitch = sinus_rot
96
- current_y = sinus_trans
97
- elif compteur < 6:
98
- current_x = sinus_trans
99
- current_y = cosinus_trans
100
- else:
101
- start_time = time.time()
102
- continue
103
-
104
- head_pose = create_head_pose(
105
- x=current_x,
106
- y=current_y,
107
- z=current_z,
108
- roll=current_roll,
109
- pitch=current_pitch,
110
- yaw=current_yaw,
111
- degrees=False,
112
- mm=False,
113
- )
114
-
115
- reachy_mini.set_target(head=head_pose, antennas=[antennas_offset, antennas_offset])
116
-
117
- time.sleep(0.01)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
 
120
  if __name__ == "__main__":
121
- print("Lancement manuel depuis le terminal...")
122
-
123
- with ReachyMini(media_backend="no_media") as robot:
124
-
125
- # πŸ‘‰ Choisis ici ta danse
126
- # mon_app = TestDance1()
127
- mon_app = TestDance2()
128
-
129
- stop_event = threading.Event()
130
-
131
- try:
132
- mon_app.run(robot, stop_event)
133
- except KeyboardInterrupt:
134
- print("\nArrΓͺt du programme")
135
- stop_event.set()
 
1
+ """
2
+ face_analysis.py – compatible MediaPipe 0.10+ (nouvelle API Tasks)
3
+ Audio preservΓ© via FFmpeg en fin de traitement.
4
+ ======================================================================
5
+ Usage :
6
+ python face_analysis.py --video chemin/video.mp4
7
+ python face_analysis.py --video chemin/video.mp4 --output_dir resultats/
8
+ """
9
+
10
+ import argparse
11
+ import csv
12
+ import json
13
+ import math
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import urllib.request
18
+
19
+ import cv2
20
+ import mediapipe as mp
21
  import numpy as np
22
+ import matplotlib
23
+ matplotlib.use("Agg")
24
+ import matplotlib.pyplot as plt
25
+
26
+ from mediapipe.tasks import python as mp_python
27
+ from mediapipe.tasks.python import vision as mp_vision
28
+ from mediapipe.tasks.python.vision import FaceLandmarkerOptions, FaceLandmarker
29
+
30
+ # ──────────────────────────────────────────────
31
+ # Modèle MediaPipe
32
+ # ──────────────────────────────────────────────
33
+ MODEL_URL = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task"
34
+ MODEL_PATH = "face_landmarker.task"
35
+
36
+ def ensure_model():
37
+ if not os.path.exists(MODEL_PATH):
38
+ print(f"Téléchargement du modèle MediaPipe -> {MODEL_PATH} ...")
39
+ urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)
40
+ print(" Modele telecharge.")
41
+
42
+ # ───────────────────���──────────────────────────
43
+ # Indices landmarks
44
+ # ──────────────────────────────────────────────
45
+ LEFT_EYE = [362, 385, 387, 263, 373, 380]
46
+ RIGHT_EYE = [33, 160, 158, 133, 153, 144]
47
+ MOUTH_OUTER = [61, 291, 0, 17, 37, 267]
48
+ MOUTH_L, MOUTH_R = 61, 291
49
+ LEFT_PUP, RIGHT_PUP = 468, 473
50
+
51
+ EAR_THRESHOLD = 0.20 # en dessous de ce seuil = Ε“il considΓ©rΓ© fermΓ©
52
+
53
+ HEAD_POSE_IDX = [1, 152, 226, 446, 57, 287]
54
+ MODEL_3D = np.array([
55
+ [ 0.0, 0.0, 0.0 ],
56
+ [ 0.0, -63.6, -12.5],
57
+ [-43.3, 32.7, -26.0],
58
+ [ 43.3, 32.7, -26.0],
59
+ [-28.9, -28.9, -24.1],
60
+ [ 28.9, -28.9, -24.1],
61
+ ], dtype=np.float64)
62
+
63
+ # ──────────────────────────────────────────────
64
+ # Calculs
65
+ # ──────────────────────────────────────────────
66
+
67
+ def _pts(lms, indices, w, h):
68
+ return np.array([[lms[i].x * w, lms[i].y * h] for i in indices])
69
+
70
+ def ear(lms, indices, w, h):
71
+ p = _pts(lms, indices, w, h)
72
+ A = np.linalg.norm(p[1]-p[5]); B = np.linalg.norm(p[2]-p[4])
73
+ return (A+B) / (2*np.linalg.norm(p[0]-p[3])+1e-6)
74
+
75
+ def mar(lms, indices, w, h):
76
+ p = _pts(lms, indices, w, h)
77
+ A = np.linalg.norm(p[1]-p[5]); B = np.linalg.norm(p[2]-p[4])
78
+ return (A+B) / (2*np.linalg.norm(p[0]-p[3])+1e-6)
79
+
80
+ def smile_index(lms, w, h):
81
+ ml = np.array([lms[MOUTH_L].x*w, lms[MOUTH_L].y*h])
82
+ mr = np.array([lms[MOUTH_R].x*w, lms[MOUTH_R].y*h])
83
+ pl = np.array([lms[LEFT_PUP].x*w, lms[LEFT_PUP].y*h])
84
+ pr = np.array([lms[RIGHT_PUP].x*w, lms[RIGHT_PUP].y*h])
85
+ return np.linalg.norm(mr-ml) / (np.linalg.norm(pr-pl)+1e-6)
86
+
87
+ def head_pose(lms, w, h):
88
+ img_pts = np.array([[lms[i].x*w, lms[i].y*h] for i in HEAD_POSE_IDX], dtype=np.float64)
89
+ cam = np.array([[w,0,w/2],[0,w,h/2],[0,0,1]], dtype=np.float64)
90
+ dist = np.zeros((4,1))
91
+ ok, rvec, _ = cv2.solvePnP(MODEL_3D, img_pts, cam, dist, flags=cv2.SOLVEPNP_ITERATIVE)
92
+ if not ok:
93
+ return 0.0, 0.0, 0.0
94
+ R, _ = cv2.Rodrigues(rvec)
95
+ sy = math.sqrt(R[0,0]**2 + R[1,0]**2)
96
+ if sy > 1e-6:
97
+ roll = math.degrees(math.atan2( R[2,1], R[2,2]))
98
+ pitch = math.degrees(math.atan2(-R[2,0], sy))
99
+ yaw = math.degrees(math.atan2( R[1,0], R[0,0]))
100
+ else:
101
+ roll = math.degrees(math.atan2(-R[1,2], R[1,1]))
102
+ pitch = math.degrees(math.atan2(-R[2,0], sy))
103
+ yaw = 0.0
104
+ return yaw, pitch, roll
105
+
106
+ def face_bbox(lms, w, h):
107
+ xs = [lm.x for lm in lms]; ys = [lm.y for lm in lms]
108
+ xmn,xmx,ymn,ymx = min(xs),max(xs),min(ys),max(ys)
109
+ return (xmn+xmx)/2,(ymn+ymx)/2,xmx-xmn,ymx-ymn,xmn,ymn,xmx,ymx
110
+
111
+ # ──────────────────────────────────────────────
112
+ # Annotation
113
+ # ──────────────────────────────────────────────
114
+
115
+ def annotate_frame(frame, lms, sig, w, h):
116
+ x1,y1 = int(sig["bbox_xmin"]*w), int(sig["bbox_ymin"]*h)
117
+ x2,y2 = int(sig["bbox_xmax"]*w), int(sig["bbox_ymax"]*h)
118
+ cv2.rectangle(frame,(x1,y1),(x2,y2),(0,255,0),2)
119
+ for idx in LEFT_EYE+RIGHT_EYE+MOUTH_OUTER:
120
+ cv2.circle(frame,(int(lms[idx].x*w),int(lms[idx].y*h)),2,(255,0,255),-1)
121
+ for idx in [LEFT_PUP, RIGHT_PUP]:
122
+ cv2.circle(frame,(int(lms[idx].x*w),int(lms[idx].y*h)),3,(0,200,255),-1)
123
+ lines = [
124
+ f"Yaw:{sig['yaw']:+.1f} Pitch:{sig['pitch']:+.1f} Roll:{sig['roll']:+.1f}",
125
+ f"EAR L:{sig['ear_left']:.3f} R:{sig['ear_right']:.3f}",
126
+ f"MAR:{sig['mar']:.3f} Smile:{sig['smile']:.3f}",
127
+ f"T:{sig['timestamp_s']:.2f}s",
128
+ ]
129
+ for i, line in enumerate(lines):
130
+ cv2.putText(frame, line, (x1, max(y1-10-i*18, 14)),
131
+ cv2.FONT_HERSHEY_SIMPLEX, 0.48, (0,255,0), 1, cv2.LINE_AA)
132
+ nose = (int(lms[1].x*w), int(lms[1].y*h))
133
+ tip = (int(nose[0]+60*math.sin(math.radians(sig["yaw"]))),
134
+ int(nose[1]-60*math.sin(math.radians(sig["pitch"]))))
135
+ cv2.arrowedLine(frame, nose, tip, (0,100,255), 2, tipLength=0.3)
136
+ return frame
137
+
138
+ # ──────────────────────────────────────────────
139
+ # TracΓ©s temporels
140
+ # ──────────────────────────────────────────────
141
+
142
+ def make_plots(records, path):
143
+ ts = [r["timestamp_s"] for r in records]
144
+ fig, axes = plt.subplots(4,1, figsize=(14,12), sharex=True)
145
+ fig.suptitle("Signaux faciaux extraits", fontsize=14, fontweight="bold")
146
+ axes[0].plot(ts,[r["yaw"] for r in records],label="Yaw")
147
+ axes[0].plot(ts,[r["pitch"] for r in records],label="Pitch")
148
+ axes[0].plot(ts,[r["roll"] for r in records],label="Roll")
149
+ axes[0].set_ylabel("Orientation (deg)"); axes[0].legend(); axes[0].grid(alpha=.3)
150
+ axes[1].plot(ts,[r["ear_left"] for r in records],label="EAR gauche")
151
+ axes[1].plot(ts,[r["ear_right"] for r in records],label="EAR droite")
152
+ axes[1].axhline(0.20,ls="--",color="gray",label="seuil ~0.20")
153
+ axes[1].set_ylabel("EAR"); axes[1].legend(); axes[1].grid(alpha=.3)
154
+ axes[2].plot(ts,[r["mar"] for r in records],label="MAR bouche")
155
+ axes[2].plot(ts,[r["smile"] for r in records],label="Sourire")
156
+ axes[2].set_ylabel("MAR / Sourire"); axes[2].legend(); axes[2].grid(alpha=.3)
157
+ axes[3].plot(ts,[r["face_cx"] for r in records],label="Centre X")
158
+ axes[3].plot(ts,[r["face_cy"] for r in records],label="Centre Y")
159
+ axes[3].set_ylabel("Position (norm.)"); axes[3].set_xlabel("Temps (s)")
160
+ axes[3].legend(); axes[3].grid(alpha=.3)
161
+ plt.tight_layout(); plt.savefig(path, dpi=130); plt.close(fig)
162
+ print(f" -> Traces : {path}")
163
+
164
+ # ──────────────────────────────────────────────
165
+ # Fusion audio via FFmpeg
166
+ # ──────────────────────────────────────────────
167
+
168
+ def merge_audio(original_video, silent_video, output_path):
169
+ """
170
+ Copie la piste audio de original_video dans silent_video.
171
+ NΓ©cessite que ffmpeg soit dans le PATH.
172
+ Produit output_path.
173
+ """
174
+ # VΓ©rifie si ffmpeg est disponible
175
+ try:
176
+ subprocess.run(
177
+ ["ffmpeg", "-version"],
178
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True
179
+ )
180
+ except (subprocess.CalledProcessError, FileNotFoundError):
181
+ print("\n [AVERTISSEMENT] ffmpeg introuvable dans le PATH.")
182
+ print(" -> L'audio n'a pas ete recollΓ©.")
183
+ print(" Installez ffmpeg puis lancez manuellement :")
184
+ print(f' ffmpeg -i "{silent_video}" -i "{original_video}" '
185
+ f'-c copy -map 0:v:0 -map 1:a:0 -shortest "{output_path}"')
186
+ return False
187
+
188
+ cmd = [
189
+ "ffmpeg", "-y",
190
+ "-i", silent_video, # vidΓ©o annotΓ©e (sans audio)
191
+ "-i", original_video, # vidΓ©o originale (source audio)
192
+ "-c", "copy", # pas de rΓ©-encodage
193
+ "-map", "0:v:0", # flux vidΓ©o de la vidΓ©o annotΓ©e
194
+ "-map", "1:a:0", # flux audio de l'originale
195
+ "-shortest", # aligne sur la durΓ©e la plus courte
196
+ output_path
197
+ ]
198
+ result = subprocess.run(cmd, capture_output=True, text=True)
199
+ if result.returncode == 0:
200
+ print(f" -> Video finale avec audio : {output_path}")
201
+ return True
202
+ else:
203
+ print(f" [ERREUR FFmpeg] {result.stderr[-300:]}")
204
+ return False
205
+
206
+ # ──────────────────────────────────────────────
207
+ # Pipeline principal
208
+ # ──────────────────────────────────────────────
209
+
210
+ def process_video(video_path, output_dir):
211
+ os.makedirs(output_dir, exist_ok=True)
212
+ ensure_model()
213
+
214
+ cap = cv2.VideoCapture(video_path)
215
+ if not cap.isOpened():
216
+ sys.exit(f"[ERREUR] Impossible d'ouvrir : {video_path}")
217
+ fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
218
+ W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
219
+ H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
220
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
221
+ print(f"Video : {W}x{H} {fps:.1f} fps {total} frames")
222
+
223
+ base_opts = mp_python.BaseOptions(model_asset_path=MODEL_PATH)
224
+ options = FaceLandmarkerOptions(
225
+ base_options=base_opts,
226
+ running_mode=mp_vision.RunningMode.VIDEO,
227
+ num_faces=1,
228
+ min_face_detection_confidence=0.5,
229
+ min_face_presence_confidence=0.5,
230
+ min_tracking_confidence=0.5,
231
+ output_face_blendshapes=False,
232
+ output_facial_transformation_matrixes=False,
233
+ )
234
+ landmarker = FaceLandmarker.create_from_options(options)
235
+
236
+ # Fichier intermΓ©diaire sans audio
237
+ silent_path = os.path.join(output_dir, "_annotated_silent.mp4")
238
+ final_path = os.path.join(output_dir, "output_annotated.mp4")
239
+
240
+ writer = cv2.VideoWriter(
241
+ silent_path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (W, H)
242
+ )
243
+
244
+ records = []
245
+ frame_idx = 0
246
+
247
+ while True:
248
+ ret, frame = cap.read()
249
+ if not ret:
250
+ break
251
+
252
+ ts_ms = int(frame_idx * 1000 / fps)
253
+ ts_s = frame_idx / fps
254
+
255
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
256
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
257
+ result = landmarker.detect_for_video(mp_image, ts_ms)
258
+
259
+ if result.face_landmarks:
260
+ lms = result.face_landmarks[0]
261
+ cx,cy,bw,bh,xmn,ymn,xmx,ymx = face_bbox(lms, W, H)
262
+ yaw,pitch,roll = head_pose(lms, W, H)
263
+ el = ear(lms, LEFT_EYE, W, H)
264
+ er = ear(lms, RIGHT_EYE, W, H)
265
+ m = mar(lms, MOUTH_OUTER, W, H)
266
+ sm = smile_index(lms, W, H)
267
+
268
+ rec = {
269
+ "frame":frame_idx, "timestamp_s":round(ts_s,4), "face_detected":True,
270
+ "face_cx":round(cx,4), "face_cy":round(cy,4),
271
+ "face_w":round(bw,4), "face_h":round(bh,4),
272
+ "bbox_xmin":round(xmn,4), "bbox_ymin":round(ymn,4),
273
+ "bbox_xmax":round(xmx,4), "bbox_ymax":round(ymx,4),
274
+ "yaw":round(yaw,2), "pitch":round(pitch,2), "roll":round(roll,2),
275
+ "ear_left":round(el,4), "ear_right":round(er,4),
276
+ "eye_left_open": bool(el >= EAR_THRESHOLD),
277
+ "eye_right_open": bool(er >= EAR_THRESHOLD),
278
+ "mar":round(m,4), "smile":round(sm,4),
279
+ }
280
+ records.append(rec)
281
+ frame = annotate_frame(frame, lms, rec, W, H)
282
+ else:
283
+ records.append({"frame":frame_idx,"timestamp_s":round(ts_s,4),"face_detected":False})
284
+ cv2.putText(frame,"Visage non detecte",(20,40),
285
+ cv2.FONT_HERSHEY_SIMPLEX,0.8,(0,0,255),2)
286
+
287
+ writer.write(frame)
288
+ frame_idx += 1
289
+ if frame_idx % 100 == 0:
290
+ pct = 100*frame_idx//total if total else 0
291
+ print(f" Frame {frame_idx}/{total} ({pct}%)")
292
+
293
+ cap.release(); writer.release(); landmarker.close()
294
+ print(f" -> Video sans audio : {silent_path}")
295
+
296
+ # ── Recoller l'audio avec FFmpeg ──────────────
297
+ audio_ok = merge_audio(video_path, silent_path, final_path)
298
+ if audio_ok:
299
+ os.remove(silent_path) # supprime le fichier intermΓ©diaire
300
+ else:
301
+ # FFmpeg absent : renommer le silencieux en sortie finale
302
+ os.replace(silent_path, final_path)
303
+
304
+ # ── CSV ──────────────────────────────────────
305
+ csv_path = os.path.join(output_dir, "signals.csv")
306
+ fields = ["frame","timestamp_s","face_detected","face_cx","face_cy","face_w","face_h",
307
+ "bbox_xmin","bbox_ymin","bbox_xmax","bbox_ymax",
308
+ "yaw","pitch","roll",
309
+ "ear_left","ear_right","eye_left_open","eye_right_open",
310
+ "mar","smile"]
311
+ with open(csv_path,"w",newline="",encoding="utf-8") as f:
312
+ wc = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
313
+ wc.writeheader(); wc.writerows(records)
314
+ print(f" -> CSV : {csv_path}")
315
+
316
+ # ── JSON ─────────────────────────────────────
317
+ json_path = os.path.join(output_dir, "signals.json")
318
+ with open(json_path,"w",encoding="utf-8") as f:
319
+ json.dump(records, f, indent=2, ensure_ascii=False)
320
+ print(f" -> JSON : {json_path}")
321
+
322
+ # ── TracΓ©s ───────────────────────────────────
323
+ detected = [r for r in records if r.get("face_detected")]
324
+ if detected:
325
+ make_plots(detected, os.path.join(output_dir, "plots.png"))
326
+
327
+ print("\n Traitement termine.")
328
+ print(f" Fichiers dans : {os.path.abspath(output_dir)}/")
329
 
330
 
331
  if __name__ == "__main__":
332
+ parser = argparse.ArgumentParser()
333
+ parser.add_argument("--video", required=True)
334
+ parser.add_argument("--output_dir", default="output")
335
+ args = parser.parse_args()
336
+ process_video(args.video, args.output_dir)