YuAnTsai commited on
Commit
10ce868
·
verified ·
1 Parent(s): 8df8798

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +243 -161
emotion.py CHANGED
@@ -5,27 +5,32 @@ from typing import Optional, Dict, Tuple, List
5
 
6
  import gradio as gr
7
  import numpy as np
 
 
8
  from deepface import DeepFace
9
 
10
 
11
  # =========================
12
- # 參數(HF Space:優先順)
13
  # =========================
14
- ANALYZE_EVERY_SEC = 2.0 # ✅ Space 上建議 >= 2.0
15
- DOWNSAMPLE_W = 320 # ✅ 影像縮小,提升速度
16
  EMA_ALPHA = 0.45
17
  CONF_TH = 40.0
18
  SWITCH_CONFIRM_SEC = 2.5
19
- ALIGN_FACE = True
 
20
 
21
- DETECTOR_BACKEND = "opencv" # ✅ 比 mtcnn 快很多
 
22
 
23
- # 防誤判(保留,但別太嚴苛避免一直紅)
24
- MIN_FACE_AREA_RATIO = 0.04 # 4%(你可調 0.03~0.06)
 
25
 
26
 
27
  # =========================
28
- # Warm-up(避免按 Record 後第一下卡很久)
29
  # =========================
30
  _WARMED = False
31
 
@@ -36,6 +41,7 @@ def _warmup_models():
36
  _WARMED = True
37
  try:
38
  dummy = np.zeros((224, 224, 3), dtype=np.uint8)
 
39
  try:
40
  DeepFace.extract_faces(
41
  img_path=dummy,
@@ -45,6 +51,7 @@ def _warmup_models():
45
  )
46
  except Exception:
47
  pass
 
48
  try:
49
  DeepFace.analyze(
50
  img_path=dummy,
@@ -93,11 +100,6 @@ SCORE_LABELS = {
93
  }
94
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
95
 
96
- def _score_radio_value(score: int) -> str:
97
- score = int(score)
98
- score = min(5, max(1, score))
99
- return SCORE_LABELS[score]
100
-
101
 
102
  # =========================
103
  # 狀態
@@ -108,6 +110,8 @@ class AppState:
108
  finished: bool = False
109
  last_analyze_t: float = 0.0
110
 
 
 
111
  ema_scores: Optional[np.ndarray] = None
112
  emo_keys: List[str] = field(default_factory=list)
113
 
@@ -119,12 +123,12 @@ class AppState:
119
  final_conf: Optional[float] = None
120
  final_score: int = 3
121
 
122
- face_ok: bool = False # ✅ 是否「偵測到人臉」(用來切紅/綠框)
123
  last_face_t: float = 0.0
 
124
 
125
 
126
  # =========================
127
- # UI HTML(結果卡 + 提示 + 前端框色)
128
  # =========================
129
  def _hint_html(msg: str) -> str:
130
  return f"""
@@ -150,15 +154,8 @@ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
150
  score = EMO_TO_SCORE.get(emo_key, 3)
151
  conf_txt = "" if conf is None else f"{conf:.1f}%"
152
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
153
-
154
  return f"""
155
- <div style="
156
- border-radius:16px;
157
- padding:20px;
158
- border:1px solid #E6E6E6;
159
- background:#FFFFFF;
160
- color:#000000;
161
- ">
162
  <div style="font-size:14px;color:#000000;margin-bottom:8px;">辨識結果</div>
163
  <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;color:#000000;">{zh}</div>
164
  <div style="font-size:16px;color:#000000;margin-bottom:6px;">心情分數:{score}({score_desc})</div>
@@ -166,34 +163,14 @@ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
166
  </div>
167
  """
168
 
169
- def _overlay_style(face_ok: bool) -> str:
170
- # ✅ 用 CSS 直接改 webcam 元件邊框顏色(不回傳影像,不畫在影像上)
171
- color = "#00C853" if face_ok else "#FF1744" # 綠 / 紅
172
- msg = "已偵測到人臉,辨識中…" if face_ok else "未偵測到人臉,請把臉放進框內"
173
- return f"""
174
- <style>
175
- /* gr.Image 內部會有 img,這裡用 elem_id=cam_box 鎖定 */
176
- #cam_box img {{
177
- outline: 4px solid {color};
178
- outline-offset: -4px;
179
- border-radius: 10px;
180
- }}
181
- #cam_msg {{
182
- margin-top: 8px;
183
- padding: 8px 12px;
184
- border-radius: 10px;
185
- background: #FFFFFF;
186
- border: 1px solid #E6E6E6;
187
- display: inline-block;
188
- color: #000000;
189
- }}
190
- </style>
191
- <div id="cam_msg">{msg}</div>
192
- """
193
 
194
 
195
  # =========================
196
- # 影像縮小(加速)
197
  # =========================
198
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
199
  if frame is None:
@@ -203,181 +180,308 @@ def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
203
  return frame
204
  scale = target_w / w
205
  new_h = max(1, int(h * scale))
206
- # 用 numpy 最近鄰簡化也可,但這裡用 slice+PIL更穩
207
- import PIL.Image
208
- img = PIL.Image.fromarray(frame)
209
  img = img.resize((target_w, new_h))
210
  return np.array(img)
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
- def _is_big_enough_face(facial_area: Dict, frame_hw: Tuple[int, int]) -> bool:
214
  fh, fw = frame_hw
215
- w = float(facial_area.get("w", 0) or 0)
216
- h = float(facial_area.get("h", 0) or 0)
217
- if w <= 0 or h <= 0:
218
- return False
219
  area_ratio = (w * h) / max(1.0, float(fw * fh))
220
- return area_ratio >= MIN_FACE_AREA_RATIO
 
 
 
 
 
 
 
 
 
221
 
222
 
223
  # =========================
224
- # 核心:只回傳「狀態」,不回傳影像(最順)
225
  # =========================
226
- def on_stream_status(frame_rgb: np.ndarray, st: AppState):
227
  if st.finished or (not st.running):
 
228
  if st.final_emo:
229
  return (
 
230
  gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
231
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
232
  gr.update(value=_camera_hint_done()),
233
  st,
234
- gr.update(value=_overlay_style(True)),
235
  gr.update(value="重新攝影機辨識"),
236
  )
237
  return (
 
238
  gr.update(value=_hint_html("尚未完成辨識。")),
239
  gr.update(value=_score_radio_value(3)),
240
  gr.update(value=_camera_hint_stopped()),
241
  st,
242
- gr.update(value=_overlay_style(False)),
243
  gr.update(value="重新攝影機辨識"),
244
  )
245
 
246
  if frame_rgb is None:
247
- st.face_ok = False
248
  return (
 
249
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
250
  gr.update(value=_score_radio_value(3)),
251
  gr.update(value=_camera_hint_before_start()),
252
  st,
253
- gr.update(value=_overlay_style(False)),
254
  gr.update(value="重新攝影機辨識"),
255
  )
256
 
 
257
  now = time.time()
258
 
259
- # ✅ 沒到時間就只回傳「框色 HTML」(幾乎不耗時)
 
 
 
 
260
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
261
  return (
262
- gr.update(value=_hint_html("已偵測到人臉,開始辨識情緒中…" if st.face_ok else "未偵測到人臉,請把臉放進框內")),
263
- gr.update(value=_score_radio_value(3 if st.final_emo is None else EMO_TO_SCORE.get(st.final_emo, 3))),
 
264
  gr.update(value=_camera_hint_before_start()),
265
  st,
266
- gr.update(value=_overlay_style(st.face_ok)),
267
  gr.update(value="重新攝影機辨識"),
268
  )
269
 
 
270
  st.last_analyze_t = now
271
-
272
- # ✅ 只用縮小後影像做偵測(快很多)
273
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
274
 
275
  try:
276
  faces = DeepFace.extract_faces(
277
  img_path=small,
278
  detector_backend=DETECTOR_BACKEND,
279
- enforce_detection=False, # ✅ 不丟例外(省時間)
280
  align=ALIGN_FACE,
281
  )
282
  except Exception:
283
  faces = []
284
 
285
- # 沒有臉
286
- if not faces:
287
- st.face_ok = False
288
- return (
289
- gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
290
- gr.update(value=_score_radio_value(3)),
291
- gr.update(value=_camera_hint_before_start()),
292
- st,
293
- gr.update(value=_overlay_style(False)),
294
- gr.update(value="重新攝影機辨識"),
295
- )
296
 
297
- # 找最大臉(以面積判斷)
298
- best = None
299
- best_area = -1.0
300
- for f in faces:
301
- fa = f.get("facial_area", {}) or {}
302
- w = float(fa.get("w", 0) or 0)
303
- h = float(fa.get("h", 0) or 0)
304
- area = w * h
305
- if area > best_area and f.get("face") is not None:
306
- best_area = area
307
- best = f
308
-
309
- if best is None:
310
- st.face_ok = False
311
  return (
 
312
  gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
313
  gr.update(value=_score_radio_value(3)),
314
  gr.update(value=_camera_hint_before_start()),
315
  st,
316
- gr.update(value=_overlay_style(False)),
317
  gr.update(value="重新攝影機辨識"),
318
  )
319
 
320
- # ✅ 防誤判:太小的臉視為沒有
321
- fa = best.get("facial_area", {}) or {}
322
- if not _is_big_enough_face(fa, small.shape[:2]):
323
- st.face_ok = False
 
 
 
 
 
 
 
 
324
  return (
 
325
  gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
326
  gr.update(value=_score_radio_value(3)),
327
  gr.update(value=_camera_hint_before_start()),
328
  st,
329
- gr.update(value=_overlay_style(False)),
330
  gr.update(value="重新攝影機辨識"),
331
  )
332
 
333
- # ✅ 這裡就算「有臉」→ 綠框
334
- st.face_ok = True
 
335
  st.last_face_t = now
 
 
336
 
337
- face = best["face"]
338
- if face is None:
339
- st.face_ok = False
340
  return (
341
- gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
 
342
  gr.update(value=_score_radio_value(3)),
343
  gr.update(value=_camera_hint_before_start()),
344
  st,
345
- gr.update(value=_overlay_style(False)),
346
  gr.update(value="重新攝影機辨識"),
347
  )
348
 
349
- # face 轉 uint8
 
350
  if face.dtype != np.uint8:
351
  face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
352
 
353
- # 情緒分析(仍然低頻做)
354
  try:
355
  result = DeepFace.analyze(
356
  img_path=face,
357
  actions=["emotion"],
358
- enforce_detection=False, # ✅ 省時間
359
  )
360
  except Exception:
 
 
 
361
  return (
362
- gr.update(value=_hint_html("已偵測到人臉,辨識中…(分析暫時失敗,請保持正面)")),
 
363
  gr.update(value=_score_radio_value(3)),
364
  gr.update(value=_camera_hint_before_start()),
365
  st,
366
- gr.update(value=_overlay_style(True)),
367
  gr.update(value="重新攝影機辨識"),
368
  )
369
 
370
  if isinstance(result, list):
371
  result = result[0]
372
  emo_dict = result.get("emotion", None)
373
-
374
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
 
375
  return (
376
- gr.update(value=_hint_html("已偵測到人臉,辨識中…")),
 
377
  gr.update(value=_score_radio_value(3)),
378
  gr.update(value=_camera_hint_before_start()),
379
  st,
380
- gr.update(value=_overlay_style(True)),
381
  gr.update(value="重新攝影機辨識"),
382
  )
383
 
@@ -385,16 +489,16 @@ def on_stream_status(frame_rgb: np.ndarray, st: AppState):
385
  top_conf = float(emo_dict[top_key])
386
 
387
  if top_conf < CONF_TH:
 
388
  return (
389
- gr.update(value=_hint_html("已偵測到人臉,辨識中…(信心值偏低,請面向鏡頭、光線再亮一點)")),
 
390
  gr.update(value=_score_radio_value(3)),
391
  gr.update(value=_camera_hint_before_start()),
392
  st,
393
- gr.update(value=_overlay_style(True)),
394
  gr.update(value="重新攝影機辨識"),
395
  )
396
 
397
- # 平滑
398
  keys = list(emo_dict.keys())
399
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
400
 
@@ -408,7 +512,6 @@ def on_stream_status(frame_rgb: np.ndarray, st: AppState):
408
  stable_key = st.emo_keys[stable_idx]
409
  st.stable_emo = stable_key
410
 
411
- # 延遲確認(避免跳來跳去)
412
  if st.candidate_emo != stable_key:
413
  st.candidate_emo = stable_key
414
  st.candidate_since = now
@@ -428,21 +531,21 @@ def on_stream_status(frame_rgb: np.ndarray, st: AppState):
428
  st.running = False
429
  st.finished = True
430
  return (
 
431
  gr.update(value=_result_card_html(stable_key, top_conf)),
432
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
433
  gr.update(value=_camera_hint_done()),
434
  st,
435
- gr.update(value=_overlay_style(True)),
436
  gr.update(value="重新攝影機辨識"),
437
  )
438
 
439
- # 還在辨識中
440
  return (
 
441
  gr.update(value=_result_card_html(stable_key, top_conf)),
442
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
443
  gr.update(value=_camera_hint_before_start()),
444
  st,
445
- gr.update(value=_overlay_style(True)),
446
  gr.update(value="重新攝影機辨識"),
447
  )
448
 
@@ -450,51 +553,35 @@ def on_stream_status(frame_rgb: np.ndarray, st: AppState):
450
  # =========================
451
  # Buttons
452
  # =========================
453
- def _reset_state(st: AppState) -> AppState:
454
- st.running = True
455
- st.finished = False
456
- st.last_analyze_t = 0.0
457
- st.ema_scores = None
458
- st.emo_keys = []
459
- st.stable_emo = "unknown"
460
- st.candidate_emo = None
461
- st.candidate_since = None
462
- st.final_emo = None
463
- st.final_conf = None
464
- st.final_score = 3
465
- st.face_ok = False
466
- st.last_face_t = 0.0
467
- return st
468
-
469
  def on_restart(st: AppState):
470
- st = _reset_state(st)
471
  return (
 
472
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
473
  gr.update(value=_score_radio_value(3)),
474
  gr.update(value=_camera_hint_before_start()),
475
  st,
476
- gr.update(value=_overlay_style(False)),
477
  gr.update(value="重新攝影機辨識"),
478
  )
479
 
480
  def on_stop(st: AppState):
481
  st.running = False
482
  st.finished = True
483
- if st.final_emo:
484
  return (
485
- gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
486
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
487
- gr.update(value=_camera_hint_done()),
 
488
  st,
489
- gr.update(value=_overlay_style(True)),
490
  gr.update(value="重新攝影機辨識"),
491
  )
492
  return (
493
- gr.update(value=_hint_html("已停止辨識。")),
494
- gr.update(value=_score_radio_value(3)),
495
- gr.update(value=_camera_hint_stopped()),
 
496
  st,
497
- gr.update(value=_overlay_style(False)),
498
  gr.update(value="重新攝影機辨識"),
499
  )
500
 
@@ -503,25 +590,21 @@ def on_stop(st: AppState):
503
  # Gradio UI
504
  # =========================
505
  if __name__ == "__main__":
506
- with gr.Blocks(title="Emotion Detector") as demo:
507
  st = gr.State(AppState())
508
 
509
  gr.Markdown("## 情緒辨識")
510
 
511
  hint = gr.HTML(_camera_hint_before_start())
512
 
513
- # ✅ elem_id 固定,讓 CSS 可以準確套用
514
  cam = gr.Image(
515
  sources=["webcam"],
516
  streaming=True,
517
  type="numpy",
518
  label="攝影機",
519
- elem_id="cam_box",
520
  )
521
 
522
- # ✅ 疊框 / 訊息(只回傳 HTML,不回傳影像)
523
- overlay = gr.HTML(_overlay_style(False))
524
-
525
  with gr.Row():
526
  btn_restart = gr.Button("重新攝影機辨識", variant="primary")
527
  btn_stop = gr.Button("停止", variant="secondary")
@@ -537,25 +620,24 @@ if __name__ == "__main__":
537
  interactive=False,
538
  )
539
 
540
- # ✅ 注意:outputs 裡沒有 cam → 前端保持本地 webcam 預覽(最順)
541
  cam.stream(
542
- fn=on_stream_status,
543
  inputs=[cam, st],
544
- outputs=[result, mood, hint, st, overlay, btn_restart],
545
  show_progress="minimal",
546
  )
547
 
548
  btn_restart.click(
549
  fn=on_restart,
550
  inputs=[st],
551
- outputs=[result, mood, hint, st, overlay, btn_restart],
552
  show_progress="minimal",
553
  )
554
 
555
  btn_stop.click(
556
  fn=on_stop,
557
  inputs=[st],
558
- outputs=[result, mood, hint, st, overlay, btn_restart],
559
  show_progress="minimal",
560
  )
561
 
 
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
  # =========================
14
+ # 參數(優先順:快)
15
  # =========================
16
+ ANALYZE_EVERY_SEC = 1.2 # ✅ 降頻率(原 0.8)
17
+ DOWNSAMPLE_W = 480 # ✅ 降解析(原 720)
18
  EMA_ALPHA = 0.45
19
  CONF_TH = 40.0
20
  SWITCH_CONFIRM_SEC = 2.5
21
+ IOU_TH = 0.25
22
+ ZOOM_FACTOR = 1.25 # ✅ 稍微降 zoom(減少失真與負擔)
23
 
24
+ DETECTOR_BACKEND = "opencv" # ✅ 改快(原 mtcnn)
25
+ ALIGN_FACE = True
26
 
27
+ # 防誤判(保留)
28
+ MIN_FACE_AREA_RATIO = 0.05
29
+ MIN_DET_CONF = 0.90
30
 
31
 
32
  # =========================
33
+ # Warm-up(優先順:按 Record 不要等)
34
  # =========================
35
  _WARMED = False
36
 
 
41
  _WARMED = True
42
  try:
43
  dummy = np.zeros((224, 224, 3), dtype=np.uint8)
44
+ # detector warmup
45
  try:
46
  DeepFace.extract_faces(
47
  img_path=dummy,
 
51
  )
52
  except Exception:
53
  pass
54
+ # emotion model warmup
55
  try:
56
  DeepFace.analyze(
57
  img_path=dummy,
 
100
  }
101
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
102
 
 
 
 
 
 
103
 
104
  # =========================
105
  # 狀態
 
110
  finished: bool = False
111
  last_analyze_t: float = 0.0
112
 
113
+ track_bbox: Optional[Tuple[int, int, int, int]] = None
114
+
115
  ema_scores: Optional[np.ndarray] = None
116
  emo_keys: List[str] = field(default_factory=list)
117
 
 
123
  final_conf: Optional[float] = None
124
  final_score: int = 3
125
 
 
126
  last_face_t: float = 0.0
127
+ face_in_box: bool = False
128
 
129
 
130
  # =========================
131
+ # UI HTML
132
  # =========================
133
  def _hint_html(msg: str) -> str:
134
  return f"""
 
154
  score = EMO_TO_SCORE.get(emo_key, 3)
155
  conf_txt = "" if conf is None else f"{conf:.1f}%"
156
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
 
157
  return f"""
158
+ <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#000000;">
 
 
 
 
 
 
159
  <div style="font-size:14px;color:#000000;margin-bottom:8px;">辨識結果</div>
160
  <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;color:#000000;">{zh}</div>
161
  <div style="font-size:16px;color:#000000;margin-bottom:6px;">心情分數:{score}({score_desc})</div>
 
163
  </div>
164
  """
165
 
166
+ def _score_radio_value(score: int) -> str:
167
+ score = int(score)
168
+ score = min(5, max(1, score))
169
+ return SCORE_LABELS[score]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
 
172
  # =========================
173
+ # 影像工具
174
  # =========================
175
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
176
  if frame is None:
 
180
  return frame
181
  scale = target_w / w
182
  new_h = max(1, int(h * scale))
183
+ img = Image.fromarray(frame)
 
 
184
  img = img.resize((target_w, new_h))
185
  return np.array(img)
186
 
187
+ def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
188
+ if frame is None or zoom <= 1.0:
189
+ return frame
190
+ h, w = frame.shape[:2]
191
+ new_w = int(w / zoom)
192
+ new_h = int(h / zoom)
193
+ start_x = (w - new_w) // 2
194
+ start_y = (h - new_h) // 2
195
+ return frame[start_y:start_y + new_h, start_x:start_x + new_w]
196
+
197
+
198
+ # =========================
199
+ # 引導框(永遠畫)
200
+ # =========================
201
+ GUIDE_W_RATIO = 0.55
202
+ GUIDE_H_RATIO = 0.70
203
+ GUIDE_Y_OFFSET = 0.00
204
+
205
+ def _guide_box(frame: np.ndarray) -> Tuple[int, int, int, int]:
206
+ h, w = frame.shape[:2]
207
+ gw = int(w * GUIDE_W_RATIO)
208
+ gh = int(h * GUIDE_H_RATIO)
209
+ cx = w // 2
210
+ cy = int(h * (0.5 + GUIDE_Y_OFFSET))
211
+ x1 = max(0, cx - gw // 2)
212
+ y1 = max(0, cy - gh // 2)
213
+ x2 = min(w - 1, cx + gw // 2)
214
+ y2 = min(h - 1, cy + gh // 2)
215
+ return x1, y1, x2, y2
216
+
217
+ def _bbox_center_in_box(bbox_xywh: Tuple[float, float, float, float], box_xyxy: Tuple[int, int, int, int]) -> bool:
218
+ x, y, w, h = bbox_xywh
219
+ cx = x + w / 2.0
220
+ cy = y + h / 2.0
221
+ x1, y1, x2, y2 = box_xyxy
222
+ return (x1 <= cx <= x2) and (y1 <= cy <= y2)
223
+
224
+ def _draw_guide_overlay(frame_rgb: np.ndarray, detected_in_box: bool) -> np.ndarray:
225
+ img = Image.fromarray(frame_rgb)
226
+ draw = ImageDraw.Draw(img)
227
+
228
+ x1, y1, x2, y2 = _guide_box(frame_rgb)
229
+ color = (0, 200, 0) if detected_in_box else (220, 0, 0)
230
+
231
+ h, w = frame_rgb.shape[:2]
232
+ thickness = max(3, int(min(w, h) * 0.008))
233
+ for t in range(thickness):
234
+ draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t], outline=color)
235
+
236
+ msg = "已偵測到人臉,開始辨識情緒中…" if detected_in_box else "未偵測到人臉,請把臉放進框內"
237
+ tx = x1
238
+ ty = max(0, y1 - int(thickness * 6))
239
+ draw.rectangle([tx, ty, tx + 360, ty + 32], fill=(255, 255, 255))
240
+ draw.text((tx + 6, ty + 6), msg, fill=(0, 0, 0))
241
+
242
+ return np.array(img)
243
+
244
+
245
+ # =========================
246
+ # Tracking / IoU
247
+ # =========================
248
+ def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
249
+ ax, ay, aw, ah = a
250
+ bx, by, bw, bh = b
251
+ ax2, ay2 = ax + aw, ay + ah
252
+ bx2, by2 = bx + bw, by + bh
253
+
254
+ ix1, iy1 = max(ax, bx), max(ay, by)
255
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
256
+ iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
257
+ inter = iw * ih
258
+ if inter <= 0:
259
+ return 0.0
260
+ union = aw * ah + bw * bh - inter
261
+ return inter / max(1e-6, union)
262
+
263
+ def _pick_face_by_tracking(faces: List[Dict], prev_bbox: Optional[Tuple[int, int, int, int]]):
264
+ if not faces:
265
+ return None, None
266
+ cand = []
267
+ for f in faces:
268
+ fa = f.get("facial_area", {}) or {}
269
+ x = int(fa.get("x", 0) or 0)
270
+ y = int(fa.get("y", 0) or 0)
271
+ w = int(fa.get("w", 0) or 0)
272
+ h = int(fa.get("h", 0) or 0)
273
+ if w > 0 and h > 0 and f.get("face") is not None:
274
+ cand.append((f, (x, y, w, h), w * h))
275
+
276
+ if not cand:
277
+ return None, None
278
+
279
+ if prev_bbox is not None:
280
+ best = None
281
+ best_i = 0.0
282
+ for f, bb, area in cand:
283
+ i = _iou(prev_bbox, bb)
284
+ if i > best_i:
285
+ best_i = i
286
+ best = (f, bb)
287
+ if best is not None and best_i >= IOU_TH:
288
+ return best[0], best[1]
289
+
290
+ cand.sort(key=lambda x: x[2], reverse=True)
291
+ return cand[0][0], cand[0][1]
292
+
293
+
294
+ def _reset_state_for_camera(st: AppState) -> AppState:
295
+ st.running = True
296
+ st.finished = False
297
+ st.last_analyze_t = 0.0
298
+ st.track_bbox = None
299
+ st.ema_scores = None
300
+ st.emo_keys = []
301
+ st.stable_emo = "unknown"
302
+ st.candidate_emo = None
303
+ st.candidate_since = None
304
+ st.final_emo = None
305
+ st.final_conf = None
306
+ st.final_score = 3
307
+ st.last_face_t = 0.0
308
+ st.face_in_box = False
309
+ return st
310
+
311
 
312
+ def _is_real_face(pick: Dict, bbox_full_xywh: Tuple[float, float, float, float], frame_hw: Tuple[int, int]) -> bool:
313
  fh, fw = frame_hw
314
+ x, y, w, h = bbox_full_xywh
 
 
 
315
  area_ratio = (w * h) / max(1.0, float(fw * fh))
316
+ if area_ratio < MIN_FACE_AREA_RATIO:
317
+ return False
318
+ det_conf = pick.get("confidence", None)
319
+ if det_conf is not None:
320
+ try:
321
+ if float(det_conf) < MIN_DET_CONF:
322
+ return False
323
+ except Exception:
324
+ pass
325
+ return True
326
 
327
 
328
  # =========================
329
+ # Stream(優先順)
330
  # =========================
331
+ def on_stream(frame_rgb: np.ndarray, st: AppState):
332
  if st.finished or (not st.running):
333
+ cam_visible = False
334
  if st.final_emo:
335
  return (
336
+ gr.update(visible=cam_visible),
337
  gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
338
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
339
  gr.update(value=_camera_hint_done()),
340
  st,
 
341
  gr.update(value="重新攝影機辨識"),
342
  )
343
  return (
344
+ gr.update(visible=cam_visible),
345
  gr.update(value=_hint_html("尚未完成辨識。")),
346
  gr.update(value=_score_radio_value(3)),
347
  gr.update(value=_camera_hint_stopped()),
348
  st,
 
349
  gr.update(value="重新攝影機辨識"),
350
  )
351
 
352
  if frame_rgb is None:
 
353
  return (
354
+ gr.update(visible=True),
355
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
356
  gr.update(value=_score_radio_value(3)),
357
  gr.update(value=_camera_hint_before_start()),
358
  st,
 
359
  gr.update(value="重新攝影機辨識"),
360
  )
361
 
362
+ frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
363
  now = time.time()
364
 
365
+ # ✅ 先畫框(永遠立即有框),不等 detector
366
+ detected_recent = st.face_in_box and (now - st.last_face_t) < 1.0
367
+ annotated_fast = _draw_guide_overlay(frame_rgb, detected_recent)
368
+
369
+ # ✅ 如果還沒到分析時間,直接回傳畫框(超順)
370
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
371
  return (
372
+ gr.update(visible=True, value=annotated_fast),
373
+ gr.update(value=_hint_html("已偵測到人臉,開始辨識情緒中…" if detected_recent else "未偵測到人臉,請把臉放進框內")),
374
+ gr.update(value=_score_radio_value(3)),
375
  gr.update(value=_camera_hint_before_start()),
376
  st,
 
377
  gr.update(value="重新攝影機辨識"),
378
  )
379
 
380
+ # 到時間才做一次重工作(低頻)
381
  st.last_analyze_t = now
 
 
382
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
383
 
384
  try:
385
  faces = DeepFace.extract_faces(
386
  img_path=small,
387
  detector_backend=DETECTOR_BACKEND,
388
+ enforce_detection=True,
389
  align=ALIGN_FACE,
390
  )
391
  except Exception:
392
  faces = []
393
 
394
+ pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
 
 
 
 
 
 
 
 
 
 
395
 
396
+ if pick is None or bbox is None or pick.get("face") is None:
397
+ st.track_bbox = None
398
+ st.face_in_box = False
399
+ st.last_face_t = now
400
+ st.stable_emo = "unknown"
401
+ annotated = _draw_guide_overlay(frame_rgb, False)
 
 
 
 
 
 
 
 
402
  return (
403
+ gr.update(visible=True, value=annotated),
404
  gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
405
  gr.update(value=_score_radio_value(3)),
406
  gr.update(value=_camera_hint_before_start()),
407
  st,
 
408
  gr.update(value="重新攝影機辨識"),
409
  )
410
 
411
+ sh, sw = small.shape[:2]
412
+ fh, fw = frame_rgb.shape[:2]
413
+ sx = fw / max(1, sw)
414
+ sy = fh / max(1, sh)
415
+ x, y, w, h = bbox
416
+ bbox_full = (x * sx, y * sy, w * sx, h * sy)
417
+
418
+ if not _is_real_face(pick, bbox_full, (fh, fw)):
419
+ st.track_bbox = None
420
+ st.face_in_box = False
421
+ st.last_face_t = now
422
+ annotated = _draw_guide_overlay(frame_rgb, False)
423
  return (
424
+ gr.update(visible=True, value=annotated),
425
  gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
426
  gr.update(value=_score_radio_value(3)),
427
  gr.update(value=_camera_hint_before_start()),
428
  st,
 
429
  gr.update(value="重新攝影機辨識"),
430
  )
431
 
432
+ guide = _guide_box(frame_rgb)
433
+ in_box = _bbox_center_in_box(bbox_full, guide)
434
+
435
  st.last_face_t = now
436
+ st.face_in_box = in_box
437
+ st.track_bbox = bbox
438
 
439
+ if not in_box:
440
+ annotated = _draw_guide_overlay(frame_rgb, False)
 
441
  return (
442
+ gr.update(visible=True, value=annotated),
443
+ gr.update(value=_hint_html("已偵測到人臉,但請把臉放進框內")),
444
  gr.update(value=_score_radio_value(3)),
445
  gr.update(value=_camera_hint_before_start()),
446
  st,
 
447
  gr.update(value="重新攝影機辨識"),
448
  )
449
 
450
+ # 只有臉在框內才分析情緒
451
+ face = pick["face"]
452
  if face.dtype != np.uint8:
453
  face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
454
 
 
455
  try:
456
  result = DeepFace.analyze(
457
  img_path=face,
458
  actions=["emotion"],
459
+ enforce_detection=True,
460
  )
461
  except Exception:
462
+ annotated = _draw_guide_overlay(frame_rgb, False)
463
+ st.face_in_box = False
464
+ st.last_face_t = now
465
  return (
466
+ gr.update(visible=True, value=annotated),
467
+ gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
468
  gr.update(value=_score_radio_value(3)),
469
  gr.update(value=_camera_hint_before_start()),
470
  st,
 
471
  gr.update(value="重新攝影機辨識"),
472
  )
473
 
474
  if isinstance(result, list):
475
  result = result[0]
476
  emo_dict = result.get("emotion", None)
 
477
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
478
+ annotated = _draw_guide_overlay(frame_rgb, True)
479
  return (
480
+ gr.update(visible=True, value=annotated),
481
+ gr.update(value=_hint_html("已偵測到人臉,開始辨識情緒中…")),
482
  gr.update(value=_score_radio_value(3)),
483
  gr.update(value=_camera_hint_before_start()),
484
  st,
 
485
  gr.update(value="重新攝影機辨識"),
486
  )
487
 
 
489
  top_conf = float(emo_dict[top_key])
490
 
491
  if top_conf < CONF_TH:
492
+ annotated = _draw_guide_overlay(frame_rgb, True)
493
  return (
494
+ gr.update(visible=True, value=annotated),
495
+ gr.update(value=_hint_html("已偵測到人臉,開始辨識情緒中…")),
496
  gr.update(value=_score_radio_value(3)),
497
  gr.update(value=_camera_hint_before_start()),
498
  st,
 
499
  gr.update(value="重新攝影機辨識"),
500
  )
501
 
 
502
  keys = list(emo_dict.keys())
503
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
504
 
 
512
  stable_key = st.emo_keys[stable_idx]
513
  st.stable_emo = stable_key
514
 
 
515
  if st.candidate_emo != stable_key:
516
  st.candidate_emo = stable_key
517
  st.candidate_since = now
 
531
  st.running = False
532
  st.finished = True
533
  return (
534
+ gr.update(visible=False),
535
  gr.update(value=_result_card_html(stable_key, top_conf)),
536
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
537
  gr.update(value=_camera_hint_done()),
538
  st,
 
539
  gr.update(value="重新攝影機辨識"),
540
  )
541
 
542
+ annotated = _draw_guide_overlay(frame_rgb, True)
543
  return (
544
+ gr.update(visible=True, value=annotated),
545
  gr.update(value=_result_card_html(stable_key, top_conf)),
546
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
547
  gr.update(value=_camera_hint_before_start()),
548
  st,
 
549
  gr.update(value="重新攝影機辨識"),
550
  )
551
 
 
553
  # =========================
554
  # Buttons
555
  # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  def on_restart(st: AppState):
557
+ st = _reset_state_for_camera(st)
558
  return (
559
+ gr.update(visible=True, value=None), # ✅ 清掉上一張畫面
560
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
561
  gr.update(value=_score_radio_value(3)),
562
  gr.update(value=_camera_hint_before_start()),
563
  st,
 
564
  gr.update(value="重新攝影機辨識"),
565
  )
566
 
567
  def on_stop(st: AppState):
568
  st.running = False
569
  st.finished = True
570
+ if st.final_emo is None:
571
  return (
572
+ gr.update(visible=False, value=None),
573
+ gr.update(value=_hint_html("已停止辨識。")),
574
+ gr.update(value=_score_radio_value(3)),
575
+ gr.update(value=_camera_hint_stopped()),
576
  st,
 
577
  gr.update(value="重新攝影機辨識"),
578
  )
579
  return (
580
+ gr.update(visible=False, value=None),
581
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
582
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
583
+ gr.update(value=_camera_hint_done()),
584
  st,
 
585
  gr.update(value="重新攝影機辨識"),
586
  )
587
 
 
590
  # Gradio UI
591
  # =========================
592
  if __name__ == "__main__":
593
+ with gr.Blocks(title="Emotion Detector", css=css) as demo:
594
  st = gr.State(AppState())
595
 
596
  gr.Markdown("## 情緒辨識")
597
 
598
  hint = gr.HTML(_camera_hint_before_start())
599
 
 
600
  cam = gr.Image(
601
  sources=["webcam"],
602
  streaming=True,
603
  type="numpy",
604
  label="攝影機",
605
+ visible=True,
606
  )
607
 
 
 
 
608
  with gr.Row():
609
  btn_restart = gr.Button("重新攝影機辨識", variant="primary")
610
  btn_stop = gr.Button("停止", variant="secondary")
 
620
  interactive=False,
621
  )
622
 
 
623
  cam.stream(
624
+ fn=on_stream,
625
  inputs=[cam, st],
626
+ outputs=[cam, result, mood, hint, st, btn_restart],
627
  show_progress="minimal",
628
  )
629
 
630
  btn_restart.click(
631
  fn=on_restart,
632
  inputs=[st],
633
+ outputs=[cam, result, mood, hint, st, btn_restart],
634
  show_progress="minimal",
635
  )
636
 
637
  btn_stop.click(
638
  fn=on_stop,
639
  inputs=[st],
640
+ outputs=[cam, result, mood, hint, st, btn_restart],
641
  show_progress="minimal",
642
  )
643