PhilipL commited on
Commit
f26e059
·
verified ·
1 Parent(s): fe8a606

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +94 -460
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,
@@ -50,7 +50,6 @@ EMO_TO_SCORE = {
50
  }
51
 
52
  # ### [整合需求 新增] ###
53
- # 為了讓主程式 (app.py) 可以呼叫這個轉換邏輯,將其包裝成函式
54
  def get_emotion_score(emo: str) -> int:
55
  return EMO_TO_SCORE.get(emo, 3)
56
  # ######################
@@ -92,8 +91,7 @@ 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
 
@@ -102,187 +100,103 @@ class AppState:
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 +204,11 @@ 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 +217,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,48 +267,24 @@ 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
  # =========================
@@ -469,81 +292,46 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
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
  # =========================
@@ -551,189 +339,35 @@ def on_upload(img: np.ndarray, st: AppState):
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(
674
- sources=["webcam"],
675
- streaming=True,
676
- type="numpy",
677
- label="攝影機",
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
-
696
- gr.Markdown("### 心情分數(系統判定)")
697
- mood = gr.Radio(
698
- choices=SCORE_CHOICES,
699
- value=_score_radio_value(3),
700
- label="心情分數",
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],
714
- outputs=[cam, result, mood, hint, st, btn_restart],
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
-
739
- demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
 
19
  SWITCH_CONFIRM_SEC = 2.5
20
  IOU_TH = 0.25
21
 
22
+ # 建議改為 "opencv" 穩定性較高,若想維持原狀可改回 "mtcnn"
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,
 
50
  }
51
 
52
  # ### [整合需求 新增] ###
 
53
  def get_emotion_score(emo: str) -> int:
54
  return EMO_TO_SCORE.get(emo, 3)
55
  # ######################
 
91
  final_emo: Optional[str] = None
92
  final_conf: Optional[float] = None
93
 
94
+ # ### [整合需求 新增] 用來儲存整數分數 ###
 
95
  final_score: int = 3
96
  # ######################
97
 
 
100
  # UI HTML
101
  # =========================
102
  def _hint_html(msg: str) -> str:
103
+ return f"""<div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#111;">{msg}</div>"""
 
 
 
 
 
104
 
105
  def _camera_hint_before_start() -> str:
106
+ return _hint_html("請先按「Click to access webcam / 允許」開啟攝影機;接著,請按「錄製(Record)」開始。")
 
 
 
 
107
 
108
  def _camera_hint_done() -> str:
109
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
110
 
 
111
  def _camera_hint_stopped() -> str:
112
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
113
 
 
114
  def _upload_hint_before() -> str:
115
  return _hint_html("請上傳一張清楚的臉部照片(臉越大越準)。")
116
 
 
117
  def _upload_hint_done() -> str:
118
  return _hint_html("已完成圖片辨識,可按「重新上傳圖片辨識」再次辨識。")
119
 
 
120
  def _upload_hint_stopped() -> str:
121
  return _hint_html("已停止圖片辨識。可按「重新上傳圖片辨識」再試一次。")
122
 
 
123
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
124
  zh = EMO_ZH.get(emo_key, emo_key)
125
  score = EMO_TO_SCORE.get(emo_key, 3)
126
  conf_txt = "" if conf is None else f"{conf:.1f}%"
127
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
128
 
 
129
  return f"""
130
+ <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#111;">
131
+ <div style="font-size:14px;color:#444;margin-bottom:8px;">辨識結果</div>
132
+ <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;color:#000;">{zh}</div>
133
+ <div style="font-size:16px;color:#111;margin-bottom:6px;">心情分數:<b>{score}</b>({score_desc})</div>
134
+ <div style="font-size:14px;color:#111;">信心值:{conf_txt}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  </div>
136
  """
137
 
 
138
  def _score_radio_value(score: int) -> str:
139
  score = int(score)
140
  score = min(5, max(1, score))
141
  return SCORE_LABELS[score]
142
 
 
143
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
144
+ if frame is None: return None
 
145
  h, w = frame.shape[:2]
146
+ if w <= target_w: return frame
 
147
  scale = target_w / w
148
  new_h = max(1, int(h * scale))
149
  img = Image.fromarray(frame)
150
  img = img.resize((target_w, new_h))
151
  return np.array(img)
152
 
153
+ def _iou(a, b) -> float:
 
154
  ax, ay, aw, ah = a
155
  bx, by, bw, bh = b
156
  ax2, ay2 = ax + aw, ay + ah
157
  bx2, by2 = bx + bw, by + bh
 
158
  ix1, iy1 = max(ax, bx), max(ay, by)
159
  ix2, iy2 = min(ax2, bx2), min(ay2, by2)
160
  iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
161
  inter = iw * ih
162
+ if inter <= 0: return 0.0
 
163
  union = aw * ah + bw * bh - inter
164
  return inter / max(1e-6, union)
165
 
166
+ def _pick_face_by_tracking(faces, prev_bbox):
167
+ if not faces: return None, None
 
 
 
 
 
 
168
  cand = []
169
  for f in faces:
170
  fa = f.get("facial_area", {}) or {}
171
+ x, y, w, h = int(fa.get("x",0)), int(fa.get("y",0)), int(fa.get("w",0)), int(fa.get("h",0))
 
 
 
172
  if w > 0 and h > 0 and f.get("face") is not None:
173
  cand.append((f, (x, y, w, h), w * h))
174
+ if not cand: return None, None
 
 
 
175
  if prev_bbox is not None:
176
+ best, best_i = None, 0.0
 
177
  for f, bb, area in cand:
178
  i = _iou(prev_bbox, bb)
179
+ if i > best_i: best_i = i; best = (f, bb)
180
+ if best is not None and best_i >= IOU_TH: return best[0], best[1]
 
 
 
 
181
  cand.sort(key=lambda x: x[2], reverse=True)
182
  return cand[0][0], cand[0][1]
183
 
 
184
  def _reset_state_for_camera(st: AppState) -> AppState:
185
+ st.running = True; st.finished = False; st.last_analyze_t = 0.0
186
+ st.track_bbox = None; st.ema_scores = None; st.emo_keys = []
187
+ st.stable_emo = "unknown"; st.candidate_emo = None; st.candidate_since = None
188
+ st.final_emo = None; st.final_conf = None
189
+ # ### [整合需求] 重置分數
 
 
 
 
 
 
 
 
190
  st.final_score = 3
 
 
191
  return st
192
 
 
193
  def _reset_state_for_upload(st: AppState) -> AppState:
194
+ st.running = False; st.finished = False; st.last_analyze_t = 0.0
195
+ st.track_bbox = None; st.ema_scores = None; st.emo_keys = []
196
+ st.stable_emo = "unknown"; st.candidate_emo = None; st.candidate_since = None
197
+ st.final_emo = None; st.final_conf = None
198
+ # ### [整合需求] 重置分數
 
 
 
 
 
 
 
 
199
  st.final_score = 3
 
 
200
  return st
201
 
202
 
 
204
  # Camera streaming
205
  # =========================
206
  def on_stream(frame_rgb: np.ndarray, st: AppState):
 
207
  if st.mode != "攝影機辨識":
208
+ return gr.update(), gr.update(), gr.update(), gr.update(value=_upload_hint_before()), st, gr.update(value="重新上傳圖片辨識")
209
+
 
 
 
 
 
 
 
 
210
  if st.finished or (not st.running):
211
  cam_visible = False
 
212
  if st.final_emo:
213
  result_html = _result_card_html(st.final_emo, st.final_conf)
214
  mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
 
217
  result_html = _hint_html("尚未完成辨識。")
218
  mood_val = _score_radio_value(3)
219
  hint_html = _camera_hint_stopped()
220
+ 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="重新攝影機辨識")
221
 
 
 
 
 
 
 
 
 
 
 
222
  if frame_rgb is None:
223
+ 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="重新攝影機辨識")
 
 
 
 
 
 
 
224
 
225
  now = time.time()
226
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
227
  show = st.stable_emo or "unknown"
228
+ 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="重新攝影機辨識")
 
 
 
 
 
 
 
229
 
230
  st.last_analyze_t = now
 
231
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
232
+ faces = DeepFace.extract_faces(img_path=small, detector_backend=DETECTOR_BACKEND, enforce_detection=False, align=ALIGN_FACE)
 
 
 
 
 
 
233
  pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
234
+
235
  if pick is None or bbox is None or pick.get("face") is None:
236
  show = st.stable_emo or "unknown"
237
+ 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="重新攝影機辨識")
 
 
 
 
 
 
 
238
 
239
  st.track_bbox = bbox
240
  face = pick["face"]
241
+ if face.dtype != np.uint8: face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
242
+
243
+ result = DeepFace.analyze(img_path=face, actions=["emotion"], enforce_detection=False)
244
+ if isinstance(result, list): result = result[0]
 
 
 
 
 
 
245
  emo_dict = result.get("emotion", None)
246
 
247
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
248
  show = st.stable_emo or "unknown"
249
+ 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="重新攝影機辨識")
 
 
 
 
 
 
 
250
 
251
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
252
  top_conf = float(emo_dict[top_key])
253
 
254
  if top_conf < CONF_TH:
255
  show = st.stable_emo or "unknown"
256
+ 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="重新攝影機辨識")
 
 
 
 
 
 
 
257
 
258
  keys = list(emo_dict.keys())
259
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
260
 
261
  if st.ema_scores is None or st.emo_keys != keys:
262
+ st.ema_scores = vec; st.emo_keys = keys
 
263
  else:
264
  st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
265
 
 
267
  stable_key = st.emo_keys[stable_idx]
268
  st.stable_emo = stable_key
269
 
 
270
  if st.candidate_emo != stable_key:
271
+ st.candidate_emo = stable_key; st.candidate_since = now
 
272
  else:
273
+ if st.candidate_since is None: st.candidate_since = now
 
274
 
275
+ confirmed = (st.candidate_since is not None and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC)
 
 
 
276
 
277
  if confirmed and st.final_emo is None:
278
+ st.final_emo = stable_key; st.final_conf = top_conf
 
279
 
280
+ # ### [整合需求] 存入分數 ###
 
281
  st.final_score = EMO_TO_SCORE.get(stable_key, 3)
282
+ # #######################
283
+
284
+ st.running = False; st.finished = True
285
+ 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="重新攝影機辨識")
286
+
287
+ 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="重新攝影機辨識")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
 
289
 
290
  # =========================
 
292
  # =========================
293
  def on_upload(img: np.ndarray, st: AppState):
294
  if st.mode != "上傳圖片辨識":
295
+ return gr.update(), gr.update(), st, gr.update(value=_camera_hint_before_start()), gr.update(value="重新攝影機辨識")
 
 
 
 
296
 
297
  st = _reset_state_for_upload(st)
 
298
  if img is None:
299
+ return gr.update(value=_upload_hint_before()), gr.update(value=_score_radio_value(3)), st, gr.update(value=_upload_hint_before()), gr.update(value="重新上傳圖片辨識")
300
+
301
+ # ### [修正] 圖片格式處理 (解決 PNG/灰階 問題) ###
302
+ if img.ndim == 2:
303
+ img = np.stack([img, img, img], axis=-1)
304
+ if img.shape[-1] == 4:
305
+ img = img[:, :, :3]
306
+ # ###########################################
307
 
308
  small = _downsample_rgb(img, DOWNSAMPLE_W)
309
+ faces = DeepFace.extract_faces(img_path=small, detector_backend=DETECTOR_BACKEND, enforce_detection=False, align=ALIGN_FACE)
 
 
 
 
 
310
  pick, bbox = _pick_face_by_tracking(faces, None)
311
+
312
  if pick is None or pick.get("face") is None:
313
+ return gr.update(value=_hint_html("沒有偵測到人臉,請換一張更清楚、臉更大的照片。")), gr.update(value=_score_radio_value(3)), st, gr.update(value=_upload_hint_before()), gr.update(value="重新上傳圖片辨識")
 
 
 
 
 
 
314
 
315
  face = pick["face"]
316
+ if face.dtype != np.uint8: face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
317
+
318
+ result = DeepFace.analyze(img_path=face, actions=["emotion"], enforce_detection=False)
319
+ if isinstance(result, list): result = result[0]
 
 
 
 
 
 
320
  emo_dict = result.get("emotion", None)
321
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
322
+ return gr.update(value=_hint_html("辨識失敗,請換一張照片再試一次。")), gr.update(value=_score_radio_value(3)), st, gr.update(value=_upload_hint_before()), gr.update(value="重新上傳圖片辨識")
 
 
 
 
 
 
323
 
324
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
325
  top_conf = float(emo_dict[top_key])
326
 
327
+ st.final_emo = top_key; st.final_conf = top_conf
 
328
 
329
+ # ### [整合需求] 存入分數 ###
 
330
  st.final_score = EMO_TO_SCORE.get(top_key, 3)
331
+ # #######################
332
+
333
  st.finished = True
334
+ return gr.update(value=_result_card_html(top_key, top_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(top_key, 3))), st, gr.update(value=_upload_hint_done()), gr.update(value="重新上傳圖片辨識")
 
 
 
 
 
 
 
335
 
336
 
337
  # =========================
 
339
  # =========================
340
  def on_mode_change(mode: str, st: AppState):
341
  st.mode = mode
 
342
  if mode == "攝影機辨識":
343
  st = _reset_state_for_camera(st)
344
+ return gr.update(visible=True), gr.update(visible=False, value=None), gr.update(value=_hint_html("等待影像…(尚未開始串流)")), gr.update(value=_score_radio_value(3)), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
 
345
  else:
346
  st = _reset_state_for_upload(st)
347
+ return gr.update(visible=False), gr.update(visible=True, value=None), gr.update(value=_upload_hint_before()), gr.update(value=_score_radio_value(3)), gr.update(value=_upload_hint_before()), st, gr.update(value="重新上傳圖片辨識")
 
 
 
 
 
 
 
 
 
348
 
349
  def on_restart(st: AppState):
350
  if st.mode == "攝影機辨識":
351
  st = _reset_state_for_camera(st)
352
+ 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="重新攝影機辨識"), gr.update()
 
 
 
 
 
 
 
 
353
  else:
354
  st = _reset_state_for_upload(st)
355
+ return gr.update(), gr.update(value=_upload_hint_before()), gr.update(value=_score_radio_value(3)), gr.update(value=_upload_hint_before()), st, gr.update(value="重新上傳圖片辨識"), gr.update(value=None)
 
 
 
 
 
 
 
 
 
356
 
357
  def on_stop(st: AppState):
358
  if st.mode == "攝影機辨識":
359
+ st.running = False; st.finished = True
 
 
360
  if st.final_emo is None:
361
+ 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="重新攝影機辨識"), gr.update()
362
+ 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="重新攝影機辨識"), gr.update()
363
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  st = _reset_state_for_upload(st)
365
+ return gr.update(), gr.update(value=_hint_html("已停止圖片辨識。")), gr.update(value=_score_radio_value(3)), gr.update(value=_upload_hint_stopped()), st, gr.update(value="重新上傳圖片辨識"), gr.update(value=None)
 
 
 
 
 
 
 
 
366
 
367
+ css = """#app_container { max-width: 960px; margin: 0 auto; } .gradio-container { background: #f1f5f9 !important; }"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
 
369
  if __name__ == "__main__":
370
+ with gr.Blocks(title="Emotion Detector", css=css) as demo:
371
+ # 隊友的單獨測試介面 (略)
372
+ pass
373
+ # demo.launch()