YuAnTsai commited on
Commit
94446ed
·
verified ·
1 Parent(s): 7746ae9

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +112 -64
emotion.py CHANGED
@@ -1,6 +1,7 @@
1
  import time
 
2
  from dataclasses import dataclass, field
3
- from typing import Optional, Dict, Tuple, List
4
 
5
  import gradio as gr
6
  import numpy as np
@@ -17,16 +18,17 @@ from deepface import DeepFace
17
  # =========================
18
  # 參數(可微調)
19
  # =========================
20
- ANALYZE_EVERY_SEC = 0.8 # 情緒分析頻率(秒)
21
  EMA_ALPHA = 0.45
22
- CONF_TH = 40.0 # DeepFace emotion confidence 門檻
23
- SWITCH_CONFIRM_SEC = 2.5 # 連續穩定多久才算完成
24
- ZOOM_FACTOR = 1.5 # 只用在「顯示」(你要保留 zoom 畫面)
 
25
 
26
  # MediaPipe face detector 門檻(太高很容易漏)
27
  MP_MIN_DET_CONF = 0.3
28
 
29
- # 模型路徑(請確認你的 repo 真的有這個檔案)
30
  MP_MODEL_PATH = "blaze_face_short_range.tflite"
31
 
32
 
@@ -163,16 +165,10 @@ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
163
 
164
 
165
  # =========================
166
- # 影像處理(重點:保命轉換)
167
  # =========================
168
  def _to_rgb_uint8(frame: np.ndarray) -> np.ndarray:
169
- """
170
- MediaPipe Tasks 最穩格式:
171
- - RGB
172
- - uint8
173
- - 0~255
174
- - contiguous
175
- """
176
  if frame is None:
177
  return frame
178
 
@@ -197,7 +193,6 @@ def _to_rgb_uint8(frame: np.ndarray) -> np.ndarray:
197
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
198
  if frame is None or zoom <= 1.0:
199
  return frame
200
-
201
  h, w = frame.shape[:2]
202
  new_w = int(w / zoom)
203
  new_h = int(h / zoom)
@@ -206,6 +201,17 @@ def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
206
  cropped = frame[start_y : start_y + new_h, start_x : start_x + new_w]
207
  return np.ascontiguousarray(cropped)
208
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  # =========================
211
  # MediaPipe Tasks Face Detector
@@ -239,7 +245,7 @@ def mp_tasks_detect_and_draw(frame_rgb: np.ndarray):
239
  return frame_rgb, None
240
 
241
  best_det = None
242
- best_area = -1
243
  best_bbox = None
244
 
245
  # 最大臉:bbox 面積最大
@@ -280,6 +286,60 @@ def mp_tasks_detect_and_draw(frame_rgb: np.ndarray):
280
  return drawn, (lx, ly, bw, bh)
281
 
282
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  # =========================
284
  # state helper
285
  # =========================
@@ -338,7 +398,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
338
  gr.update(value="重新攝影機辨識"),
339
  )
340
 
341
- # 先整理格式(超重要:不然可能永遠偵測不到)
342
  frame_rgb = _to_rgb_uint8(frame_rgb)
343
 
344
  # ✅ 偵測用原圖(穩)
@@ -346,6 +406,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
346
 
347
  # ✅ 顯示用 zoom(你要保留 zoom 畫面)
348
  frame_show = _zoom_center(frame_drawn if bbox_px is not None else frame_rgb, ZOOM_FACTOR)
 
349
 
350
  # 沒有人臉:不畫框/點 + 提示
351
  if bbox_px is None:
@@ -360,44 +421,28 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
360
 
361
  now = time.time()
362
 
363
- # 還沒到分析時間:只更新畫面(框/點會一直在),不做情緒分析
364
- if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
365
- show = st.stable_emo or "unknown"
366
- return (
367
- gr.update(value=frame_show, visible=True),
368
- gr.update(value=_result_card_html(show, st.final_conf)),
369
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
370
- gr.update(value=_camera_hint_before_start()),
371
- st,
372
- gr.update(value="重新攝影機辨識"),
373
- )
374
-
375
- # 到分析時間:用「原圖 bbox」裁 ROI 做情緒辨識(不要用 zoom 後的圖裁)
376
- st.last_analyze_t = now
377
-
378
- x, y, bw, bh = bbox_px
379
- pad_x = int(bw * 0.10)
380
- pad_y = int(bh * 0.10)
381
 
382
- H, W = frame_rgb.shape[:2]
383
- x1 = max(0, x - pad_x)
384
- y1 = max(0, y - pad_y)
385
- x2 = min(W, x + bw + pad_x)
386
- y2 = min(H, y + bh + pad_y)
 
 
 
 
387
 
388
- face_roi = frame_rgb[y1:y2, x1:x2].astype(np.uint8)
389
 
390
- # DeepFace.analyze
391
- result = DeepFace.analyze(
392
- img_path=face_roi,
393
- actions=["emotion"],
394
- enforce_detection=False,
395
- )
396
- if isinstance(result, list):
397
- result = result[0]
398
 
399
- emo_dict = result.get("emotion", None)
400
- if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
401
  show = st.stable_emo or "unknown"
402
  return (
403
  gr.update(value=frame_show, visible=True),
@@ -408,9 +453,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
408
  gr.update(value="重新攝影機辨識"),
409
  )
410
 
411
- top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
412
- top_conf = float(emo_dict[top_key])
413
-
414
  if top_conf < CONF_TH:
415
  show = st.stable_emo or "unknown"
416
  return (
@@ -422,18 +465,23 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
422
  gr.update(value="重新攝影機辨識"),
423
  )
424
 
425
- # EMA smoothing
426
- keys = list(emo_dict.keys())
427
- vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
 
 
 
 
 
428
 
429
- if st.ema_scores is None or st.emo_keys != keys:
430
  st.ema_scores = vec
431
- st.emo_keys = keys
432
  else:
433
  st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
434
 
435
  stable_idx = int(np.argmax(st.ema_scores))
436
- stable_key = st.emo_keys[stable_idx]
437
  st.stable_emo = stable_key
438
 
439
  # 延遲確認,避免跳動
@@ -449,10 +497,10 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
449
  and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
450
  )
451
 
452
- # 完成一次辨識後停止(沿用你原本需求)
453
  if confirmed and st.final_emo is None:
454
  st.final_emo = stable_key
455
- st.final_conf = top_conf
456
  st.final_score = EMO_TO_SCORE.get(stable_key, 3)
457
 
458
  st.running = False
@@ -460,7 +508,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
460
 
461
  return (
462
  gr.update(value=None, visible=False),
463
- gr.update(value=_result_card_html(stable_key, top_conf)),
464
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
465
  gr.update(value=_camera_hint_done()),
466
  st,
@@ -469,7 +517,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
469
 
470
  return (
471
  gr.update(value=frame_show, visible=True),
472
- gr.update(value=_result_card_html(stable_key, top_conf)),
473
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
474
  gr.update(value=_camera_hint_before_start()),
475
  st,
 
1
  import time
2
+ import threading
3
  from dataclasses import dataclass, field
4
+ from typing import Optional, List, Tuple, Dict
5
 
6
  import gradio as gr
7
  import numpy as np
 
18
  # =========================
19
  # 參數(可微調)
20
  # =========================
21
+ ANALYZE_EVERY_SEC = 1.5 # ⭐ 建議 1.5~2.0:HF CPU 比較順
22
  EMA_ALPHA = 0.45
23
+ CONF_TH = 40.0 # DeepFace emotion confidence 門檻
24
+ SWITCH_CONFIRM_SEC = 2.5 # 連續穩定多久才算完成
25
+ ZOOM_FACTOR = 1.5 # ✅ 保留:只用在「顯示」
26
+ UI_TARGET_W = 720 # ✅ 回傳畫面縮小寬度,提升順暢度
27
 
28
  # MediaPipe face detector 門檻(太高很容易漏)
29
  MP_MIN_DET_CONF = 0.3
30
 
31
+ # 模型路徑(確認 repo 中模型真的在這裡)
32
  MP_MODEL_PATH = "blaze_face_short_range.tflite"
33
 
34
 
 
165
 
166
 
167
  # =========================
168
+ # 影像處理(保命 + 顯示優化)
169
  # =========================
170
  def _to_rgb_uint8(frame: np.ndarray) -> np.ndarray:
171
+ """統一成 MediaPipe Tasks 最穩格式:RGB、uint8、0~255、contiguous。"""
 
 
 
 
 
 
172
  if frame is None:
173
  return frame
174
 
 
193
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
194
  if frame is None or zoom <= 1.0:
195
  return frame
 
196
  h, w = frame.shape[:2]
197
  new_w = int(w / zoom)
198
  new_h = int(h / zoom)
 
201
  cropped = frame[start_y : start_y + new_h, start_x : start_x + new_w]
202
  return np.ascontiguousarray(cropped)
203
 
204
+ def _resize_for_ui(frame_rgb: np.ndarray, target_w: int = UI_TARGET_W) -> np.ndarray:
205
+ """回傳給 Gradio 的畫面縮到固定寬度,減少傳輸/渲染負擔。"""
206
+ if frame_rgb is None:
207
+ return frame_rgb
208
+ h, w = frame_rgb.shape[:2]
209
+ if w <= target_w:
210
+ return frame_rgb
211
+ scale = target_w / w
212
+ new_h = max(1, int(h * scale))
213
+ return cv2.resize(frame_rgb, (target_w, new_h), interpolation=cv2.INTER_AREA)
214
+
215
 
216
  # =========================
217
  # MediaPipe Tasks Face Detector
 
245
  return frame_rgb, None
246
 
247
  best_det = None
248
+ best_area = -1.0
249
  best_bbox = None
250
 
251
  # 最大臉:bbox 面積最大
 
286
  return drawn, (lx, ly, bw, bh)
287
 
288
 
289
+ # =========================
290
+ # 背景推論(避免卡頓)
291
+ # =========================
292
+ _ANALYZE_LOCK = threading.Lock()
293
+ _ANALYZE_THREAD: Optional[threading.Thread] = None
294
+ _PENDING_FACE: Optional[np.ndarray] = None
295
+ _LAST_ANALYZE: Dict[str, Optional[float]] = {"ts": 0.0, "conf": None, "emo": "unknown"}
296
+
297
+ def _analyze_worker():
298
+ """背景 thread:取最新 face_roi 做 DeepFace.analyze,更新 _LAST_ANALYZE。"""
299
+ global _PENDING_FACE, _ANALYZE_THREAD, _LAST_ANALYZE
300
+
301
+ while True:
302
+ with _ANALYZE_LOCK:
303
+ face = _PENDING_FACE
304
+ _PENDING_FACE = None
305
+
306
+ if face is None:
307
+ break
308
+
309
+ try:
310
+ r = DeepFace.analyze(
311
+ img_path=face,
312
+ actions=["emotion"],
313
+ enforce_detection=False,
314
+ detector_backend="skip", # ⭐ 關鍵:你已經裁好 ROI
315
+ )
316
+ if isinstance(r, list):
317
+ r = r[0]
318
+ emo_dict = r.get("emotion", None)
319
+
320
+ if isinstance(emo_dict, dict) and len(emo_dict) > 0:
321
+ top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
322
+ top_conf = float(emo_dict[top_key])
323
+ _LAST_ANALYZE = {"ts": time.time(), "emo": top_key, "conf": top_conf}
324
+ except Exception:
325
+ # 失敗就保持上一次結果
326
+ pass
327
+
328
+ _ANALYZE_THREAD = None
329
+
330
+
331
+ def _enqueue_analyze(face_roi: np.ndarray):
332
+ """把最新 ROI 丟給背景 thread,若 thread 沒在跑就啟動。"""
333
+ global _PENDING_FACE, _ANALYZE_THREAD
334
+
335
+ with _ANALYZE_LOCK:
336
+ _PENDING_FACE = face_roi
337
+
338
+ if _ANALYZE_THREAD is None:
339
+ _ANALYZE_THREAD = threading.Thread(target=_analyze_worker, daemon=True)
340
+ _ANALYZE_THREAD.start()
341
+
342
+
343
  # =========================
344
  # state helper
345
  # =========================
 
398
  gr.update(value="重新攝影機辨識"),
399
  )
400
 
401
+ # 統一格式
402
  frame_rgb = _to_rgb_uint8(frame_rgb)
403
 
404
  # ✅ 偵測用原圖(穩)
 
406
 
407
  # ✅ 顯示用 zoom(你要保留 zoom 畫面)
408
  frame_show = _zoom_center(frame_drawn if bbox_px is not None else frame_rgb, ZOOM_FACTOR)
409
+ frame_show = _resize_for_ui(frame_show, UI_TARGET_W)
410
 
411
  # 沒有人臉:不畫框/點 + 提示
412
  if bbox_px is None:
 
421
 
422
  now = time.time()
423
 
424
+ # 到分析時間:只排隊給背景 thread(不在 on_stream 裡跑重推論)
425
+ if now - st.last_analyze_t >= ANALYZE_EVERY_SEC:
426
+ st.last_analyze_t = now
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
 
428
+ x, y, bw, bh = bbox_px
429
+ pad_x = int(bw * 0.10)
430
+ pad_y = int(bh * 0.10)
431
+ H, W = frame_rgb.shape[:2]
432
+ x1 = max(0, x - pad_x)
433
+ y1 = max(0, y - pad_y)
434
+ x2 = min(W, x + bw + pad_x)
435
+ y2 = min(H, y + bh + pad_y)
436
+ face_roi = frame_rgb[y1:y2, x1:x2].astype(np.uint8)
437
 
438
+ _enqueue_analyze(face_roi)
439
 
440
+ # 取「最後一次背景推論結果」
441
+ top_key = _LAST_ANALYZE.get("emo", "unknown") or "unknown"
442
+ top_conf = _LAST_ANALYZE.get("conf", None)
 
 
 
 
 
443
 
444
+ # 還沒有推論到結果:先用目前穩定值顯示
445
+ if top_conf is None:
446
  show = st.stable_emo or "unknown"
447
  return (
448
  gr.update(value=frame_show, visible=True),
 
453
  gr.update(value="重新攝影機辨識"),
454
  )
455
 
456
+ # 低信心就不切換,顯示目前穩定值
 
 
457
  if top_conf < CONF_TH:
458
  show = st.stable_emo or "unknown"
459
  return (
 
465
  gr.update(value="重新攝影機辨識"),
466
  )
467
 
468
+ # === EMA smoothing ===
469
+ # DeepFace emotion dict 的 keys 順序可能固定,但我們這裡用單一 top_conf/top_key 也能做穩定
470
+ # 仍然保留你原本的「看整個分佈」的 smoothing:用 top_key/top_conf 建一個簡化 dict
471
+ # 為了兼容你原本寫法,我們用 7 類別做一個向量(缺的填 0)
472
+ all_keys = ["angry", "disgust", "fear", "happy", "sad", "surprise", "neutral"]
473
+ vec = np.zeros((len(all_keys),), dtype=np.float32)
474
+ if top_key in all_keys:
475
+ vec[all_keys.index(top_key)] = float(top_conf)
476
 
477
+ if st.ema_scores is None or st.emo_keys != all_keys:
478
  st.ema_scores = vec
479
+ st.emo_keys = all_keys
480
  else:
481
  st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
482
 
483
  stable_idx = int(np.argmax(st.ema_scores))
484
+ stable_key = st.emo_keys[stable_idx] if st.emo_keys else "unknown"
485
  st.stable_emo = stable_key
486
 
487
  # 延遲確認,避免跳動
 
497
  and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
498
  )
499
 
500
+ # ✅ 保留你原本「辨識完成後自動跳轉/停止」邏輯
501
  if confirmed and st.final_emo is None:
502
  st.final_emo = stable_key
503
+ st.final_conf = float(top_conf) if top_conf is not None else None
504
  st.final_score = EMO_TO_SCORE.get(stable_key, 3)
505
 
506
  st.running = False
 
508
 
509
  return (
510
  gr.update(value=None, visible=False),
511
+ gr.update(value=_result_card_html(stable_key, st.final_conf)),
512
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
513
  gr.update(value=_camera_hint_done()),
514
  st,
 
517
 
518
  return (
519
  gr.update(value=frame_show, visible=True),
520
+ gr.update(value=_result_card_html(stable_key, float(top_conf))),
521
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
522
  gr.update(value=_camera_hint_before_start()),
523
  st,