YuAnTsai commited on
Commit
6c37102
·
verified ·
1 Parent(s): 4869d9f

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +245 -129
emotion.py CHANGED
@@ -4,28 +4,36 @@ from typing import Optional, Dict, Tuple, List
4
 
5
  import gradio as gr
6
  import numpy as np
7
- from PIL import Image
8
 
9
  from deepface import DeepFace
10
 
11
 
 
 
 
 
12
  # =========================
13
- # 參數(可微調)
14
  # =========================
15
- ANALYZE_EVERY_SEC = 0.8
16
- DOWNSAMPLE_W = 720
17
  EMA_ALPHA = 0.45
18
  CONF_TH = 40.0
19
- SWITCH_CONFIRM_SEC = 2.5
20
  IOU_TH = 0.25
21
- ZOOM_FACTOR = 1.5
22
 
23
- DETECTOR_BACKEND = "mtcnn" # 想快一點可改 "opencv"
 
24
  ALIGN_FACE = True
25
 
 
 
 
26
 
27
  # =========================
28
- # 情緒中文 + 分數映射(7 -> 5)
29
  # =========================
30
  EMO_ZH = {
31
  "angry": "生氣",
@@ -38,7 +46,6 @@ EMO_ZH = {
38
  "unknown": "未知",
39
  }
40
 
41
- # 你要的:把 7 種情緒塞進 5 分心情分數
42
  EMO_TO_SCORE = {
43
  "angry": 1,
44
  "disgust": 1,
@@ -50,9 +57,6 @@ EMO_TO_SCORE = {
50
  "unknown": 3,
51
  }
52
 
53
- def get_emotion_score(emo: str) -> int:
54
- return EMO_TO_SCORE.get(emo, 3)
55
-
56
  SCORE_LABELS = {
57
  1: "1(心情差)",
58
  2: "2(不太好)",
@@ -62,14 +66,17 @@ SCORE_LABELS = {
62
  }
63
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
64
 
 
 
 
 
 
65
 
66
  # =========================
67
  # 狀態
68
  # =========================
69
  @dataclass
70
  class AppState:
71
- mode: str = "攝影機辨識"
72
-
73
  running: bool = True
74
  finished: bool = False
75
  last_analyze_t: float = 0.0
@@ -89,80 +96,55 @@ class AppState:
89
  # final
90
  final_emo: Optional[str] = None
91
  final_conf: Optional[float] = None
 
92
 
93
- final_score: int = 3
 
 
 
94
 
95
 
96
  # =========================
97
- # UI HTML (強制黑色文字)
98
  # =========================
99
  def _hint_html(msg: str) -> str:
100
- # 加入 color:#000000 強制黑色
101
  return f"""
102
  <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">
103
  {msg}
104
  </div>
105
  """
106
 
107
-
108
  def _camera_hint_before_start() -> str:
109
  return _hint_html(
110
  "請先按「Click to access webcam / 允許」開啟攝影機;"
111
  "接著,請按「錄製(Record)」開始。"
112
  )
113
 
114
-
115
  def _camera_hint_done() -> str:
116
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
117
 
118
-
119
  def _camera_hint_stopped() -> str:
120
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
121
 
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
- # ✅ 強制設定 color: #000000
130
  return f"""
131
- <div style="
132
- border-radius:16px;
133
- padding:20px;
134
- border:1px solid #E6E6E6;
135
- background:#FFFFFF;
136
- color:#000000;
137
- ">
138
- <div style="font-size:14px;color:#000000;margin-bottom:8px;">
139
- 辨識結果
140
- </div>
141
- <div style="
142
- font-size:42px;
143
- font-weight:800;
144
- line-height:1.1;
145
- margin-bottom:14px;
146
- color:#000000;
147
- ">
148
- {zh}
149
- </div>
150
- <div style="font-size:16px;color:#000000;margin-bottom:6px;">
151
- 心情分數:{score}({score_desc})
152
- </div>
153
- <div style="font-size:14px;color:#000000;">
154
- 信心值:{conf_txt}
155
- </div>
156
  </div>
157
  """
158
 
159
 
160
- def _score_radio_value(score: int) -> str:
161
- score = int(score)
162
- score = min(5, max(1, score))
163
- return SCORE_LABELS[score]
164
-
165
-
166
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
167
  if frame is None:
168
  return None
@@ -176,28 +158,67 @@ def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
176
  return np.array(img)
177
 
178
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
179
- """
180
- 裁切畫面中心以模擬數位變焦。
181
- zoom: 放大倍率 (例如 1.5)
182
- """
183
  if frame is None or zoom <= 1.0:
184
  return frame
185
-
186
  h, w = frame.shape[:2]
187
-
188
- # 計算新的寬高
189
  new_w = int(w / zoom)
190
  new_h = int(h / zoom)
191
-
192
- # 計算起始點 (讓裁切框置中)
193
  start_x = (w - new_w) // 2
194
  start_y = (h - new_h) // 2
195
-
196
- # 進行裁切
197
- cropped = frame[start_y : start_y + new_h, start_x : start_x + new_w]
198
- return cropped
199
 
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
202
  ax, ay, aw, ah = a
203
  bx, by, bw, bh = b
@@ -213,11 +234,10 @@ def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
213
  union = aw * ah + bw * bh - inter
214
  return inter / max(1e-6, union)
215
 
216
-
217
  def _pick_face_by_tracking(
218
  faces: List[Dict],
219
  prev_bbox: Optional[Tuple[int, int, int, int]]
220
- ) -> Tuple[Optional[Dict], Optional[Tuple[int, int, int, int]]]:
221
  if not faces:
222
  return None, None
223
 
@@ -262,102 +282,194 @@ def _reset_state_for_camera(st: AppState) -> AppState:
262
  st.final_emo = None
263
  st.final_conf = None
264
  st.final_score = 3
 
 
 
265
  return st
266
 
267
 
 
 
 
 
 
 
 
268
  # =========================
269
- # Camera streaming
270
  # =========================
271
  def on_stream(frame_rgb: np.ndarray, st: AppState):
 
272
  if st.finished or (not st.running):
273
  cam_visible = False
274
-
275
  if st.final_emo:
276
- result_html = _result_card_html(st.final_emo, st.final_conf)
277
- mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
278
- hint_html = _camera_hint_done()
279
- else:
280
- result_html = _hint_html("尚未完成辨識。")
281
- mood_val = _score_radio_value(3)
282
- hint_html = _camera_hint_stopped()
283
-
284
  return (
285
  gr.update(visible=cam_visible),
286
- gr.update(value=result_html),
287
- gr.update(value=mood_val),
288
- gr.update(value=hint_html),
289
  st,
290
  gr.update(value="重新攝影機辨識"),
291
  )
292
 
 
293
  if frame_rgb is None:
294
  return (
295
- gr.update(visible=True),
296
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
297
  gr.update(value=_score_radio_value(3)),
298
  gr.update(value=_camera_hint_before_start()),
299
  st,
300
  gr.update(value="重新攝影機辨識"),
301
  )
302
-
303
- frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
304
 
 
 
305
  now = time.time()
 
 
 
 
 
 
 
306
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
307
- show = st.stable_emo or "unknown"
 
 
 
 
 
 
 
 
308
  return (
309
- gr.update(visible=True),
310
- gr.update(value=_result_card_html(show, st.final_conf)),
311
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
312
  gr.update(value=_camera_hint_before_start()),
313
  st,
314
  gr.update(value="重新攝影機辨識"),
315
  )
316
 
 
317
  st.last_analyze_t = now
318
-
319
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
320
 
321
- # ✅ 這裡固定 enforce_detection=False(避免沒臉就丟例外,造成卡頓/誤判)
322
- faces = DeepFace.extract_faces(
323
- img_path=small,
324
- detector_backend=DETECTOR_BACKEND,
325
- enforce_detection=False,
326
- align=ALIGN_FACE,
327
- )
 
 
 
 
328
  pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
 
 
329
  if pick is None or bbox is None or pick.get("face") is None:
330
- show = st.stable_emo or "unknown"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  return (
332
- gr.update(visible=True),
333
- gr.update(value=_result_card_html(show, st.final_conf)),
334
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
335
  gr.update(value=_camera_hint_before_start()),
336
  st,
337
  gr.update(value="重新攝影機辨識"),
338
  )
339
 
 
 
 
 
340
  st.track_bbox = bbox
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  face = pick["face"]
342
  if face.dtype != np.uint8:
343
  face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
344
 
345
- # ✅ 這裡也固定 enforce_detection=False(避免偶發抓不到臉時丟例外)
346
- result = DeepFace.analyze(
347
- img_path=face,
348
- actions=["emotion"],
349
- enforce_detection=False,
350
- )
 
 
 
 
 
 
 
 
 
 
 
351
  if isinstance(result, list):
352
  result = result[0]
353
  emo_dict = result.get("emotion", None)
354
 
355
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
356
- show = st.stable_emo or "unknown"
357
  return (
358
- gr.update(visible=True),
359
- gr.update(value=_result_card_html(show, st.final_conf)),
360
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
361
  gr.update(value=_camera_hint_before_start()),
362
  st,
363
  gr.update(value="重新攝影機辨識"),
@@ -367,16 +479,17 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
367
  top_conf = float(emo_dict[top_key])
368
 
369
  if top_conf < CONF_TH:
370
- show = st.stable_emo or "unknown"
371
  return (
372
- gr.update(visible=True),
373
- gr.update(value=_result_card_html(show, st.final_conf)),
374
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
375
  gr.update(value=_camera_hint_before_start()),
376
  st,
377
  gr.update(value="重新攝影機辨識"),
378
  )
379
 
 
380
  keys = list(emo_dict.keys())
381
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
382
 
@@ -390,7 +503,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
390
  stable_key = st.emo_keys[stable_idx]
391
  st.stable_emo = stable_key
392
 
393
- # 延遲確認
394
  if st.candidate_emo != stable_key:
395
  st.candidate_emo = stable_key
396
  st.candidate_since = now
@@ -403,6 +516,8 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
403
  and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
404
  )
405
 
 
 
406
  if confirmed and st.final_emo is None:
407
  st.final_emo = stable_key
408
  st.final_conf = top_conf
@@ -412,7 +527,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
412
  st.finished = True
413
 
414
  return (
415
- gr.update(visible=False),
416
  gr.update(value=_result_card_html(stable_key, top_conf)),
417
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
418
  gr.update(value=_camera_hint_done()),
@@ -421,7 +536,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
421
  )
422
 
423
  return (
424
- gr.update(visible=True),
425
  gr.update(value=_result_card_html(stable_key, top_conf)),
426
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
427
  gr.update(value=_camera_hint_before_start()),
@@ -431,26 +546,26 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
431
 
432
 
433
  # =========================
434
- # Mode / Buttons
435
  # =========================
436
  def on_restart(st: AppState):
437
  st = _reset_state_for_camera(st)
 
438
  return (
439
- gr.update(visible=True),
440
- gr.update(value=_hint_html("等待影像…(尚未開���串流)")),
441
- gr.update(value=_score_radio_value(3)),
442
- gr.update(value=_camera_hint_before_start()),
443
  st,
444
- gr.update(value="重新攝影機辨識"),
445
  )
446
 
447
-
448
  def on_stop(st: AppState):
449
  st.running = False
450
  st.finished = True
451
  if st.final_emo is None:
452
  return (
453
- gr.update(visible=False),
454
  gr.update(value=_hint_html("已停止辨識。")),
455
  gr.update(value=_score_radio_value(3)),
456
  gr.update(value=_camera_hint_stopped()),
@@ -459,7 +574,7 @@ def on_stop(st: AppState):
459
  )
460
 
461
  return (
462
- gr.update(visible=False),
463
  gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
464
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
465
  gr.update(value=_camera_hint_done()),
@@ -475,15 +590,16 @@ if __name__ == "__main__":
475
  with gr.Blocks(title="Emotion Detector", css=css) as demo:
476
  st = gr.State(AppState())
477
 
478
- gr.Markdown("## 情緒辨識")
479
-
 
480
  hint = gr.HTML(_camera_hint_before_start())
481
 
482
  cam = gr.Image(
483
  sources=["webcam"],
484
  streaming=True,
485
  type="numpy",
486
- label="攝影機",
487
  visible=True,
488
  )
489
 
 
4
 
5
  import gradio as gr
6
  import numpy as np
7
+ from PIL import Image, ImageDraw
8
 
9
  from deepface import DeepFace
10
 
11
 
12
+ # ✅ 避免 css 未定義造成 NameError
13
+ css = ""
14
+
15
+
16
  # =========================
17
+ # 參數(HF Space 建議偏順)
18
  # =========================
19
+ ANALYZE_EVERY_SEC = 1.4 # 越大越順(Space 建議 1.2~2.0)
20
+ DOWNSAMPLE_W = 480 # 越小越順(360/480)
21
  EMA_ALPHA = 0.45
22
  CONF_TH = 40.0
23
+ SWITCH_CONFIRM_SEC = 2.2 # 穩定多久才定案(秒)
24
  IOU_TH = 0.25
25
+ ZOOM_FACTOR = 1.25 # 太大會失真且更慢,Space 建議 <= 1.25
26
 
27
+ # ✅ 快很多:opencv(比 mtcnn 順非常多)
28
+ DETECTOR_BACKEND = "opencv"
29
  ALIGN_FACE = True
30
 
31
+ # ✅ 防誤判(太小的人臉當作沒臉)
32
+ MIN_FACE_AREA_RATIO = 0.04 # 4%(可調 0.03~0.06)
33
+
34
 
35
  # =========================
36
+ # 情緒中文 + 分數映射
37
  # =========================
38
  EMO_ZH = {
39
  "angry": "生氣",
 
46
  "unknown": "未知",
47
  }
48
 
 
49
  EMO_TO_SCORE = {
50
  "angry": 1,
51
  "disgust": 1,
 
57
  "unknown": 3,
58
  }
59
 
 
 
 
60
  SCORE_LABELS = {
61
  1: "1(心情差)",
62
  2: "2(不太好)",
 
66
  }
67
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
68
 
69
+ def _score_radio_value(score: int) -> str:
70
+ score = int(score)
71
+ score = min(5, max(1, score))
72
+ return SCORE_LABELS[score]
73
+
74
 
75
  # =========================
76
  # 狀態
77
  # =========================
78
  @dataclass
79
  class AppState:
 
 
80
  running: bool = True
81
  finished: bool = False
82
  last_analyze_t: float = 0.0
 
96
  # final
97
  final_emo: Optional[str] = None
98
  final_conf: Optional[float] = None
99
+ final_score: int = 3
100
 
101
+ # face status for overlay
102
+ face_ok: bool = False
103
+ last_face_t: float = 0.0
104
+ face_in_box: bool = False
105
 
106
 
107
  # =========================
108
+ # UI HTML
109
  # =========================
110
  def _hint_html(msg: str) -> str:
 
111
  return f"""
112
  <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">
113
  {msg}
114
  </div>
115
  """
116
 
 
117
  def _camera_hint_before_start() -> str:
118
  return _hint_html(
119
  "請先按「Click to access webcam / 允許」開啟攝影機;"
120
  "接著,請按「錄製(Record)」開始。"
121
  )
122
 
 
123
  def _camera_hint_done() -> str:
124
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
125
 
 
126
  def _camera_hint_stopped() -> str:
127
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
128
 
 
129
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
130
  zh = EMO_ZH.get(emo_key, emo_key)
131
  score = EMO_TO_SCORE.get(emo_key, 3)
132
  conf_txt = "" if conf is None else f"{conf:.1f}%"
133
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
134
 
 
135
  return f"""
136
+ <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#000000;">
137
+ <div style="font-size:14px;color:#000000;margin-bottom:8px;">辨識結果</div>
138
+ <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;color:#000000;">{zh}</div>
139
+ <div style="font-size:16px;color:#000000;margin-bottom:6px;">心情分數:{score}({score_desc})</div>
140
+ <div style="font-size:14px;color:#000000;">信心值:{conf_txt}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  </div>
142
  """
143
 
144
 
145
+ # =========================
146
+ # 影像工具
147
+ # =========================
 
 
 
148
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
149
  if frame is None:
150
  return None
 
158
  return np.array(img)
159
 
160
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
 
 
 
 
161
  if frame is None or zoom <= 1.0:
162
  return frame
 
163
  h, w = frame.shape[:2]
 
 
164
  new_w = int(w / zoom)
165
  new_h = int(h / zoom)
 
 
166
  start_x = (w - new_w) // 2
167
  start_y = (h - new_h) // 2
168
+ return frame[start_y:start_y + new_h, start_x:start_x + new_w]
 
 
 
169
 
170
 
171
+ # =========================
172
+ # 引導框(固定位置)
173
+ # =========================
174
+ GUIDE_W_RATIO = 0.60
175
+ GUIDE_H_RATIO = 0.72
176
+ GUIDE_Y_OFFSET = 0.00
177
+
178
+ def _guide_box_xyxy(frame: np.ndarray) -> Tuple[int, int, int, int]:
179
+ h, w = frame.shape[:2]
180
+ gw = int(w * GUIDE_W_RATIO)
181
+ gh = int(h * GUIDE_H_RATIO)
182
+ cx = w // 2
183
+ cy = int(h * (0.5 + GUIDE_Y_OFFSET))
184
+ x1 = max(0, cx - gw // 2)
185
+ y1 = max(0, cy - gh // 2)
186
+ x2 = min(w - 1, cx + gw // 2)
187
+ y2 = min(h - 1, cy + gh // 2)
188
+ return x1, y1, x2, y2
189
+
190
+ def _bbox_center_in_box(bbox_xywh: Tuple[float, float, float, float], box_xyxy: Tuple[int, int, int, int]) -> bool:
191
+ x, y, w, h = bbox_xywh
192
+ cx = x + w / 2.0
193
+ cy = y + h / 2.0
194
+ x1, y1, x2, y2 = box_xyxy
195
+ return (x1 <= cx <= x2) and (y1 <= cy <= y2)
196
+
197
+ def _draw_guide_overlay(frame_rgb: np.ndarray, green: bool, msg: str) -> np.ndarray:
198
+ img = Image.fromarray(frame_rgb)
199
+ draw = ImageDraw.Draw(img)
200
+
201
+ x1, y1, x2, y2 = _guide_box_xyxy(frame_rgb)
202
+ color = (0, 200, 0) if green else (220, 0, 0)
203
+
204
+ h, w = frame_rgb.shape[:2]
205
+ thickness = max(3, int(min(w, h) * 0.008))
206
+
207
+ for t in range(thickness):
208
+ draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t], outline=color)
209
+
210
+ # 提示文字底條
211
+ tx = x1
212
+ ty = max(0, y1 - int(thickness * 7))
213
+ draw.rectangle([tx, ty, min(w - 1, tx + 420), ty + 34], fill=(255, 255, 255))
214
+ draw.text((tx + 8, ty + 7), msg, fill=(0, 0, 0))
215
+
216
+ return np.array(img)
217
+
218
+
219
+ # =========================
220
+ # Tracking / IoU
221
+ # =========================
222
  def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
223
  ax, ay, aw, ah = a
224
  bx, by, bw, bh = b
 
234
  union = aw * ah + bw * bh - inter
235
  return inter / max(1e-6, union)
236
 
 
237
  def _pick_face_by_tracking(
238
  faces: List[Dict],
239
  prev_bbox: Optional[Tuple[int, int, int, int]]
240
+ ):
241
  if not faces:
242
  return None, None
243
 
 
282
  st.final_emo = None
283
  st.final_conf = None
284
  st.final_score = 3
285
+ st.face_ok = False
286
+ st.last_face_t = 0.0
287
+ st.face_in_box = False
288
  return st
289
 
290
 
291
+ def _is_face_big_enough(bbox_xywh: Tuple[float, float, float, float], frame_hw: Tuple[int, int]) -> bool:
292
+ fh, fw = frame_hw
293
+ x, y, w, h = bbox_xywh
294
+ area_ratio = (w * h) / max(1.0, float(fw * fh))
295
+ return area_ratio >= MIN_FACE_AREA_RATIO
296
+
297
+
298
  # =========================
299
+ # Camera streaming (含紅/綠框)
300
  # =========================
301
  def on_stream(frame_rgb: np.ndarray, st: AppState):
302
+ # 已結束/停止
303
  if st.finished or (not st.running):
304
  cam_visible = False
 
305
  if st.final_emo:
306
+ return (
307
+ gr.update(visible=cam_visible),
308
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
309
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
310
+ gr.update(value=_camera_hint_done()),
311
+ st,
312
+ gr.update(value="重新攝影機辨識"),
313
+ )
314
  return (
315
  gr.update(visible=cam_visible),
316
+ gr.update(value=_hint_html("尚未完成辨識。")),
317
+ gr.update(value=_score_radio_value(3)),
318
+ gr.update(value=_camera_hint_stopped()),
319
  st,
320
  gr.update(value="重新攝影機辨識"),
321
  )
322
 
323
+ # 尚未開始串流
324
  if frame_rgb is None:
325
  return (
326
+ gr.update(visible=True, value=None),
327
  gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
328
  gr.update(value=_score_radio_value(3)),
329
  gr.update(value=_camera_hint_before_start()),
330
  st,
331
  gr.update(value="重新攝影機辨識"),
332
  )
 
 
333
 
334
+ # zoom(提升臉部比例)
335
+ frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
336
  now = time.time()
337
+
338
+ # ✅ 先畫一張「即時框」:用最近一次人臉狀態(避免每幀都做重運算)
339
+ recent_face = st.face_ok and (now - st.last_face_t) < 1.2
340
+ msg_fast = "已偵測到人臉,開始辨識情緒中…" if recent_face else "未偵測到人臉,請把臉放進框內"
341
+ annotated_fast = _draw_guide_overlay(frame_rgb, green=recent_face, msg=msg_fast)
342
+
343
+ # 沒到分析時間 → 直接回傳快速版本(順)
344
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
345
+ # 沒臉時不要顯示任何情緒卡(避免誤導)
346
+ if recent_face:
347
+ show = st.stable_emo or "unknown"
348
+ result_html = _result_card_html(show, st.final_conf)
349
+ mood_val = _score_radio_value(EMO_TO_SCORE.get(show, 3))
350
+ else:
351
+ result_html = _hint_html("未偵測到人臉,請把臉放進框內(紅框)")
352
+ mood_val = _score_radio_value(3)
353
+
354
  return (
355
+ gr.update(visible=True, value=annotated_fast),
356
+ gr.update(value=result_html),
357
+ gr.update(value=mood_val),
358
  gr.update(value=_camera_hint_before_start()),
359
  st,
360
  gr.update(value="重新攝影機辨識"),
361
  )
362
 
363
+ # 到分析時間 → 做一次偵測/分析
364
  st.last_analyze_t = now
 
365
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
366
 
367
+ # ✅ enforce_detection=False:沒有臉不丟例外(更順)
368
+ try:
369
+ faces = DeepFace.extract_faces(
370
+ img_path=small,
371
+ detector_backend=DETECTOR_BACKEND,
372
+ enforce_detection=False,
373
+ align=ALIGN_FACE,
374
+ )
375
+ except Exception:
376
+ faces = []
377
+
378
  pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
379
+
380
+ # 沒臉:紅框 + 不跑情緒
381
  if pick is None or bbox is None or pick.get("face") is None:
382
+ st.track_bbox = None
383
+ st.face_ok = False
384
+ st.face_in_box = False
385
+ st.last_face_t = now
386
+
387
+ annotated = _draw_guide_overlay(frame_rgb, green=False, msg="未偵測到人臉,請把臉放進框內")
388
+ return (
389
+ gr.update(visible=True, value=annotated),
390
+ gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內(紅框)")),
391
+ gr.update(value=_score_radio_value(3)),
392
+ gr.update(value=_camera_hint_before_start()),
393
+ st,
394
+ gr.update(value="重新攝影機辨識"),
395
+ )
396
+
397
+ # 把 bbox 從 small 映射回原 frame
398
+ sh, sw = small.shape[:2]
399
+ fh, fw = frame_rgb.shape[:2]
400
+ sx = fw / max(1, sw)
401
+ sy = fh / max(1, sh)
402
+ x, y, w, h = bbox
403
+ bbox_full = (x * sx, y * sy, w * sx, h * sy)
404
+
405
+ # 太小:當作沒臉(紅框)
406
+ if not _is_face_big_enough(bbox_full, (fh, fw)):
407
+ st.track_bbox = None
408
+ st.face_ok = False
409
+ st.face_in_box = False
410
+ st.last_face_t = now
411
+ annotated = _draw_guide_overlay(frame_rgb, green=False, msg="人臉太小/太遠,請靠近鏡頭並放進框內")
412
  return (
413
+ gr.update(visible=True, value=annotated),
414
+ gr.update(value=_hint_html("人臉太小/太遠,請靠近鏡頭並放進框內(紅框)")),
415
+ gr.update(value=_score_radio_value(3)),
416
  gr.update(value=_camera_hint_before_start()),
417
  st,
418
  gr.update(value="重新攝影機辨識"),
419
  )
420
 
421
+ # 檢查臉是否在引導框內:不在就紅框,不跑情緒
422
+ guide = _guide_box_xyxy(frame_rgb)
423
+ in_box = _bbox_center_in_box(bbox_full, guide)
424
+
425
  st.track_bbox = bbox
426
+ st.last_face_t = now
427
+ st.face_ok = True
428
+ st.face_in_box = in_box
429
+
430
+ if not in_box:
431
+ annotated = _draw_guide_overlay(frame_rgb, green=False, msg="已偵測到人臉,請把臉移到框內")
432
+ return (
433
+ gr.update(visible=True, value=annotated),
434
+ gr.update(value=_hint_html("已偵測到人臉,但請把臉移到框內(紅框)")),
435
+ gr.update(value=_score_radio_value(3)),
436
+ gr.update(value=_camera_hint_before_start()),
437
+ st,
438
+ gr.update(value="重新攝影機辨識"),
439
+ )
440
+
441
+ # ✅ 有臉且在框內:綠框,開始情緒分析
442
  face = pick["face"]
443
  if face.dtype != np.uint8:
444
  face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
445
 
446
+ try:
447
+ result = DeepFace.analyze(
448
+ img_path=face,
449
+ actions=["emotion"],
450
+ enforce_detection=False, # ✅ 避免偶發例外造成卡頓
451
+ )
452
+ except Exception:
453
+ annotated = _draw_guide_overlay(frame_rgb, green=True, msg="已偵測到人臉,辨識中…(分析暫時失敗)")
454
+ return (
455
+ gr.update(visible=True, value=annotated),
456
+ gr.update(value=_hint_html("已偵測到人臉,辨識中…(分析暫時失敗,請保持正面/光線亮一點)")),
457
+ gr.update(value=_score_radio_value(3)),
458
+ gr.update(value=_camera_hint_before_start()),
459
+ st,
460
+ gr.update(value="重新攝影機辨識"),
461
+ )
462
+
463
  if isinstance(result, list):
464
  result = result[0]
465
  emo_dict = result.get("emotion", None)
466
 
467
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
468
+ annotated = _draw_guide_overlay(frame_rgb, green=True, msg="已偵測到人臉,辨識中…")
469
  return (
470
+ gr.update(visible=True, value=annotated),
471
+ gr.update(value=_hint_html("已偵測到人臉,辨識中…")),
472
+ gr.update(value=_score_radio_value(3)),
473
  gr.update(value=_camera_hint_before_start()),
474
  st,
475
  gr.update(value="重新攝影機辨識"),
 
479
  top_conf = float(emo_dict[top_key])
480
 
481
  if top_conf < CONF_TH:
482
+ annotated = _draw_guide_overlay(frame_rgb, green=True, msg="已偵測到人臉,辨識中…(信心偏低)")
483
  return (
484
+ gr.update(visible=True, value=annotated),
485
+ gr.update(value=_hint_html("已偵測到人臉,辨識中…(信心值偏低,請面向鏡頭/光線亮一點)")),
486
+ gr.update(value=_score_radio_value(3)),
487
  gr.update(value=_camera_hint_before_start()),
488
  st,
489
  gr.update(value="重新攝影機辨識"),
490
  )
491
 
492
+ # smoothing
493
  keys = list(emo_dict.keys())
494
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
495
 
 
503
  stable_key = st.emo_keys[stable_idx]
504
  st.stable_emo = stable_key
505
 
506
+ # 延遲確認(避免跳來跳去)
507
  if st.candidate_emo != stable_key:
508
  st.candidate_emo = stable_key
509
  st.candidate_since = now
 
516
  and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
517
  )
518
 
519
+ annotated = _draw_guide_overlay(frame_rgb, green=True, msg="已偵測到人臉,開始辨識情緒中…")
520
+
521
  if confirmed and st.final_emo is None:
522
  st.final_emo = stable_key
523
  st.final_conf = top_conf
 
527
  st.finished = True
528
 
529
  return (
530
+ gr.update(visible=False, value=None),
531
  gr.update(value=_result_card_html(stable_key, top_conf)),
532
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
533
  gr.update(value=_camera_hint_done()),
 
536
  )
537
 
538
  return (
539
+ gr.update(visible=True, value=annotated),
540
  gr.update(value=_result_card_html(stable_key, top_conf)),
541
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
542
  gr.update(value=_camera_hint_before_start()),
 
546
 
547
 
548
  # =========================
549
+ # Buttons
550
  # =========================
551
  def on_restart(st: AppState):
552
  st = _reset_state_for_camera(st)
553
+ # ✅ 清掉上一張,避免殘影
554
  return (
555
+ gr.update(visible=True, value=None),
556
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
557
+ gr.update(value=_score_radio_value(3)),
558
+ gr.update(value=_camera_hint_before_start()),
559
  st,
560
+ gr.update(value="重新攝影機辨識"),
561
  )
562
 
 
563
  def on_stop(st: AppState):
564
  st.running = False
565
  st.finished = True
566
  if st.final_emo is None:
567
  return (
568
+ gr.update(visible=False, value=None),
569
  gr.update(value=_hint_html("已停止辨識。")),
570
  gr.update(value=_score_radio_value(3)),
571
  gr.update(value=_camera_hint_stopped()),
 
574
  )
575
 
576
  return (
577
+ gr.update(visible=False, value=None),
578
  gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
579
  gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
580
  gr.update(value=_camera_hint_done()),
 
590
  with gr.Blocks(title="Emotion Detector", css=css) as demo:
591
  st = gr.State(AppState())
592
 
593
+ gr.Markdown("## 第一步:測測你的心情能量")
594
+ gr.Markdown("讓 AI 看看你的表情,自動幫你決定心情分數!(辨識完畢會自動跳轉)")
595
+
596
  hint = gr.HTML(_camera_hint_before_start())
597
 
598
  cam = gr.Image(
599
  sources=["webcam"],
600
  streaming=True,
601
  type="numpy",
602
+ label="攝影機畫面",
603
  visible=True,
604
  )
605