YuAnTsai commited on
Commit
1baad3f
·
verified ·
1 Parent(s): 08629ea

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +84 -114
emotion.py CHANGED
@@ -1,4 +1,5 @@
1
  import time
 
2
  from dataclasses import dataclass, field
3
  from typing import Optional, Dict, Tuple, List
4
 
@@ -9,31 +10,68 @@ from PIL import Image, ImageDraw
9
  from deepface import DeepFace
10
 
11
 
12
- # ===== 避免 css 未定義造成 NameError(不影響功能)=====
13
  css = ""
14
 
15
 
16
  # =========================
17
- # 參數(可微調)
18
  # =========================
19
- ANALYZE_EVERY_SEC = 0.8
20
- DOWNSAMPLE_W = 720
21
  EMA_ALPHA = 0.45
22
  CONF_TH = 40.0
23
  SWITCH_CONFIRM_SEC = 2.5
24
  IOU_TH = 0.25
25
- ZOOM_FACTOR = 1.5
26
 
27
- DETECTOR_BACKEND = "mtcnn" # 想快一點可改 "opencv"
28
  ALIGN_FACE = True
29
 
30
- # ✅ 防止背景誤判為臉(重要)
31
- MIN_FACE_AREA_RATIO = 0.05 # 臉框面積佔整張畫面比例下限(3%~8%可調)
32
- MIN_DET_CONF = 0.90 # 偵測器信心值下限(若 backend 提供 confidence)
33
 
34
 
35
  # =========================
36
- # 情緒中文 + 分數映射(7 -> 5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  # =========================
38
  EMO_ZH = {
39
  "angry": "生氣",
@@ -72,30 +110,23 @@ SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
72
  # =========================
73
  @dataclass
74
  class AppState:
75
- mode: str = "攝影機辨識"
76
-
77
  running: bool = True
78
  finished: bool = False
79
  last_analyze_t: float = 0.0
80
 
81
- # tracking(用於連續幀追蹤)
82
  track_bbox: Optional[Tuple[int, int, int, int]] = None
83
 
84
- # smoothing
85
  ema_scores: Optional[np.ndarray] = None
86
  emo_keys: List[str] = field(default_factory=list)
87
 
88
- # stable/candidate
89
  stable_emo: str = "unknown"
90
  candidate_emo: Optional[str] = None
91
  candidate_since: Optional[float] = None
92
 
93
- # final
94
  final_emo: Optional[str] = None
95
  final_conf: Optional[float] = None
96
  final_score: int = 3
97
 
98
- # ✅ 新增:最近有沒有臉(避免節流期間框顏色亂跳)
99
  last_face_t: float = 0.0
100
  face_in_box: bool = False
101
 
@@ -127,33 +158,12 @@ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
127
  score = EMO_TO_SCORE.get(emo_key, 3)
128
  conf_txt = "" if conf is None else f"{conf:.1f}%"
129
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
130
-
131
  return f"""
132
- <div style="
133
- border-radius:16px;
134
- padding:20px;
135
- border:1px solid #E6E6E6;
136
- background:#FFFFFF;
137
- color:#000000;
138
- ">
139
- <div style="font-size:14px;color:#000000;margin-bottom:8px;">
140
- 辨識結果
141
- </div>
142
- <div style="
143
- font-size:42px;
144
- font-weight:800;
145
- line-height:1.1;
146
- margin-bottom:14px;
147
- color:#000000;
148
- ">
149
- {zh}
150
- </div>
151
- <div style="font-size:16px;color:#000000;margin-bottom:6px;">
152
- 心情分數:{score}({score_desc})
153
- </div>
154
- <div style="font-size:14px;color:#000000;">
155
- 信心值:{conf_txt}
156
- </div>
157
  </div>
158
  """
159
 
@@ -186,12 +196,11 @@ def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
186
  new_h = int(h / zoom)
187
  start_x = (w - new_w) // 2
188
  start_y = (h - new_h) // 2
189
- cropped = frame[start_y : start_y + new_h, start_x : start_x + new_w]
190
- return cropped
191
 
192
 
193
  # =========================
194
- # ��導框
195
  # =========================
196
  GUIDE_W_RATIO = 0.55
197
  GUIDE_H_RATIO = 0.70
@@ -217,9 +226,6 @@ def _bbox_center_in_box(bbox_xywh: Tuple[float, float, float, float], box_xyxy:
217
  return (x1 <= cx <= x2) and (y1 <= cy <= y2)
218
 
219
  def _draw_guide_overlay(frame_rgb: np.ndarray, detected_in_box: bool) -> np.ndarray:
220
- if frame_rgb is None:
221
- return frame_rgb
222
-
223
  img = Image.fromarray(frame_rgb)
224
  draw = ImageDraw.Draw(img)
225
 
@@ -228,7 +234,6 @@ def _draw_guide_overlay(frame_rgb: np.ndarray, detected_in_box: bool) -> np.ndar
228
 
229
  h, w = frame_rgb.shape[:2]
230
  thickness = max(3, int(min(w, h) * 0.008))
231
-
232
  for t in range(thickness):
233
  draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t], outline=color)
234
 
@@ -259,13 +264,9 @@ def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
259
  union = aw * ah + bw * bh - inter
260
  return inter / max(1e-6, union)
261
 
262
- def _pick_face_by_tracking(
263
- faces: List[Dict],
264
- prev_bbox: Optional[Tuple[int, int, int, int]]
265
- ) -> Tuple[Optional[Dict], Optional[Tuple[int, int, int, int]]]:
266
  if not faces:
267
  return None, None
268
-
269
  cand = []
270
  for f in faces:
271
  fa = f.get("facial_area", {}) or {}
@@ -312,21 +313,12 @@ def _reset_state_for_camera(st: AppState) -> AppState:
312
  return st
313
 
314
 
315
- # =========================
316
- # ✅ 判斷這個 bbox 是否真的像「人臉」
317
- # =========================
318
- def _is_real_face(
319
- pick: Dict,
320
- bbox_full_xywh: Tuple[float, float, float, float],
321
- frame_hw: Tuple[int, int],
322
- ) -> bool:
323
  fh, fw = frame_hw
324
  x, y, w, h = bbox_full_xywh
325
-
326
  area_ratio = (w * h) / max(1.0, float(fw * fh))
327
  if area_ratio < MIN_FACE_AREA_RATIO:
328
  return False
329
-
330
  det_conf = pick.get("confidence", None)
331
  if det_conf is not None:
332
  try:
@@ -334,31 +326,29 @@ def _is_real_face(
334
  return False
335
  except Exception:
336
  pass
337
-
338
  return True
339
 
340
 
341
  # =========================
342
- # Camera streaming
343
  # =========================
344
  def on_stream(frame_rgb: np.ndarray, st: AppState):
345
- # 已結束/停止
346
  if st.finished or (not st.running):
347
  cam_visible = False
348
  if st.final_emo:
349
- result_html = _result_card_html(st.final_emo, st.final_conf)
350
- mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
351
- hint_html = _camera_hint_done()
352
- else:
353
- result_html = _hint_html("尚未完成辨識。")
354
- mood_val = _score_radio_value(3)
355
- hint_html = _camera_hint_stopped()
356
-
357
  return (
358
  gr.update(visible=cam_visible),
359
- gr.update(value=result_html),
360
- gr.update(value=mood_val),
361
- gr.update(value=hint_html),
362
  st,
363
  gr.update(value="重新攝影機辨識"),
364
  )
@@ -374,29 +364,27 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
374
  )
375
 
376
  frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
377
-
378
  now = time.time()
379
 
380
- # 節流:沿用最近一次臉狀態(避免顏色亂跳)
381
- detected_in_box_recent = st.face_in_box and (now - st.last_face_t) < 1.0
 
382
 
 
383
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
384
- annotated = _draw_guide_overlay(frame_rgb, detected_in_box_recent)
385
- result_html = _hint_html("已偵測到人臉,開始辨識情緒中…" if detected_in_box_recent else "未偵測到人臉,請把臉放進框內")
386
  return (
387
- gr.update(visible=True, value=annotated),
388
- gr.update(value=result_html),
389
  gr.update(value=_score_radio_value(3)),
390
  gr.update(value=_camera_hint_before_start()),
391
  st,
392
  gr.update(value="重新攝影機辨識"),
393
  )
394
 
 
395
  st.last_analyze_t = now
396
-
397
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
398
 
399
- # ✅ extract_faces 變嚴格:沒臉就當 faces=[]
400
  try:
401
  faces = DeepFace.extract_faces(
402
  img_path=small,
@@ -409,13 +397,11 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
409
 
410
  pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
411
 
412
- # (A) 完全沒臉
413
  if pick is None or bbox is None or pick.get("face") is None:
414
  st.track_bbox = None
415
  st.face_in_box = False
416
  st.last_face_t = now
417
  st.stable_emo = "unknown"
418
-
419
  annotated = _draw_guide_overlay(frame_rgb, False)
420
  return (
421
  gr.update(visible=True, value=annotated),
@@ -426,22 +412,17 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
426
  gr.update(value="重新攝影機辨識"),
427
  )
428
 
429
- # bbox small -> frame
430
  sh, sw = small.shape[:2]
431
  fh, fw = frame_rgb.shape[:2]
432
  sx = fw / max(1, sw)
433
  sy = fh / max(1, sh)
434
-
435
  x, y, w, h = bbox
436
  bbox_full = (x * sx, y * sy, w * sx, h * sy)
437
 
438
- # ✅ 防背景誤判:不夠大/信心不足 -> 視為沒臉
439
  if not _is_real_face(pick, bbox_full, (fh, fw)):
440
  st.track_bbox = None
441
  st.face_in_box = False
442
  st.last_face_t = now
443
- st.stable_emo = "unknown"
444
-
445
  annotated = _draw_guide_overlay(frame_rgb, False)
446
  return (
447
  gr.update(visible=True, value=annotated),
@@ -453,14 +434,13 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
453
  )
454
 
455
  guide = _guide_box(frame_rgb)
456
- detected_in_box = _bbox_center_in_box(bbox_full, guide)
457
 
458
  st.last_face_t = now
459
- st.face_in_box = detected_in_box
460
  st.track_bbox = bbox
461
 
462
- # (B) 有臉但不在框內
463
- if not detected_in_box:
464
  annotated = _draw_guide_overlay(frame_rgb, False)
465
  return (
466
  gr.update(visible=True, value=annotated),
@@ -471,12 +451,11 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
471
  gr.update(value="重新攝影機辨識"),
472
  )
473
 
474
- # (C) 有臉且在框內:綠框,開始 analyze
475
  face = pick["face"]
476
  if face.dtype != np.uint8:
477
  face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
478
 
479
- # ✅ analyze 也嚴格:避免誤判一路走到底
480
  try:
481
  result = DeepFace.analyze(
482
  img_path=face,
@@ -484,12 +463,9 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
484
  enforce_detection=True,
485
  )
486
  except Exception:
487
- st.track_bbox = None
488
  st.face_in_box = False
489
  st.last_face_t = now
490
- st.stable_emo = "unknown"
491
-
492
- annotated = _draw_guide_overlay(frame_rgb, False)
493
  return (
494
  gr.update(visible=True, value=annotated),
495
  gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
@@ -502,7 +478,6 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
502
  if isinstance(result, list):
503
  result = result[0]
504
  emo_dict = result.get("emotion", None)
505
-
506
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
507
  annotated = _draw_guide_overlay(frame_rgb, True)
508
  return (
@@ -541,7 +516,6 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
541
  stable_key = st.emo_keys[stable_idx]
542
  st.stable_emo = stable_key
543
 
544
- # 延遲確認
545
  if st.candidate_emo != stable_key:
546
  st.candidate_emo = stable_key
547
  st.candidate_since = now
@@ -558,10 +532,8 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
558
  st.final_emo = stable_key
559
  st.final_conf = top_conf
560
  st.final_score = EMO_TO_SCORE.get(stable_key, 3)
561
-
562
  st.running = False
563
  st.finished = True
564
-
565
  return (
566
  gr.update(visible=False),
567
  gr.update(value=_result_card_html(stable_key, top_conf)),
@@ -588,7 +560,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
588
  def on_restart(st: AppState):
589
  st = _reset_state_for_camera(st)
590
  return (
591
- gr.update(visible=True, value=None), # ✅ 清掉上一張畫面(你問的重點)
592
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
593
  gr.update(value=_score_radio_value(3)),
594
  gr.update(value=_camera_hint_before_start()),
@@ -599,19 +571,17 @@ def on_restart(st: AppState):
599
  def on_stop(st: AppState):
600
  st.running = False
601
  st.finished = True
602
-
603
  if st.final_emo is None:
604
  return (
605
- gr.update(visible=False, value=None), # ✅ 順便清空畫面(可選,但我先幫你加上)
606
  gr.update(value=_hint_html("已停止辨識。")),
607
  gr.update(value=_score_radio_value(3)),
608
  gr.update(value=_camera_hint_stopped()),
609
  st,
610
  gr.update(value="重新攝影機辨識"),
611
  )
612
-
613
  return (
614
- gr.update(visible=False, value=None), # ✅ 順便清空
615
  gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
616
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
617
  gr.update(value=_camera_hint_done()),
 
1
  import time
2
+ import threading
3
  from dataclasses import dataclass, field
4
  from typing import Optional, Dict, Tuple, List
5
 
 
10
  from deepface import DeepFace
11
 
12
 
13
+ # ===== 避免 css 未定義造成 NameError =====
14
  css = ""
15
 
16
 
17
  # =========================
18
+ # 參數(優先順:快)
19
  # =========================
20
+ ANALYZE_EVERY_SEC = 1.2 # ✅ 降頻率(原 0.8)
21
+ DOWNSAMPLE_W = 480 # ✅ 降解析(原 720)
22
  EMA_ALPHA = 0.45
23
  CONF_TH = 40.0
24
  SWITCH_CONFIRM_SEC = 2.5
25
  IOU_TH = 0.25
26
+ ZOOM_FACTOR = 1.25 # ✅ 稍微降 zoom(減少失真與負擔)
27
 
28
+ DETECTOR_BACKEND = "opencv" # ✅ 改快(原 mtcnn)
29
  ALIGN_FACE = True
30
 
31
+ # 防誤判(保留)
32
+ MIN_FACE_AREA_RATIO = 0.05
33
+ MIN_DET_CONF = 0.90
34
 
35
 
36
  # =========================
37
+ # Warm-up(優先順:按 Record 不要等)
38
+ # =========================
39
+ _WARMED = False
40
+
41
+ def _warmup_models():
42
+ global _WARMED
43
+ if _WARMED:
44
+ return
45
+ _WARMED = True
46
+ try:
47
+ dummy = np.zeros((224, 224, 3), dtype=np.uint8)
48
+ # detector warmup
49
+ try:
50
+ DeepFace.extract_faces(
51
+ img_path=dummy,
52
+ detector_backend=DETECTOR_BACKEND,
53
+ enforce_detection=False,
54
+ align=ALIGN_FACE,
55
+ )
56
+ except Exception:
57
+ pass
58
+ # emotion model warmup
59
+ try:
60
+ DeepFace.analyze(
61
+ img_path=dummy,
62
+ actions=["emotion"],
63
+ enforce_detection=False,
64
+ )
65
+ except Exception:
66
+ pass
67
+ except Exception:
68
+ pass
69
+
70
+ threading.Thread(target=_warmup_models, daemon=True).start()
71
+
72
+
73
+ # =========================
74
+ # 情緒中文 + 分數映射
75
  # =========================
76
  EMO_ZH = {
77
  "angry": "生氣",
 
110
  # =========================
111
  @dataclass
112
  class AppState:
 
 
113
  running: bool = True
114
  finished: bool = False
115
  last_analyze_t: float = 0.0
116
 
 
117
  track_bbox: Optional[Tuple[int, int, int, int]] = None
118
 
 
119
  ema_scores: Optional[np.ndarray] = None
120
  emo_keys: List[str] = field(default_factory=list)
121
 
 
122
  stable_emo: str = "unknown"
123
  candidate_emo: Optional[str] = None
124
  candidate_since: Optional[float] = None
125
 
 
126
  final_emo: Optional[str] = None
127
  final_conf: Optional[float] = None
128
  final_score: int = 3
129
 
 
130
  last_face_t: float = 0.0
131
  face_in_box: bool = False
132
 
 
158
  score = EMO_TO_SCORE.get(emo_key, 3)
159
  conf_txt = "" if conf is None else f"{conf:.1f}%"
160
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
 
161
  return f"""
162
+ <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#000000;">
163
+ <div style="font-size:14px;color:#000000;margin-bottom:8px;">辨識結果</div>
164
+ <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;color:#000000;">{zh}</div>
165
+ <div style="font-size:16px;color:#000000;margin-bottom:6px;">心情分數:{score}({score_desc})</div>
166
+ <div style="font-size:14px;color:#000000;">信心值:{conf_txt}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  </div>
168
  """
169
 
 
196
  new_h = int(h / zoom)
197
  start_x = (w - new_w) // 2
198
  start_y = (h - new_h) // 2
199
+ return frame[start_y:start_y + new_h, start_x:start_x + new_w]
 
200
 
201
 
202
  # =========================
203
+ # 引導框(永遠畫)
204
  # =========================
205
  GUIDE_W_RATIO = 0.55
206
  GUIDE_H_RATIO = 0.70
 
226
  return (x1 <= cx <= x2) and (y1 <= cy <= y2)
227
 
228
  def _draw_guide_overlay(frame_rgb: np.ndarray, detected_in_box: bool) -> np.ndarray:
 
 
 
229
  img = Image.fromarray(frame_rgb)
230
  draw = ImageDraw.Draw(img)
231
 
 
234
 
235
  h, w = frame_rgb.shape[:2]
236
  thickness = max(3, int(min(w, h) * 0.008))
 
237
  for t in range(thickness):
238
  draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t], outline=color)
239
 
 
264
  union = aw * ah + bw * bh - inter
265
  return inter / max(1e-6, union)
266
 
267
+ def _pick_face_by_tracking(faces: List[Dict], prev_bbox: Optional[Tuple[int, int, int, int]]):
 
 
 
268
  if not faces:
269
  return None, None
 
270
  cand = []
271
  for f in faces:
272
  fa = f.get("facial_area", {}) or {}
 
313
  return st
314
 
315
 
316
+ def _is_real_face(pick: Dict, bbox_full_xywh: Tuple[float, float, float, float], frame_hw: Tuple[int, int]) -> bool:
 
 
 
 
 
 
 
317
  fh, fw = frame_hw
318
  x, y, w, h = bbox_full_xywh
 
319
  area_ratio = (w * h) / max(1.0, float(fw * fh))
320
  if area_ratio < MIN_FACE_AREA_RATIO:
321
  return False
 
322
  det_conf = pick.get("confidence", None)
323
  if det_conf is not None:
324
  try:
 
326
  return False
327
  except Exception:
328
  pass
 
329
  return True
330
 
331
 
332
  # =========================
333
+ # Stream(優先順)
334
  # =========================
335
  def on_stream(frame_rgb: np.ndarray, st: AppState):
 
336
  if st.finished or (not st.running):
337
  cam_visible = False
338
  if st.final_emo:
339
+ return (
340
+ gr.update(visible=cam_visible),
341
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
342
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
343
+ gr.update(value=_camera_hint_done()),
344
+ st,
345
+ gr.update(value="重新攝影機辨識"),
346
+ )
347
  return (
348
  gr.update(visible=cam_visible),
349
+ gr.update(value=_hint_html("尚未完成辨識。")),
350
+ gr.update(value=_score_radio_value(3)),
351
+ gr.update(value=_camera_hint_stopped()),
352
  st,
353
  gr.update(value="重新攝影機辨識"),
354
  )
 
364
  )
365
 
366
  frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
 
367
  now = time.time()
368
 
369
+ # ✅ 先畫框(永遠立即有框),不等 detector
370
+ detected_recent = st.face_in_box and (now - st.last_face_t) < 1.0
371
+ annotated_fast = _draw_guide_overlay(frame_rgb, detected_recent)
372
 
373
+ # ✅ 如果還沒到分析時間,直接回傳畫框(超順)
374
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
 
 
375
  return (
376
+ gr.update(visible=True, value=annotated_fast),
377
+ gr.update(value=_hint_html("已偵測到人臉,開始辨識情緒中…" if detected_recent else "未偵測到人臉,請把臉放進框內")),
378
  gr.update(value=_score_radio_value(3)),
379
  gr.update(value=_camera_hint_before_start()),
380
  st,
381
  gr.update(value="重新攝影機辨識"),
382
  )
383
 
384
+ # 到時間���做一次重工作(低頻)
385
  st.last_analyze_t = now
 
386
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
387
 
 
388
  try:
389
  faces = DeepFace.extract_faces(
390
  img_path=small,
 
397
 
398
  pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
399
 
 
400
  if pick is None or bbox is None or pick.get("face") is None:
401
  st.track_bbox = None
402
  st.face_in_box = False
403
  st.last_face_t = now
404
  st.stable_emo = "unknown"
 
405
  annotated = _draw_guide_overlay(frame_rgb, False)
406
  return (
407
  gr.update(visible=True, value=annotated),
 
412
  gr.update(value="重新攝影機辨識"),
413
  )
414
 
 
415
  sh, sw = small.shape[:2]
416
  fh, fw = frame_rgb.shape[:2]
417
  sx = fw / max(1, sw)
418
  sy = fh / max(1, sh)
 
419
  x, y, w, h = bbox
420
  bbox_full = (x * sx, y * sy, w * sx, h * sy)
421
 
 
422
  if not _is_real_face(pick, bbox_full, (fh, fw)):
423
  st.track_bbox = None
424
  st.face_in_box = False
425
  st.last_face_t = now
 
 
426
  annotated = _draw_guide_overlay(frame_rgb, False)
427
  return (
428
  gr.update(visible=True, value=annotated),
 
434
  )
435
 
436
  guide = _guide_box(frame_rgb)
437
+ in_box = _bbox_center_in_box(bbox_full, guide)
438
 
439
  st.last_face_t = now
440
+ st.face_in_box = in_box
441
  st.track_bbox = bbox
442
 
443
+ if not in_box:
 
444
  annotated = _draw_guide_overlay(frame_rgb, False)
445
  return (
446
  gr.update(visible=True, value=annotated),
 
451
  gr.update(value="重新攝影機辨識"),
452
  )
453
 
454
+ # 只有臉在框內才分析情緒
455
  face = pick["face"]
456
  if face.dtype != np.uint8:
457
  face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
458
 
 
459
  try:
460
  result = DeepFace.analyze(
461
  img_path=face,
 
463
  enforce_detection=True,
464
  )
465
  except Exception:
466
+ annotated = _draw_guide_overlay(frame_rgb, False)
467
  st.face_in_box = False
468
  st.last_face_t = now
 
 
 
469
  return (
470
  gr.update(visible=True, value=annotated),
471
  gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
 
478
  if isinstance(result, list):
479
  result = result[0]
480
  emo_dict = result.get("emotion", None)
 
481
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
482
  annotated = _draw_guide_overlay(frame_rgb, True)
483
  return (
 
516
  stable_key = st.emo_keys[stable_idx]
517
  st.stable_emo = stable_key
518
 
 
519
  if st.candidate_emo != stable_key:
520
  st.candidate_emo = stable_key
521
  st.candidate_since = now
 
532
  st.final_emo = stable_key
533
  st.final_conf = top_conf
534
  st.final_score = EMO_TO_SCORE.get(stable_key, 3)
 
535
  st.running = False
536
  st.finished = True
 
537
  return (
538
  gr.update(visible=False),
539
  gr.update(value=_result_card_html(stable_key, top_conf)),
 
560
  def on_restart(st: AppState):
561
  st = _reset_state_for_camera(st)
562
  return (
563
+ gr.update(visible=True, value=None), # ✅ 清掉上一張畫面
564
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
565
  gr.update(value=_score_radio_value(3)),
566
  gr.update(value=_camera_hint_before_start()),
 
571
  def on_stop(st: AppState):
572
  st.running = False
573
  st.finished = True
 
574
  if st.final_emo is None:
575
  return (
576
+ gr.update(visible=False, value=None),
577
  gr.update(value=_hint_html("已停止辨識。")),
578
  gr.update(value=_score_radio_value(3)),
579
  gr.update(value=_camera_hint_stopped()),
580
  st,
581
  gr.update(value="重新攝影機辨識"),
582
  )
 
583
  return (
584
+ gr.update(visible=False, value=None),
585
  gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
586
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
587
  gr.update(value=_camera_hint_done()),