YuAnTsai commited on
Commit
bedd50b
·
verified ·
1 Parent(s): e2069e1

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +332 -306
emotion.py CHANGED
@@ -1,39 +1,77 @@
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
8
- from PIL import Image
9
-
10
- import cv2
11
- import mediapipe as mp
12
- from mediapipe.tasks import python
13
- from mediapipe.tasks.python import vision
14
 
15
  from deepface import DeepFace
16
 
17
 
 
 
 
 
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
 
35
  # =========================
36
- # 情緒中文 + 分數映射(7 -> 5)
37
  # =========================
38
  EMO_ZH = {
39
  "angry": "生氣",
@@ -72,26 +110,26 @@ SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
72
  # =========================
73
  @dataclass
74
  class AppState:
75
- mode: str = "攝影機辨識"
76
-
77
  running: bool = True
78
  finished: bool = False
79
  last_analyze_t: float = 0.0
80
 
81
- # smoothing
 
82
  ema_scores: Optional[np.ndarray] = None
83
  emo_keys: List[str] = field(default_factory=list)
84
 
85
- # stable/candidate
86
  stable_emo: str = "unknown"
87
  candidate_emo: Optional[str] = None
88
  candidate_since: Optional[float] = None
89
 
90
- # final
91
  final_emo: Optional[str] = None
92
  final_conf: Optional[float] = None
93
  final_score: int = 3
94
 
 
 
 
95
 
96
  # =========================
97
  # UI HTML
@@ -109,86 +147,46 @@ def _camera_hint_before_start() -> str:
109
  "接著,請按「錄製(Record)」開始。"
110
  )
111
 
112
- def _camera_hint_need_face() -> str:
113
- return _hint_html(
114
- "⚠️ 目前未偵測到人臉,請將臉部移至畫面中央、靠近鏡頭,並避免瀏海遮住眉眼,才可進行情緒辨識。"
115
- )
116
-
117
  def _camera_hint_done() -> str:
118
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
119
 
120
  def _camera_hint_stopped() -> str:
121
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
122
 
123
- def _score_radio_value(score: int) -> str:
124
- score = int(score)
125
- score = min(5, max(1, score))
126
- return SCORE_LABELS[score]
127
-
128
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
129
  zh = EMO_ZH.get(emo_key, emo_key)
130
  score = EMO_TO_SCORE.get(emo_key, 3)
131
  conf_txt = "" if conf is None else f"{conf:.1f}%"
132
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
133
-
134
  return f"""
135
- <div style="
136
- border-radius:16px;
137
- padding:20px;
138
- border:1px solid #E6E6E6;
139
- background:#FFFFFF;
140
- color:#000000;
141
- ">
142
- <div style="font-size:14px;color:#000000;margin-bottom:8px;">
143
- 辨識結果
144
- </div>
145
-
146
- <div style="
147
- font-size:42px;
148
- font-weight:800;
149
- line-height:1.1;
150
- margin-bottom:14px;
151
- color:#000000;
152
- ">
153
- {zh}
154
- </div>
155
-
156
- <div style="font-size:16px;color:#000000;margin-bottom:6px;">
157
- 心情���數:{score}({score_desc})
158
- </div>
159
-
160
- <div style="font-size:14px;color:#000000;">
161
- 信心值:{conf_txt}
162
- </div>
163
  </div>
164
  """
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
-
175
- # RGBA -> RGB
176
- if frame.ndim == 3 and frame.shape[2] == 4:
177
- frame = frame[:, :, :3]
178
-
179
- # 灰階 -> RGB
180
- if frame.ndim == 2:
181
- frame = np.stack([frame, frame, frame], axis=-1)
182
-
183
- # dtype / range 修正
184
- if frame.dtype != np.uint8:
185
- mx = float(np.max(frame)) if frame.size else 0.0
186
- if mx <= 1.5: # 多半是 0~1
187
- frame = (frame * 255.0).clip(0, 255).astype(np.uint8)
188
- else: # 多半是 0~255 但 float
189
- frame = np.clip(frame, 0, 255).astype(np.uint8)
190
-
191
- return np.ascontiguousarray(frame)
192
 
193
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
194
  if frame is None or zoom <= 1.0:
@@ -198,199 +196,166 @@ def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
198
  new_h = int(h / zoom)
199
  start_x = (w - new_w) // 2
200
  start_y = (h - new_h) // 2
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
218
  # =========================
219
- BaseOptions = python.BaseOptions
220
- FaceDetector = vision.FaceDetector
221
- FaceDetectorOptions = vision.FaceDetectorOptions
222
- VisionRunningMode = vision.RunningMode
223
-
224
- _face_detector = FaceDetector.create_from_options(
225
- FaceDetectorOptions(
226
- base_options=BaseOptions(model_asset_path=MP_MODEL_PATH),
227
- running_mode=VisionRunningMode.IMAGE,
228
- min_detection_confidence=MP_MIN_DET_CONF,
229
- )
230
- )
231
 
232
- def mp_tasks_detect_and_draw(frame_rgb: np.ndarray):
233
- """
234
- - 最大臉策略(bbox 面積最大)
235
- - 有臉:畫白框 + 紅點 keypoints
236
- - 回 bbox_px (x, y, w, h) 供 ROI 裁切
237
- """
238
- frame_rgb = _to_rgb_uint8(frame_rgb)
239
- h, w = frame_rgb.shape[:2]
240
-
241
- mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=frame_rgb)
242
- result = _face_detector.detect(mp_image)
243
-
244
- if not result.detections:
245
- return frame_rgb, None
246
-
247
- best_det = None
248
- best_area = -1.0
249
- best_bbox = None
250
-
251
- # 最大臉:bbox 面積最大
252
- for det in result.detections:
253
- bbox = det.bounding_box
254
- area = float(bbox.width) * float(bbox.height)
255
- if area > best_area:
256
- best_area = area
257
- best_det = det
258
- best_bbox = bbox
259
-
260
- if best_det is None or best_bbox is None:
261
- return frame_rgb, None
262
-
263
- lx = int(best_bbox.origin_x)
264
- ly = int(best_bbox.origin_y)
265
- bw = int(best_bbox.width)
266
- bh = int(best_bbox.height)
267
-
268
- # clamp
269
- lx = max(0, min(w - 1, lx))
270
- ly = max(0, min(h - 1, ly))
271
- bw = max(1, min(w - lx, bw))
272
- bh = max(1, min(h - ly, bh))
273
-
274
- bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
275
 
276
- # 白框
277
- cv2.rectangle(bgr, (lx, ly), (lx + bw, ly + bh), (255, 255, 255), 3)
 
 
278
 
279
- # 紅點:五官特徵點(相對座標)
280
- for kp in best_det.keypoints:
281
- cx = int(kp.x * w)
282
- cy = int(kp.y * h)
283
- cv2.circle(bgr, (cx, cy), 6, (0, 0, 255), -1)
284
 
285
- drawn = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
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
- # =========================
346
  def _reset_state_for_camera(st: AppState) -> AppState:
347
  st.running = True
348
  st.finished = False
349
  st.last_analyze_t = 0.0
350
-
351
  st.ema_scores = None
352
  st.emo_keys = []
353
-
354
  st.stable_emo = "unknown"
355
  st.candidate_emo = None
356
  st.candidate_since = None
357
-
358
  st.final_emo = None
359
  st.final_conf = None
360
  st.final_score = 3
 
 
361
  return st
362
 
363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  # =========================
365
- # streaming callback
366
  # =========================
367
  def on_stream(frame_rgb: np.ndarray, st: AppState):
368
- # stopped / finished
369
  if st.finished or (not st.running):
370
  cam_visible = False
371
-
372
  if st.final_emo:
373
- result_html = _result_card_html(st.final_emo, st.final_conf)
374
- mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
375
- hint_html = _camera_hint_done()
376
- else:
377
- result_html = _hint_html("尚未完成辨識。")
378
- mood_val = _score_radio_value(3)
379
- hint_html = _camera_hint_stopped()
380
-
381
  return (
382
- gr.update(value=None, visible=cam_visible),
383
- gr.update(value=result_html),
384
- gr.update(value=mood_val),
385
- gr.update(value=hint_html),
386
  st,
387
  gr.update(value="重新攝影機辨識"),
388
  )
389
 
390
- # no frame yet
391
  if frame_rgb is None:
392
  return (
393
- gr.update(value=None, visible=True),
394
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
395
  gr.update(value=_score_radio_value(3)),
396
  gr.update(value=_camera_hint_before_start()),
@@ -398,93 +363,159 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
398
  gr.update(value="重新攝影機辨識"),
399
  )
400
 
401
- # 統一格式
402
- frame_rgb = _to_rgb_uint8(frame_rgb)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
 
404
- # ✅ 偵測用原圖(穩)
405
- frame_drawn, bbox_px = mp_tasks_detect_and_draw(frame_rgb)
 
 
 
 
 
 
 
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:
 
 
 
 
413
  return (
414
- gr.update(value=frame_show, visible=True),
415
- gr.update(value=_hint_html("尚未偵測到人臉")),
416
  gr.update(value=_score_radio_value(3)),
417
- gr.update(value=_camera_hint_need_face()),
418
  st,
419
  gr.update(value="重新攝影機辨識"),
420
  )
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),
449
- gr.update(value=_result_card_html(show, st.final_conf)),
450
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
451
  gr.update(value=_camera_hint_before_start()),
452
  st,
453
  gr.update(value="重新攝影機辨識"),
454
  )
455
 
456
- # 低信心就不切換,顯示目前穩定值
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  if top_conf < CONF_TH:
458
- show = st.stable_emo or "unknown"
459
  return (
460
- gr.update(value=frame_show, visible=True),
461
- gr.update(value=_result_card_html(show, st.final_conf)),
462
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
463
  gr.update(value=_camera_hint_before_start()),
464
  st,
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
- # 延遲確認,避免跳動
488
  if st.candidate_emo != stable_key:
489
  st.candidate_emo = stable_key
490
  st.candidate_since = now
@@ -497,27 +528,25 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
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
507
  st.finished = True
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,
515
  gr.update(value="重新攝影機辨識"),
516
  )
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,
@@ -531,7 +560,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
531
  def on_restart(st: AppState):
532
  st = _reset_state_for_camera(st)
533
  return (
534
- gr.update(value=None, visible=True),
535
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
536
  gr.update(value=_score_radio_value(3)),
537
  gr.update(value=_camera_hint_before_start()),
@@ -542,19 +571,17 @@ def on_restart(st: AppState):
542
  def on_stop(st: AppState):
543
  st.running = False
544
  st.finished = True
545
-
546
  if st.final_emo is None:
547
  return (
548
- gr.update(value=None, visible=False),
549
  gr.update(value=_hint_html("已停止辨識。")),
550
  gr.update(value=_score_radio_value(3)),
551
  gr.update(value=_camera_hint_stopped()),
552
  st,
553
  gr.update(value="重新攝影機辨識"),
554
  )
555
-
556
  return (
557
- gr.update(value=None, visible=False),
558
  gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
559
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
560
  gr.update(value=_camera_hint_done()),
@@ -566,20 +593,19 @@ def on_stop(st: AppState):
566
  # =========================
567
  # Gradio UI
568
  # =========================
569
- css = ""
570
-
571
  if __name__ == "__main__":
572
  with gr.Blocks(title="Emotion Detector", css=css) as demo:
573
  st = gr.State(AppState())
574
 
575
  gr.Markdown("## 情緒辨識")
 
576
  hint = gr.HTML(_camera_hint_before_start())
577
 
578
  cam = gr.Image(
579
  sources=["webcam"],
580
  streaming=True,
581
  type="numpy",
582
- label="攝影機畫面",
583
  visible=True,
584
  )
585
 
 
1
  import time
2
  import threading
3
  from dataclasses import dataclass, field
4
+ from typing import Optional, Dict, Tuple, List
5
 
6
  import gradio as gr
7
  import numpy as np
8
+ from PIL import Image, ImageDraw
 
 
 
 
 
9
 
10
  from deepface import DeepFace
11
 
12
 
13
+ # ===== 避免 css 未定義造成 NameError =====
14
+ css = ""
15
+
16
+
17
  # =========================
18
+ # 參數(優先順:快)
19
  # =========================
20
+ ANALYZE_EVERY_SEC = 1.2 # ✅ 降頻率(原 0.8)
21
+ DOWNSAMPLE_W = 480 # ✅ 降解析(原 720)
22
  EMA_ALPHA = 0.45
23
+ CONF_TH = 40.0
24
+ SWITCH_CONFIRM_SEC = 2.5
25
+ IOU_TH = 0.25
26
+ ZOOM_FACTOR = 1.25 # ✅ 稍微降 zoom(減少失真與負擔)
27
+
28
+ DETECTOR_BACKEND = "opencv" # ✅ 改快(原 mtcnn)
29
+ ALIGN_FACE = True
30
 
31
+ # 防誤判(保留)
32
+ MIN_FACE_AREA_RATIO = 0.05
33
+ MIN_DET_CONF = 0.90
34
+
35
+
36
+ # =========================
37
+ # Warm-up(優先順:按 Record 不要等)
38
+ # =========================
39
+ _WARMED = False
40
+
41
+ def _warmup_models():
42
+ global _WARMED
43
+ if _WARMED:
44
+ return
45
+ _WARMED = True
46
+ try:
47
+ dummy = np.zeros((224, 224, 3), dtype=np.uint8)
48
+ # detector warmup
49
+ try:
50
+ DeepFace.extract_faces(
51
+ img_path=dummy,
52
+ detector_backend=DETECTOR_BACKEND,
53
+ enforce_detection=False,
54
+ align=ALIGN_FACE,
55
+ )
56
+ except Exception:
57
+ pass
58
+ # emotion model warmup
59
+ try:
60
+ DeepFace.analyze(
61
+ img_path=dummy,
62
+ actions=["emotion"],
63
+ enforce_detection=False,
64
+ )
65
+ except Exception:
66
+ pass
67
+ except Exception:
68
+ pass
69
 
70
+ threading.Thread(target=_warmup_models, daemon=True).start()
 
71
 
72
 
73
  # =========================
74
+ # 情緒中文 + 分數映射
75
  # =========================
76
  EMO_ZH = {
77
  "angry": "生氣",
 
110
  # =========================
111
  @dataclass
112
  class AppState:
 
 
113
  running: bool = True
114
  finished: bool = False
115
  last_analyze_t: float = 0.0
116
 
117
+ track_bbox: Optional[Tuple[int, int, int, int]] = None
118
+
119
  ema_scores: Optional[np.ndarray] = None
120
  emo_keys: List[str] = field(default_factory=list)
121
 
 
122
  stable_emo: str = "unknown"
123
  candidate_emo: Optional[str] = None
124
  candidate_since: Optional[float] = None
125
 
 
126
  final_emo: Optional[str] = None
127
  final_conf: Optional[float] = None
128
  final_score: int = 3
129
 
130
+ last_face_t: float = 0.0
131
+ face_in_box: bool = False
132
+
133
 
134
  # =========================
135
  # UI HTML
 
147
  "接著,請按「錄製(Record)」開始。"
148
  )
149
 
 
 
 
 
 
150
  def _camera_hint_done() -> str:
151
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
152
 
153
  def _camera_hint_stopped() -> str:
154
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
155
 
 
 
 
 
 
156
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
157
  zh = EMO_ZH.get(emo_key, emo_key)
158
  score = EMO_TO_SCORE.get(emo_key, 3)
159
  conf_txt = "" if conf is None else f"{conf:.1f}%"
160
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
 
161
  return f"""
162
+ <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#000000;">
163
+ <div style="font-size:14px;color:#000000;margin-bottom:8px;">辨識結果</div>
164
+ <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;color:#000000;">{zh}</div>
165
+ <div style="font-size:16px;color:#000000;margin-bottom:6px;">心情分數:{score}({score_desc})</div>
166
+ <div style="font-size:14px;color:#000000;">信心值:{conf_txt}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  </div>
168
  """
169
 
170
+ def _score_radio_value(score: int) -> str:
171
+ score = int(score)
172
+ score = min(5, max(1, score))
173
+ return SCORE_LABELS[score]
174
+
175
 
176
  # =========================
177
+ # 影像工具
178
  # =========================
179
+ def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
 
180
  if frame is None:
181
+ return None
182
+ h, w = frame.shape[:2]
183
+ if w <= target_w:
184
  return frame
185
+ scale = target_w / w
186
+ new_h = max(1, int(h * scale))
187
+ img = Image.fromarray(frame)
188
+ img = img.resize((target_w, new_h))
189
+ return np.array(img)
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
192
  if frame is None or zoom <= 1.0:
 
196
  new_h = int(h / zoom)
197
  start_x = (w - new_w) // 2
198
  start_y = (h - new_h) // 2
199
+ return frame[start_y:start_y + new_h, start_x:start_x + new_w]
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
 
202
  # =========================
203
+ # 引導框(永遠畫)
204
  # =========================
205
+ GUIDE_W_RATIO = 0.55
206
+ GUIDE_H_RATIO = 0.70
207
+ GUIDE_Y_OFFSET = 0.00
 
 
 
 
 
 
 
 
 
208
 
209
+ def _guide_box(frame: np.ndarray) -> Tuple[int, int, int, int]:
210
+ h, w = frame.shape[:2]
211
+ gw = int(w * GUIDE_W_RATIO)
212
+ gh = int(h * GUIDE_H_RATIO)
213
+ cx = w // 2
214
+ cy = int(h * (0.5 + GUIDE_Y_OFFSET))
215
+ x1 = max(0, cx - gw // 2)
216
+ y1 = max(0, cy - gh // 2)
217
+ x2 = min(w - 1, cx + gw // 2)
218
+ y2 = min(h - 1, cy + gh // 2)
219
+ return x1, y1, x2, y2
220
+
221
+ def _bbox_center_in_box(bbox_xywh: Tuple[float, float, float, float], box_xyxy: Tuple[int, int, int, int]) -> bool:
222
+ x, y, w, h = bbox_xywh
223
+ cx = x + w / 2.0
224
+ cy = y + h / 2.0
225
+ x1, y1, x2, y2 = box_xyxy
226
+ return (x1 <= cx <= x2) and (y1 <= cy <= y2)
227
+
228
+ def _draw_guide_overlay(frame_rgb: np.ndarray, detected_in_box: bool) -> np.ndarray:
229
+ img = Image.fromarray(frame_rgb)
230
+ draw = ImageDraw.Draw(img)
231
+
232
+ x1, y1, x2, y2 = _guide_box(frame_rgb)
233
+ color = (0, 200, 0) if detected_in_box else (220, 0, 0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
+ h, w = frame_rgb.shape[:2]
236
+ thickness = max(3, int(min(w, h) * 0.008))
237
+ for t in range(thickness):
238
+ draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t], outline=color)
239
 
240
+ msg = "已偵測到人臉,開始辨識情緒中…" if detected_in_box else "未偵測到人臉,請把臉放進框內"
241
+ tx = x1
242
+ ty = max(0, y1 - int(thickness * 6))
243
+ draw.rectangle([tx, ty, tx + 360, ty + 32], fill=(255, 255, 255))
244
+ draw.text((tx + 6, ty + 6), msg, fill=(0, 0, 0))
245
 
246
+ return np.array(img)
 
247
 
248
 
249
  # =========================
250
+ # Tracking / IoU
251
  # =========================
252
+ def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
253
+ ax, ay, aw, ah = a
254
+ bx, by, bw, bh = b
255
+ ax2, ay2 = ax + aw, ay + ah
256
+ bx2, by2 = bx + bw, by + bh
257
+
258
+ ix1, iy1 = max(ax, bx), max(ay, by)
259
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
260
+ iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
261
+ inter = iw * ih
262
+ if inter <= 0:
263
+ return 0.0
264
+ union = aw * ah + bw * bh - inter
265
+ return inter / max(1e-6, union)
266
+
267
+ def _pick_face_by_tracking(faces: List[Dict], prev_bbox: Optional[Tuple[int, int, int, int]]):
268
+ if not faces:
269
+ return None, None
270
+ cand = []
271
+ for f in faces:
272
+ fa = f.get("facial_area", {}) or {}
273
+ x = int(fa.get("x", 0) or 0)
274
+ y = int(fa.get("y", 0) or 0)
275
+ w = int(fa.get("w", 0) or 0)
276
+ h = int(fa.get("h", 0) or 0)
277
+ if w > 0 and h > 0 and f.get("face") is not None:
278
+ cand.append((f, (x, y, w, h), w * h))
279
+
280
+ if not cand:
281
+ return None, None
282
+
283
+ if prev_bbox is not None:
284
+ best = None
285
+ best_i = 0.0
286
+ for f, bb, area in cand:
287
+ i = _iou(prev_bbox, bb)
288
+ if i > best_i:
289
+ best_i = i
290
+ best = (f, bb)
291
+ if best is not None and best_i >= IOU_TH:
292
+ return best[0], best[1]
293
+
294
+ cand.sort(key=lambda x: x[2], reverse=True)
295
+ return cand[0][0], cand[0][1]
 
 
 
 
 
296
 
297
 
 
 
 
298
  def _reset_state_for_camera(st: AppState) -> AppState:
299
  st.running = True
300
  st.finished = False
301
  st.last_analyze_t = 0.0
302
+ st.track_bbox = None
303
  st.ema_scores = None
304
  st.emo_keys = []
 
305
  st.stable_emo = "unknown"
306
  st.candidate_emo = None
307
  st.candidate_since = None
 
308
  st.final_emo = None
309
  st.final_conf = None
310
  st.final_score = 3
311
+ st.last_face_t = 0.0
312
+ st.face_in_box = False
313
  return st
314
 
315
 
316
+ def _is_real_face(pick: Dict, bbox_full_xywh: Tuple[float, float, float, float], frame_hw: Tuple[int, int]) -> bool:
317
+ fh, fw = frame_hw
318
+ x, y, w, h = bbox_full_xywh
319
+ area_ratio = (w * h) / max(1.0, float(fw * fh))
320
+ if area_ratio < MIN_FACE_AREA_RATIO:
321
+ return False
322
+ det_conf = pick.get("confidence", None)
323
+ if det_conf is not None:
324
+ try:
325
+ if float(det_conf) < MIN_DET_CONF:
326
+ return False
327
+ except Exception:
328
+ pass
329
+ return True
330
+
331
+
332
  # =========================
333
+ # Stream(優先順)
334
  # =========================
335
  def on_stream(frame_rgb: np.ndarray, st: AppState):
 
336
  if st.finished or (not st.running):
337
  cam_visible = False
 
338
  if st.final_emo:
339
+ return (
340
+ gr.update(visible=cam_visible),
341
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
342
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
343
+ gr.update(value=_camera_hint_done()),
344
+ st,
345
+ gr.update(value="重新攝影機辨識"),
346
+ )
347
  return (
348
+ gr.update(visible=cam_visible),
349
+ gr.update(value=_hint_html("尚未完成辨識。")),
350
+ gr.update(value=_score_radio_value(3)),
351
+ gr.update(value=_camera_hint_stopped()),
352
  st,
353
  gr.update(value="重新攝影機辨識"),
354
  )
355
 
 
356
  if frame_rgb is None:
357
  return (
358
+ gr.update(visible=True),
359
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
360
  gr.update(value=_score_radio_value(3)),
361
  gr.update(value=_camera_hint_before_start()),
 
363
  gr.update(value="重新攝影機辨識"),
364
  )
365
 
366
+ frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
367
+ now = time.time()
368
+
369
+ # ✅ 先畫框(永遠立即有框),不等 detector
370
+ detected_recent = st.face_in_box and (now - st.last_face_t) < 1.0
371
+ annotated_fast = _draw_guide_overlay(frame_rgb, detected_recent)
372
+
373
+ # ✅ 如果還沒到分析時間,直接回傳畫框(超順)
374
+ if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
375
+ return (
376
+ gr.update(visible=True, value=annotated_fast),
377
+ gr.update(value=_hint_html("已偵測到人臉,開始辨識情緒中…" if detected_recent else "未偵測到人臉,請把臉放進框內")),
378
+ gr.update(value=_score_radio_value(3)),
379
+ gr.update(value=_camera_hint_before_start()),
380
+ st,
381
+ gr.update(value="重新攝影機辨識"),
382
+ )
383
+
384
+ # 到時間才做一次重工作(低頻)
385
+ st.last_analyze_t = now
386
+ small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
387
 
388
+ try:
389
+ faces = DeepFace.extract_faces(
390
+ img_path=small,
391
+ detector_backend=DETECTOR_BACKEND,
392
+ enforce_detection=True,
393
+ align=ALIGN_FACE,
394
+ )
395
+ except Exception:
396
+ faces = []
397
 
398
+ pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
 
 
399
 
400
+ if pick is None or bbox is None or pick.get("face") is None:
401
+ st.track_bbox = None
402
+ st.face_in_box = False
403
+ st.last_face_t = now
404
+ st.stable_emo = "unknown"
405
+ annotated = _draw_guide_overlay(frame_rgb, False)
406
  return (
407
+ gr.update(visible=True, value=annotated),
408
+ gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
409
  gr.update(value=_score_radio_value(3)),
410
+ gr.update(value=_camera_hint_before_start()),
411
  st,
412
  gr.update(value="重新攝影機辨識"),
413
  )
414
 
415
+ sh, sw = small.shape[:2]
416
+ fh, fw = frame_rgb.shape[:2]
417
+ sx = fw / max(1, sw)
418
+ sy = fh / max(1, sh)
419
+ x, y, w, h = bbox
420
+ bbox_full = (x * sx, y * sy, w * sx, h * sy)
421
+
422
+ if not _is_real_face(pick, bbox_full, (fh, fw)):
423
+ st.track_bbox = None
424
+ st.face_in_box = False
425
+ st.last_face_t = now
426
+ annotated = _draw_guide_overlay(frame_rgb, False)
427
+ return (
428
+ gr.update(visible=True, value=annotated),
429
+ gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
430
+ gr.update(value=_score_radio_value(3)),
431
+ gr.update(value=_camera_hint_before_start()),
432
+ st,
433
+ gr.update(value="重新攝影機辨識"),
434
+ )
435
+
436
+ guide = _guide_box(frame_rgb)
437
+ in_box = _bbox_center_in_box(bbox_full, guide)
438
+
439
+ st.last_face_t = now
440
+ st.face_in_box = in_box
441
+ st.track_bbox = bbox
442
 
443
+ if not in_box:
444
+ annotated = _draw_guide_overlay(frame_rgb, False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  return (
446
+ gr.update(visible=True, value=annotated),
447
+ gr.update(value=_hint_html("已偵測到人臉,但請把臉放進框內")),
448
+ gr.update(value=_score_radio_value(3)),
449
  gr.update(value=_camera_hint_before_start()),
450
  st,
451
  gr.update(value="重新攝影機辨識"),
452
  )
453
 
454
+ # 只有臉在框內才分析情緒
455
+ face = pick["face"]
456
+ if face.dtype != np.uint8:
457
+ face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
458
+
459
+ try:
460
+ result = DeepFace.analyze(
461
+ img_path=face,
462
+ actions=["emotion"],
463
+ enforce_detection=True,
464
+ )
465
+ except Exception:
466
+ annotated = _draw_guide_overlay(frame_rgb, False)
467
+ st.face_in_box = False
468
+ st.last_face_t = now
469
+ return (
470
+ gr.update(visible=True, value=annotated),
471
+ gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
472
+ gr.update(value=_score_radio_value(3)),
473
+ gr.update(value=_camera_hint_before_start()),
474
+ st,
475
+ gr.update(value="重新攝影機辨識"),
476
+ )
477
+
478
+ if isinstance(result, list):
479
+ result = result[0]
480
+ emo_dict = result.get("emotion", None)
481
+ if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
482
+ annotated = _draw_guide_overlay(frame_rgb, True)
483
+ return (
484
+ gr.update(visible=True, value=annotated),
485
+ gr.update(value=_hint_html("已偵測到人臉,開始辨識情緒中…")),
486
+ gr.update(value=_score_radio_value(3)),
487
+ gr.update(value=_camera_hint_before_start()),
488
+ st,
489
+ gr.update(value="重新攝影機辨識"),
490
+ )
491
+
492
+ top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
493
+ top_conf = float(emo_dict[top_key])
494
+
495
  if top_conf < CONF_TH:
496
+ annotated = _draw_guide_overlay(frame_rgb, True)
497
  return (
498
+ gr.update(visible=True, value=annotated),
499
+ gr.update(value=_hint_html("已偵測到人臉,開始辨識情緒中…")),
500
+ gr.update(value=_score_radio_value(3)),
501
  gr.update(value=_camera_hint_before_start()),
502
  st,
503
  gr.update(value="重新攝影機辨識"),
504
  )
505
 
506
+ keys = list(emo_dict.keys())
507
+ vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
 
 
 
 
 
 
508
 
509
+ if st.ema_scores is None or st.emo_keys != keys:
510
  st.ema_scores = vec
511
+ st.emo_keys = keys
512
  else:
513
  st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
514
 
515
  stable_idx = int(np.argmax(st.ema_scores))
516
+ stable_key = st.emo_keys[stable_idx]
517
  st.stable_emo = stable_key
518
 
 
519
  if st.candidate_emo != stable_key:
520
  st.candidate_emo = stable_key
521
  st.candidate_since = now
 
528
  and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
529
  )
530
 
 
531
  if confirmed and st.final_emo is None:
532
  st.final_emo = stable_key
533
+ st.final_conf = top_conf
534
  st.final_score = EMO_TO_SCORE.get(stable_key, 3)
 
535
  st.running = False
536
  st.finished = True
 
537
  return (
538
+ gr.update(visible=False),
539
+ gr.update(value=_result_card_html(stable_key, top_conf)),
540
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
541
  gr.update(value=_camera_hint_done()),
542
  st,
543
  gr.update(value="重新攝影機辨識"),
544
  )
545
 
546
+ annotated = _draw_guide_overlay(frame_rgb, True)
547
  return (
548
+ gr.update(visible=True, value=annotated),
549
+ gr.update(value=_result_card_html(stable_key, top_conf)),
550
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
551
  gr.update(value=_camera_hint_before_start()),
552
  st,
 
560
  def on_restart(st: AppState):
561
  st = _reset_state_for_camera(st)
562
  return (
563
+ gr.update(visible=True, value=None), # ✅ 清掉上一張畫面
564
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
565
  gr.update(value=_score_radio_value(3)),
566
  gr.update(value=_camera_hint_before_start()),
 
571
  def on_stop(st: AppState):
572
  st.running = False
573
  st.finished = True
 
574
  if st.final_emo is None:
575
  return (
576
+ gr.update(visible=False, value=None),
577
  gr.update(value=_hint_html("已停止辨識。")),
578
  gr.update(value=_score_radio_value(3)),
579
  gr.update(value=_camera_hint_stopped()),
580
  st,
581
  gr.update(value="重新攝影機辨識"),
582
  )
 
583
  return (
584
+ gr.update(visible=False, value=None),
585
  gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
586
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
587
  gr.update(value=_camera_hint_done()),
 
593
  # =========================
594
  # Gradio UI
595
  # =========================
 
 
596
  if __name__ == "__main__":
597
  with gr.Blocks(title="Emotion Detector", css=css) as demo:
598
  st = gr.State(AppState())
599
 
600
  gr.Markdown("## 情緒辨識")
601
+
602
  hint = gr.HTML(_camera_hint_before_start())
603
 
604
  cam = gr.Image(
605
  sources=["webcam"],
606
  streaming=True,
607
  type="numpy",
608
+ label="攝影機",
609
  visible=True,
610
  )
611