YuAnTsai commited on
Commit
b734249
·
verified ·
1 Parent(s): 928c465

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +123 -159
emotion.py CHANGED
@@ -6,7 +6,8 @@ import gradio as gr
6
  import numpy as np
7
  from PIL import Image
8
 
9
- import cv2 # ✅ 新增:用來畫框
 
10
 
11
  from deepface import DeepFace
12
 
@@ -14,19 +15,24 @@ from deepface import DeepFace
14
  # =========================
15
  # 參數(可微調)
16
  # =========================
17
- ANALYZE_EVERY_SEC = 0.8
18
- DOWNSAMPLE_W = 720
19
  EMA_ALPHA = 0.45
20
  CONF_TH = 40.0
21
  SWITCH_CONFIRM_SEC = 2.5
22
- IOU_TH = 0.25
23
- ZOOM_FACTOR = 1.5
24
 
25
- DETECTOR_BACKEND = "mtcnn" # 想快一點可改 "opencv"
26
- ALIGN_FACE = True
 
27
 
28
- # ✅ 新增:bbox 過期時間(秒),避免框卡死
29
- BBOX_TTL_SEC = 2.0
 
 
 
 
 
30
 
31
 
32
  # =========================
@@ -43,7 +49,7 @@ EMO_ZH = {
43
  "unknown": "未知",
44
  }
45
 
46
- # 你要的:把 7 種情緒塞進 5 分心情分數
47
  EMO_TO_SCORE = {
48
  "angry": 1,
49
  "disgust": 1,
@@ -73,17 +79,11 @@ SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
73
  # =========================
74
  @dataclass
75
  class AppState:
76
- mode: str = "攝影機辨識"
77
-
78
  running: bool = True
79
  finished: bool = False
80
  last_analyze_t: float = 0.0
81
 
82
- # tracking(bbox 在 small 座標系)
83
- track_bbox: Optional[Tuple[int, int, int, int]] = None
84
- track_small_shape: Optional[Tuple[int, int]] = None # ✅ 新增:(h, w) of small
85
- track_bbox_time: float = 0.0 # ✅ 新增:bbox 更新時間
86
-
87
  # smoothing
88
  ema_scores: Optional[np.ndarray] = None
89
  emo_keys: List[str] = field(default_factory=list)
@@ -96,8 +96,7 @@ class AppState:
96
  # final
97
  final_emo: Optional[str] = None
98
  final_conf: Optional[float] = None
99
-
100
- final_score: int = 3
101
 
102
 
103
  # =========================
@@ -110,21 +109,20 @@ def _hint_html(msg: str) -> str:
110
  </div>
111
  """
112
 
113
-
114
  def _camera_hint_before_start() -> str:
115
  return _hint_html(
116
  "請先按「Click to access webcam / 允許」開啟攝影機;"
117
  "接著,請按「錄製(Record)」開始。"
118
  )
119
 
120
-
121
  def _camera_hint_done() -> str:
122
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
123
 
124
-
125
  def _camera_hint_stopped() -> str:
126
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
127
 
 
 
128
 
129
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
130
  zh = EMO_ZH.get(emo_key, emo_key)
@@ -138,7 +136,7 @@ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
138
  padding:20px;
139
  border:1px solid #E6E6E6;
140
  background:#FFFFFF;
141
- color:#000000;
142
  ">
143
  <div style="font-size:14px;color:#000000;margin-bottom:8px;">
144
  辨識結果
@@ -164,13 +162,15 @@ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
164
  </div>
165
  """
166
 
167
-
168
  def _score_radio_value(score: int) -> str:
169
  score = int(score)
170
  score = min(5, max(1, score))
171
  return SCORE_LABELS[score]
172
 
173
 
 
 
 
174
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
175
  if frame is None:
176
  return None
@@ -186,7 +186,7 @@ def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
186
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
187
  if frame is None or zoom <= 1.0:
188
  return frame
189
-
190
  h, w = frame.shape[:2]
191
  new_w = int(w / zoom)
192
  new_h = int(h / zoom)
@@ -196,105 +196,84 @@ def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
196
  return cropped
197
 
198
 
199
- def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
200
- ax, ay, aw, ah = a
201
- bx, by, bw, bh = b
202
- ax2, ay2 = ax + aw, ay + ah
203
- bx2, by2 = bx + bw, by + bh
204
-
205
- ix1, iy1 = max(ax, bx), max(ay, by)
206
- ix2, iy2 = min(ax2, bx2), min(ay2, by2)
207
- iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
208
- inter = iw * ih
209
- if inter <= 0:
210
- return 0.0
211
- union = aw * ah + bw * bh - inter
212
- return inter / max(1e-6, union)
213
-
214
-
215
- def _pick_face_by_tracking(
216
- faces: List[Dict],
217
- prev_bbox: Optional[Tuple[int, int, int, int]]
218
- ) -> Tuple[Optional[Dict], Optional[Tuple[int, int, int, int]]]:
219
- if not faces:
220
- return None, None
221
-
222
- cand = []
223
- for f in faces:
224
- fa = f.get("facial_area", {}) or {}
225
- x = int(fa.get("x", 0) or 0)
226
- y = int(fa.get("y", 0) or 0)
227
- w = int(fa.get("w", 0) or 0)
228
- h = int(fa.get("h", 0) or 0)
229
- if w > 0 and h > 0 and f.get("face") is not None:
230
- cand.append((f, (x, y, w, h), w * h))
231
-
232
- if not cand:
233
- return None, None
234
-
235
- if prev_bbox is not None:
236
- best = None
237
- best_i = 0.0
238
- for f, bb, area in cand:
239
- i = _iou(prev_bbox, bb)
240
- if i > best_i:
241
- best_i = i
242
- best = (f, bb)
243
- if best is not None and best_i >= IOU_TH:
244
- return best[0], best[1]
245
-
246
- cand.sort(key=lambda x: x[2], reverse=True)
247
- return cand[0][0], cand[0][1]
248
-
249
-
250
- # ✅ 新增:每一幀都畫上 bbox(如果 bbox 沒過期)
251
- def _draw_bbox_always(frame_rgb: np.ndarray, st: AppState, ttl_sec: float = BBOX_TTL_SEC) -> np.ndarray:
252
  if frame_rgb is None:
253
- return frame_rgb
254
 
255
- if st.track_bbox is None or st.track_small_shape is None:
256
- return frame_rgb
257
 
258
- now = time.time()
259
- if st.track_bbox_time and (now - st.track_bbox_time) > ttl_sec:
260
- return frame_rgb
 
 
 
261
 
262
- fh, fw = frame_rgb.shape[:2]
263
- sh, sw = st.track_small_shape
264
- if sw <= 0 or sh <= 0:
265
- return frame_rgb
 
 
 
 
266
 
267
- x, y, w, h = st.track_bbox # small 座標系
268
- sx = fw / sw
269
- sy = fh / sh
 
 
270
 
271
- x1 = int(x * sx)
272
- y1 = int(y * sy)
273
- x2 = int((x + w) * sx)
274
- y2 = int((y + h) * sy)
 
275
 
276
- x1 = max(0, min(fw - 1, x1))
277
- y1 = max(0, min(fh - 1, y1))
278
- x2 = max(0, min(fw - 1, x2))
279
- y2 = max(0, min(fh - 1, y2))
 
280
 
281
  bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
282
- cv2.rectangle(bgr, (x1, y1), (x2, y2), (255, 255, 255), 3) # 白框
283
- return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
 
 
 
 
 
 
 
 
 
 
284
 
285
 
286
  def _reset_state_for_camera(st: AppState) -> AppState:
287
  st.running = True
288
  st.finished = False
289
  st.last_analyze_t = 0.0
290
- st.track_bbox = None
291
- st.track_small_shape = None
292
- st.track_bbox_time = 0.0
293
  st.ema_scores = None
294
  st.emo_keys = []
 
295
  st.stable_emo = "unknown"
296
  st.candidate_emo = None
297
  st.candidate_since = None
 
298
  st.final_emo = None
299
  st.final_conf = None
300
  st.final_score = 3
@@ -319,7 +298,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
319
  hint_html = _camera_hint_stopped()
320
 
321
  return (
322
- gr.update(value=None, visible=cam_visible), # ✅ cam
323
  gr.update(value=result_html),
324
  gr.update(value=mood_val),
325
  gr.update(value=hint_html),
@@ -330,7 +309,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
330
  # --- no frame yet ---
331
  if frame_rgb is None:
332
  return (
333
- gr.update(value=None, visible=True), # ✅ cam
334
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
335
  gr.update(value=_score_radio_value(3)),
336
  gr.update(value=_camera_hint_before_start()),
@@ -341,42 +320,27 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
341
  # zoom
342
  frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
343
 
344
- # ✅ 每一幀先畫「上一個 bbox」,讓框一直存在
345
- frame_rgb = _draw_bbox_always(frame_rgb, st, ttl_sec=BBOX_TTL_SEC)
346
-
347
- now = time.time()
348
 
349
- # --- not time to analyze: return frame with bbox ---
350
- if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
351
- show = st.stable_emo or "unknown"
352
  return (
353
- gr.update(value=frame_rgb, visible=True), # ✅ cam:回傳影像
354
- gr.update(value=_result_card_html(show, st.final_conf)),
355
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
356
- gr.update(value=_camera_hint_before_start()),
357
  st,
358
  gr.update(value="重新攝影機辨識"),
359
  )
360
 
361
- # --- analyze tick ---
362
- st.last_analyze_t = now
363
-
364
- small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
365
-
366
- faces = DeepFace.extract_faces(
367
- img_path=small,
368
- detector_backend=DETECTOR_BACKEND,
369
- enforce_detection=False,
370
- align=ALIGN_FACE,
371
- )
372
-
373
- pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
374
 
375
- # 若偵測不到:仍回傳「上一個框」(已經畫過),不要閃爍
376
- if pick is None or bbox is None or pick.get("face") is None:
377
  show = st.stable_emo or "unknown"
378
  return (
379
- gr.update(value=frame_rgb, visible=True), # ✅ cam
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()),
@@ -384,21 +348,23 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
384
  gr.update(value="重新攝影機辨識"),
385
  )
386
 
387
- # ✅ 更新追蹤 bbox(small 座標系)
388
- st.track_bbox = bbox
389
- st.track_small_shape = small.shape[:2]
390
- st.track_bbox_time = now
391
 
392
- # ✅ 立刻再畫一次最新 bbox(讓框馬上跟上)
393
- frame_rgb = _draw_bbox_always(frame_rgb, st, ttl_sec=BBOX_TTL_SEC)
 
 
 
 
 
 
 
394
 
395
- # ROI face for emotion
396
- face = pick["face"]
397
- if face.dtype != np.uint8:
398
- face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
399
 
400
  result = DeepFace.analyze(
401
- img_path=face,
402
  actions=["emotion"],
403
  enforce_detection=False,
404
  )
@@ -409,7 +375,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
409
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
410
  show = st.stable_emo or "unknown"
411
  return (
412
- gr.update(value=frame_rgb, visible=True),
413
  gr.update(value=_result_card_html(show, st.final_conf)),
414
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
415
  gr.update(value=_camera_hint_before_start()),
@@ -420,10 +386,11 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
420
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
421
  top_conf = float(emo_dict[top_key])
422
 
 
423
  if top_conf < CONF_TH:
424
  show = st.stable_emo or "unknown"
425
  return (
426
- gr.update(value=frame_rgb, visible=True),
427
  gr.update(value=_result_card_html(show, st.final_conf)),
428
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
429
  gr.update(value=_camera_hint_before_start()),
@@ -444,7 +411,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
444
  stable_key = st.emo_keys[stable_idx]
445
  st.stable_emo = stable_key
446
 
447
- # 延遲確認
448
  if st.candidate_emo != stable_key:
449
  st.candidate_emo = stable_key
450
  st.candidate_since = now
@@ -457,6 +424,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
457
  and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
458
  )
459
 
 
460
  if confirmed and st.final_emo is None:
461
  st.final_emo = stable_key
462
  st.final_conf = top_conf
@@ -466,7 +434,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
466
  st.finished = True
467
 
468
  return (
469
- gr.update(value=None, visible=False), # ✅ cam hide
470
  gr.update(value=_result_card_html(stable_key, top_conf)),
471
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
472
  gr.update(value=_camera_hint_done()),
@@ -475,7 +443,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
475
  )
476
 
477
  return (
478
- gr.update(value=frame_rgb, visible=True), # ✅ cam
479
  gr.update(value=_result_card_html(stable_key, top_conf)),
480
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
481
  gr.update(value=_camera_hint_before_start()),
@@ -485,13 +453,12 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
485
 
486
 
487
  # =========================
488
- # Mode / Buttons
489
  # =========================
490
-
491
  def on_restart(st: AppState):
492
  st = _reset_state_for_camera(st)
493
  return (
494
- gr.update(value=None, visible=True), # ✅ cam
495
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
496
  gr.update(value=_score_radio_value(3)),
497
  gr.update(value=_camera_hint_before_start()),
@@ -499,13 +466,13 @@ def on_restart(st: AppState):
499
  gr.update(value="重新攝影機辨識"),
500
  )
501
 
502
-
503
  def on_stop(st: AppState):
504
  st.running = False
505
  st.finished = True
 
506
  if st.final_emo is None:
507
  return (
508
- gr.update(value=None, visible=False), # ✅ cam
509
  gr.update(value=_hint_html("已停止辨識。")),
510
  gr.update(value=_score_radio_value(3)),
511
  gr.update(value=_camera_hint_stopped()),
@@ -514,7 +481,7 @@ def on_stop(st: AppState):
514
  )
515
 
516
  return (
517
- gr.update(value=None, visible=False), # ✅ cam
518
  gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
519
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
520
  gr.update(value=_camera_hint_done()),
@@ -526,9 +493,6 @@ def on_stop(st: AppState):
526
  # =========================
527
  # Gradio UI
528
  # =========================
529
-
530
- # 你原本這裡的 css 變數在 pasted 檔裡沒顯示出來
531
- # 如果你原檔有 css = "...", 請保持原樣;沒有的話請自行加上或移除 css 參數
532
  css = ""
533
 
534
  if __name__ == "__main__":
@@ -536,7 +500,7 @@ if __name__ == "__main__":
536
  st = gr.State(AppState())
537
 
538
  gr.Markdown("## 情緒辨識")
539
-
540
  hint = gr.HTML(_camera_hint_before_start())
541
 
542
  cam = gr.Image(
 
6
  import numpy as np
7
  from PIL import Image
8
 
9
+ import cv2
10
+ import mediapipe as mp
11
 
12
  from deepface import DeepFace
13
 
 
15
  # =========================
16
  # 參數(可微調)
17
  # =========================
18
+ ANALYZE_EVERY_SEC = 0.8 # 情緒分析頻率(秒)
19
+ DOWNSAMPLE_W = 720 # 目前保留(可不使用)
20
  EMA_ALPHA = 0.45
21
  CONF_TH = 40.0
22
  SWITCH_CONFIRM_SEC = 2.5
23
+ ZOOM_FACTOR = 1.5 # 你原本就有的 zoom
 
24
 
25
+ # DeepFace 相關(我們這版用 MediaPipe 偵測臉,再把 ROI 丟進 DeepFace.analyze)
26
+ ALIGN_FACE = True # 保留參數(這版 ROI 已是人臉)
27
+ # DETECTOR_BACKEND = "mtcnn" # 這版不再用 extract_faces,就不需要
28
 
29
+
30
+ # =========================
31
+ # MediaPipe Face Detection(白框 + 紅點 keypoints)
32
+ # =========================
33
+ _mp_face = mp.solutions.face_detection
34
+ _FACE_DET = _mp_face.FaceDetection(model_selection=0, min_detection_confidence=0.5)
35
+ # model_selection=0 偏短距離偵測(接近 blaze_face_short_range)
36
 
37
 
38
  # =========================
 
49
  "unknown": "未知",
50
  }
51
 
52
+ # 把 7 種情緒塞進 5 分心情分數
53
  EMO_TO_SCORE = {
54
  "angry": 1,
55
  "disgust": 1,
 
79
  # =========================
80
  @dataclass
81
  class AppState:
82
+ mode: str = "攝影機辨識"
 
83
  running: bool = True
84
  finished: bool = False
85
  last_analyze_t: float = 0.0
86
 
 
 
 
 
 
87
  # smoothing
88
  ema_scores: Optional[np.ndarray] = None
89
  emo_keys: List[str] = field(default_factory=list)
 
96
  # final
97
  final_emo: Optional[str] = None
98
  final_conf: Optional[float] = None
99
+ final_score: int = 3
 
100
 
101
 
102
  # =========================
 
109
  </div>
110
  """
111
 
 
112
  def _camera_hint_before_start() -> str:
113
  return _hint_html(
114
  "請先按「Click to access webcam / 允許」開啟攝影機;"
115
  "接著,請按「錄製(Record)」開始。"
116
  )
117
 
 
118
  def _camera_hint_done() -> str:
119
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
120
 
 
121
  def _camera_hint_stopped() -> str:
122
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
123
 
124
+ def _camera_hint_need_face() -> str:
125
+ return _hint_html("⚠️ 目前未偵測到人臉,請讓臉部出現在畫面中央、靠近一點並面向鏡頭,才可進行情緒辨識。")
126
 
127
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
128
  zh = EMO_ZH.get(emo_key, emo_key)
 
136
  padding:20px;
137
  border:1px solid #E6E6E6;
138
  background:#FFFFFF;
139
+ color:#000000;
140
  ">
141
  <div style="font-size:14px;color:#000000;margin-bottom:8px;">
142
  辨識結果
 
162
  </div>
163
  """
164
 
 
165
  def _score_radio_value(score: int) -> str:
166
  score = int(score)
167
  score = min(5, max(1, score))
168
  return SCORE_LABELS[score]
169
 
170
 
171
+ # =========================
172
+ # 影像處理
173
+ # =========================
174
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
175
  if frame is None:
176
  return None
 
186
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
187
  if frame is None or zoom <= 1.0:
188
  return frame
189
+
190
  h, w = frame.shape[:2]
191
  new_w = int(w / zoom)
192
  new_h = int(h / zoom)
 
196
  return cropped
197
 
198
 
199
+ # =========================
200
+ # MediaPipe:偵測臉 + 畫白框/紅點
201
+ # =========================
202
+ def _mp_detect_draw_bbox_kp_largest(frame_rgb: np.ndarray):
203
+ """
204
+ 最大臉策略:
205
+ - 多人入鏡:挑 bbox 面積最大者
206
+ 回傳:
207
+ drawn_rgb:有臉就畫白框+紅點;沒臉回原圖
208
+ bbox_px: (x, y, w, h) pixel;沒臉則 None
209
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  if frame_rgb is None:
211
+ return frame_rgb, None
212
 
213
+ h, w = frame_rgb.shape[:2]
214
+ res = _FACE_DET.process(frame_rgb) # MediaPipe 吃 RGB
215
 
216
+ if not res.detections:
217
+ return frame_rgb, None
218
+
219
+ best_det = None
220
+ best_area = -1
221
+ best_bbox = None
222
 
223
+ # 逐個算 bbox 面積,挑最大
224
+ for det in res.detections:
225
+ loc = det.location_data
226
+ rbb = loc.relative_bounding_box
227
+ x = int(rbb.xmin * w)
228
+ y = int(rbb.ymin * h)
229
+ bw = int(rbb.width * w)
230
+ bh = int(rbb.height * h)
231
 
232
+ # clamp
233
+ x = max(0, min(w - 1, x))
234
+ y = max(0, min(h - 1, y))
235
+ bw = max(1, min(w - x, bw))
236
+ bh = max(1, min(h - y, bh))
237
 
238
+ area = bw * bh
239
+ if area > best_area:
240
+ best_area = area
241
+ best_det = det
242
+ best_bbox = (x, y, bw, bh)
243
 
244
+ if best_det is None or best_bbox is None:
245
+ return frame_rgb, None
246
+
247
+ x, y, bw, bh = best_bbox
248
+ loc = best_det.location_data
249
 
250
  bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
251
+
252
+ # 白框
253
+ cv2.rectangle(bgr, (x, y), (x + bw, y + bh), (255, 255, 255), 3)
254
+
255
+ # 紅點(五官特徵點)
256
+ for kp in loc.relative_keypoints:
257
+ cx = int(kp.x * w)
258
+ cy = int(kp.y * h)
259
+ cv2.circle(bgr, (cx, cy), 6, (0, 0, 255), -1)
260
+
261
+ drawn = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
262
+ return drawn, (x, y, bw, bh)
263
 
264
 
265
  def _reset_state_for_camera(st: AppState) -> AppState:
266
  st.running = True
267
  st.finished = False
268
  st.last_analyze_t = 0.0
269
+
 
 
270
  st.ema_scores = None
271
  st.emo_keys = []
272
+
273
  st.stable_emo = "unknown"
274
  st.candidate_emo = None
275
  st.candidate_since = None
276
+
277
  st.final_emo = None
278
  st.final_conf = None
279
  st.final_score = 3
 
298
  hint_html = _camera_hint_stopped()
299
 
300
  return (
301
+ gr.update(value=None, visible=cam_visible),
302
  gr.update(value=result_html),
303
  gr.update(value=mood_val),
304
  gr.update(value=hint_html),
 
309
  # --- no frame yet ---
310
  if frame_rgb is None:
311
  return (
312
+ gr.update(value=None, visible=True),
313
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
314
  gr.update(value=_score_radio_value(3)),
315
  gr.update(value=_camera_hint_before_start()),
 
320
  # zoom
321
  frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
322
 
323
+ # 每一幀先偵測臉:有臉才畫框/紅點
324
+ frame_drawn, bbox_px = _mp_detect_draw_bbox_kp_largest(frame_rgb)
 
 
325
 
326
+ # 沒有人臉:不畫框/點,並提醒
327
+ if bbox_px is None:
 
328
  return (
329
+ gr.update(value=frame_rgb, visible=True), # 回原圖(無框)
330
+ gr.update(value=_hint_html("尚未偵測到人臉")),
331
+ gr.update(value=_score_radio_value(3)),
332
+ gr.update(value=_camera_hint_need_face()),
333
  st,
334
  gr.update(value="重新攝影機辨識"),
335
  )
336
 
337
+ now = time.time()
 
 
 
 
 
 
 
 
 
 
 
 
338
 
339
+ # 還沒到分析時間:只更新畫面(框/點一直在),不做情緒分析
340
+ if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
341
  show = st.stable_emo or "unknown"
342
  return (
343
+ gr.update(value=frame_drawn, visible=True),
344
  gr.update(value=_result_card_html(show, st.final_conf)),
345
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
346
  gr.update(value=_camera_hint_before_start()),
 
348
  gr.update(value="重新攝影機辨識"),
349
  )
350
 
351
+ # --- analyze tick ---
352
+ st.last_analyze_t = now
 
 
353
 
354
+ # 用 bbox 裁 ROI 做情緒辨識(可加 padding)
355
+ x, y, bw, bh = bbox_px
356
+ pad_x = int(bw * 0.10)
357
+ pad_y = int(bh * 0.10)
358
+ H, W = frame_rgb.shape[:2]
359
+ x1 = max(0, x - pad_x)
360
+ y1 = max(0, y - pad_y)
361
+ x2 = min(W, x + bw + pad_x)
362
+ y2 = min(H, y + bh + pad_y)
363
 
364
+ face_roi = frame_rgb[y1:y2, x1:x2].astype(np.uint8)
 
 
 
365
 
366
  result = DeepFace.analyze(
367
+ img_path=face_roi,
368
  actions=["emotion"],
369
  enforce_detection=False,
370
  )
 
375
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
376
  show = st.stable_emo or "unknown"
377
  return (
378
+ gr.update(value=frame_drawn, visible=True),
379
  gr.update(value=_result_card_html(show, st.final_conf)),
380
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
381
  gr.update(value=_camera_hint_before_start()),
 
386
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
387
  top_conf = float(emo_dict[top_key])
388
 
389
+ # 低信心就不切換,顯示目前穩定值
390
  if top_conf < CONF_TH:
391
  show = st.stable_emo or "unknown"
392
  return (
393
+ gr.update(value=frame_drawn, visible=True),
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()),
 
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
 
424
  and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
425
  )
426
 
427
+ # 一旦 confirmed:完成一次辨識��你原本就是這個邏輯)
428
  if confirmed and st.final_emo is None:
429
  st.final_emo = stable_key
430
  st.final_conf = top_conf
 
434
  st.finished = True
435
 
436
  return (
437
+ gr.update(value=None, visible=False),
438
  gr.update(value=_result_card_html(stable_key, top_conf)),
439
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
440
  gr.update(value=_camera_hint_done()),
 
443
  )
444
 
445
  return (
446
+ gr.update(value=frame_drawn, visible=True),
447
  gr.update(value=_result_card_html(stable_key, top_conf)),
448
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
449
  gr.update(value=_camera_hint_before_start()),
 
453
 
454
 
455
  # =========================
456
+ # Buttons
457
  # =========================
 
458
  def on_restart(st: AppState):
459
  st = _reset_state_for_camera(st)
460
  return (
461
+ gr.update(value=None, visible=True),
462
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
463
  gr.update(value=_score_radio_value(3)),
464
  gr.update(value=_camera_hint_before_start()),
 
466
  gr.update(value="重新攝影機辨識"),
467
  )
468
 
 
469
  def on_stop(st: AppState):
470
  st.running = False
471
  st.finished = True
472
+
473
  if st.final_emo is None:
474
  return (
475
+ gr.update(value=None, visible=False),
476
  gr.update(value=_hint_html("已停止辨識。")),
477
  gr.update(value=_score_radio_value(3)),
478
  gr.update(value=_camera_hint_stopped()),
 
481
  )
482
 
483
  return (
484
+ gr.update(value=None, visible=False),
485
  gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
486
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
487
  gr.update(value=_camera_hint_done()),
 
493
  # =========================
494
  # Gradio UI
495
  # =========================
 
 
 
496
  css = ""
497
 
498
  if __name__ == "__main__":
 
500
  st = gr.State(AppState())
501
 
502
  gr.Markdown("## 情緒辨識")
503
+
504
  hint = gr.HTML(_camera_hint_before_start())
505
 
506
  cam = gr.Image(