YuAnTsai commited on
Commit
1aaac95
·
verified ·
1 Parent(s): 5038320

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +153 -444
emotion.py CHANGED
@@ -1,5 +1,5 @@
1
  import time
2
- import cv2 # 用於繪製導引框
3
  from dataclasses import dataclass, field
4
  from typing import Optional, Dict, Tuple, List
5
  import gradio as gr
@@ -17,525 +17,234 @@ CONF_TH = 40.0
17
  SWITCH_CONFIRM_SEC = 2.5
18
  IOU_TH = 0.25
19
  ZOOM_FACTOR = 1.5
20
- DETECTOR_BACKEND = "mtcnn" # 想快一點可改 "opencv"
21
  ALIGN_FACE = True
22
 
23
  # =========================
24
- # 情緒中文 + 分數映射(7 -> 5)
25
  # =========================
26
- EMO_ZH = {
27
- "angry": "生氣",
28
- "disgust": "厭惡",
29
- "fear": "害怕",
30
- "happy": "開心",
31
- "sad": "難過",
32
- "surprise": "驚訝",
33
- "neutral": "平靜",
34
- "unknown": "未知",
35
- }
36
-
37
- # 把 7 種情緒塞進 5 分心情分數
38
- EMO_TO_SCORE = {
39
- "angry": 1,
40
- "disgust": 1,
41
- "fear": 1,
42
- "sad": 2,
43
- "neutral": 3,
44
- "surprise": 4,
45
- "happy": 5,
46
- "unknown": 3,
47
- }
48
-
49
- def get_emotion_score(emo: str) -> int:
50
- return EMO_TO_SCORE.get(emo, 3)
51
-
52
- SCORE_LABELS = {
53
- 1: "1(心情差)",
54
- 2: "2(不太好)",
55
- 3: "3(普通)",
56
- 4: "4(不錯)",
57
- 5: "5(超棒)",
58
- }
59
-
60
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
61
 
62
- # =========================
63
- # 狀態
64
- # =========================
65
  @dataclass
66
  class AppState:
67
  mode: str = "攝影機辨識"
68
  running: bool = True
69
  finished: bool = False
70
  last_analyze_t: float = 0.0
71
- # tracking
72
  track_bbox: Optional[Tuple[int, int, int, int]] = None
73
- # smoothing
74
  ema_scores: Optional[np.ndarray] = None
75
  emo_keys: List[str] = field(default_factory=list)
76
- # stable/candidate
77
  stable_emo: str = "unknown"
78
  candidate_emo: Optional[str] = None
79
  candidate_since: Optional[float] = None
80
- # final
81
  final_emo: Optional[str] = None
82
  final_conf: Optional[float] = None
83
  final_score: int = 3
84
 
85
  # =========================
86
- # UI HTML (強制黑色文字)
87
  # =========================
88
  def _hint_html(msg: str) -> str:
89
- # 加入 color:#000000 強制黑色
90
- return f"""
91
- <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">
92
- {msg}
93
- </div>
94
- """
95
 
96
  def _camera_hint_before_start() -> str:
97
- return _hint_html(
98
- "請先按「Click to access webcam / 允許」開啟攝影機;"
99
- "接著,請按「錄製(Record)」開始。"
100
- )
101
 
102
  def _camera_hint_done() -> str:
103
- return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
104
 
105
  def _camera_hint_stopped() -> str:
106
- return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
107
 
108
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
109
  zh = EMO_ZH.get(emo_key, emo_key)
110
  score = EMO_TO_SCORE.get(emo_key, 3)
111
  conf_txt = "" if conf is None else f"{conf:.1f}%"
112
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
113
- # ✅ 強制設定 color: #000000
114
  return f"""
115
- <div style="
116
- border-radius:16px;
117
- padding:20px;
118
- border:1px solid #E6E6E6;
119
- background:#FFFFFF;
120
- color:#000000;
121
- ">
122
- <div style="font-size:14px;color:#000000;margin-bottom:8px;">
123
- 辨識結果
124
- </div>
125
- <div style="
126
- font-size:42px;
127
- font-weight:800;
128
- line-height:1.1;
129
- margin-bottom:14px;
130
- color:#000000;
131
- ">
132
- {zh}
133
- </div>
134
- <div style="font-size:16px;color:#000000;margin-bottom:6px;">
135
- 心情分數:{score}({score_desc})
136
- </div>
137
- <div style="font-size:14px;color:#000000;">
138
- 信心值:{conf_txt}
139
- </div>
140
  </div>
141
  """
142
 
143
  def _score_radio_value(score: int) -> str:
144
- score = int(score)
145
- score = min(5, max(1, score))
146
- return SCORE_LABELS[score]
 
 
 
 
 
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
- """
162
- 裁切畫面中心以模擬數位變焦。
163
- zoom: 放大倍率 (例如 1.5)
164
- """
165
- if frame is None or zoom <= 1.0:
166
- return frame
167
-
168
- h, w = frame.shape[:2]
169
-
170
- # 計算新的寬高
171
- new_w = int(w / zoom)
172
- new_h = int(h / zoom)
173
-
174
- # 計算起始點 (讓裁切框置中)
175
- start_x = (w - new_w) // 2
176
- start_y = (h - new_h) // 2
177
-
178
- # 進行裁切
179
- cropped = frame[start_y : start_y + new_h, start_x : start_x + new_w]
180
- return cropped
 
 
 
 
 
 
181
 
182
- def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
183
  ax, ay, aw, ah = a
184
  bx, by, bw, bh = b
185
- ax2, ay2 = ax + aw, ay + ah
186
- bx2, by2 = bx + bw, by + bh
187
  ix1, iy1 = max(ax, bx), max(ay, by)
188
- ix2, iy2 = min(ax2, bx2), min(ay2, by2)
189
  iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
190
  inter = iw * ih
191
- if inter <= 0:
192
- return 0.0
193
- union = aw * ah + bw * bh - inter
194
- return inter / max(1e-6, union)
195
 
196
- def _pick_face_by_tracking(
197
- faces: List[Dict],
198
- prev_bbox: Optional[Tuple[int, int, int, int]]) -> Tuple[Optional[Dict], Optional[Tuple[int, int, int, int]]]:
199
-
200
- if not faces:
201
- return None, None
202
-
203
  cand = []
204
  for f in faces:
205
- fa = f.get("facial_area", {}) or {}
206
- x = int(fa.get("x", 0) or 0)
207
- y = int(fa.get("y", 0) or 0)
208
- w = int(fa.get("w", 0) or 0)
209
- h = int(fa.get("h", 0) or 0)
210
- if w > 0 and h > 0 and f.get("face") is not None:
211
- cand.append((f, (x, y, w, h), w * h))
212
-
213
- if not cand:
214
- return None, None
215
-
216
- if prev_bbox is not None:
217
- best = None
218
- best_i = 0.0
219
- for f, bb, area in cand:
220
- i = _iou(prev_bbox, bb)
221
- if i > best_i:
222
- best_i = i
223
- best = (f, bb)
224
- if best is not None and best_i >= IOU_TH:
225
- return best[0], best[1]
226
-
227
- cand.sort(key=lambda x: x[2], reverse=True)
228
- return cand[0][0], cand[0][1]
229
-
230
- def _draw_guide_box(frame: np.ndarray, is_detected: bool):
231
- """
232
- 在畫面上畫一個提示框:
233
- 未偵測到臉 -> 紅色框
234
- 偵測到臉部 -> 綠色框
235
- """
236
- h, w = frame.shape[:2]
237
- # 定義一個框的大小
238
- box_w = int(w * 0.5)
239
- box_h = int(h * 0.6)
240
- x1 = (w - box_w) // 2
241
- y1 = (h - box_h) // 2
242
- x2 = x1 + box_w
243
- y2 = y1 + box_h
244
-
245
- # 顏色 BGR (OpenCV 使用 BGR)
246
- # 但因為 Gradio 傳入的是 RGB,所以我們直接當成 RGB 畫
247
- # 綠色 (0, 255, 0), 紅色 (255, 0, 0)
248
- color = (0, 255, 0) if is_detected else (255, 0, 0)
249
- label = "Scanning..." if is_detected else "Align Face Here"
250
-
251
- # 畫矩形 (粗度 3)
252
- cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3)
253
- # 寫���
254
- cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, 2)
255
- return frame
256
-
257
- def _reset_state_for_camera(st: AppState) -> AppState:
258
- st.running = True
259
- st.finished = False
260
- st.last_analyze_t = 0.0
261
- st.track_bbox = None
262
- st.ema_scores = None
263
- st.emo_keys = []
264
- st.stable_emo = "unknown"
265
- st.candidate_emo = None
266
- st.candidate_since = None
267
- st.final_emo = None
268
- st.final_conf = None
269
- st.final_score = 3
270
- return st
271
 
272
  # =========================
273
  # Camera streaming
274
  # =========================
275
  def on_stream(frame_rgb: np.ndarray, st: AppState):
 
 
 
 
276
  if st.finished or (not st.running):
277
- cam_visible = False
278
- if st.final_emo:
279
- result_html = _result_card_html(st.final_emo, st.final_conf)
280
- mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
281
- hint_html = _camera_hint_done()
282
- else:
283
- result_html = _hint_html("尚未完成辨識。")
284
- mood_val = _score_radio_value(3)
285
- hint_html = _camera_hint_stopped()
286
-
287
- return (
288
- None,
289
- gr.update(value=result_html),
290
- gr.update(value=mood_val),
291
- gr.update(value=hint_html),
292
- st,
293
- gr.update(value="重新攝影機辨識"),
294
- )
295
 
296
- if frame_rgb is None:
297
- return (
298
- None,
299
- gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
300
- gr.update(value=_score_radio_value(3)),
301
- gr.update(value=_camera_hint_before_start()),
302
- st,
303
- gr.update(value="重新攝影機辨識"),
304
- )
305
-
306
- # 1. 數位變焦
307
  frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
308
-
309
- # 2. 預檢測 (用於判斷框的顏色)
310
  now = time.time()
311
- small_check = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
312
- faces_check = DeepFace.extract_faces(
313
- img_path=small_check,
314
- detector_backend="opencv", # 快速檢查用
315
- enforce_detection=False,
316
- align=False,
317
- )
318
- pick_check, bbox_check = _pick_face_by_tracking(faces_check, st.track_bbox)
319
- is_detected = pick_check is not None and pick_check.get("face") is not None
320
-
321
- # 3. 繪製框框
322
- frame_with_box = _draw_guide_box(frame_rgb.copy(), is_detected)
323
-
324
- # 4. 真正分析邏輯 (時間到了才跑 DeepFace.analyze)
325
- if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
326
- show = st.stable_emo or "unknown"
327
- return (
328
- frame_with_box,
329
- gr.update(value=_result_card_html(show, st.final_conf)),
330
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
331
- gr.update(value=_camera_hint_before_start()),
332
- st,
333
- gr.update(value="重新攝影機辨識"),
334
- )
335
-
336
- st.last_analyze_t = now
337
- small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
338
-
339
- # 使用正式後端偵測
340
- faces = DeepFace.extract_faces(
341
- img_path=small,
342
- detector_backend=DETECTOR_BACKEND,
343
- enforce_detection=False,
344
- align=ALIGN_FACE,
345
- )
346
-
347
- pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
348
-
349
- if pick is None or bbox is None or pick.get("face") is None:
350
- show = st.stable_emo or "unknown"
351
- return (
352
- frame_with_box,
353
- gr.update(value=_result_card_html(show, st.final_conf)),
354
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
355
- gr.update(value=_camera_hint_before_start()),
356
- st,
357
- gr.update(value="重新攝影機辨識"),
358
- )
359
-
360
- st.track_bbox = bbox
361
- face = pick["face"]
362
 
363
- if face.dtype != np.uint8:
364
- face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
365
-
366
- result = DeepFace.analyze(
367
- img_path=face,
368
- actions=["emotion"],
369
- enforce_detection=False,
370
- )
371
 
372
- if isinstance(result, list):
373
- result = result[0]
374
-
375
- emo_dict = result.get("emotion", None)
376
- if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
377
- show = st.stable_emo or "unknown"
378
- return (
379
- frame_with_box,
380
- gr.update(value=_result_card_html(show, st.final_conf)),
381
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
382
- gr.update(value=_camera_hint_before_start()),
383
- st,
384
- gr.update(value="重新攝影機辨識"),
385
- )
386
-
387
- top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
388
- top_conf = float(emo_dict[top_key])
389
-
390
- if top_conf < CONF_TH:
391
- show = st.stable_emo or "unknown"
392
- return (
393
- frame_with_box,
394
- gr.update(value=_result_card_html(show, st.final_conf)),
395
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
396
- gr.update(value=_camera_hint_before_start()),
397
- st,
398
- gr.update(value="重新攝影機辨識"),
399
- )
400
-
401
- keys = list(emo_dict.keys())
402
- vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
403
-
404
- if st.ema_scores is None or st.emo_keys != keys:
405
- st.ema_scores = vec
406
- st.emo_keys = keys
407
- else:
408
- st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
409
-
410
- stable_idx = int(np.argmax(st.ema_scores))
411
- stable_key = st.emo_keys[stable_idx]
412
- st.stable_emo = stable_key
413
-
414
- # 延遲確認
415
- if st.candidate_emo != stable_key:
416
- st.candidate_emo = stable_key
417
- st.candidate_since = now
418
- else:
419
- if st.candidate_since is None:
420
- st.candidate_since = now
421
 
422
- confirmed = (
423
- st.candidate_since is not None
424
- and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
425
- )
426
-
427
- if confirmed and st.final_emo is None:
428
- st.final_emo = stable_key
429
- st.final_conf = top_conf
430
- st.final_score = EMO_TO_SCORE.get(stable_key, 3)
431
- st.running = False
432
- st.finished = True
433
- return (
434
- None,
435
- gr.update(value=_result_card_html(stable_key, top_conf)),
436
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
437
- gr.update(value=_camera_hint_done()),
438
- st,
439
- gr.update(value="重新攝影機辨識"),
440
- )
441
-
 
 
 
 
 
 
442
  return (
443
- frame_with_box,
444
- gr.update(value=_result_card_html(stable_key, top_conf)),
445
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
446
- gr.update(value=_camera_hint_before_start()),
447
- st,
448
- gr.update(value="重新攝影機辨識"),
449
  )
450
 
451
- # =========================
452
- # Mode / Buttons
453
- # =========================
454
  def on_restart(st: AppState):
455
- st = _reset_state_for_camera(st)
456
- return (
457
- gr.update(visible=True),
458
- gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
459
- gr.update(value=_score_radio_value(3)),
460
- gr.update(value=_camera_hint_before_start()),
461
- st,
462
- gr.update(value="重新攝影機辨識"),
463
- )
464
 
465
  def on_stop(st: AppState):
466
- st.running = False
467
- st.finished = True
468
- if st.final_emo is None:
469
- return (
470
- gr.update(visible=False),
471
- gr.update(value=_hint_html("已停止辨識。")),
472
- gr.update(value=_score_radio_value(3)),
473
- gr.update(value=_camera_hint_stopped()),
474
- st,
475
- gr.update(value="重新攝影機辨識"),
476
- )
477
- return (
478
- gr.update(visible=False),
479
- gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
480
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
481
- gr.update(value=_camera_hint_done()),
482
- st,
483
- gr.update(value="重新攝影機辨識"),
484
- )
485
 
486
  # =========================
487
- # Gradio UI
488
  # =========================
489
- if __name__ == "__main__":
490
- # 若有 CSS 可以寫在這裡,目前維持原本調用的 css 變數
491
- css = ""
492
- with gr.Blocks(title="Emotion Detector", css=css) as demo:
493
- st = gr.State(AppState())
494
- gr.Markdown("## 情緒辨識")
495
-
496
- hint = gr.HTML(_camera_hint_before_start())
497
- cam = gr.Image(
498
- sources=["webcam"],
499
- streaming=True,
500
- type="numpy",
501
- label="攝影機",
502
- visible=True,
503
- )
504
-
505
- with gr.Row():
506
- btn_restart = gr.Button("重新攝影機辨識", variant="primary")
507
- btn_stop = gr.Button("停止", variant="secondary")
508
-
509
- gr.Markdown("### 辨識結果")
510
- result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
511
-
512
- gr.Markdown("### 心情分數(系統判定)")
513
- mood = gr.Radio(
514
- choices=SCORE_CHOICES,
515
- value=_score_radio_value(3),
516
- label="心情分數",
517
- interactive=False,
518
- )
519
-
520
- cam.stream(
521
- fn=on_stream,
522
- inputs=[cam, st],
523
- outputs=[cam, result, mood, hint, st, btn_restart],
524
- show_progress="minimal",
525
- )
526
-
527
- btn_restart.click(
528
- fn=on_restart,
529
- inputs=[st],
530
- outputs=[cam, result, mood, hint, st, btn_restart],
531
- show_progress="minimal",
532
- )
533
-
534
- btn_stop.click(
535
- fn=on_stop,
536
- inputs=[st],
537
- outputs=[cam, result, mood, hint, st, btn_restart],
538
- show_progress="minimal",
539
- )
540
 
541
- demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
 
 
 
 
 
 
 
 
 
 
1
  import time
2
+ import cv2 # 確保有安裝 opencv-python
3
  from dataclasses import dataclass, field
4
  from typing import Optional, Dict, Tuple, List
5
  import gradio as gr
 
17
  SWITCH_CONFIRM_SEC = 2.5
18
  IOU_TH = 0.25
19
  ZOOM_FACTOR = 1.5
20
+ DETECTOR_BACKEND = "mtcnn"
21
  ALIGN_FACE = True
22
 
23
  # =========================
24
+ # 狀態與映射 (保持原本邏輯)
25
  # =========================
26
+ EMO_ZH = {"angry": "生氣", "disgust": "厭惡", "fear": "害怕", "happy": "開心", "sad": "難過", "surprise": "驚訝", "neutral": "平靜", "unknown": "未知"}
27
+ EMO_TO_SCORE = {"angry": 1, "disgust": 1, "fear": 1, "sad": 2, "neutral": 3, "surprise": 4, "happy": 5, "unknown": 3}
28
+ SCORE_LABELS = {1: "1(心情差)", 2: "2(不太好)", 3: "3(普通)", 4: "4(不錯)", 5: "5(超棒)"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
30
 
 
 
 
31
  @dataclass
32
  class AppState:
33
  mode: str = "攝影機辨識"
34
  running: bool = True
35
  finished: bool = False
36
  last_analyze_t: float = 0.0
 
37
  track_bbox: Optional[Tuple[int, int, int, int]] = None
 
38
  ema_scores: Optional[np.ndarray] = None
39
  emo_keys: List[str] = field(default_factory=list)
 
40
  stable_emo: str = "unknown"
41
  candidate_emo: Optional[str] = None
42
  candidate_since: Optional[float] = None
 
43
  final_emo: Optional[str] = None
44
  final_conf: Optional[float] = None
45
  final_score: int = 3
46
 
47
  # =========================
48
+ # 輔助函式
49
  # =========================
50
  def _hint_html(msg: str) -> str:
51
+ return f'<div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">{msg}</div>'
 
 
 
 
 
52
 
53
  def _camera_hint_before_start() -> str:
54
+ return _hint_html("請先按「Click to access webcam」開啟攝影機,接著按「錄製 (Record)」開始。")
 
 
 
55
 
56
  def _camera_hint_done() -> str:
57
+ return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行。")
58
 
59
  def _camera_hint_stopped() -> str:
60
+ return _hint_html("已停止辨識。")
61
 
62
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
63
  zh = EMO_ZH.get(emo_key, emo_key)
64
  score = EMO_TO_SCORE.get(emo_key, 3)
65
  conf_txt = "" if conf is None else f"{conf:.1f}%"
66
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
 
67
  return f"""
68
+ <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#000000;">
69
+ <div style="font-size:14px;margin-bottom:8px;">辨識結果</div>
70
+ <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;">{zh}</div>
71
+ <div style="font-size:16px;margin-bottom:6px;">心情分數:{score}({score_desc})</div>
72
+ <div style="font-size:14px;">信心值:{conf_txt}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  </div>
74
  """
75
 
76
  def _score_radio_value(score: int) -> str:
77
+ return SCORE_LABELS[min(5, max(1, int(score)))]
78
+
79
+ def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
80
+ if frame is None or zoom <= 1.0: return frame
81
+ h, w = frame.shape[:2]
82
+ new_w, new_h = int(w / zoom), int(h / zoom)
83
+ start_x, start_y = (w - new_w) // 2, (h - new_h) // 2
84
+ return frame[start_y : start_y + new_h, start_x : start_x + new_w]
85
 
86
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
87
+ if frame is None: return None
 
88
  h, w = frame.shape[:2]
89
+ if w <= target_w: return frame
 
90
  scale = target_w / w
91
  new_h = max(1, int(h * scale))
92
+ return np.array(Image.fromarray(frame).resize((target_w, new_h)))
 
 
93
 
94
+ # 這是關鍵:新增繪圖函式,確保顏色與座標正確
95
+ def _draw_ui_overlay(frame: np.ndarray, is_detected: bool):
96
+ canvas = frame.copy()
97
+ h, w = canvas.shape[:2]
98
+
99
+ # 框的大小 (寬50%, 高60%)
100
+ bw, bh = int(w * 0.5), int(h * 0.6)
101
+ x1, y1 = (w - bw) // 2, (h - bh) // 2
102
+ x2, y2 = x1 + bw, y1 + bh
103
+
104
+ # 顏色判定 (RGB 格式)
105
+ # 如果抓到臉 -> 綠色 (0, 255, 0);沒抓到 -> 紅色 (255, 0, 0)
106
+ color = (0, 255, 0) if is_detected else (255, 0, 0)
107
+ text = "DETECTED" if is_detected else "ALIGN FACE"
108
+
109
+ # 1. 畫外框矩形
110
+ cv2.rectangle(canvas, (x1, y1), (x2, y2), color, 4)
111
+
112
+ # 2. 畫四個角落增強視覺感 (可選)
113
+ length = 30
114
+ cv2.line(canvas, (x1, y1), (x1 + length, y1), color, 8)
115
+ cv2.line(canvas, (x1, y1), (x1, y1 + length), color, 8)
116
+
117
+ # 3. 加上文字
118
+ cv2.putText(canvas, text, (x1, y1 - 15), cv2.FONT_HERSHEY_DUPLEX, 1.0, color, 2)
119
+
120
+ return canvas
121
 
122
+ def _iou(a, b):
123
  ax, ay, aw, ah = a
124
  bx, by, bw, bh = b
 
 
125
  ix1, iy1 = max(ax, bx), max(ay, by)
126
+ ix2, iy2 = min(ax + aw, bx + bw), min(ay + ah, by + bh)
127
  iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
128
  inter = iw * ih
129
+ if inter <= 0: return 0.0
130
+ return inter / (aw * ah + bw * bh - inter)
 
 
131
 
132
+ def _pick_face_by_tracking(faces, prev_bbox):
133
+ if not faces: return None, None
 
 
 
 
 
134
  cand = []
135
  for f in faces:
136
+ fa = f.get("facial_area", {})
137
+ x, y, w, h = fa.get("x",0), fa.get("y",0), fa.get("w",0), fa.get("h",0)
138
+ if w > 0 and h > 0: cand.append((f, (x, y, w, h), w * h))
139
+ if not cand: return None, None
140
+ if prev_bbox:
141
+ best = max(cand, key=lambda c: _iou(prev_bbox, c[1]), default=(None, None, 0))
142
+ if best[0] and _iou(prev_bbox, best[1]) >= IOU_TH: return best[0], best[1]
143
+ return sorted(cand, key=lambda x: x[2], reverse=True)[0][:2]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
  # =========================
146
  # Camera streaming
147
  # =========================
148
  def on_stream(frame_rgb: np.ndarray, st: AppState):
149
+ if frame_rgb is None:
150
+ return (None, gr.update(), gr.update(), gr.update(), st, gr.update())
151
+
152
+ # 如果已經結束,回傳空影像讓攝像頭不再更新
153
  if st.finished or (not st.running):
154
+ return (None, gr.update(), gr.update(), gr.update(), st, gr.update())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
+ # 1. 處理影像
 
 
 
 
 
 
 
 
 
 
157
  frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
 
 
158
  now = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
+ # 2. 快速檢測人臉 (每一幀都跑,確保框框顏色反應即時)
161
+ small_check = _downsample_rgb(frame_rgb, 400) # 用更小的圖加快速度
162
+ faces_check = DeepFace.extract_faces(img_path=small_check, detector_backend="opencv", enforce_detection=False)
163
+ pick_check, _ = _pick_face_by_tracking(faces_check, st.track_bbox)
164
+ is_detected = (pick_check is not None)
 
 
 
165
 
166
+ # 3. 繪製導引框 (這張圖會回傳給 UI)
167
+ frame_with_box = _draw_ui_overlay(frame_rgb, is_detected)
168
+
169
+ # 4. 深度辨識邏輯 (每 ANALYZE_EVERY_SEC 秒執行一次)
170
+ if is_detected and (now - st.last_analyze_t >= ANALYZE_EVERY_SEC):
171
+ st.last_analyze_t = now
172
+ # 正式分析
173
+ small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
174
+ faces = DeepFace.extract_faces(img_path=small, detector_backend=DETECTOR_BACKEND, enforce_detection=False, align=ALIGN_FACE)
175
+ pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
176
+
177
+ if pick:
178
+ st.track_bbox = bbox
179
+ face = pick["face"]
180
+ if face.dtype != np.uint8: face = (face * 255).astype(np.uint8)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
+ res = DeepFace.analyze(img_path=face, actions=["emotion"], enforce_detection=False)
183
+ emo_dict = res[0].get("emotion") if isinstance(res, list) else res.get("emotion")
184
+
185
+ if emo_dict:
186
+ top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
187
+ top_conf = float(emo_dict[top_key])
188
+
189
+ # EMA & Stable 邏輯
190
+ keys = list(emo_dict.keys())
191
+ vec = np.array([float(emo_dict[k]) for k in keys])
192
+ if st.ema_scores is None: st.ema_scores, st.emo_keys = vec, keys
193
+ else: st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
194
+
195
+ stable_key = st.emo_keys[np.argmax(st.ema_scores)]
196
+ st.stable_emo = stable_key
197
+
198
+ # 確認穩定度
199
+ if st.candidate_emo != stable_key:
200
+ st.candidate_emo, st.candidate_since = stable_key, now
201
+ elif st.candidate_since and (now - st.candidate_since >= SWITCH_CONFIRM_SEC):
202
+ # 完成辨識
203
+ st.final_emo, st.final_conf, st.running, st.finished = stable_key, top_conf, False, True
204
+ return (frame_with_box, _result_card_html(stable_key, top_conf), _score_radio_value(EMO_TO_SCORE.get(stable_key, 3)), _camera_hint_done(), st, gr.update(value="重新攝影機辨識"))
205
+
206
+ # 持續回傳帶有 UI 框的畫面
207
+ show_emo = st.stable_emo or "unknown"
208
  return (
209
+ frame_with_box,
210
+ gr.update(value=_result_card_html(show_emo, st.final_conf)),
211
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show_emo, 3))),
212
+ gr.update(value=_camera_hint_before_start()),
213
+ st,
214
+ gr.update()
215
  )
216
 
 
 
 
217
  def on_restart(st: AppState):
218
+ st.running, st.finished, st.last_analyze_t = True, False, 0.0
219
+ st.track_bbox, st.ema_scores, st.stable_emo, st.final_emo = None, None, "unknown", None
220
+ return (gr.update(visible=True), _hint_html("等待影像..."), _score_radio_value(3), _camera_hint_before_start(), st, "重新攝影機辨識")
 
 
 
 
 
 
221
 
222
  def on_stop(st: AppState):
223
+ st.running, st.finished = False, True
224
+ return (gr.update(visible=False), _hint_html("已停止"), _score_radio_value(3), _camera_hint_stopped(), st, "重新攝影機辨識")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
  # =========================
227
+ # UI
228
  # =========================
229
+ with gr.Blocks(title="Emotion Detector") as demo:
230
+ state = gr.State(AppState())
231
+ gr.Markdown("## 情緒辨識系統")
232
+ hint = gr.HTML(_camera_hint_before_start())
233
+
234
+ # 這裡的 cam 元件接收輸出的影像
235
+ cam = gr.Image(sources=["webcam"], streaming=True, type="numpy", label="攝影機預覽")
236
+
237
+ with gr.Row():
238
+ btn_restart = gr.Button("重新攝影機辨識", variant="primary")
239
+ btn_stop = gr.Button("停止", variant="secondary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
+ result = gr.HTML(_hint_html("等待串流..."))
242
+ mood = gr.Radio(choices=SCORE_CHOICES, value=SCORE_LABELS[3], label="系統判定心情", interactive=False)
243
+
244
+ # 串流綁定
245
+ cam.stream(on_stream, [cam, state], [cam, result, mood, hint, state, btn_restart], show_progress="minimal")
246
+ btn_restart.click(on_restart, [state], [cam, result, mood, hint, state, btn_restart])
247
+ btn_stop.click(on_stop, [state], [cam, result, mood, hint, state, btn_restart])
248
+
249
+ if __name__ == "__main__":
250
+ demo.launch(server_name="0.0.0.0", server_port=7860)