YuAnTsai commited on
Commit
7746ae9
·
verified ·
1 Parent(s): 8474f56

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +104 -92
emotion.py CHANGED
@@ -11,42 +11,23 @@ import mediapipe as mp
11
  from mediapipe.tasks import python
12
  from mediapipe.tasks.python import vision
13
 
14
-
15
  from deepface import DeepFace
16
 
17
 
18
  # =========================
19
  # 參數(可微調)
20
  # =========================
21
- ANALYZE_EVERY_SEC = 0.8 # 情緒分析頻率(秒)
22
- DOWNSAMPLE_W = 720 # 目前保留(可不使用)
23
  EMA_ALPHA = 0.45
24
- CONF_TH = 40.0
25
- SWITCH_CONFIRM_SEC = 2.5
26
- ZOOM_FACTOR = 1.5 # 你原本就有的 zoom
27
 
28
- # DeepFace 相關(我們這版用 MediaPipe 偵測臉,再把 ROI 丟進 DeepFace.analyze)
29
- ALIGN_FACE = True # 保留參數(這版 ROI 已是人臉)
30
- # DETECTOR_BACKEND = "mtcnn" # 這版不再用 extract_faces,就不需要
31
-
32
-
33
- # =========================
34
- # MediaPipe Tasks Face Detector
35
- # =========================
36
- BaseOptions = python.BaseOptions
37
- FaceDetector = vision.FaceDetector
38
- FaceDetectorOptions = vision.FaceDetectorOptions
39
- VisionRunningMode = vision.RunningMode
40
-
41
- _face_detector = FaceDetector.create_from_options(
42
- FaceDetectorOptions(
43
- base_options=BaseOptions(
44
- model_asset_path="blaze_face_short_range.tflite"
45
- ),
46
- running_mode=VisionRunningMode.IMAGE,
47
- )
48
- )
49
 
 
 
50
 
51
 
52
  # =========================
@@ -63,7 +44,6 @@ EMO_ZH = {
63
  "unknown": "未知",
64
  }
65
 
66
- # 把 7 種情緒塞進 5 分心情分數
67
  EMO_TO_SCORE = {
68
  "angry": 1,
69
  "disgust": 1,
@@ -75,9 +55,6 @@ EMO_TO_SCORE = {
75
  "unknown": 3,
76
  }
77
 
78
- def get_emotion_score(emo: str) -> int:
79
- return EMO_TO_SCORE.get(emo, 3)
80
-
81
  SCORE_LABELS = {
82
  1: "1(心情差)",
83
  2: "2(不太好)",
@@ -94,6 +71,7 @@ SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
94
  @dataclass
95
  class AppState:
96
  mode: str = "攝影機辨識"
 
97
  running: bool = True
98
  finished: bool = False
99
  last_analyze_t: float = 0.0
@@ -114,7 +92,7 @@ class AppState:
114
 
115
 
116
  # =========================
117
- # UI HTML (強制黑色文字)
118
  # =========================
119
  def _hint_html(msg: str) -> str:
120
  return f"""
@@ -129,14 +107,21 @@ def _camera_hint_before_start() -> str:
129
  "接著,請按「錄製(Record)」開始。"
130
  )
131
 
 
 
 
 
 
132
  def _camera_hint_done() -> str:
133
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
134
 
135
  def _camera_hint_stopped() -> str:
136
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
137
 
138
- def _camera_hint_need_face() -> str:
139
- return _hint_html("⚠️ 目前未偵測到人臉,請讓臉部出現在畫面中央、靠近一點並面向鏡頭,才可進行情緒辨識。")
 
 
140
 
141
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
142
  zh = EMO_ZH.get(emo_key, emo_key)
@@ -176,26 +161,38 @@ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
176
  </div>
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
  # =========================
186
- # 影像處理
187
  # =========================
188
- def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
 
 
 
 
 
 
 
189
  if frame is None:
190
- return None
191
- h, w = frame.shape[:2]
192
- if w <= target_w:
193
  return frame
194
- scale = target_w / w
195
- new_h = max(1, int(h * scale))
196
- img = Image.fromarray(frame)
197
- img = img.resize((target_w, new_h))
198
- return np.array(img)
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
201
  if frame is None or zoom <= 1.0:
@@ -207,40 +204,56 @@ def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
207
  start_x = (w - new_w) // 2
208
  start_y = (h - new_h) // 2
209
  cropped = frame[start_y : start_y + new_h, start_x : start_x + new_w]
210
- return cropped
211
 
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  def mp_tasks_detect_and_draw(frame_rgb: np.ndarray):
214
  """
215
- 使用 MediaPipe Tasks
216
- - 有臉:畫白框 + 紅色 keypoints
217
- - 回傳 bbox (x,y,w,h)
218
  """
 
219
  h, w = frame_rgb.shape[:2]
220
 
221
- mp_image = mp.Image(
222
- image_format=mp.ImageFormat.SRGB,
223
- data=frame_rgb
224
- )
225
-
226
  result = _face_detector.detect(mp_image)
227
 
228
  if not result.detections:
229
  return frame_rgb, None
230
 
231
- # 取「最大臉」
232
  best_det = None
233
  best_area = -1
234
  best_bbox = None
235
 
 
236
  for det in result.detections:
237
  bbox = det.bounding_box
238
- area = bbox.width * bbox.height
239
  if area > best_area:
240
  best_area = area
241
  best_det = det
242
  best_bbox = bbox
243
 
 
 
 
244
  lx = int(best_bbox.origin_x)
245
  ly = int(best_bbox.origin_y)
246
  bw = int(best_bbox.width)
@@ -254,16 +267,10 @@ def mp_tasks_detect_and_draw(frame_rgb: np.ndarray):
254
 
255
  bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
256
 
257
- # 白色框
258
- cv2.rectangle(
259
- bgr,
260
- (lx, ly),
261
- (lx + bw, ly + bh),
262
- (255, 255, 255),
263
- 3
264
- )
265
 
266
- # 紅色五官點
267
  for kp in best_det.keypoints:
268
  cx = int(kp.x * w)
269
  cy = int(kp.y * h)
@@ -273,7 +280,9 @@ def mp_tasks_detect_and_draw(frame_rgb: np.ndarray):
273
  return drawn, (lx, ly, bw, bh)
274
 
275
 
276
-
 
 
277
  def _reset_state_for_camera(st: AppState) -> AppState:
278
  st.running = True
279
  st.finished = False
@@ -293,10 +302,10 @@ def _reset_state_for_camera(st: AppState) -> AppState:
293
 
294
 
295
  # =========================
296
- # Camera streaming
297
  # =========================
298
  def on_stream(frame_rgb: np.ndarray, st: AppState):
299
- # --- stopped/finished ---
300
  if st.finished or (not st.running):
301
  cam_visible = False
302
 
@@ -318,7 +327,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
318
  gr.update(value="重新攝影機辨識"),
319
  )
320
 
321
- # --- no frame yet ---
322
  if frame_rgb is None:
323
  return (
324
  gr.update(value=None, visible=True),
@@ -329,16 +338,19 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
329
  gr.update(value="重新攝影機辨識"),
330
  )
331
 
332
- # zoom
333
- frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
334
 
335
- # 每一幀先偵測臉:有臉才畫框/紅點
336
  frame_drawn, bbox_px = mp_tasks_detect_and_draw(frame_rgb)
337
-
338
- # 沒有人臉:不畫框/點,並提醒
 
 
 
339
  if bbox_px is None:
340
  return (
341
- gr.update(value=frame_rgb, visible=True), # 回原圖(無框)
342
  gr.update(value=_hint_html("尚未偵測到人臉")),
343
  gr.update(value=_score_radio_value(3)),
344
  gr.update(value=_camera_hint_need_face()),
@@ -348,11 +360,11 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
348
 
349
  now = time.time()
350
 
351
- # 還沒到分析時間:只更新畫面(框/點一直在),不做情緒分析
352
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
353
  show = st.stable_emo or "unknown"
354
  return (
355
- gr.update(value=frame_drawn, visible=True),
356
  gr.update(value=_result_card_html(show, st.final_conf)),
357
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
358
  gr.update(value=_camera_hint_before_start()),
@@ -360,13 +372,13 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
360
  gr.update(value="重新攝影機辨識"),
361
  )
362
 
363
- # --- analyze tick ---
364
  st.last_analyze_t = now
365
 
366
- # 用 bbox 裁 ROI 做情緒辨識(可加 padding)
367
  x, y, bw, bh = bbox_px
368
  pad_x = int(bw * 0.10)
369
  pad_y = int(bh * 0.10)
 
370
  H, W = frame_rgb.shape[:2]
371
  x1 = max(0, x - pad_x)
372
  y1 = max(0, y - pad_y)
@@ -375,6 +387,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
375
 
376
  face_roi = frame_rgb[y1:y2, x1:x2].astype(np.uint8)
377
 
 
378
  result = DeepFace.analyze(
379
  img_path=face_roi,
380
  actions=["emotion"],
@@ -382,12 +395,12 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
382
  )
383
  if isinstance(result, list):
384
  result = result[0]
385
- emo_dict = result.get("emotion", None)
386
 
 
387
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
388
  show = st.stable_emo or "unknown"
389
  return (
390
- gr.update(value=frame_drawn, visible=True),
391
  gr.update(value=_result_card_html(show, st.final_conf)),
392
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
393
  gr.update(value=_camera_hint_before_start()),
@@ -398,11 +411,10 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
398
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
399
  top_conf = float(emo_dict[top_key])
400
 
401
- # 低信心就不切換,顯示目前穩定值
402
  if top_conf < CONF_TH:
403
  show = st.stable_emo or "unknown"
404
  return (
405
- gr.update(value=frame_drawn, visible=True),
406
  gr.update(value=_result_card_html(show, st.final_conf)),
407
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
408
  gr.update(value=_camera_hint_before_start()),
@@ -410,6 +422,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
410
  gr.update(value="重新攝影機辨識"),
411
  )
412
 
 
413
  keys = list(emo_dict.keys())
414
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
415
 
@@ -423,7 +436,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
423
  stable_key = st.emo_keys[stable_idx]
424
  st.stable_emo = stable_key
425
 
426
- # 延遲確認(避免一直跳)
427
  if st.candidate_emo != stable_key:
428
  st.candidate_emo = stable_key
429
  st.candidate_since = now
@@ -436,7 +449,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
436
  and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
437
  )
438
 
439
- # 一旦 confirmed:完成一次辨識(你原本就是這個邏輯)
440
  if confirmed and st.final_emo is None:
441
  st.final_emo = stable_key
442
  st.final_conf = top_conf
@@ -455,7 +468,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
455
  )
456
 
457
  return (
458
- gr.update(value=frame_drawn, 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()),
@@ -512,14 +525,13 @@ if __name__ == "__main__":
512
  st = gr.State(AppState())
513
 
514
  gr.Markdown("## 情緒辨識")
515
-
516
  hint = gr.HTML(_camera_hint_before_start())
517
 
518
  cam = gr.Image(
519
  sources=["webcam"],
520
  streaming=True,
521
  type="numpy",
522
- label="攝影機",
523
  visible=True,
524
  )
525
 
 
11
  from mediapipe.tasks import python
12
  from mediapipe.tasks.python import vision
13
 
 
14
  from deepface import DeepFace
15
 
16
 
17
  # =========================
18
  # 參數(可微調)
19
  # =========================
20
+ ANALYZE_EVERY_SEC = 0.8 # 情緒分析頻率(秒)
 
21
  EMA_ALPHA = 0.45
22
+ CONF_TH = 40.0 # DeepFace emotion confidence 門檻
23
+ SWITCH_CONFIRM_SEC = 2.5 # 連續穩定多久才算完成
24
+ ZOOM_FACTOR = 1.5 # 只用在「顯示」(你要保留 zoom 畫面)
25
 
26
+ # MediaPipe face detector 門檻(太高很容易漏)
27
+ MP_MIN_DET_CONF = 0.3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
+ # 模型路徑(請確認你的 repo 真的有這個檔案)
30
+ MP_MODEL_PATH = "blaze_face_short_range.tflite"
31
 
32
 
33
  # =========================
 
44
  "unknown": "未知",
45
  }
46
 
 
47
  EMO_TO_SCORE = {
48
  "angry": 1,
49
  "disgust": 1,
 
55
  "unknown": 3,
56
  }
57
 
 
 
 
58
  SCORE_LABELS = {
59
  1: "1(心情差)",
60
  2: "2(不太好)",
 
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
 
93
 
94
  # =========================
95
+ # UI HTML
96
  # =========================
97
  def _hint_html(msg: str) -> str:
98
  return f"""
 
107
  "接著,請按「錄製(Record)」開始。"
108
  )
109
 
110
+ def _camera_hint_need_face() -> str:
111
+ return _hint_html(
112
+ "⚠️ 目前未偵測到人臉,請將臉部移至畫面中央、靠近鏡頭,並避免瀏海遮住眉眼,才可進行情緒辨識。"
113
+ )
114
+
115
  def _camera_hint_done() -> str:
116
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
117
 
118
  def _camera_hint_stopped() -> str:
119
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
120
 
121
+ def _score_radio_value(score: int) -> str:
122
+ score = int(score)
123
+ score = min(5, max(1, score))
124
+ return SCORE_LABELS[score]
125
 
126
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
127
  zh = EMO_ZH.get(emo_key, emo_key)
 
161
  </div>
162
  """
163
 
 
 
 
 
 
164
 
165
  # =========================
166
+ # 影像處理(重點:保命轉換)
167
  # =========================
168
+ def _to_rgb_uint8(frame: np.ndarray) -> np.ndarray:
169
+ """
170
+ MediaPipe Tasks 最穩格式:
171
+ - RGB
172
+ - uint8
173
+ - 0~255
174
+ - contiguous
175
+ """
176
  if frame is None:
 
 
 
177
  return frame
178
+
179
+ # RGBA -> RGB
180
+ if frame.ndim == 3 and frame.shape[2] == 4:
181
+ frame = frame[:, :, :3]
182
+
183
+ # 灰階 -> RGB
184
+ if frame.ndim == 2:
185
+ frame = np.stack([frame, frame, frame], axis=-1)
186
+
187
+ # dtype / range 修正
188
+ if frame.dtype != np.uint8:
189
+ mx = float(np.max(frame)) if frame.size else 0.0
190
+ if mx <= 1.5: # 多半是 0~1
191
+ frame = (frame * 255.0).clip(0, 255).astype(np.uint8)
192
+ else: # 多半是 0~255 但 float
193
+ frame = np.clip(frame, 0, 255).astype(np.uint8)
194
+
195
+ return np.ascontiguousarray(frame)
196
 
197
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
198
  if frame is None or zoom <= 1.0:
 
204
  start_x = (w - new_w) // 2
205
  start_y = (h - new_h) // 2
206
  cropped = frame[start_y : start_y + new_h, start_x : start_x + new_w]
207
+ return np.ascontiguousarray(cropped)
208
 
209
 
210
+ # =========================
211
+ # MediaPipe Tasks Face Detector
212
+ # =========================
213
+ BaseOptions = python.BaseOptions
214
+ FaceDetector = vision.FaceDetector
215
+ FaceDetectorOptions = vision.FaceDetectorOptions
216
+ VisionRunningMode = vision.RunningMode
217
+
218
+ _face_detector = FaceDetector.create_from_options(
219
+ FaceDetectorOptions(
220
+ base_options=BaseOptions(model_asset_path=MP_MODEL_PATH),
221
+ running_mode=VisionRunningMode.IMAGE,
222
+ min_detection_confidence=MP_MIN_DET_CONF,
223
+ )
224
+ )
225
+
226
  def mp_tasks_detect_and_draw(frame_rgb: np.ndarray):
227
  """
228
+ - 最大臉策略(bbox 面積最大)
229
+ - 有臉:畫白框 + 紅點 keypoints
230
+ - 回 bbox_px (x, y, w, h) 供 ROI 裁切
231
  """
232
+ frame_rgb = _to_rgb_uint8(frame_rgb)
233
  h, w = frame_rgb.shape[:2]
234
 
235
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=frame_rgb)
 
 
 
 
236
  result = _face_detector.detect(mp_image)
237
 
238
  if not result.detections:
239
  return frame_rgb, None
240
 
 
241
  best_det = None
242
  best_area = -1
243
  best_bbox = None
244
 
245
+ # 最大臉:bbox 面積最大
246
  for det in result.detections:
247
  bbox = det.bounding_box
248
+ area = float(bbox.width) * float(bbox.height)
249
  if area > best_area:
250
  best_area = area
251
  best_det = det
252
  best_bbox = bbox
253
 
254
+ if best_det is None or best_bbox is None:
255
+ return frame_rgb, None
256
+
257
  lx = int(best_bbox.origin_x)
258
  ly = int(best_bbox.origin_y)
259
  bw = int(best_bbox.width)
 
267
 
268
  bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
269
 
270
+ # 白框
271
+ cv2.rectangle(bgr, (lx, ly), (lx + bw, ly + bh), (255, 255, 255), 3)
 
 
 
 
 
 
272
 
273
+ # 紅點:五官特徵點(相對座標)
274
  for kp in best_det.keypoints:
275
  cx = int(kp.x * w)
276
  cy = int(kp.y * h)
 
280
  return drawn, (lx, ly, bw, bh)
281
 
282
 
283
+ # =========================
284
+ # state helper
285
+ # =========================
286
  def _reset_state_for_camera(st: AppState) -> AppState:
287
  st.running = True
288
  st.finished = False
 
302
 
303
 
304
  # =========================
305
+ # streaming callback
306
  # =========================
307
  def on_stream(frame_rgb: np.ndarray, st: AppState):
308
+ # stopped / finished
309
  if st.finished or (not st.running):
310
  cam_visible = False
311
 
 
327
  gr.update(value="重新攝影機辨識"),
328
  )
329
 
330
+ # no frame yet
331
  if frame_rgb is None:
332
  return (
333
  gr.update(value=None, visible=True),
 
338
  gr.update(value="重新攝影機辨識"),
339
  )
340
 
341
+ # 先整理格式(超重要:不然可能永遠偵測不到)
342
+ frame_rgb = _to_rgb_uint8(frame_rgb)
343
 
344
+ # ✅ 偵測用原圖(穩)
345
  frame_drawn, bbox_px = mp_tasks_detect_and_draw(frame_rgb)
346
+
347
+ # ✅ 顯示用 zoom(你要保留 zoom 畫面)
348
+ frame_show = _zoom_center(frame_drawn if bbox_px is not None else frame_rgb, ZOOM_FACTOR)
349
+
350
+ # 沒有人臉:不畫框/點 + 提示
351
  if bbox_px is None:
352
  return (
353
+ gr.update(value=frame_show, visible=True),
354
  gr.update(value=_hint_html("尚未偵測到人臉")),
355
  gr.update(value=_score_radio_value(3)),
356
  gr.update(value=_camera_hint_need_face()),
 
360
 
361
  now = time.time()
362
 
363
+ # 還沒到分析時間:只更新畫面(框/點會一直在),不做情緒分析
364
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
365
  show = st.stable_emo or "unknown"
366
  return (
367
+ gr.update(value=frame_show, visible=True),
368
  gr.update(value=_result_card_html(show, st.final_conf)),
369
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
370
  gr.update(value=_camera_hint_before_start()),
 
372
  gr.update(value="重新攝影機辨識"),
373
  )
374
 
375
+ # 到分析時間:用「原圖 bbox」裁 ROI 做情緒辨識(不要用 zoom 後的圖裁)
376
  st.last_analyze_t = now
377
 
 
378
  x, y, bw, bh = bbox_px
379
  pad_x = int(bw * 0.10)
380
  pad_y = int(bh * 0.10)
381
+
382
  H, W = frame_rgb.shape[:2]
383
  x1 = max(0, x - pad_x)
384
  y1 = max(0, y - pad_y)
 
387
 
388
  face_roi = frame_rgb[y1:y2, x1:x2].astype(np.uint8)
389
 
390
+ # DeepFace.analyze
391
  result = DeepFace.analyze(
392
  img_path=face_roi,
393
  actions=["emotion"],
 
395
  )
396
  if isinstance(result, list):
397
  result = result[0]
 
398
 
399
+ emo_dict = result.get("emotion", None)
400
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
401
  show = st.stable_emo or "unknown"
402
  return (
403
+ gr.update(value=frame_show, visible=True),
404
  gr.update(value=_result_card_html(show, st.final_conf)),
405
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
406
  gr.update(value=_camera_hint_before_start()),
 
411
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
412
  top_conf = float(emo_dict[top_key])
413
 
 
414
  if top_conf < CONF_TH:
415
  show = st.stable_emo or "unknown"
416
  return (
417
+ gr.update(value=frame_show, visible=True),
418
  gr.update(value=_result_card_html(show, st.final_conf)),
419
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
420
  gr.update(value=_camera_hint_before_start()),
 
422
  gr.update(value="重新攝影機辨識"),
423
  )
424
 
425
+ # EMA smoothing
426
  keys = list(emo_dict.keys())
427
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
428
 
 
436
  stable_key = st.emo_keys[stable_idx]
437
  st.stable_emo = stable_key
438
 
439
+ # 延遲確認,避免跳動
440
  if st.candidate_emo != stable_key:
441
  st.candidate_emo = stable_key
442
  st.candidate_since = now
 
449
  and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
450
  )
451
 
452
+ # 完成一次辨識後停止(沿用你原本需求)
453
  if confirmed and st.final_emo is None:
454
  st.final_emo = stable_key
455
  st.final_conf = top_conf
 
468
  )
469
 
470
  return (
471
+ gr.update(value=frame_show, visible=True),
472
  gr.update(value=_result_card_html(stable_key, top_conf)),
473
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
474
  gr.update(value=_camera_hint_before_start()),
 
525
  st = gr.State(AppState())
526
 
527
  gr.Markdown("## 情緒辨識")
 
528
  hint = gr.HTML(_camera_hint_before_start())
529
 
530
  cam = gr.Image(
531
  sources=["webcam"],
532
  streaming=True,
533
  type="numpy",
534
+ label="攝影機畫面",
535
  visible=True,
536
  )
537