PhilipL commited on
Commit
324b70f
·
verified ·
1 Parent(s): dd93b64

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +65 -470
emotion.py CHANGED
@@ -19,12 +19,13 @@ CONF_TH = 40.0
19
  SWITCH_CONFIRM_SEC = 2.5
20
  IOU_TH = 0.25
21
 
22
- DETECTOR_BACKEND = "mtcnn" # 想快一點可改 "opencv"
 
23
  ALIGN_FACE = True
24
 
25
 
26
  # =========================
27
- # 情緒中文 + 分數映射(7 -> 5)
28
  # =========================
29
  EMO_ZH = {
30
  "angry": "生氣",
@@ -37,7 +38,6 @@ EMO_ZH = {
37
  "unknown": "未知",
38
  }
39
 
40
- # 你要的:把 7 種情緒塞進 5 分心情分數
41
  EMO_TO_SCORE = {
42
  "angry": 1,
43
  "disgust": 1,
@@ -49,11 +49,10 @@ EMO_TO_SCORE = {
49
  "unknown": 3,
50
  }
51
 
52
- # ### [整合需求 新增] ###
53
- # 為了讓主程式 (app.py) 可以呼叫這個轉換邏輯,將其包裝成函式
54
  def get_emotion_score(emo: str) -> int:
55
  return EMO_TO_SCORE.get(emo, 3)
56
- # ######################
57
 
58
  SCORE_LABELS = {
59
  1: "1(心情差)",
@@ -66,12 +65,10 @@ SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
66
 
67
 
68
  # =========================
69
- # 狀態
70
  # =========================
71
  @dataclass
72
  class AppState:
73
- mode: str = "攝影機辨識"
74
-
75
  running: bool = True
76
  finished: bool = False
77
  last_analyze_t: float = 0.0
@@ -92,197 +89,92 @@ class AppState:
92
  final_emo: Optional[str] = None
93
  final_conf: Optional[float] = None
94
 
95
- # ### [整合需求 新增] ###
96
- # 用來儲存最後計算出的整數分數 (1~5),讓外部程式容易讀取
97
  final_score: int = 3
98
- # ######################
99
 
100
 
101
  # =========================
102
  # UI HTML
103
  # =========================
104
  def _hint_html(msg: str) -> str:
105
- return f"""
106
- <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#111;">
107
- {msg}
108
- </div>
109
- """
110
-
111
 
112
  def _camera_hint_before_start() -> str:
113
- return _hint_html(
114
- "請先按「Click to access webcam / 允許」開啟攝影機;"
115
- "接著,請按「錄製(Record)」開始。"
116
- )
117
-
118
 
119
  def _camera_hint_done() -> str:
120
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
121
 
122
-
123
  def _camera_hint_stopped() -> str:
124
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
125
 
126
-
127
- def _upload_hint_before() -> str:
128
- return _hint_html("請上傳一張清楚的臉部照片(臉越大越準)。")
129
-
130
-
131
- def _upload_hint_done() -> str:
132
- return _hint_html("已完成圖片辨識,可按「重新上傳圖片辨識」再次辨識。")
133
-
134
-
135
- def _upload_hint_stopped() -> str:
136
- return _hint_html("已停止圖片辨識。可按「重新上傳圖片辨識」再試一次。")
137
-
138
-
139
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
140
  zh = EMO_ZH.get(emo_key, emo_key)
141
  score = EMO_TO_SCORE.get(emo_key, 3)
142
  conf_txt = "" if conf is None else f"{conf:.1f}%"
143
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
144
 
145
- # ✅ 結果字體用黑色,避免你說的灰字看不清楚
146
  return f"""
147
- <div style="
148
- border-radius:16px;
149
- padding:20px;
150
- border:1px solid #E6E6E6;
151
- background:#FFFFFF;
152
- color:#111;
153
- ">
154
- <div style="font-size:14px;color:#444;margin-bottom:8px;">
155
- 辨識結果
156
- </div>
157
-
158
- <div style="
159
- font-size:42px;
160
- font-weight:800;
161
- line-height:1.1;
162
- margin-bottom:14px;
163
- color:#000;
164
- ">
165
- {zh}
166
- </div>
167
-
168
- <div style="font-size:16px;color:#111;margin-bottom:6px;">
169
- 心情分數:<b>{score}</b>({score_desc})
170
- </div>
171
-
172
- <div style="font-size:14px;color:#111;">
173
- 信心值:{conf_txt}
174
- </div>
175
  </div>
176
  """
177
 
178
-
179
  def _score_radio_value(score: int) -> str:
180
  score = int(score)
181
  score = min(5, max(1, score))
182
  return SCORE_LABELS[score]
183
 
184
-
185
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
186
- if frame is None:
187
- return None
188
  h, w = frame.shape[:2]
189
- if w <= target_w:
190
- return frame
191
  scale = target_w / w
192
  new_h = max(1, int(h * scale))
193
  img = Image.fromarray(frame)
194
  img = img.resize((target_w, new_h))
195
  return np.array(img)
196
 
197
-
198
- def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
199
  ax, ay, aw, ah = a
200
  bx, by, bw, bh = b
201
  ax2, ay2 = ax + aw, ay + ah
202
  bx2, by2 = bx + bw, by + bh
203
-
204
  ix1, iy1 = max(ax, bx), max(ay, by)
205
  ix2, iy2 = min(ax2, bx2), min(ay2, by2)
206
  iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
207
  inter = iw * ih
208
- if inter <= 0:
209
- return 0.0
210
  union = aw * ah + bw * bh - inter
211
  return inter / max(1e-6, union)
212
 
213
-
214
- def _pick_face_by_tracking(
215
- faces: List[Dict],
216
- prev_bbox: Optional[Tuple[int, int, int, int]]
217
- ) -> Tuple[Optional[Dict], Optional[Tuple[int, int, int, int]]]:
218
- if not faces:
219
- return None, None
220
-
221
  cand = []
222
  for f in faces:
223
  fa = f.get("facial_area", {}) or {}
224
- x = int(fa.get("x", 0) or 0)
225
- y = int(fa.get("y", 0) or 0)
226
- w = int(fa.get("w", 0) or 0)
227
- h = int(fa.get("h", 0) or 0)
228
  if w > 0 and h > 0 and f.get("face") is not None:
229
  cand.append((f, (x, y, w, h), w * h))
230
-
231
- if not cand:
232
- return None, None
233
-
234
  if prev_bbox is not None:
235
- best = None
236
- best_i = 0.0
237
  for f, bb, area in cand:
238
  i = _iou(prev_bbox, bb)
239
- if i > best_i:
240
- best_i = i
241
- best = (f, bb)
242
- if best is not None and best_i >= IOU_TH:
243
- return best[0], best[1]
244
-
245
  cand.sort(key=lambda x: x[2], reverse=True)
246
  return cand[0][0], cand[0][1]
247
 
248
-
249
  def _reset_state_for_camera(st: AppState) -> AppState:
250
- st.running = True
251
- st.finished = False
252
- st.last_analyze_t = 0.0
253
- st.track_bbox = None
254
- st.ema_scores = None
255
- st.emo_keys = []
256
- st.stable_emo = "unknown"
257
- st.candidate_emo = None
258
- st.candidate_since = None
259
- st.final_emo = None
260
- st.final_conf = None
261
-
262
- # ### [整合需求 新增] ###
263
- st.final_score = 3
264
- # ######################
265
-
266
- return st
267
-
268
-
269
- def _reset_state_for_upload(st: AppState) -> AppState:
270
- st.running = False
271
- st.finished = False
272
- st.last_analyze_t = 0.0
273
- st.track_bbox = None
274
- st.ema_scores = None
275
- st.emo_keys = []
276
- st.stable_emo = "unknown"
277
- st.candidate_emo = None
278
- st.candidate_since = None
279
- st.final_emo = None
280
- st.final_conf = None
281
-
282
- # ### [整合需求 新增] ###
283
  st.final_score = 3
284
- # ######################
285
-
286
  return st
287
 
288
 
@@ -290,21 +182,10 @@ def _reset_state_for_upload(st: AppState) -> AppState:
290
  # Camera streaming
291
  # =========================
292
  def on_stream(frame_rgb: np.ndarray, st: AppState):
293
- # 上傳模式不走攝影機流程
294
- if st.mode != "攝影機辨識":
295
- return (
296
- gr.update(), # cam
297
- gr.update(), # result
298
- gr.update(), # mood
299
- gr.update(value=_upload_hint_before()),
300
- st,
301
- gr.update(value="重新上傳圖片辨識"),
302
- )
303
 
304
- # ✅ 完成/停止狀態:不要再顯示「Click to access…/Record…」
305
  if st.finished or (not st.running):
306
  cam_visible = False
307
-
308
  if st.final_emo:
309
  result_html = _result_card_html(st.final_emo, st.final_conf)
310
  mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
@@ -313,106 +194,49 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
313
  result_html = _hint_html("尚未完成辨識。")
314
  mood_val = _score_radio_value(3)
315
  hint_html = _camera_hint_stopped()
 
316
 
317
- return (
318
- gr.update(visible=cam_visible),
319
- gr.update(value=result_html),
320
- gr.update(value=mood_val),
321
- gr.update(value=hint_html),
322
- st,
323
- gr.update(value="重新攝影機辨識"),
324
- )
325
-
326
- # ✅ 還沒拿到 frame:提示使用者按允許/Record(而不是亂寫已開啟)
327
  if frame_rgb is None:
328
- return (
329
- gr.update(visible=True),
330
- gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
331
- gr.update(value=_score_radio_value(3)),
332
- gr.update(value=_camera_hint_before_start()),
333
- st,
334
- gr.update(value="重新攝影機辨識"),
335
- )
336
 
337
  now = time.time()
338
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
339
  show = st.stable_emo or "unknown"
340
- return (
341
- gr.update(visible=True),
342
- gr.update(value=_result_card_html(show, st.final_conf)),
343
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
344
- gr.update(value=_camera_hint_before_start()),
345
- st,
346
- gr.update(value="重新攝影機辨識"),
347
- )
348
 
349
  st.last_analyze_t = now
350
-
351
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
352
-
353
- faces = DeepFace.extract_faces(
354
- img_path=small,
355
- detector_backend=DETECTOR_BACKEND,
356
- enforce_detection=False,
357
- align=ALIGN_FACE,
358
- )
359
  pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
 
360
  if pick is None or bbox is None or pick.get("face") is None:
361
  show = st.stable_emo or "unknown"
362
- return (
363
- gr.update(visible=True),
364
- gr.update(value=_result_card_html(show, st.final_conf)),
365
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
366
- gr.update(value=_camera_hint_before_start()),
367
- st,
368
- gr.update(value="重新攝影機辨識"),
369
- )
370
 
371
  st.track_bbox = bbox
372
  face = pick["face"]
373
- if face.dtype != np.uint8:
374
- face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
375
-
376
- result = DeepFace.analyze(
377
- img_path=face,
378
- actions=["emotion"],
379
- enforce_detection=False,
380
- )
381
- if isinstance(result, list):
382
- result = result[0]
383
  emo_dict = result.get("emotion", None)
384
 
385
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
386
  show = st.stable_emo or "unknown"
387
- return (
388
- gr.update(visible=True),
389
- gr.update(value=_result_card_html(show, st.final_conf)),
390
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
391
- gr.update(value=_camera_hint_before_start()),
392
- st,
393
- gr.update(value="重新攝影機辨識"),
394
- )
395
 
396
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
397
  top_conf = float(emo_dict[top_key])
398
 
399
  if top_conf < CONF_TH:
400
  show = st.stable_emo or "unknown"
401
- return (
402
- gr.update(visible=True),
403
- gr.update(value=_result_card_html(show, st.final_conf)),
404
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
405
- gr.update(value=_camera_hint_before_start()),
406
- st,
407
- gr.update(value="重新攝影機辨識"),
408
- )
409
 
410
  keys = list(emo_dict.keys())
411
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
412
 
413
  if st.ema_scores is None or st.emo_keys != keys:
414
- st.ema_scores = vec
415
- st.emo_keys = keys
416
  else:
417
  st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
418
 
@@ -420,254 +244,47 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
420
  stable_key = st.emo_keys[stable_idx]
421
  st.stable_emo = stable_key
422
 
423
- # 延遲確認
424
  if st.candidate_emo != stable_key:
425
- st.candidate_emo = stable_key
426
- st.candidate_since = now
427
  else:
428
- if st.candidate_since is None:
429
- st.candidate_since = now
430
 
431
- confirmed = (
432
- st.candidate_since is not None
433
- and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
434
- )
435
 
436
  if confirmed and st.final_emo is None:
437
- st.final_emo = stable_key
438
- st.final_conf = top_conf
439
-
440
- # ### [整合需求 新增] ###
441
- # 在辨識完成的瞬間,順便存入整數分數
442
  st.final_score = EMO_TO_SCORE.get(stable_key, 3)
443
- # ######################
444
-
445
- st.running = False
446
- st.finished = True
447
-
448
- return (
449
- gr.update(visible=False),
450
- gr.update(value=_result_card_html(stable_key, top_conf)),
451
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
452
- gr.update(value=_camera_hint_done()),
453
- st,
454
- gr.update(value="重新攝影機辨識"),
455
- )
456
 
457
- return (
458
- gr.update(visible=True),
459
- gr.update(value=_result_card_html(stable_key, top_conf)),
460
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
461
- gr.update(value=_camera_hint_before_start()),
462
- st,
463
- gr.update(value="重新攝影機辨識"),
464
- )
465
 
466
 
467
  # =========================
468
- # Upload image
469
  # =========================
470
- def on_upload(img: np.ndarray, st: AppState):
471
- if st.mode != "上傳圖片辨識":
472
- return (
473
- gr.update(), gr.update(), st,
474
- gr.update(value=_camera_hint_before_start()),
475
- gr.update(value="重新攝影機辨識")
476
- )
477
-
478
- st = _reset_state_for_upload(st)
479
-
480
- if img is None:
481
- return (
482
- gr.update(value=_upload_hint_before()),
483
- gr.update(value=_score_radio_value(3)),
484
- st,
485
- gr.update(value=_upload_hint_before()),
486
- gr.update(value="重新上傳圖片辨識"),
487
- )
488
-
489
- small = _downsample_rgb(img, DOWNSAMPLE_W)
490
- faces = DeepFace.extract_faces(
491
- img_path=small,
492
- detector_backend=DETECTOR_BACKEND,
493
- enforce_detection=False,
494
- align=ALIGN_FACE,
495
- )
496
- pick, bbox = _pick_face_by_tracking(faces, None)
497
- if pick is None or pick.get("face") is None:
498
- return (
499
- gr.update(value=_hint_html("沒有偵測到人臉,請換一張更清楚、臉更大的照片。")),
500
- gr.update(value=_score_radio_value(3)),
501
- st,
502
- gr.update(value=_upload_hint_before()),
503
- gr.update(value="重新上傳圖片辨識"),
504
- )
505
-
506
- face = pick["face"]
507
- if face.dtype != np.uint8:
508
- face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
509
-
510
- result = DeepFace.analyze(
511
- img_path=face,
512
- actions=["emotion"],
513
- enforce_detection=False,
514
- )
515
- if isinstance(result, list):
516
- result = result[0]
517
- emo_dict = result.get("emotion", None)
518
- if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
519
- return (
520
- gr.update(value=_hint_html("辨識失敗,請換一張照片再試一次。")),
521
- gr.update(value=_score_radio_value(3)),
522
- st,
523
- gr.update(value=_upload_hint_before()),
524
- gr.update(value="重新上傳圖片辨識"),
525
- )
526
-
527
- top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
528
- top_conf = float(emo_dict[top_key])
529
-
530
- st.final_emo = top_key
531
- st.final_conf = top_conf
532
-
533
- # ### [整合需求 新增] ###
534
- # 圖片辨識完成,存入分數
535
- st.final_score = EMO_TO_SCORE.get(top_key, 3)
536
- # ######################
537
-
538
- st.finished = True
539
-
540
- return (
541
- gr.update(value=_result_card_html(top_key, top_conf)),
542
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(top_key, 3))),
543
- st,
544
- gr.update(value=_upload_hint_done()),
545
- gr.update(value="重新上傳圖片辨識"),
546
- )
547
-
548
-
549
- # =========================
550
- # Mode / Buttons
551
- # =========================
552
- def on_mode_change(mode: str, st: AppState):
553
- st.mode = mode
554
-
555
- if mode == "攝影機辨識":
556
- st = _reset_state_for_camera(st)
557
- return (
558
- gr.update(visible=True), # cam
559
- gr.update(visible=False, value=None), # upload
560
- gr.update(value=_hint_html("等待影像…(���未開始串流)")), # result
561
- gr.update(value=_score_radio_value(3)), # mood
562
- gr.update(value=_camera_hint_before_start()),
563
- st,
564
- gr.update(value="重新攝影機辨識"),
565
- )
566
- else:
567
- st = _reset_state_for_upload(st)
568
- return (
569
- gr.update(visible=False), # cam
570
- gr.update(visible=True, value=None), # upload
571
- gr.update(value=_upload_hint_before()), # result
572
- gr.update(value=_score_radio_value(3)), # mood
573
- gr.update(value=_upload_hint_before()), # hint
574
- st,
575
- gr.update(value="重新上傳圖片辨識"),
576
- )
577
-
578
 
579
  def on_restart(st: AppState):
580
- if st.mode == "攝影機辨識":
581
- st = _reset_state_for_camera(st)
582
- return (
583
- gr.update(visible=True), # cam
584
- gr.update(value=_hint_html("等待影像…(尚未開始串流)")), # result
585
- gr.update(value=_score_radio_value(3)), # mood
586
- gr.update(value=_camera_hint_before_start()), # hint
587
- st,
588
- gr.update(value="重新攝影機辨識"), # btn label
589
- gr.update(), # upload
590
- )
591
- else:
592
- st = _reset_state_for_upload(st)
593
- return (
594
- gr.update(), # cam
595
- gr.update(value=_upload_hint_before()), # result
596
- gr.update(value=_score_radio_value(3)), # mood
597
- gr.update(value=_upload_hint_before()), # hint
598
- st,
599
- gr.update(value="重新上傳圖片辨識"),
600
- gr.update(value=None), # upload clear
601
- )
602
-
603
 
604
  def on_stop(st: AppState):
605
- if st.mode == "攝影機辨識":
606
- st.running = False
607
- st.finished = True
608
- # ✅ 停止後也不要再顯示 Click/Record 提示,改成「已停止/可重新」
609
- if st.final_emo is None:
610
- return (
611
- gr.update(visible=False),
612
- gr.update(value=_hint_html("已停止辨識。")),
613
- gr.update(value=_score_radio_value(3)),
614
- gr.update(value=_camera_hint_stopped()),
615
- st,
616
- gr.update(value="重新攝影機辨識"),
617
- gr.update(),
618
- )
619
-
620
- return (
621
- gr.update(visible=False),
622
- gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
623
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
624
- gr.update(value=_camera_hint_done()),
625
- st,
626
- gr.update(value="重新攝影機辨識"),
627
- gr.update(),
628
- )
629
 
630
- # 上傳模式:停止 → 回到可重新上傳
631
- st = _reset_state_for_upload(st)
632
- return (
633
- gr.update(),
634
- gr.update(value=_hint_html("已停止圖片辨識。")),
635
- gr.update(value=_score_radio_value(3)),
636
- gr.update(value=_upload_hint_stopped()),
637
- st,
638
- gr.update(value="重新上傳圖片辨識"),
639
- gr.update(value=None),
640
- )
641
-
642
-
643
- # =========================
644
- # Gradio UI
645
- # =========================
646
- # ### [整合需求] 下方的 UI Block 可以保留,
647
- # 之後整合進 app.py 時,app.py 會只 import 上面的函式,不會執行這裡。
648
- # 這樣隊友還是可以單獨執行這個檔案來測試。
649
- css = """
650
- #app_container { max-width: 960px; margin: 0 auto; }
651
- .gradio-container { background: #f1f5f9 !important; }
652
- """
653
-
654
- # 為了方便 app.py 整合,我們把 UI 宣告包成一個函式 render_ui (或是 create_demo)
655
- # 但為了不改動隊友原本的結構,我們保留下面的寫法,但加上 if __name__ == "__main__"
656
- # 這樣 app.py import 時不會自動跑出介面
657
 
658
  if __name__ == "__main__":
659
- with gr.Blocks(title="Emotion Detector") as demo:
660
  st = gr.State(AppState())
661
 
662
  gr.Markdown("## 情緒���識")
663
- gr.Markdown("請先選擇辨識方式:**攝影機** 或 **上傳圖片**。")
664
-
665
- mode = gr.Radio(
666
- choices=["攝影機辨識", "上傳圖片辨識"],
667
- value="攝影機辨識",
668
- label="辨識方式",
669
- )
670
-
671
  hint = gr.HTML(_camera_hint_before_start())
672
 
673
  cam = gr.Image(
@@ -678,18 +295,10 @@ if __name__ == "__main__":
678
  visible=True,
679
  )
680
 
681
- upload = gr.Image(
682
- sources=["upload"],
683
- type="numpy",
684
- label="上傳圖片",
685
- visible=False,
686
- )
687
-
688
  with gr.Row():
689
  btn_restart = gr.Button("重新攝影機辨識", variant="primary")
690
  btn_stop = gr.Button("停止", variant="secondary")
691
 
692
- # ✅ 你要的排序:結果在上、分數在下
693
  gr.Markdown("### 辨識結果")
694
  result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
695
 
@@ -701,13 +310,6 @@ if __name__ == "__main__":
701
  interactive=False,
702
  )
703
 
704
- mode.change(
705
- fn=on_mode_change,
706
- inputs=[mode, st],
707
- outputs=[cam, upload, result, mood, hint, st, btn_restart],
708
- show_progress="minimal",
709
- )
710
-
711
  cam.stream(
712
  fn=on_stream,
713
  inputs=[cam, st],
@@ -715,24 +317,17 @@ if __name__ == "__main__":
715
  show_progress="minimal",
716
  )
717
 
718
- upload.change(
719
- fn=on_upload,
720
- inputs=[upload, st],
721
- outputs=[result, mood, st, hint, btn_restart],
722
- show_progress="minimal",
723
- )
724
-
725
  btn_restart.click(
726
  fn=on_restart,
727
  inputs=[st],
728
- outputs=[cam, result, mood, hint, st, btn_restart, upload],
729
  show_progress="minimal",
730
  )
731
 
732
  btn_stop.click(
733
  fn=on_stop,
734
  inputs=[st],
735
- outputs=[cam, result, mood, hint, st, btn_restart, upload],
736
  show_progress="minimal",
737
  )
738
 
 
19
  SWITCH_CONFIRM_SEC = 2.5
20
  IOU_TH = 0.25
21
 
22
+ # 建議改為 "opencv" 穩定性較高
23
+ DETECTOR_BACKEND = "opencv"
24
  ALIGN_FACE = True
25
 
26
 
27
  # =========================
28
+ # 情緒中文 + 分數映射
29
  # =========================
30
  EMO_ZH = {
31
  "angry": "生氣",
 
38
  "unknown": "未知",
39
  }
40
 
 
41
  EMO_TO_SCORE = {
42
  "angry": 1,
43
  "disgust": 1,
 
49
  "unknown": 3,
50
  }
51
 
52
+ # 供外部主程式呼叫的介面
 
53
  def get_emotion_score(emo: str) -> int:
54
  return EMO_TO_SCORE.get(emo, 3)
55
+
56
 
57
  SCORE_LABELS = {
58
  1: "1(心情差)",
 
65
 
66
 
67
  # =========================
68
+ # 狀態 (已移除 mode 欄位)
69
  # =========================
70
  @dataclass
71
  class AppState:
 
 
72
  running: bool = True
73
  finished: bool = False
74
  last_analyze_t: float = 0.0
 
89
  final_emo: Optional[str] = None
90
  final_conf: Optional[float] = None
91
 
92
+ # 儲存整數分數
 
93
  final_score: int = 3
 
94
 
95
 
96
  # =========================
97
  # UI HTML
98
  # =========================
99
  def _hint_html(msg: str) -> str:
100
+ return f"""<div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#111;">{msg}</div>"""
 
 
 
 
 
101
 
102
  def _camera_hint_before_start() -> str:
103
+ return _hint_html("請先按「Click to access webcam / 允許」開啟攝影機;接著,請按「錄製(Record)」開始。")
 
 
 
 
104
 
105
  def _camera_hint_done() -> str:
106
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
107
 
 
108
  def _camera_hint_stopped() -> str:
109
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
112
  zh = EMO_ZH.get(emo_key, emo_key)
113
  score = EMO_TO_SCORE.get(emo_key, 3)
114
  conf_txt = "" if conf is None else f"{conf:.1f}%"
115
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
116
 
 
117
  return f"""
118
+ <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#111;">
119
+ <div style="font-size:14px;color:#444;margin-bottom:8px;">辨識結果</div>
120
+ <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;color:#000;">{zh}</div>
121
+ <div style="font-size:16px;color:#111;margin-bottom:6px;">心情分數:<b>{score}</b>({score_desc})</div>
122
+ <div style="font-size:14px;color:#111;">信心值:{conf_txt}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  </div>
124
  """
125
 
 
126
  def _score_radio_value(score: int) -> str:
127
  score = int(score)
128
  score = min(5, max(1, score))
129
  return SCORE_LABELS[score]
130
 
 
131
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
132
+ if frame is None: return None
 
133
  h, w = frame.shape[:2]
134
+ if w <= target_w: return frame
 
135
  scale = target_w / w
136
  new_h = max(1, int(h * scale))
137
  img = Image.fromarray(frame)
138
  img = img.resize((target_w, new_h))
139
  return np.array(img)
140
 
141
+ def _iou(a, b) -> float:
 
142
  ax, ay, aw, ah = a
143
  bx, by, bw, bh = b
144
  ax2, ay2 = ax + aw, ay + ah
145
  bx2, by2 = bx + bw, by + bh
 
146
  ix1, iy1 = max(ax, bx), max(ay, by)
147
  ix2, iy2 = min(ax2, bx2), min(ay2, by2)
148
  iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
149
  inter = iw * ih
150
+ if inter <= 0: return 0.0
 
151
  union = aw * ah + bw * bh - inter
152
  return inter / max(1e-6, union)
153
 
154
+ def _pick_face_by_tracking(faces, prev_bbox):
155
+ if not faces: return None, None
 
 
 
 
 
 
156
  cand = []
157
  for f in faces:
158
  fa = f.get("facial_area", {}) or {}
159
+ x, y, w, h = int(fa.get("x",0)), int(fa.get("y",0)), int(fa.get("w",0)), int(fa.get("h",0))
 
 
 
160
  if w > 0 and h > 0 and f.get("face") is not None:
161
  cand.append((f, (x, y, w, h), w * h))
162
+ if not cand: return None, None
 
 
 
163
  if prev_bbox is not None:
164
+ best, best_i = None, 0.0
 
165
  for f, bb, area in cand:
166
  i = _iou(prev_bbox, bb)
167
+ if i > best_i: best_i = i; best = (f, bb)
168
+ if best is not None and best_i >= IOU_TH: return best[0], best[1]
 
 
 
 
169
  cand.sort(key=lambda x: x[2], reverse=True)
170
  return cand[0][0], cand[0][1]
171
 
 
172
  def _reset_state_for_camera(st: AppState) -> AppState:
173
+ st.running = True; st.finished = False; st.last_analyze_t = 0.0
174
+ st.track_bbox = None; st.ema_scores = None; st.emo_keys = []
175
+ st.stable_emo = "unknown"; st.candidate_emo = None; st.candidate_since = None
176
+ st.final_emo = None; st.final_conf = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  st.final_score = 3
 
 
178
  return st
179
 
180
 
 
182
  # Camera streaming
183
  # =========================
184
  def on_stream(frame_rgb: np.ndarray, st: AppState):
185
+ # 移除模式檢查,直接執行
 
 
 
 
 
 
 
 
 
186
 
 
187
  if st.finished or (not st.running):
188
  cam_visible = False
 
189
  if st.final_emo:
190
  result_html = _result_card_html(st.final_emo, st.final_conf)
191
  mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
 
194
  result_html = _hint_html("尚未完成辨識。")
195
  mood_val = _score_radio_value(3)
196
  hint_html = _camera_hint_stopped()
197
+ return gr.update(visible=cam_visible), gr.update(value=result_html), gr.update(value=mood_val), gr.update(value=hint_html), st, gr.update(value="重新攝影機辨識")
198
 
 
 
 
 
 
 
 
 
 
 
199
  if frame_rgb is None:
200
+ return gr.update(visible=True), gr.update(value=_hint_html("等待影像…(尚未開始串流)")), gr.update(value=_score_radio_value(3)), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
201
 
202
  now = time.time()
203
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
204
  show = st.stable_emo or "unknown"
205
+ return gr.update(visible=True), gr.update(value=_result_card_html(show, st.final_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
206
 
207
  st.last_analyze_t = now
 
208
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
209
+ faces = DeepFace.extract_faces(img_path=small, detector_backend=DETECTOR_BACKEND, enforce_detection=False, align=ALIGN_FACE)
 
 
 
 
 
 
210
  pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
211
+
212
  if pick is None or bbox is None or pick.get("face") is None:
213
  show = st.stable_emo or "unknown"
214
+ return gr.update(visible=True), gr.update(value=_result_card_html(show, st.final_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
215
 
216
  st.track_bbox = bbox
217
  face = pick["face"]
218
+ if face.dtype != np.uint8: face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
219
+
220
+ result = DeepFace.analyze(img_path=face, actions=["emotion"], enforce_detection=False)
221
+ if isinstance(result, list): result = result[0]
 
 
 
 
 
 
222
  emo_dict = result.get("emotion", None)
223
 
224
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
225
  show = st.stable_emo or "unknown"
226
+ return gr.update(visible=True), gr.update(value=_result_card_html(show, st.final_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
227
 
228
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
229
  top_conf = float(emo_dict[top_key])
230
 
231
  if top_conf < CONF_TH:
232
  show = st.stable_emo or "unknown"
233
+ return gr.update(visible=True), gr.update(value=_result_card_html(show, st.final_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
234
 
235
  keys = list(emo_dict.keys())
236
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
237
 
238
  if st.ema_scores is None or st.emo_keys != keys:
239
+ st.ema_scores = vec; st.emo_keys = keys
 
240
  else:
241
  st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
242
 
 
244
  stable_key = st.emo_keys[stable_idx]
245
  st.stable_emo = stable_key
246
 
 
247
  if st.candidate_emo != stable_key:
248
+ st.candidate_emo = stable_key; st.candidate_since = now
 
249
  else:
250
+ if st.candidate_since is None: st.candidate_since = now
 
251
 
252
+ confirmed = (st.candidate_since is not None and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC)
 
 
 
253
 
254
  if confirmed and st.final_emo is None:
255
+ st.final_emo = stable_key; st.final_conf = top_conf
 
 
 
 
256
  st.final_score = EMO_TO_SCORE.get(stable_key, 3)
257
+ st.running = False; st.finished = True
258
+ return gr.update(visible=False), gr.update(value=_result_card_html(stable_key, top_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))), gr.update(value=_camera_hint_done()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
 
 
 
 
259
 
260
+ return gr.update(visible=True), gr.update(value=_result_card_html(stable_key, top_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
261
 
262
 
263
  # =========================
264
+ # Buttons Logic (Removed Upload)
265
  # =========================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
  def on_restart(st: AppState):
268
+ st = _reset_state_for_camera(st)
269
+ # 回傳值已減少 (移除最後一個 upload update)
270
+ return gr.update(visible=True), gr.update(value=_hint_html("等待影像…(尚未開始串流)")), gr.update(value=_score_radio_value(3)), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
 
272
  def on_stop(st: AppState):
273
+ st.running = False; st.finished = True
274
+ if st.final_emo is None:
275
+ return gr.update(visible=False), gr.update(value=_hint_html("已停止辨識。")), gr.update(value=_score_radio_value(3)), gr.update(value=_camera_hint_stopped()), st, gr.update(value="重新攝影機辨識")
276
+ return gr.update(visible=False), gr.update(value=_result_card_html(st.final_emo, st.final_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))), gr.update(value=_camera_hint_done()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
278
+ css = """#app_container { max-width: 960px; margin: 0 auto; } .gradio-container { background: #f1f5f9 !important; }"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
  if __name__ == "__main__":
281
+ with gr.Blocks(title="Emotion Detector", css=css) as demo:
282
  st = gr.State(AppState())
283
 
284
  gr.Markdown("## 情緒���識")
285
+
286
+ # 移除了模式選擇 Radio
287
+
 
 
 
 
 
288
  hint = gr.HTML(_camera_hint_before_start())
289
 
290
  cam = gr.Image(
 
295
  visible=True,
296
  )
297
 
 
 
 
 
 
 
 
298
  with gr.Row():
299
  btn_restart = gr.Button("重新攝影機辨識", variant="primary")
300
  btn_stop = gr.Button("停止", variant="secondary")
301
 
 
302
  gr.Markdown("### 辨識結果")
303
  result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
304
 
 
310
  interactive=False,
311
  )
312
 
 
 
 
 
 
 
 
313
  cam.stream(
314
  fn=on_stream,
315
  inputs=[cam, st],
 
317
  show_progress="minimal",
318
  )
319
 
 
 
 
 
 
 
 
320
  btn_restart.click(
321
  fn=on_restart,
322
  inputs=[st],
323
+ outputs=[cam, result, mood, hint, st, btn_restart],
324
  show_progress="minimal",
325
  )
326
 
327
  btn_stop.click(
328
  fn=on_stop,
329
  inputs=[st],
330
+ outputs=[cam, result, mood, hint, st, btn_restart],
331
  show_progress="minimal",
332
  )
333