YuAnTsai commited on
Commit
74a97af
·
verified ·
1 Parent(s): 45aa716

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +155 -585
emotion.py CHANGED
@@ -4,685 +4,255 @@ from typing import Optional, Dict, Tuple, List
4
 
5
  import gradio as gr
6
  import numpy as np
7
- from PIL import Image, ImageDraw
 
 
 
 
8
 
9
  from deepface import DeepFace
10
 
11
-
12
- # ✅ 避免 css 未定義造成 NameError
13
- css = ""
14
-
15
-
16
  # =========================
17
- # 參數(HF Space 建議偏順)
18
  # =========================
19
- ANALYZE_EVERY_SEC = 1.4
20
- DOWNSAMPLE_W = 480
21
  EMA_ALPHA = 0.45
22
  CONF_TH = 40.0
23
- SWITCH_CONFIRM_SEC = 2.2
24
  IOU_TH = 0.25
25
- ZOOM_FACTOR = 1.25
26
-
27
- DETECTOR_BACKEND = "opencv" # Space 上較順
28
- ALIGN_FACE = True
29
-
30
- MIN_FACE_AREA_RATIO = 0.04 # 人臉太小視為沒偵測到
31
- RECENT_FACE_SEC = 0.25 # ✅ 綠框快取時間:縮短避免空景綠框殘留
32
-
 
 
 
33
 
34
  # =========================
35
- # OpenCV 眼睛驗證(降低背景假臉)
36
- # =========================
37
- try:
38
- import cv2
39
- _HAS_CV2 = True
40
- _EYE_CASCADE = cv2.CascadeClassifier(
41
- cv2.data.haarcascades + "haarcascade_eye.xml"
42
- )
43
- except Exception:
44
- _HAS_CV2 = False
45
- _EYE_CASCADE = None
46
-
47
-
48
- def _verify_face_has_eyes(face_rgb_uint8: np.ndarray) -> bool:
49
- """
50
- 用眼睛 HaarCascade 做簡單驗證:
51
- - 背景假臉通常偵測不到眼睛
52
- - 真臉通常能偵測到 >= 1 個眼睛
53
- """
54
- if not _HAS_CV2 or _EYE_CASCADE is None:
55
- # 沒有 cv2 就不做驗證(維持原行為)
56
- return True
57
-
58
- if face_rgb_uint8 is None or face_rgb_uint8.size == 0:
59
- return False
60
-
61
- # 影像太小就直接判 False
62
- h, w = face_rgb_uint8.shape[:2]
63
- if h < 40 or w < 40:
64
- return False
65
-
66
- gray = cv2.cvtColor(face_rgb_uint8, cv2.COLOR_RGB2GRAY)
67
- gray = cv2.equalizeHist(gray)
68
-
69
- # scaleFactor / minNeighbors 調得偏嚴格 → 更能擋掉背景假臉
70
- eyes = _EYE_CASCADE.detectMultiScale(
71
- gray,
72
- scaleFactor=1.12,
73
- minNeighbors=6,
74
- minSize=(12, 12),
75
- )
76
- return len(eyes) >= 1
77
-
78
-
79
- # =========================
80
- # 情緒中文 + 分數映射
81
  # =========================
82
  EMO_ZH = {
83
- "angry": "生氣",
84
- "disgust": "厭惡",
85
- "fear": "害怕",
86
- "happy": "開心",
87
- "sad": "難過",
88
- "surprise": "驚訝",
89
- "neutral": "平靜",
90
- "unknown": "未知",
91
  }
92
 
93
  EMO_TO_SCORE = {
94
- "angry": 1,
95
- "disgust": 1,
96
- "fear": 1,
97
- "sad": 2,
98
- "neutral": 3,
99
- "surprise": 4,
100
- "happy": 5,
101
- "unknown": 3,
102
  }
103
 
104
  SCORE_LABELS = {
105
- 1: "1(心情差)",
106
- 2: "2(不太好)",
107
- 3: "3(普通)",
108
- 4: "4(不錯)",
109
- 5: "5(超棒)",
110
  }
111
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
112
 
113
- def _score_radio_value(score: int) -> str:
114
- score = int(score)
115
- score = min(5, max(1, score))
116
- return SCORE_LABELS[score]
117
-
118
-
119
- # =========================
120
- # 狀態
121
- # =========================
122
  @dataclass
123
  class AppState:
124
  running: bool = True
125
  finished: bool = False
126
  last_analyze_t: float = 0.0
127
-
128
  track_bbox: Optional[Tuple[int, int, int, int]] = None
129
-
130
  ema_scores: Optional[np.ndarray] = None
131
  emo_keys: List[str] = field(default_factory=list)
132
-
133
  stable_emo: str = "unknown"
134
  candidate_emo: Optional[str] = None
135
  candidate_since: Optional[float] = None
136
-
137
  final_emo: Optional[str] = None
138
  final_conf: Optional[float] = None
139
  final_score: int = 3
140
 
141
- face_ok: bool = False
142
- last_face_t: float = 0.0
143
- face_in_box: bool = False
144
-
145
-
146
  # =========================
147
- # UI HTML
148
  # =========================
149
  def _hint_html(msg: str) -> str:
150
- return f"""
151
- <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">
152
- {msg}
153
- </div>
154
- """
155
-
156
- def _camera_hint_before_start() -> str:
157
- return _hint_html(
158
- "請先按「Click to access webcam / 允許」開啟攝影機;"
159
- "接著,請按「錄製(Record)」開始。"
160
- )
161
-
162
- def _camera_hint_done() -> str:
163
- return _hint_html("已完成辨識,可按「重���攝影機辨識」再次進行辨識。")
164
-
165
- def _camera_hint_stopped() -> str:
166
- return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
167
 
168
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
169
  zh = EMO_ZH.get(emo_key, emo_key)
170
  score = EMO_TO_SCORE.get(emo_key, 3)
171
  conf_txt = "" if conf is None else f"{conf:.1f}%"
172
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
173
-
174
  return f"""
175
  <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#000000;">
176
- <div style="font-size:14px;color:#000000;margin-bottom:8px;">辨識結果</div>
177
- <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;color:#000000;">{zh}</div>
178
- <div style="font-size:16px;color:#000000;margin-bottom:6px;">心情分數:{score}({score_desc})</div>
179
- <div style="font-size:14px;color:#000000;">信心值:{conf_txt}</div>
180
  </div>
181
  """
182
 
 
 
183
 
184
- # =========================
185
- # 影像工具
186
- # =========================
187
- def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
188
- if frame is None:
189
- return None
190
- h, w = frame.shape[:2]
191
- if w <= target_w:
192
- return frame
193
- scale = target_w / w
194
- new_h = max(1, int(h * scale))
195
- img = Image.fromarray(frame)
196
- img = img.resize((target_w, new_h))
197
- return np.array(img)
198
 
199
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
200
- if frame is None or zoom <= 1.0:
201
- return frame
202
  h, w = frame.shape[:2]
203
- new_w = int(w / zoom)
204
- new_h = int(h / zoom)
205
- start_x = (w - new_w) // 2
206
- start_y = (h - new_h) // 2
207
- return frame[start_y:start_y + new_h, start_x:start_x + new_w]
208
-
209
 
210
  # =========================
211
- # 引導框(固定位置)
212
  # =========================
213
- GUIDE_W_RATIO = 0.60
214
- GUIDE_H_RATIO = 0.72
215
- GUIDE_Y_OFFSET = 0.00
216
-
217
- def _guide_box_xyxy(frame: np.ndarray) -> Tuple[int, int, int, int]:
218
  h, w = frame.shape[:2]
219
- gw = int(w * GUIDE_W_RATIO)
220
- gh = int(h * GUIDE_H_RATIO)
221
- cx = w // 2
222
- cy = int(h * (0.5 + GUIDE_Y_OFFSET))
223
- x1 = max(0, cx - gw // 2)
224
- y1 = max(0, cy - gh // 2)
225
- x2 = min(w - 1, cx + gw // 2)
226
- y2 = min(h - 1, cy + gh // 2)
227
- return x1, y1, x2, y2
228
-
229
- def _bbox_center_in_box(bbox_xywh: Tuple[float, float, float, float], box_xyxy: Tuple[int, int, int, int]) -> bool:
230
- x, y, w, h = bbox_xywh
231
- cx = x + w / 2.0
232
- cy = y + h / 2.0
233
- x1, y1, x2, y2 = box_xyxy
234
- return (x1 <= cx <= x2) and (y1 <= cy <= y2)
235
-
236
- def _draw_guide_overlay(frame_rgb: np.ndarray, green: bool, msg: str) -> np.ndarray:
237
- img = Image.fromarray(frame_rgb)
238
- draw = ImageDraw.Draw(img)
239
-
240
- x1, y1, x2, y2 = _guide_box_xyxy(frame_rgb)
241
- color = (0, 200, 0) if green else (220, 0, 0)
242
-
243
- h, w = frame_rgb.shape[:2]
244
- thickness = max(3, int(min(w, h) * 0.008))
245
-
246
- for t in range(thickness):
247
- draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t], outline=color)
248
-
249
- tx = x1
250
- ty = max(0, y1 - int(thickness * 7))
251
- draw.rectangle([tx, ty, min(w - 1, tx + 520), ty + 34], fill=(255, 255, 255))
252
- draw.text((tx + 8, ty + 7), msg, fill=(0, 0, 0))
253
-
254
- return np.array(img)
255
-
256
-
257
- # =========================
258
- # Tracking / IoU
259
- # =========================
260
- def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
261
- ax, ay, aw, ah = a
262
- bx, by, bw, bh = b
263
- ax2, ay2 = ax + aw, ay + ah
264
- bx2, by2 = bx + bw, by + bh
265
-
266
- ix1, iy1 = max(ax, bx), max(ay, by)
267
- ix2, iy2 = min(ax2, bx2), min(ay2, by2)
268
- iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
269
- inter = iw * ih
270
- if inter <= 0:
271
- return 0.0
272
- union = aw * ah + bw * bh - inter
273
- return inter / max(1e-6, union)
274
-
275
- def _pick_face_by_tracking(
276
- faces: List[Dict],
277
- prev_bbox: Optional[Tuple[int, int, int, int]]
278
- ):
279
- if not faces:
280
  return None, None
281
 
282
- cand = []
283
- for f in faces:
284
- fa = f.get("facial_area", {}) or {}
285
- x = int(fa.get("x", 0) or 0)
286
- y = int(fa.get("y", 0) or 0)
287
- w = int(fa.get("w", 0) or 0)
288
- h = int(fa.get("h", 0) or 0)
289
- if w > 0 and h > 0 and f.get("face") is not None:
290
- cand.append((f, (x, y, w, h), w * h))
291
-
292
- if not cand:
293
  return None, None
294
 
295
- if prev_bbox is not None:
296
- best = None
297
- best_i = 0.0
298
- for f, bb, area in cand:
299
- i = _iou(prev_bbox, bb)
300
- if i > best_i:
301
- best_i = i
302
- best = (f, bb)
303
- if best is not None and best_i >= IOU_TH:
304
- return best[0], best[1]
305
-
306
- cand.sort(key=lambda x: x[2], reverse=True)
307
- return cand[0][0], cand[0][1]
308
-
309
-
310
- def _reset_state_for_camera(st: AppState) -> AppState:
311
- st.running = True
312
- st.finished = False
313
- st.last_analyze_t = 0.0
314
- st.track_bbox = None
315
- st.ema_scores = None
316
- st.emo_keys = []
317
- st.stable_emo = "unknown"
318
- st.candidate_emo = None
319
- st.candidate_since = None
320
- st.final_emo = None
321
- st.final_conf = None
322
- st.final_score = 3
323
- st.face_ok = False
324
- st.last_face_t = 0.0
325
- st.face_in_box = False
326
- return st
327
-
328
-
329
- def _is_face_big_enough(bbox_xywh: Tuple[float, float, float, float], frame_hw: Tuple[int, int]) -> bool:
330
- fh, fw = frame_hw
331
- x, y, w, h = bbox_xywh
332
- area_ratio = (w * h) / max(1.0, float(fw * fh))
333
- return area_ratio >= MIN_FACE_AREA_RATIO
334
 
 
 
335
 
336
  # =========================
337
- # Camera streaming(含紅/綠框 + 眼睛驗證)
338
  # =========================
339
  def on_stream(frame_rgb: np.ndarray, st: AppState):
340
- if st.finished or (not st.running):
341
- cam_visible = False
342
- if st.final_emo:
343
- return (
344
- gr.update(visible=cam_visible),
345
- gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
346
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
347
- gr.update(value=_camera_hint_done()),
348
- st,
349
- gr.update(value="重新攝影機辨識"),
350
- )
351
- return (
352
- gr.update(visible=cam_visible),
353
- gr.update(value=_hint_html("尚未完成辨識。")),
354
- gr.update(value=_score_radio_value(3)),
355
- gr.update(value=_camera_hint_stopped()),
356
- st,
357
- gr.update(value="重新攝影機辨識"),
358
- )
359
 
360
  if frame_rgb is None:
361
- return (
362
- gr.update(visible=True, value=None),
363
- gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
364
- gr.update(value=_score_radio_value(3)),
365
- gr.update(value=_camera_hint_before_start()),
366
- st,
367
- gr.update(value="重新攝影機辨識"),
368
- )
369
 
370
  frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
371
  now = time.time()
372
-
373
- # ✅ 快取只留很短時間(避免空景也綠)
374
- recent_face = st.face_ok and st.face_in_box and (now - st.last_face_t) < RECENT_FACE_SEC
375
- msg_fast = "已偵測到人臉,開始辨識情緒中…" if recent_face else "未偵測到人臉,請把臉放進框內"
376
- annotated_fast = _draw_guide_overlay(frame_rgb, green=recent_face, msg=msg_fast)
377
-
378
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
379
- if recent_face:
380
- show = st.stable_emo or "unknown"
381
- result_html = _result_card_html(show, st.final_conf)
382
- mood_val = _score_radio_value(EMO_TO_SCORE.get(show, 3))
383
- else:
384
- result_html = _hint_html("未偵測到人臉,請把臉放進框內(紅框)")
385
- mood_val = _score_radio_value(3)
386
-
387
- return (
388
- gr.update(visible=True, value=annotated_fast),
389
- gr.update(value=result_html),
390
- gr.update(value=mood_val),
391
- gr.update(value=_camera_hint_before_start()),
392
- st,
393
- gr.update(value="重新攝影機辨識"),
394
- )
395
-
396
  st.last_analyze_t = now
397
- small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
398
 
399
- try:
400
- faces = DeepFace.extract_faces(
401
- img_path=small,
402
- detector_backend=DETECTOR_BACKEND,
403
- enforce_detection=False,
404
- align=ALIGN_FACE,
405
- )
406
- except Exception:
407
- faces = []
408
-
409
- pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
410
-
411
- # 沒臉
412
- if pick is None or bbox is None or pick.get("face") is None:
413
- st.track_bbox = None
414
- st.face_ok = False
415
- st.face_in_box = False
416
- st.last_face_t = now
417
- st.stable_emo = "unknown" # ✅ 清掉殘留情緒
418
-
419
- annotated = _draw_guide_overlay(frame_rgb, green=False, msg="未偵測到人臉,請把臉放進框內")
420
- return (
421
- gr.update(visible=True, value=annotated),
422
- gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內(紅框)")),
423
- gr.update(value=_score_radio_value(3)),
424
- gr.update(value=_camera_hint_before_start()),
425
- st,
426
- gr.update(value="重新攝影機辨識"),
427
- )
428
-
429
- # bbox small -> full
430
- sh, sw = small.shape[:2]
431
- fh, fw = frame_rgb.shape[:2]
432
- sx = fw / max(1, sw)
433
- sy = fh / max(1, sh)
434
- x, y, w, h = bbox
435
- bbox_full = (x * sx, y * sy, w * sx, h * sy)
436
-
437
- # 太小
438
- if not _is_face_big_enough(bbox_full, (fh, fw)):
439
- st.track_bbox = None
440
- st.face_ok = False
441
- st.face_in_box = False
442
- st.last_face_t = now
443
- st.stable_emo = "unknown"
444
-
445
- annotated = _draw_guide_overlay(frame_rgb, green=False, msg="人臉太小/太遠,請靠近鏡頭並放進框內")
446
- return (
447
- gr.update(visible=True, value=annotated),
448
- gr.update(value=_hint_html("人臉太小/太遠,請靠近鏡頭並放進框內(紅框)")),
449
- gr.update(value=_score_radio_value(3)),
450
- gr.update(value=_camera_hint_before_start()),
451
- st,
452
- gr.update(value="重新攝影機辨識"),
453
- )
454
-
455
- guide = _guide_box_xyxy(frame_rgb)
456
- in_box = _bbox_center_in_box(bbox_full, guide)
457
 
458
  st.track_bbox = bbox
459
- st.last_face_t = now
460
- st.face_ok = True
461
- st.face_in_box = in_box
462
-
463
- if not in_box:
464
- st.stable_emo = "unknown"
465
- annotated = _draw_guide_overlay(frame_rgb, green=False, msg="已偵測到人臉,請把臉移到框內")
466
- return (
467
- gr.update(visible=True, value=annotated),
468
- gr.update(value=_hint_html("已偵測到人臉,但請把臉移到框內(紅框)")),
469
- gr.update(value=_score_radio_value(3)),
470
- gr.update(value=_camera_hint_before_start()),
471
- st,
472
- gr.update(value="重新攝影機辨識"),
473
- )
474
-
475
- # ✅ 眼睛驗證(關鍵)
476
- face = pick["face"]
477
- if face.dtype != np.uint8:
478
- face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
479
-
480
- if not _verify_face_has_eyes(face):
481
- # 當作沒臉:避免背景假臉
482
- st.track_bbox = None
483
- st.face_ok = False
484
- st.face_in_box = False
485
- st.last_face_t = now
486
- st.stable_emo = "unknown"
487
-
488
- annotated = _draw_guide_overlay(frame_rgb, green=False, msg="未偵測到真實人臉(找不到眼睛),請對準鏡頭")
489
- return (
490
- gr.update(visible=True, value=annotated),
491
- gr.update(value=_hint_html("未偵測到真實人臉(找不到眼睛),請把臉放進框內(紅框)")),
492
- gr.update(value=_score_radio_value(3)),
493
- gr.update(value=_camera_hint_before_start()),
494
- st,
495
- gr.update(value="重新攝影機辨識"),
496
- )
497
-
498
- # ✅ 到這裡才算「真的有人臉」→ 綠框 + 情緒分析
499
- annotated = _draw_guide_overlay(frame_rgb, green=True, msg="已偵測到人臉,開始辨識情緒中…")
500
 
 
501
  try:
502
- result = DeepFace.analyze(
503
- img_path=face,
504
- actions=["emotion"],
505
- enforce_detection=False,
506
- )
507
- except Exception:
508
- return (
509
- gr.update(visible=True, value=annotated),
510
- gr.update(value=_hint_html("已偵測到人臉,辨識中…(分析暫時失敗,請保持正面/光線亮一點)")),
511
- gr.update(value=_score_radio_value(3)),
512
- gr.update(value=_camera_hint_before_start()),
513
- st,
514
- gr.update(value="重新攝影機辨識"),
515
- )
516
-
517
- if isinstance(result, list):
518
- result = result[0]
519
- emo_dict = result.get("emotion", None)
520
-
521
- if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
522
- return (
523
- gr.update(visible=True, value=annotated),
524
- gr.update(value=_hint_html("已偵測到人臉,辨識中…")),
525
- gr.update(value=_score_radio_value(3)),
526
- gr.update(value=_camera_hint_before_start()),
527
- st,
528
- gr.update(value="重新攝影機辨識"),
529
- )
530
 
531
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
532
  top_conf = float(emo_dict[top_key])
533
 
534
- if top_conf < CONF_TH:
535
- return (
536
- gr.update(visible=True, value=annotated),
537
- gr.update(value=_hint_html("已偵測到人臉,辨識中…(信心偏低,請面向鏡頭/光線亮一點)")),
538
- gr.update(value=_score_radio_value(3)),
539
- gr.update(value=_camera_hint_before_start()),
540
- st,
541
- gr.update(value="重新攝影機辨識"),
542
- )
543
-
544
- keys = list(emo_dict.keys())
545
- vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
546
-
547
- if st.ema_scores is None or st.emo_keys != keys:
548
- st.ema_scores = vec
549
- st.emo_keys = keys
550
- else:
551
- st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
552
-
553
- stable_idx = int(np.argmax(st.ema_scores))
554
- stable_key = st.emo_keys[stable_idx]
555
- st.stable_emo = stable_key
556
-
557
- if st.candidate_emo != stable_key:
558
- st.candidate_emo = stable_key
559
- st.candidate_since = now
560
- else:
561
- if st.candidate_since is None:
562
- st.candidate_since = now
563
-
564
- confirmed = (
565
- st.candidate_since is not None
566
- and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
567
- )
568
-
569
- if confirmed and st.final_emo is None:
570
- st.final_emo = stable_key
571
- st.final_conf = top_conf
572
- st.final_score = EMO_TO_SCORE.get(stable_key, 3)
573
- st.running = False
574
- st.finished = True
575
-
576
- return (
577
- gr.update(visible=False, value=None),
578
- gr.update(value=_result_card_html(stable_key, top_conf)),
579
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
580
- gr.update(value=_camera_hint_done()),
581
- st,
582
- gr.update(value="重新攝影機辨識"),
583
- )
584
 
585
  return (
586
- gr.update(visible=True, value=annotated),
587
- gr.update(value=_result_card_html(stable_key, top_conf)),
588
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
589
- gr.update(value=_camera_hint_before_start()),
590
  st,
591
- gr.update(value="重新攝影機辨識"),
592
  )
593
 
594
-
595
- # =========================
596
- # Buttons
597
- # =========================
598
  def on_restart(st: AppState):
599
- st = _reset_state_for_camera(st)
600
  return (
601
- gr.update(visible=True, value=None),
602
- gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
603
- gr.update(value=_score_radio_value(3)),
604
- gr.update(value=_camera_hint_before_start()),
605
- st,
606
- gr.update(value="重新攝影機辨識"),
607
  )
608
 
609
  def on_stop(st: AppState):
610
- st.running = False
611
- st.finished = True
612
- if st.final_emo is None:
613
- return (
614
- gr.update(visible=False, value=None),
615
- gr.update(value=_hint_html("已停止辨識。")),
616
- gr.update(value=_score_radio_value(3)),
617
- gr.update(value=_camera_hint_stopped()),
618
- st,
619
- gr.update(value="重新攝影機辨識"),
620
- )
621
-
622
- return (
623
- gr.update(visible=False, value=None),
624
- gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
625
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
626
- gr.update(value=_camera_hint_done()),
627
- st,
628
- gr.update(value="重新攝影機辨識"),
629
- )
630
-
631
 
632
  # =========================
633
- # Gradio UI
634
  # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
635
  if __name__ == "__main__":
636
- with gr.Blocks(title="Emotion Detector", css=css) as demo:
637
- st = gr.State(AppState())
638
-
639
- gr.Markdown("## 第一步:測測你的心情能量")
640
- gr.Markdown("讓 AI 看看你的表情,自動幫你決定心情分數!(辨識完畢會自動跳轉)")
641
-
642
- hint = gr.HTML(_camera_hint_before_start())
643
-
644
- cam = gr.Image(
645
- sources=["webcam"],
646
- streaming=True,
647
- type="numpy",
648
- label="攝影機畫面",
649
- visible=True,
650
- )
651
-
652
- with gr.Row():
653
- btn_restart = gr.Button("重新攝影機辨識", variant="primary")
654
- btn_stop = gr.Button("停止", variant="secondary")
655
-
656
- gr.Markdown("### 辨識結果")
657
- result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
658
-
659
- gr.Markdown("### 心情分數(系統判定)")
660
- mood = gr.Radio(
661
- choices=SCORE_CHOICES,
662
- value=_score_radio_value(3),
663
- label="心情分數",
664
- interactive=False,
665
- )
666
-
667
- cam.stream(
668
- fn=on_stream,
669
- inputs=[cam, st],
670
- outputs=[cam, result, mood, hint, st, btn_restart],
671
- show_progress="minimal",
672
- )
673
-
674
- btn_restart.click(
675
- fn=on_restart,
676
- inputs=[st],
677
- outputs=[cam, result, mood, hint, st, btn_restart],
678
- show_progress="minimal",
679
- )
680
-
681
- btn_stop.click(
682
- fn=on_stop,
683
- inputs=[st],
684
- outputs=[cam, result, mood, hint, st, btn_restart],
685
- show_progress="minimal",
686
- )
687
-
688
- demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
 
4
 
5
  import gradio as gr
6
  import numpy as np
7
+ import cv2
8
+ from PIL import Image
9
+ import mediapipe as mp
10
+ from mediapipe.tasks import python
11
+ from mediapipe.tasks.vision import FaceDetector, FaceDetectorOptions, RunningMode
12
 
13
  from deepface import DeepFace
14
 
 
 
 
 
 
15
  # =========================
16
+ # 參數與 MediaPipe 初始化
17
  # =========================
18
+ ANALYZE_EVERY_SEC = 0.6 # MediaPipe 很快,可以稍微縮短間隔
19
+ DOWNSAMPLE_W = 720
20
  EMA_ALPHA = 0.45
21
  CONF_TH = 40.0
22
+ SWITCH_CONFIRM_SEC = 2.0
23
  IOU_TH = 0.25
24
+ ZOOM_FACTOR = 1.3
25
+
26
+ # MediaPipe 設定
27
+ MODEL_PATH = 'model/blaze_face_short_range.tflite'
28
+ base_options = python.BaseOptions(model_asset_path=MODEL_PATH)
29
+ options = FaceDetectorOptions(
30
+ base_options=base_options,
31
+ running_mode=RunningMode.IMAGE
32
+ )
33
+ # 建立全域偵測器
34
+ mp_detector = FaceDetector.create_from_options(options)
35
 
36
  # =========================
37
+ # 情緒映射與標籤
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  # =========================
39
  EMO_ZH = {
40
+ "angry": "生氣", "disgust": "厭惡", "fear": "害怕",
41
+ "happy": "開心", "sad": "難過", "surprise": "驚訝",
42
+ "neutral": "平靜", "unknown": "未知",
 
 
 
 
 
43
  }
44
 
45
  EMO_TO_SCORE = {
46
+ "angry": 1, "disgust": 1, "fear": 1,
47
+ "sad": 2, "neutral": 3, "surprise": 4,
48
+ "happy": 5, "unknown": 3,
 
 
 
 
 
49
  }
50
 
51
  SCORE_LABELS = {
52
+ 1: "1(心情差)", 2: "2(不太好)", 3: "3(普通)",
53
+ 4: "4(不錯)", 5: "5(超棒)",
 
 
 
54
  }
55
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
56
 
 
 
 
 
 
 
 
 
 
57
  @dataclass
58
  class AppState:
59
  running: bool = True
60
  finished: bool = False
61
  last_analyze_t: float = 0.0
 
62
  track_bbox: Optional[Tuple[int, int, int, int]] = None
 
63
  ema_scores: Optional[np.ndarray] = None
64
  emo_keys: List[str] = field(default_factory=list)
 
65
  stable_emo: str = "unknown"
66
  candidate_emo: Optional[str] = None
67
  candidate_since: Optional[float] = None
 
68
  final_emo: Optional[str] = None
69
  final_conf: Optional[float] = None
70
  final_score: int = 3
71
 
 
 
 
 
 
72
  # =========================
73
+ # UI 工具與輔助函式
74
  # =========================
75
  def _hint_html(msg: str) -> str:
76
+ return f'<div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">{msg}</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
79
  zh = EMO_ZH.get(emo_key, emo_key)
80
  score = EMO_TO_SCORE.get(emo_key, 3)
81
  conf_txt = "" if conf is None else f"{conf:.1f}%"
82
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
 
83
  return f"""
84
  <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#000000;">
85
+ <div style="font-size:14px;margin-bottom:8px;">即時辨識結果</div>
86
+ <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;">{zh}</div>
87
+ <div style="font-size:16px;margin-bottom:6px;">心情分數:{score}({score_desc})</div>
88
+ <div style="font-size:14px;">信心值:{conf_txt}</div>
89
  </div>
90
  """
91
 
92
+ def _score_radio_value(score: int) -> str:
93
+ return SCORE_LABELS[min(5, max(1, int(score)))]
94
 
95
+ def _iou(a, b) -> float:
96
+ ax, ay, aw, ah = a
97
+ bx, by, bw, bh = b
98
+ ix, iy = max(ax, bx), max(ay, by)
99
+ iw, ih = max(0, min(ax+aw, bx+bw)-ix), max(0, min(ay+ah, by+bh)-iy)
100
+ area_i = iw * ih
101
+ if area_i <= 0: return 0.0
102
+ return area_i / (aw*ah + bw*bh - area_i)
 
 
 
 
 
 
103
 
104
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
105
+ if frame is None or zoom <= 1.0: return frame
 
106
  h, w = frame.shape[:2]
107
+ nw, nh = int(w / zoom), int(h / zoom)
108
+ sx, sy = (w - nw) // 2, (h - nh) // 2
109
+ return frame[sy:sy+nh, sx:sx+nw]
 
 
 
110
 
111
  # =========================
112
+ # MediaPipe 偵測邏輯
113
  # =========================
114
+ def get_face_mediapipe(frame: np.ndarray, prev_bbox: Optional[Tuple[int, int, int, int]]):
 
 
 
 
115
  h, w = frame.shape[:2]
116
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=frame)
117
+ res = mp_detector.detect(mp_image)
118
+
119
+ if not res.detections:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  return None, None
121
 
122
+ candidates = []
123
+ for det in res.detections:
124
+ b = det.bounding_box
125
+ # 數值校正,避免超出邊界導致 DeepFace 報錯
126
+ nx, ny = max(0, b.origin_x), max(0, b.origin_y)
127
+ nw, nh = min(b.width, w - nx), min(b.height, h - ny)
128
+ if nw > 10 and nh > 10:
129
+ candidates.append(((nx, ny, nw, nh), frame[ny:ny+nh, nx:nx+nw]))
130
+
131
+ if not candidates:
 
132
  return None, None
133
 
134
+ # 優先匹配上一幀的位置
135
+ if prev_bbox:
136
+ best_match = max(candidates, key=lambda c: _iou(prev_bbox, c[0]), default=(None, None))
137
+ if best_match[0] and _iou(prev_bbox, best_match[0]) > IOU_TH:
138
+ return best_match
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
+ # 否則回傳面積最大者
141
+ return max(candidates, key=lambda c: c[0][2] * c[0][3])
142
 
143
  # =========================
144
+ # Gradio 事件處理
145
  # =========================
146
  def on_stream(frame_rgb: np.ndarray, st: AppState):
147
+ if not st.running or st.finished:
148
+ res_html = _result_card_html(st.final_emo, st.final_conf) if st.final_emo else _hint_html("辨識停止")
149
+ return gr.update(visible=False), res_html, _score_radio_value(st.final_score), _hint_html("已完成"), st, gr.update()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  if frame_rgb is None:
152
+ return gr.update(), gr.update(), gr.update(), _hint_html("等待攝影機..."), st, gr.update()
 
 
 
 
 
 
 
153
 
154
  frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
155
  now = time.time()
156
+
157
+ # 頻率控制
 
 
 
 
158
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
159
+ return gr.update(), gr.update(), gr.update(), gr.update(), st, gr.update()
160
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  st.last_analyze_t = now
 
162
 
163
+ # 使用 MediaPipe 找臉
164
+ bbox, face_crop = get_face_mediapipe(frame_rgb, st.track_bbox)
165
+
166
+ if face_crop is None:
167
+ return gr.update(), gr.update(), gr.update(), _hint_html("未偵測到人臉"), st, gr.update()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
 
169
  st.track_bbox = bbox
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
+ # DeepFace 情緒分析 (傳入已裁切的人臉)
172
  try:
173
+ res = DeepFace.analyze(face_crop, actions=['emotion'], enforce_detection=False, silent=True)
174
+ emo_dict = res[0]['emotion'] if isinstance(res, list) else res['emotion']
175
+ except:
176
+ return gr.update(), gr.update(), gr.update(), gr.update(), st, gr.update()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
179
  top_conf = float(emo_dict[top_key])
180
 
181
+ # 平滑處理 (EMA)
182
+ if top_conf >= CONF_TH:
183
+ keys = list(emo_dict.keys())
184
+ vals = np.array([float(emo_dict[k]) for k in keys])
185
+ if st.ema_scores is None or st.emo_keys != keys:
186
+ st.ema_scores, st.emo_keys = vals, keys
187
+ else:
188
+ st.ema_scores = EMA_ALPHA * vals + (1.0 - EMA_ALPHA) * st.ema_scores
189
+
190
+ st.stable_emo = st.emo_keys[int(np.argmax(st.ema_scores))]
191
+
192
+ # 穩定切換邏輯
193
+ if st.candidate_emo != st.stable_emo:
194
+ st.candidate_emo, st.candidate_since = st.stable_emo, now
195
+ elif st.candidate_since and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC:
196
+ st.final_emo, st.final_conf = st.stable_emo, top_conf
197
+ st.final_score = EMO_TO_SCORE.get(st.stable_emo, 3)
198
+ st.finished, st.running = True, False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
  return (
201
+ gr.update(visible=not st.finished),
202
+ gr.update(value=_result_card_html(st.stable_emo, top_conf)),
203
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.stable_emo, 3))),
204
+ gr.update(value=_hint_html("正在辨識中,請保持表情穩定...")),
205
  st,
206
+ gr.update()
207
  )
208
 
 
 
 
 
209
  def on_restart(st: AppState):
210
+ new_st = AppState()
211
  return (
212
+ gr.update(visible=True),
213
+ _hint_html("等待影像..."),
214
+ _score_radio_value(3),
215
+ _hint_html("請點擊 Record 開始"),
216
+ new_st,
217
+ gr.update()
218
  )
219
 
220
  def on_stop(st: AppState):
221
+ st.running, st.finished = False, True
222
+ return gr.update(visible=False), gr.update(), gr.update(), _hint_html("已手動停止"), st, gr.update()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
  # =========================
225
+ # Gradio UI 介面
226
  # =========================
227
+ css = ".gradio-container { background-color: #f7f9fc; }"
228
+
229
+ with gr.Blocks(title="AI 情緒偵測系統", css=css) as demo:
230
+ state = gr.State(AppState())
231
+
232
+ gr.Markdown("# 🎭 AI 情緒辨識評分系統")
233
+ status_hint = gr.HTML(_hint_html("請開啟攝影機並點擊錄製"))
234
+
235
+ with gr.Row():
236
+ with gr.Column(scale=1):
237
+ webcam = gr.Image(sources=["webcam"], streaming=True, type="numpy", label="攝影機畫面")
238
+ with gr.Row():
239
+ btn_re = gr.Button("🔄 重新辨識", variant="primary")
240
+ btn_stop = gr.Button("🛑 停止", variant="secondary")
241
+
242
+ with gr.Column(scale=1):
243
+ res_display = gr.HTML(_result_card_html("unknown", 0))
244
+ gr.Markdown("### 系統判定心情分數")
245
+ mood_radio = gr.Radio(choices=SCORE_CHOICES, value=SCORE_LABELS[3], interactive=False, label="")
246
+
247
+ # 事件綁定
248
+ webcam.stream(
249
+ fn=on_stream,
250
+ inputs=[webcam, state],
251
+ outputs=[webcam, res_display, mood_radio, status_hint, state, btn_re]
252
+ )
253
+
254
+ btn_re.click(fn=on_restart, inputs=[state], outputs=[webcam, res_display, mood_radio, status_hint, state, btn_re])
255
+ btn_stop.click(fn=on_stop, inputs=[state], outputs=[webcam, res_display, mood_radio, status_hint, state, btn_re])
256
+
257
  if __name__ == "__main__":
258
+ demo.launch(server_name="0.0.0.0", server_port=7860)