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

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +161 -247
emotion.py CHANGED
@@ -5,36 +5,27 @@ 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
 
@@ -45,7 +36,6 @@ def _warmup_models():
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,
@@ -55,7 +45,6 @@ def _warmup_models():
55
  )
56
  except Exception:
57
  pass
58
- # emotion model warmup
59
  try:
60
  DeepFace.analyze(
61
  img_path=dummy,
@@ -104,6 +93,11 @@ SCORE_LABELS = {
104
  }
105
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
106
 
 
 
 
 
 
107
 
108
  # =========================
109
  # 狀態
@@ -114,8 +108,6 @@ class AppState:
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
 
@@ -127,12 +119,12 @@ class AppState:
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
136
  # =========================
137
  def _hint_html(msg: str) -> str:
138
  return f"""
@@ -158,8 +150,15 @@ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
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>
@@ -167,14 +166,34 @@ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
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:
@@ -184,308 +203,181 @@ def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
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:
193
- return frame
194
- h, w = frame.shape[:2]
195
- new_w = int(w / zoom)
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()),
362
  st,
 
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
 
@@ -493,16 +385,16 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
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
 
@@ -516,6 +408,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
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
@@ -535,21 +428,21 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
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,
 
553
  gr.update(value="重新攝影機辨識"),
554
  )
555
 
@@ -557,35 +450,51 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
557
  # =========================
558
  # Buttons
559
  # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()),
567
  st,
 
568
  gr.update(value="重新攝影機辨識"),
569
  )
570
 
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()),
588
  st,
 
589
  gr.update(value="重新攝影機辨識"),
590
  )
591
 
@@ -594,21 +503,25 @@ def on_stop(st: AppState):
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
 
 
 
 
612
  with gr.Row():
613
  btn_restart = gr.Button("重新攝影機辨識", variant="primary")
614
  btn_stop = gr.Button("停止", variant="secondary")
@@ -624,24 +537,25 @@ if __name__ == "__main__":
624
  interactive=False,
625
  )
626
 
 
627
  cam.stream(
628
- fn=on_stream,
629
  inputs=[cam, st],
630
- outputs=[cam, result, mood, hint, st, btn_restart],
631
  show_progress="minimal",
632
  )
633
 
634
  btn_restart.click(
635
  fn=on_restart,
636
  inputs=[st],
637
- outputs=[cam, result, mood, hint, st, btn_restart],
638
  show_progress="minimal",
639
  )
640
 
641
  btn_stop.click(
642
  fn=on_stop,
643
  inputs=[st],
644
- outputs=[cam, result, mood, hint, st, btn_restart],
645
  show_progress="minimal",
646
  )
647
 
 
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
  _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
  )
46
  except Exception:
47
  pass
 
48
  try:
49
  DeepFace.analyze(
50
  img_path=dummy,
 
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
  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
  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
  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
  </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
  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
  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
  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
  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
  # =========================
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
  # 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
  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