YuAnTsai commited on
Commit
31cf7c5
·
verified ·
1 Parent(s): 6c37102

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +120 -583
emotion.py CHANGED
@@ -1,642 +1,179 @@
1
  import time
 
2
  from dataclasses import dataclass, field
3
  from typing import Optional, Dict, Tuple, List
4
 
5
  import gradio as gr
6
  import numpy as np
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 # 越大越順(Space 建議 1.2~2.0)
20
- DOWNSAMPLE_W = 480 # 越小越順(360/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 # 太大會失真且更慢,Space 建議 <= 1.25
26
-
27
- # ✅ 快很多:opencv(比 mtcnn 順非常多)
28
- DETECTOR_BACKEND = "opencv"
29
- ALIGN_FACE = True
30
 
31
- # ✅ 防誤判(太小的人臉當作沒臉)
32
- MIN_FACE_AREA_RATIO = 0.04 # 4%(可調 0.03~0.06)
33
 
 
 
34
 
35
  # =========================
36
- # 情緒中文 + 分數映射
37
- # =========================
38
- EMO_ZH = {
39
- "angry": "生氣",
40
- "disgust": "厭惡",
41
- "fear": "害怕",
42
- "happy": "開心",
43
- "sad": "難過",
44
- "surprise": "驚訝",
45
- "neutral": "平靜",
46
- "unknown": "未知",
47
- }
48
-
49
- EMO_TO_SCORE = {
50
- "angry": 1,
51
- "disgust": 1,
52
- "fear": 1,
53
- "sad": 2,
54
- "neutral": 3,
55
- "surprise": 4,
56
- "happy": 5,
57
- "unknown": 3,
58
- }
59
-
60
- SCORE_LABELS = {
61
- 1: "1(心情差)",
62
- 2: "2(不太好)",
63
- 3: "3(普通)",
64
- 4: "4(不錯)",
65
- 5: "5(超棒)",
66
- }
67
- SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
68
-
69
- def _score_radio_value(score: int) -> str:
70
- score = int(score)
71
- score = min(5, max(1, score))
72
- return SCORE_LABELS[score]
73
-
74
-
75
- # =========================
76
- # 狀態
77
  # =========================
78
  @dataclass
79
  class AppState:
80
  running: bool = True
81
  finished: bool = False
82
  last_analyze_t: float = 0.0
83
-
84
- # tracking
85
  track_bbox: Optional[Tuple[int, int, int, int]] = None
86
-
87
- # smoothing
88
  ema_scores: Optional[np.ndarray] = None
89
  emo_keys: List[str] = field(default_factory=list)
90
-
91
- # stable/candidate
92
  stable_emo: str = "unknown"
93
  candidate_emo: Optional[str] = None
94
  candidate_since: Optional[float] = None
95
-
96
- # final
97
  final_emo: Optional[str] = None
98
  final_conf: Optional[float] = None
99
- final_score: int = 3
100
-
101
- # face status for overlay
102
- face_ok: bool = False
103
  last_face_t: float = 0.0
104
  face_in_box: bool = False
105
 
106
-
107
- # =========================
108
- # UI HTML
109
- # =========================
110
- def _hint_html(msg: str) -> str:
111
- return f"""
112
- <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">
113
- {msg}
114
- </div>
115
- """
116
-
117
- def _camera_hint_before_start() -> str:
118
- return _hint_html(
119
- "請先按「Click to access webcam / 允許」開啟攝影機;"
120
- "接著,請按「錄製(Record)」開始。"
121
- )
122
-
123
- def _camera_hint_done() -> str:
124
- return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
125
-
126
- def _camera_hint_stopped() -> str:
127
- return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
128
-
129
- def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
130
- zh = EMO_ZH.get(emo_key, emo_key)
131
- score = EMO_TO_SCORE.get(emo_key, 3)
132
- conf_txt = "" if conf is None else f"{conf:.1f}%"
133
- score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
134
-
135
- return f"""
136
- <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#000000;">
137
- <div style="font-size:14px;color:#000000;margin-bottom:8px;">辨識結果</div>
138
- <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;color:#000000;">{zh}</div>
139
- <div style="font-size:16px;color:#000000;margin-bottom:6px;">心情分數:{score}({score_desc})</div>
140
- <div style="font-size:14px;color:#000000;">信心值:{conf_txt}</div>
141
- </div>
142
- """
143
-
144
-
145
- # =========================
146
- # 影像工具
147
- # =========================
148
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
149
- if frame is None:
150
- return None
151
  h, w = frame.shape[:2]
152
- if w <= target_w:
153
- return frame
154
  scale = target_w / w
155
- new_h = max(1, int(h * scale))
156
- img = Image.fromarray(frame)
157
- img = img.resize((target_w, new_h))
158
  return np.array(img)
159
 
160
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
161
- if frame is None or zoom <= 1.0:
162
- return frame
163
  h, w = frame.shape[:2]
164
- new_w = int(w / zoom)
165
- new_h = int(h / zoom)
166
- start_x = (w - new_w) // 2
167
- start_y = (h - new_h) // 2
168
- return frame[start_y:start_y + new_h, start_x:start_x + new_w]
169
-
170
-
171
- # =========================
172
- # 引導框(固定位置)
173
- # =========================
174
- GUIDE_W_RATIO = 0.60
175
- GUIDE_H_RATIO = 0.72
176
- GUIDE_Y_OFFSET = 0.00
177
-
178
- def _guide_box_xyxy(frame: np.ndarray) -> Tuple[int, int, int, int]:
179
- h, w = frame.shape[:2]
180
- gw = int(w * GUIDE_W_RATIO)
181
- gh = int(h * GUIDE_H_RATIO)
182
- cx = w // 2
183
- cy = int(h * (0.5 + GUIDE_Y_OFFSET))
184
- x1 = max(0, cx - gw // 2)
185
- y1 = max(0, cy - gh // 2)
186
- x2 = min(w - 1, cx + gw // 2)
187
- y2 = min(h - 1, cy + gh // 2)
188
- return x1, y1, x2, y2
189
-
190
- def _bbox_center_in_box(bbox_xywh: Tuple[float, float, float, float], box_xyxy: Tuple[int, int, int, int]) -> bool:
191
- x, y, w, h = bbox_xywh
192
- cx = x + w / 2.0
193
- cy = y + h / 2.0
194
- x1, y1, x2, y2 = box_xyxy
195
- return (x1 <= cx <= x2) and (y1 <= cy <= y2)
196
-
197
- def _draw_guide_overlay(frame_rgb: np.ndarray, green: bool, msg: str) -> np.ndarray:
198
- img = Image.fromarray(frame_rgb)
199
- draw = ImageDraw.Draw(img)
200
-
201
- x1, y1, x2, y2 = _guide_box_xyxy(frame_rgb)
202
- color = (0, 200, 0) if green else (220, 0, 0)
203
-
204
- h, w = frame_rgb.shape[:2]
205
- thickness = max(3, int(min(w, h) * 0.008))
206
-
207
- for t in range(thickness):
208
- draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t], outline=color)
209
-
210
- # 提示文字底條
211
- tx = x1
212
- ty = max(0, y1 - int(thickness * 7))
213
- draw.rectangle([tx, ty, min(w - 1, tx + 420), ty + 34], fill=(255, 255, 255))
214
- draw.text((tx + 8, ty + 7), msg, fill=(0, 0, 0))
215
-
216
- return np.array(img)
217
-
218
 
219
  # =========================
220
- # Tracking / IoU
221
  # =========================
222
- def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
223
- ax, ay, aw, ah = a
224
- bx, by, bw, bh = b
225
- ax2, ay2 = ax + aw, ay + ah
226
- bx2, by2 = bx + bw, by + bh
227
-
228
- ix1, iy1 = max(ax, bx), max(ay, by)
229
- ix2, iy2 = min(ax2, bx2), min(ay2, by2)
230
- iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
231
- inter = iw * ih
232
- if inter <= 0:
233
- return 0.0
234
- union = aw * ah + bw * bh - inter
235
- return inter / max(1e-6, union)
236
-
237
- def _pick_face_by_tracking(
238
- faces: List[Dict],
239
- prev_bbox: Optional[Tuple[int, int, int, int]]
240
- ):
241
- if not faces:
242
- return None, None
243
-
244
- cand = []
245
- for f in faces:
246
- fa = f.get("facial_area", {}) or {}
247
- x = int(fa.get("x", 0) or 0)
248
- y = int(fa.get("y", 0) or 0)
249
- w = int(fa.get("w", 0) or 0)
250
- h = int(fa.get("h", 0) or 0)
251
- if w > 0 and h > 0 and f.get("face") is not None:
252
- cand.append((f, (x, y, w, h), w * h))
253
-
254
- if not cand:
255
- return None, None
256
-
257
- if prev_bbox is not None:
258
- best = None
259
- best_i = 0.0
260
- for f, bb, area in cand:
261
- i = _iou(prev_bbox, bb)
262
- if i > best_i:
263
- best_i = i
264
- best = (f, bb)
265
- if best is not None and best_i >= IOU_TH:
266
- return best[0], best[1]
267
-
268
- cand.sort(key=lambda x: x[2], reverse=True)
269
- return cand[0][0], cand[0][1]
270
-
271
-
272
- def _reset_state_for_camera(st: AppState) -> AppState:
273
- st.running = True
274
- st.finished = False
275
- st.last_analyze_t = 0.0
276
- st.track_bbox = None
277
- st.ema_scores = None
278
- st.emo_keys = []
279
- st.stable_emo = "unknown"
280
- st.candidate_emo = None
281
- st.candidate_since = None
282
- st.final_emo = None
283
- st.final_conf = None
284
- st.final_score = 3
285
- st.face_ok = False
286
- st.last_face_t = 0.0
287
- st.face_in_box = False
288
- return st
289
-
290
-
291
- def _is_face_big_enough(bbox_xywh: Tuple[float, float, float, float], frame_hw: Tuple[int, int]) -> bool:
292
- fh, fw = frame_hw
293
- x, y, w, h = bbox_xywh
294
- area_ratio = (w * h) / max(1.0, float(fw * fh))
295
- return area_ratio >= MIN_FACE_AREA_RATIO
296
 
 
 
 
 
 
 
 
 
 
 
297
 
298
  # =========================
299
- # Camera streaming (含紅/綠框)
300
  # =========================
301
  def on_stream(frame_rgb: np.ndarray, st: AppState):
302
- # 已結束/停止
303
- if st.finished or (not st.running):
304
- cam_visible = False
305
- if st.final_emo:
306
- return (
307
- gr.update(visible=cam_visible),
308
- gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
309
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
310
- gr.update(value=_camera_hint_done()),
311
- st,
312
- gr.update(value="重新攝影機辨識"),
313
- )
314
- return (
315
- gr.update(visible=cam_visible),
316
- gr.update(value=_hint_html("尚未完成辨識。")),
317
- gr.update(value=_score_radio_value(3)),
318
- gr.update(value=_camera_hint_stopped()),
319
- st,
320
- gr.update(value="重新攝影機辨識"),
321
- )
322
-
323
- # 尚未開始串流
324
- if frame_rgb is None:
325
- return (
326
- gr.update(visible=True, value=None),
327
- gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
328
- gr.update(value=_score_radio_value(3)),
329
- gr.update(value=_camera_hint_before_start()),
330
- st,
331
- gr.update(value="重新攝影機辨識"),
332
- )
333
-
334
- # zoom(提升臉部比例)
335
- frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
336
  now = time.time()
337
 
338
- # ✅ 先畫一張「即時框」:用最近一次人臉狀態(避免每幀都做重運算)
339
- recent_face = st.face_ok and (now - st.last_face_t) < 1.2
340
- msg_fast = "已偵測到人臉,開始辨識情緒中…" if recent_face else "未偵測到人臉,請把臉放進框內"
341
- annotated_fast = _draw_guide_overlay(frame_rgb, green=recent_face, msg=msg_fast)
342
 
343
- # 沒到分析時間 → 直接回傳快速版本(順)
344
- if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
345
- # 沒臉時不要顯示任何情緒卡(避免誤導)
346
- if recent_face:
347
- show = st.stable_emo or "unknown"
348
- result_html = _result_card_html(show, st.final_conf)
349
- mood_val = _score_radio_value(EMO_TO_SCORE.get(show, 3))
350
- else:
351
- result_html = _hint_html("未偵測到人臉,請把臉放進框內(紅框)")
352
- mood_val = _score_radio_value(3)
353
-
354
- return (
355
- gr.update(visible=True, value=annotated_fast),
356
- gr.update(value=result_html),
357
- gr.update(value=mood_val),
358
- gr.update(value=_camera_hint_before_start()),
359
- st,
360
- gr.update(value="重新攝影機辨識"),
361
- )
362
-
363
- # 到分析時間 → 做一次偵測/分析
364
- st.last_analyze_t = now
365
- small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
366
-
367
- # ✅ enforce_detection=False:沒有臉不丟例外(更順)
368
  try:
369
- faces = DeepFace.extract_faces(
 
 
 
 
370
  img_path=small,
 
371
  detector_backend=DETECTOR_BACKEND,
372
  enforce_detection=False,
373
- align=ALIGN_FACE,
374
- )
375
- except Exception:
376
- faces = []
377
-
378
- pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
379
-
380
- # 沒臉:紅框 + 不跑情緒
381
- if pick is None or bbox is None or pick.get("face") is None:
382
- st.track_bbox = None
383
- st.face_ok = False
384
- st.face_in_box = False
385
- st.last_face_t = now
386
-
387
- annotated = _draw_guide_overlay(frame_rgb, green=False, msg="未偵測到人臉,請把臉放進框內")
388
- return (
389
- gr.update(visible=True, value=annotated),
390
- gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內(紅框)")),
391
- gr.update(value=_score_radio_value(3)),
392
- gr.update(value=_camera_hint_before_start()),
393
- st,
394
- gr.update(value="重新攝影機辨識"),
395
- )
396
-
397
- # 把 bbox 從 small 映射回原 frame
398
- sh, sw = small.shape[:2]
399
- fh, fw = frame_rgb.shape[:2]
400
- sx = fw / max(1, sw)
401
- sy = fh / max(1, sh)
402
- x, y, w, h = bbox
403
- bbox_full = (x * sx, y * sy, w * sx, h * sy)
404
-
405
- # 太小:當作沒臉(紅框)
406
- if not _is_face_big_enough(bbox_full, (fh, fw)):
407
- st.track_bbox = None
408
- st.face_ok = False
409
- st.face_in_box = False
410
- st.last_face_t = now
411
- annotated = _draw_guide_overlay(frame_rgb, green=False, msg="人臉太小/太遠,請靠近鏡頭並放進框內")
412
- return (
413
- gr.update(visible=True, value=annotated),
414
- gr.update(value=_hint_html("人臉太小/太遠,請靠近鏡頭並放進框內(紅框)")),
415
- gr.update(value=_score_radio_value(3)),
416
- gr.update(value=_camera_hint_before_start()),
417
- st,
418
- gr.update(value="重新攝影機辨識"),
419
- )
420
-
421
- # 檢查臉是否在引導框內:不在就紅框,不跑情緒
422
- guide = _guide_box_xyxy(frame_rgb)
423
- in_box = _bbox_center_in_box(bbox_full, guide)
424
-
425
- st.track_bbox = bbox
426
- st.last_face_t = now
427
- st.face_ok = True
428
- st.face_in_box = in_box
429
-
430
- if not in_box:
431
- annotated = _draw_guide_overlay(frame_rgb, green=False, msg="已偵測到人臉,請把臉移到框內")
432
- return (
433
- gr.update(visible=True, value=annotated),
434
- gr.update(value=_hint_html("已偵測到人臉,但請把臉移到框內(紅框)")),
435
- gr.update(value=_score_radio_value(3)),
436
- gr.update(value=_camera_hint_before_start()),
437
- st,
438
- gr.update(value="重新攝影機辨識"),
439
- )
440
-
441
- # ✅ 有臉且在框內:綠框,開始情緒分析
442
- face = pick["face"]
443
- if face.dtype != np.uint8:
444
- face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
445
-
446
- try:
447
- result = DeepFace.analyze(
448
- img_path=face,
449
- actions=["emotion"],
450
- enforce_detection=False, # ✅ 避免偶發例外造成卡頓
451
- )
452
- except Exception:
453
- annotated = _draw_guide_overlay(frame_rgb, green=True, msg="已偵測到人臉,辨識中…(分析暫時失敗)")
454
- return (
455
- gr.update(visible=True, value=annotated),
456
- gr.update(value=_hint_html("已偵測到人臉,辨識中…(分析暫時失敗,請保持正面/光線亮一點)")),
457
- gr.update(value=_score_radio_value(3)),
458
- gr.update(value=_camera_hint_before_start()),
459
- st,
460
- gr.update(value="重新攝影機辨識"),
461
- )
462
-
463
- if isinstance(result, list):
464
- result = result[0]
465
- emo_dict = result.get("emotion", None)
466
-
467
- if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
468
- annotated = _draw_guide_overlay(frame_rgb, green=True, msg="已偵測到人臉,辨識中…")
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
- top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
479
- top_conf = float(emo_dict[top_key])
480
-
481
- if top_conf < CONF_TH:
482
- annotated = _draw_guide_overlay(frame_rgb, green=True, msg="已偵測到人臉,辨識中…(信心偏低)")
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
- # smoothing
493
- keys = list(emo_dict.keys())
494
- vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
495
-
496
- if st.ema_scores is None or st.emo_keys != keys:
497
- st.ema_scores = vec
498
- st.emo_keys = keys
499
- else:
500
- st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
501
-
502
- stable_idx = int(np.argmax(st.ema_scores))
503
- stable_key = st.emo_keys[stable_idx]
504
- st.stable_emo = stable_key
505
-
506
- # 延遲確認(避免跳來跳去)
507
- if st.candidate_emo != stable_key:
508
- st.candidate_emo = stable_key
509
- st.candidate_since = now
510
- else:
511
- if st.candidate_since is None:
512
- st.candidate_since = now
513
-
514
- confirmed = (
515
- st.candidate_since is not None
516
- and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
517
- )
518
-
519
- annotated = _draw_guide_overlay(frame_rgb, green=True, msg="已偵測到人臉,開始辨識情緒中…")
520
-
521
- if confirmed and st.final_emo is None:
522
- st.final_emo = stable_key
523
- st.final_conf = top_conf
524
- st.final_score = EMO_TO_SCORE.get(stable_key, 3)
525
-
526
- st.running = False
527
- st.finished = True
528
-
529
- return (
530
- gr.update(visible=False, value=None),
531
- gr.update(value=_result_card_html(stable_key, top_conf)),
532
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
533
- gr.update(value=_camera_hint_done()),
534
- st,
535
- gr.update(value="重新攝影機辨識"),
536
- )
537
-
538
- return (
539
- gr.update(visible=True, value=annotated),
540
- gr.update(value=_result_card_html(stable_key, top_conf)),
541
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
542
- gr.update(value=_camera_hint_before_start()),
543
- st,
544
- gr.update(value="重新攝影機辨識"),
545
- )
546
-
547
 
548
- # =========================
549
- # Buttons
550
- # =========================
551
- def on_restart(st: AppState):
552
- st = _reset_state_for_camera(st)
553
- # ✅ 清掉上一張,避免殘影
554
- return (
555
- gr.update(visible=True, value=None),
556
- gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
557
- gr.update(value=_score_radio_value(3)),
558
- gr.update(value=_camera_hint_before_start()),
559
- st,
560
- gr.update(value="重新攝影機辨識"),
561
- )
562
-
563
- def on_stop(st: AppState):
564
- st.running = False
565
- st.finished = True
566
- if st.final_emo is None:
567
- return (
568
- gr.update(visible=False, value=None),
569
- gr.update(value=_hint_html("已停止辨識。")),
570
- gr.update(value=_score_radio_value(3)),
571
- gr.update(value=_camera_hint_stopped()),
572
- st,
573
- gr.update(value="重新攝影機辨識"),
574
- )
575
-
576
- return (
577
- gr.update(visible=False, value=None),
578
- gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
579
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
580
- gr.update(value=_camera_hint_done()),
581
- st,
582
- gr.update(value="重新攝影機辨識"),
583
  )
584
 
585
-
586
- # =========================
587
- # Gradio UI
588
- # =========================
589
  if __name__ == "__main__":
590
- with gr.Blocks(title="Emotion Detector", css=css) as demo:
591
- st = gr.State(AppState())
592
-
593
- gr.Markdown("## 第一步:測測你的心情能量")
594
- gr.Markdown("讓 AI 看看你的表情,自動幫你決定心情分數!(辨識完畢會自動跳轉)")
595
-
596
- hint = gr.HTML(_camera_hint_before_start())
597
-
598
- cam = gr.Image(
599
- sources=["webcam"],
600
- streaming=True,
601
- type="numpy",
602
- label="攝影機���面",
603
- visible=True,
604
- )
605
-
606
- with gr.Row():
607
- btn_restart = gr.Button("重新攝影機辨識", variant="primary")
608
- btn_stop = gr.Button("停止", variant="secondary")
609
-
610
- gr.Markdown("### 辨識結果")
611
- result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
612
-
613
- gr.Markdown("### 心情分數(系統判定)")
614
- mood = gr.Radio(
615
- choices=SCORE_CHOICES,
616
- value=_score_radio_value(3),
617
- label="心情分數",
618
- interactive=False,
619
- )
620
-
621
- cam.stream(
622
- fn=on_stream,
623
- inputs=[cam, st],
624
- outputs=[cam, result, mood, hint, st, btn_restart],
625
- show_progress="minimal",
626
- )
627
-
628
- btn_restart.click(
629
- fn=on_restart,
630
- inputs=[st],
631
- outputs=[cam, result, mood, hint, st, btn_restart],
632
- show_progress="minimal",
633
- )
634
-
635
- btn_stop.click(
636
- fn=on_stop,
637
- inputs=[st],
638
- outputs=[cam, result, mood, hint, st, btn_restart],
639
- show_progress="minimal",
640
- )
641
-
642
- demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
 
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
  from deepface import DeepFace
10
 
 
 
 
 
 
11
  # =========================
12
+ # 參數優化(平衡效能與準度)
13
  # =========================
14
+ ANALYZE_EVERY_SEC = 1.0 # 辨識頻率:1秒一次
15
+ DOWNSAMPLE_W = 320 # 再次降解析度,大幅提升速度
16
  EMA_ALPHA = 0.45
17
+ CONF_TH = 35.0 # 稍微調低門檻,增加反應靈敏度
18
+ SWITCH_CONFIRM_SEC = 2.0 # 縮短確認時間,體感變快
19
+ ZOOM_FACTOR = 1.2
 
 
 
 
 
20
 
21
+ DETECTOR_BACKEND = "opencv" # 最速模式
22
+ ALIGN_FACE = False # 關閉對齊以節省運算
23
 
24
+ # 全域狀態:防止運算堆積
25
+ _is_processing = False
26
 
27
  # =========================
28
+ # 狀態與工具
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  # =========================
30
  @dataclass
31
  class AppState:
32
  running: bool = True
33
  finished: bool = False
34
  last_analyze_t: float = 0.0
 
 
35
  track_bbox: Optional[Tuple[int, int, int, int]] = None
 
 
36
  ema_scores: Optional[np.ndarray] = None
37
  emo_keys: List[str] = field(default_factory=list)
 
 
38
  stable_emo: str = "unknown"
39
  candidate_emo: Optional[str] = None
40
  candidate_since: Optional[float] = None
 
 
41
  final_emo: Optional[str] = None
42
  final_conf: Optional[float] = None
 
 
 
 
43
  last_face_t: float = 0.0
44
  face_in_box: bool = False
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
47
+ if frame is None: return None
 
48
  h, w = frame.shape[:2]
49
+ if w <= target_w: return frame
 
50
  scale = target_w / w
51
+ img = Image.fromarray(frame).resize((target_w, int(h * scale)), Image.NEAREST)
 
 
52
  return np.array(img)
53
 
54
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
55
+ if frame is None or zoom <= 1.0: return frame
 
56
  h, w = frame.shape[:2]
57
+ nw, nh = int(w / zoom), int(h / zoom)
58
+ sx, sy = (w - nw) // 2, (h - nh) // 2
59
+ return frame[sy:sy + nh, sx:sx + nw]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  # =========================
62
+ # UI 組件 (情緒映射)
63
  # =========================
64
+ EMO_ZH = {"angry": "生氣", "disgust": "厭惡", "fear": "害怕", "happy": "開心", "sad": "難過", "surprise": "驚訝", "neutral": "平靜", "unknown": "未知"}
65
+ EMO_TO_SCORE = {"angry": 1, "disgust": 1, "fear": 1, "sad": 2, "neutral": 3, "surprise": 4, "happy": 5, "unknown": 3}
66
+ SCORE_LABELS = {1: "1(心情差)", 2: "2(不太好)", 3: "3(普通)", 4: "4(不錯)", 5: "5(超棒)"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
+ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
69
+ zh = EMO_ZH.get(emo_key, "分析中...")
70
+ score = EMO_TO_SCORE.get(emo_key, 3)
71
+ conf_txt = f"{conf:.1f}%" if conf else "--"
72
+ return f"""
73
+ <div style="border-radius:12px;padding:15px;border:1px solid #EEE;background:#FFF;color:#333;">
74
+ <div style="font-size:32px;font-weight:bold;">{zh}</div>
75
+ <div style="font-size:16px;">心情分數:{score} | 信心值:{conf_txt}</div>
76
+ </div>
77
+ """
78
 
79
  # =========================
80
+ # 核心 Stream 邏輯
81
  # =========================
82
  def on_stream(frame_rgb: np.ndarray, st: AppState):
83
+ global _is_processing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  now = time.time()
85
 
86
+ if not st.running or st.finished or frame_rgb is None:
87
+ return (gr.update(), gr.update(), gr.update(), gr.update(), st, gr.update())
 
 
88
 
89
+ # 1. 基礎處理 (輕量)
90
+ frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
91
+
92
+ # 2. 判斷是否要跳過重度運算 (防止阻塞關鍵)
93
+ # 如果正在處理中,或距離上次處理不到時間,只回傳畫框影像
94
+ if _is_processing or (now - st.last_analyze_t < ANALYZE_EVERY_SEC):
95
+ # 即使不運算,也回傳當前畫面讓 UI 不會卡死
96
+ is_detected = (now - st.last_face_t) < 1.0
97
+ # 此處省略畫框 draw 邏輯以極大化流暢度,或可沿用你的 _draw_guide_overlay
98
+ return (frame_rgb, gr.update(), gr.update(), gr.update(), st, gr.update())
99
+
100
+ # 3. 開始重度運算
101
+ _is_processing = True
 
 
 
 
 
 
 
 
 
 
 
 
102
  try:
103
+ st.last_analyze_t = now
104
+ small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
105
+
106
+ # 快速臉部偵測
107
+ results = DeepFace.analyze(
108
  img_path=small,
109
+ actions=['emotion'],
110
  detector_backend=DETECTOR_BACKEND,
111
  enforce_detection=False,
112
+ align=ALIGN_FACE
113
+ )
114
+
115
+ if results and results[0].get('face_confidence', 0) > 0.4:
116
+ res = results[0]
117
+ st.last_face_t = now
118
+ emo_dict = res['emotion']
119
+ top_key = max(emo_dict, key=emo_dict.get)
120
+ top_conf = emo_dict[top_key]
121
+
122
+ # EMA 平滑處理
123
+ keys = list(emo_dict.keys())
124
+ vec = np.array([emo_dict[k] for k in keys])
125
+ if st.ema_scores is None:
126
+ st.ema_scores = vec
127
+ st.emo_keys = keys
128
+ else:
129
+ st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
130
+
131
+ stable_key = st.emo_keys[np.argmax(st.ema_scores)]
132
+ st.stable_emo = stable_key
133
+
134
+ # 確認邏輯
135
+ if st.candidate_emo != stable_key:
136
+ st.candidate_emo = stable_key
137
+ st.candidate_since = now
138
+ elif st.candidate_since and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC:
139
+ st.final_emo = stable_key
140
+ st.final_conf = top_conf
141
+ st.finished = True
142
+ st.running = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
+ return (
145
+ frame_rgb,
146
+ _result_card_html(stable_key, top_conf),
147
+ SCORE_LABELS[EMO_TO_SCORE.get(stable_key, 3)],
148
+ "偵測中...",
149
+ st,
150
+ gr.update()
151
+ )
152
+
153
+ except Exception as e:
154
+ print(f"Analysis Error: {e}")
155
+ finally:
156
+ _is_processing = False # 釋放鎖定,允許下一波分析
157
+
158
+ return (frame_rgb, gr.update(), gr.update(), gr.update(), st, gr.update())
159
+
160
+ # =========================
161
+ # Gradio 介面省略 (沿用原架構但簡化)
162
+ # =========================
163
+ with gr.Blocks(title="Fast Emotion Detector") as demo:
164
+ st = gr.State(AppState())
165
+ cam = gr.Image(sources=["webcam"], streaming=True, type="numpy", label="Live")
166
+ result_html = gr.HTML(_result_card_html("unknown", 0))
167
+ mood_radio = gr.Radio(choices=list(SCORE_LABELS.values()), label="心情分數")
168
+
169
+ # 串流設定
170
+ cam.stream(
171
+ fn=on_stream,
172
+ inputs=[cam, st],
173
+ outputs=[cam, result_html, mood_radio, gr.HTML(), st, gr.Button()],
174
+ show_progress="hidden", # 隱藏進度條可以減少 UI 負擔
175
+ queue=False # 關鍵:不進入隊列,直接拋棄過期請求
 
 
 
176
  )
177
 
 
 
 
 
178
  if __name__ == "__main__":
179
+ demo.launch(server_name="0.0.0.0", server_port=7860)