PhilipL commited on
Commit
c2c53b0
·
verified ·
1 Parent(s): 3b344b0

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +460 -94
emotion.py CHANGED
@@ -19,13 +19,12 @@ CONF_TH = 40.0
19
  SWITCH_CONFIRM_SEC = 2.5
20
  IOU_TH = 0.25
21
 
22
- # 建議改為 "opencv" 穩定性較高,若想維持原狀可改回 "mtcnn"
23
- DETECTOR_BACKEND = "opencv"
24
  ALIGN_FACE = True
25
 
26
 
27
  # =========================
28
- # 情緒中文 + 分數映射
29
  # =========================
30
  EMO_ZH = {
31
  "angry": "生氣",
@@ -38,6 +37,7 @@ EMO_ZH = {
38
  "unknown": "未知",
39
  }
40
 
 
41
  EMO_TO_SCORE = {
42
  "angry": 1,
43
  "disgust": 1,
@@ -50,6 +50,7 @@ EMO_TO_SCORE = {
50
  }
51
 
52
  # ### [整合需求 新增] ###
 
53
  def get_emotion_score(emo: str) -> int:
54
  return EMO_TO_SCORE.get(emo, 3)
55
  # ######################
@@ -91,7 +92,8 @@ class AppState:
91
  final_emo: Optional[str] = None
92
  final_conf: Optional[float] = None
93
 
94
- # ### [整合需求 新增] 用來儲存整數分數 ###
 
95
  final_score: int = 3
96
  # ######################
97
 
@@ -100,103 +102,187 @@ class AppState:
100
  # UI HTML
101
  # =========================
102
  def _hint_html(msg: str) -> str:
103
- return f"""<div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#111;">{msg}</div>"""
 
 
 
 
 
104
 
105
  def _camera_hint_before_start() -> str:
106
- return _hint_html("請先按「Click to access webcam / 允許」開啟攝影機;接著,請按「錄製(Record)」開始。")
 
 
 
 
107
 
108
  def _camera_hint_done() -> str:
109
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
110
 
 
111
  def _camera_hint_stopped() -> str:
112
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
113
 
 
114
  def _upload_hint_before() -> str:
115
  return _hint_html("請上傳一張清楚的臉部照片(臉越大越準)。")
116
 
 
117
  def _upload_hint_done() -> str:
118
  return _hint_html("已完成圖片辨識,可按「重新上傳圖片辨識」再次辨識。")
119
 
 
120
  def _upload_hint_stopped() -> str:
121
  return _hint_html("已停止圖片辨識。可按「重新上傳圖片辨識」再試一次。")
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
  return f"""
130
- <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#111;">
131
- <div style="font-size:14px;color:#444;margin-bottom:8px;">辨識結果</div>
132
- <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;color:#000;">{zh}</div>
133
- <div style="font-size:16px;color:#111;margin-bottom:6px;">心情分數:<b>{score}</b>({score_desc})</div>
134
- <div style="font-size:14px;color:#111;">信心值:{conf_txt}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  </div>
136
  """
137
 
 
138
  def _score_radio_value(score: int) -> str:
139
  score = int(score)
140
  score = min(5, max(1, score))
141
  return SCORE_LABELS[score]
142
 
 
143
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
144
- if frame is None: return None
 
145
  h, w = frame.shape[:2]
146
- if w <= target_w: return frame
 
147
  scale = target_w / w
148
  new_h = max(1, int(h * scale))
149
  img = Image.fromarray(frame)
150
  img = img.resize((target_w, new_h))
151
  return np.array(img)
152
 
153
- def _iou(a, b) -> float:
 
154
  ax, ay, aw, ah = a
155
  bx, by, bw, bh = b
156
  ax2, ay2 = ax + aw, ay + ah
157
  bx2, by2 = bx + bw, by + bh
 
158
  ix1, iy1 = max(ax, bx), max(ay, by)
159
  ix2, iy2 = min(ax2, bx2), min(ay2, by2)
160
  iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
161
  inter = iw * ih
162
- if inter <= 0: return 0.0
 
163
  union = aw * ah + bw * bh - inter
164
  return inter / max(1e-6, union)
165
 
166
- def _pick_face_by_tracking(faces, prev_bbox):
167
- if not faces: return None, None
 
 
 
 
 
 
168
  cand = []
169
  for f in faces:
170
  fa = f.get("facial_area", {}) or {}
171
- x, y, w, h = int(fa.get("x",0)), int(fa.get("y",0)), int(fa.get("w",0)), int(fa.get("h",0))
 
 
 
172
  if w > 0 and h > 0 and f.get("face") is not None:
173
  cand.append((f, (x, y, w, h), w * h))
174
- if not cand: return None, None
 
 
 
175
  if prev_bbox is not None:
176
- best, best_i = None, 0.0
 
177
  for f, bb, area in cand:
178
  i = _iou(prev_bbox, bb)
179
- if i > best_i: best_i = i; best = (f, bb)
180
- if best is not None and best_i >= IOU_TH: return best[0], best[1]
 
 
 
 
181
  cand.sort(key=lambda x: x[2], reverse=True)
182
  return cand[0][0], cand[0][1]
183
 
 
184
  def _reset_state_for_camera(st: AppState) -> AppState:
185
- st.running = True; st.finished = False; st.last_analyze_t = 0.0
186
- st.track_bbox = None; st.ema_scores = None; st.emo_keys = []
187
- st.stable_emo = "unknown"; st.candidate_emo = None; st.candidate_since = None
188
- st.final_emo = None; st.final_conf = None
189
- # ### [整合需求] 重置分數
 
 
 
 
 
 
 
 
190
  st.final_score = 3
 
 
191
  return st
192
 
 
193
  def _reset_state_for_upload(st: AppState) -> AppState:
194
- st.running = False; st.finished = False; st.last_analyze_t = 0.0
195
- st.track_bbox = None; st.ema_scores = None; st.emo_keys = []
196
- st.stable_emo = "unknown"; st.candidate_emo = None; st.candidate_since = None
197
- st.final_emo = None; st.final_conf = None
198
- # ### [整合需求] 重置分數
 
 
 
 
 
 
 
 
199
  st.final_score = 3
 
 
200
  return st
201
 
202
 
@@ -204,11 +290,21 @@ def _reset_state_for_upload(st: AppState) -> AppState:
204
  # Camera streaming
205
  # =========================
206
  def on_stream(frame_rgb: np.ndarray, st: AppState):
 
207
  if st.mode != "攝影機辨識":
208
- return gr.update(), gr.update(), gr.update(), gr.update(value=_upload_hint_before()), st, gr.update(value="重新上傳圖片辨識")
209
-
 
 
 
 
 
 
 
 
210
  if st.finished or (not st.running):
211
  cam_visible = False
 
212
  if st.final_emo:
213
  result_html = _result_card_html(st.final_emo, st.final_conf)
214
  mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
@@ -217,49 +313,106 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
217
  result_html = _hint_html("尚未完成辨識。")
218
  mood_val = _score_radio_value(3)
219
  hint_html = _camera_hint_stopped()
220
- return gr.update(visible=cam_visible), gr.update(value=result_html), gr.update(value=mood_val), gr.update(value=hint_html), st, gr.update(value="重新攝影機辨識")
221
 
 
 
 
 
 
 
 
 
 
 
222
  if frame_rgb is None:
223
- return gr.update(visible=True), gr.update(value=_hint_html("等待影像…(尚未開始串流)")), gr.update(value=_score_radio_value(3)), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
224
 
225
  now = time.time()
226
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
227
  show = st.stable_emo or "unknown"
228
- return gr.update(visible=True), gr.update(value=_result_card_html(show, st.final_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
229
 
230
  st.last_analyze_t = now
 
231
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
232
- faces = DeepFace.extract_faces(img_path=small, detector_backend=DETECTOR_BACKEND, enforce_detection=False, align=ALIGN_FACE)
233
- pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
234
 
 
 
 
 
 
 
 
235
  if pick is None or bbox is None or pick.get("face") is None:
236
  show = st.stable_emo or "unknown"
237
- return gr.update(visible=True), gr.update(value=_result_card_html(show, st.final_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
238
 
239
  st.track_bbox = bbox
240
  face = pick["face"]
241
- if face.dtype != np.uint8: face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
242
-
243
- result = DeepFace.analyze(img_path=face, actions=["emotion"], enforce_detection=False)
244
- if isinstance(result, list): result = result[0]
 
 
 
 
 
 
245
  emo_dict = result.get("emotion", None)
246
 
247
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
248
  show = st.stable_emo or "unknown"
249
- return gr.update(visible=True), gr.update(value=_result_card_html(show, st.final_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
250
 
251
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
252
  top_conf = float(emo_dict[top_key])
253
 
254
  if top_conf < CONF_TH:
255
  show = st.stable_emo or "unknown"
256
- return gr.update(visible=True), gr.update(value=_result_card_html(show, st.final_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
257
 
258
  keys = list(emo_dict.keys())
259
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
260
 
261
  if st.ema_scores is None or st.emo_keys != keys:
262
- st.ema_scores = vec; st.emo_keys = keys
 
263
  else:
264
  st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
265
 
@@ -267,24 +420,48 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
267
  stable_key = st.emo_keys[stable_idx]
268
  st.stable_emo = stable_key
269
 
 
270
  if st.candidate_emo != stable_key:
271
- st.candidate_emo = stable_key; st.candidate_since = now
 
272
  else:
273
- if st.candidate_since is None: st.candidate_since = now
 
274
 
275
- confirmed = (st.candidate_since is not None and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC)
 
 
 
276
 
277
  if confirmed and st.final_emo is None:
278
- st.final_emo = stable_key; st.final_conf = top_conf
 
279
 
280
- # ### [整合需求] 存入分數 ###
 
281
  st.final_score = EMO_TO_SCORE.get(stable_key, 3)
282
- # #######################
283
-
284
- st.running = False; st.finished = True
285
- return gr.update(visible=False), gr.update(value=_result_card_html(stable_key, top_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))), gr.update(value=_camera_hint_done()), st, gr.update(value="重新攝影機辨識")
286
-
287
- return gr.update(visible=True), gr.update(value=_result_card_html(stable_key, top_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
 
289
 
290
  # =========================
@@ -292,46 +469,81 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
292
  # =========================
293
  def on_upload(img: np.ndarray, st: AppState):
294
  if st.mode != "上傳圖片辨識":
295
- return gr.update(), gr.update(), st, gr.update(value=_camera_hint_before_start()), gr.update(value="重新攝影機辨識")
 
 
 
 
296
 
297
  st = _reset_state_for_upload(st)
298
- if img is None:
299
- return gr.update(value=_upload_hint_before()), gr.update(value=_score_radio_value(3)), st, gr.update(value=_upload_hint_before()), gr.update(value="重新上傳圖片辨識")
300
 
301
- # ### [修正] 圖片格式處理 (解決 PNG/灰階 問題) ###
302
- if img.ndim == 2:
303
- img = np.stack([img, img, img], axis=-1)
304
- if img.shape[-1] == 4:
305
- img = img[:, :, :3]
306
- # ###########################################
 
 
307
 
308
  small = _downsample_rgb(img, DOWNSAMPLE_W)
309
- faces = DeepFace.extract_faces(img_path=small, detector_backend=DETECTOR_BACKEND, enforce_detection=False, align=ALIGN_FACE)
 
 
 
 
 
310
  pick, bbox = _pick_face_by_tracking(faces, None)
311
-
312
  if pick is None or pick.get("face") is None:
313
- return gr.update(value=_hint_html("沒有偵測到人臉,請換一張更清楚、臉更大的照片。")), gr.update(value=_score_radio_value(3)), st, gr.update(value=_upload_hint_before()), gr.update(value="重新上傳圖片辨識")
 
 
 
 
 
 
314
 
315
  face = pick["face"]
316
- if face.dtype != np.uint8: face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
317
-
318
- result = DeepFace.analyze(img_path=face, actions=["emotion"], enforce_detection=False)
319
- if isinstance(result, list): result = result[0]
 
 
 
 
 
 
320
  emo_dict = result.get("emotion", None)
321
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
322
- return gr.update(value=_hint_html("辨識失敗,請換一張照片再試一次。")), gr.update(value=_score_radio_value(3)), st, gr.update(value=_upload_hint_before()), gr.update(value="重新上傳圖片辨識")
 
 
 
 
 
 
323
 
324
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
325
  top_conf = float(emo_dict[top_key])
326
 
327
- st.final_emo = top_key; st.final_conf = top_conf
 
328
 
329
- # ### [整合需求] 存入分數 ###
 
330
  st.final_score = EMO_TO_SCORE.get(top_key, 3)
331
- # #######################
332
-
333
  st.finished = True
334
- return gr.update(value=_result_card_html(top_key, top_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(top_key, 3))), st, gr.update(value=_upload_hint_done()), gr.update(value="重新上傳圖片辨識")
 
 
 
 
 
 
 
335
 
336
 
337
  # =========================
@@ -339,35 +551,189 @@ def on_upload(img: np.ndarray, st: AppState):
339
  # =========================
340
  def on_mode_change(mode: str, st: AppState):
341
  st.mode = mode
 
342
  if mode == "攝影機辨識":
343
  st = _reset_state_for_camera(st)
344
- return gr.update(visible=True), gr.update(visible=False, value=None), gr.update(value=_hint_html("等待影像…(尚未開始串流)")), gr.update(value=_score_radio_value(3)), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識")
 
 
 
 
 
 
 
 
345
  else:
346
  st = _reset_state_for_upload(st)
347
- return gr.update(visible=False), gr.update(visible=True, value=None), gr.update(value=_upload_hint_before()), gr.update(value=_score_radio_value(3)), gr.update(value=_upload_hint_before()), st, gr.update(value="重新上傳圖片辨識")
 
 
 
 
 
 
 
 
 
348
 
349
  def on_restart(st: AppState):
350
  if st.mode == "攝影機辨識":
351
  st = _reset_state_for_camera(st)
352
- return gr.update(visible=True), gr.update(value=_hint_html("等待影像…(尚未開始串流)")), gr.update(value=_score_radio_value(3)), gr.update(value=_camera_hint_before_start()), st, gr.update(value="重新攝影機辨識"), gr.update()
 
 
 
 
 
 
 
 
353
  else:
354
  st = _reset_state_for_upload(st)
355
- return gr.update(), gr.update(value=_upload_hint_before()), gr.update(value=_score_radio_value(3)), gr.update(value=_upload_hint_before()), st, gr.update(value="重新上傳圖片辨識"), gr.update(value=None)
 
 
 
 
 
 
 
 
 
356
 
357
  def on_stop(st: AppState):
358
  if st.mode == "攝影機辨識":
359
- st.running = False; st.finished = True
 
 
360
  if st.final_emo is None:
361
- return gr.update(visible=False), gr.update(value=_hint_html("已停止辨識。")), gr.update(value=_score_radio_value(3)), gr.update(value=_camera_hint_stopped()), st, gr.update(value="重新攝影機辨識"), gr.update()
362
- return gr.update(visible=False), gr.update(value=_result_card_html(st.final_emo, st.final_conf)), gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))), gr.update(value=_camera_hint_done()), st, gr.update(value="重新攝影機辨識"), gr.update()
363
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
  st = _reset_state_for_upload(st)
365
- return gr.update(), gr.update(value=_hint_html("已停止圖片辨識。")), gr.update(value=_score_radio_value(3)), gr.update(value=_upload_hint_stopped()), st, gr.update(value="重新上傳圖片辨識"), gr.update(value=None)
 
 
 
 
 
 
 
 
366
 
367
- css = """#app_container { max-width: 960px; margin: 0 auto; } .gradio-container { background: #f1f5f9 !important; }"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
 
369
  if __name__ == "__main__":
370
- with gr.Blocks(title="Emotion Detector", css=css) as demo:
371
- # 隊友的單獨測試介面 (略)
372
- pass
373
- # demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  SWITCH_CONFIRM_SEC = 2.5
20
  IOU_TH = 0.25
21
 
22
+ DETECTOR_BACKEND = "mtcnn" # 想快一點可改 "opencv"
 
23
  ALIGN_FACE = True
24
 
25
 
26
  # =========================
27
+ # 情緒中文 + 分數映射(7 -> 5)
28
  # =========================
29
  EMO_ZH = {
30
  "angry": "生氣",
 
37
  "unknown": "未知",
38
  }
39
 
40
+ # 你要的:把 7 種情緒塞進 5 分心情分數
41
  EMO_TO_SCORE = {
42
  "angry": 1,
43
  "disgust": 1,
 
50
  }
51
 
52
  # ### [整合需求 新增] ###
53
+ # 為了讓主程式 (app.py) 可以呼叫這個轉換邏輯,將其包裝成函式
54
  def get_emotion_score(emo: str) -> int:
55
  return EMO_TO_SCORE.get(emo, 3)
56
  # ######################
 
92
  final_emo: Optional[str] = None
93
  final_conf: Optional[float] = None
94
 
95
+ # ### [整合需求 新增] ###
96
+ # 用來儲存最後計算出的整數分數 (1~5),讓外部程式容易讀取
97
  final_score: int = 3
98
  # ######################
99
 
 
102
  # UI HTML
103
  # =========================
104
  def _hint_html(msg: str) -> str:
105
+ return f"""
106
+ <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#111;">
107
+ {msg}
108
+ </div>
109
+ """
110
+
111
 
112
  def _camera_hint_before_start() -> str:
113
+ return _hint_html(
114
+ "請先按「Click to access webcam / 允許」開啟攝影機;"
115
+ "接著,請按「錄製(Record)」開始。"
116
+ )
117
+
118
 
119
  def _camera_hint_done() -> str:
120
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
121
 
122
+
123
  def _camera_hint_stopped() -> str:
124
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
125
 
126
+
127
  def _upload_hint_before() -> str:
128
  return _hint_html("請上傳一張清楚的臉部照片(臉越大越準)。")
129
 
130
+
131
  def _upload_hint_done() -> str:
132
  return _hint_html("已完成圖片辨識,可按「重新上傳圖片辨識」再次辨識。")
133
 
134
+
135
  def _upload_hint_stopped() -> str:
136
  return _hint_html("已停止圖片辨識。可按「重新上傳圖片辨識」再試一次。")
137
 
138
+
139
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
140
  zh = EMO_ZH.get(emo_key, emo_key)
141
  score = EMO_TO_SCORE.get(emo_key, 3)
142
  conf_txt = "" if conf is None else f"{conf:.1f}%"
143
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
144
 
145
+ # ✅ 結果字體用黑色,避免你說的灰字看不清楚
146
  return f"""
147
+ <div style="
148
+ border-radius:16px;
149
+ padding:20px;
150
+ border:1px solid #E6E6E6;
151
+ background:#FFFFFF;
152
+ color:#111;
153
+ ">
154
+ <div style="font-size:14px;color:#444;margin-bottom:8px;">
155
+ 辨識結果
156
+ </div>
157
+
158
+ <div style="
159
+ font-size:42px;
160
+ font-weight:800;
161
+ line-height:1.1;
162
+ margin-bottom:14px;
163
+ color:#000;
164
+ ">
165
+ {zh}
166
+ </div>
167
+
168
+ <div style="font-size:16px;color:#111;margin-bottom:6px;">
169
+ 心情分數:<b>{score}</b>({score_desc})
170
+ </div>
171
+
172
+ <div style="font-size:14px;color:#111;">
173
+ 信心值:{conf_txt}
174
+ </div>
175
  </div>
176
  """
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
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
186
+ if frame is None:
187
+ return None
188
  h, w = frame.shape[:2]
189
+ if w <= target_w:
190
+ return frame
191
  scale = target_w / w
192
  new_h = max(1, int(h * scale))
193
  img = Image.fromarray(frame)
194
  img = img.resize((target_w, new_h))
195
  return np.array(img)
196
 
197
+
198
+ def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
199
  ax, ay, aw, ah = a
200
  bx, by, bw, bh = b
201
  ax2, ay2 = ax + aw, ay + ah
202
  bx2, by2 = bx + bw, by + bh
203
+
204
  ix1, iy1 = max(ax, bx), max(ay, by)
205
  ix2, iy2 = min(ax2, bx2), min(ay2, by2)
206
  iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
207
  inter = iw * ih
208
+ if inter <= 0:
209
+ return 0.0
210
  union = aw * ah + bw * bh - inter
211
  return inter / max(1e-6, union)
212
 
213
+
214
+ def _pick_face_by_tracking(
215
+ faces: List[Dict],
216
+ prev_bbox: Optional[Tuple[int, int, int, int]]
217
+ ) -> Tuple[Optional[Dict], Optional[Tuple[int, int, int, int]]]:
218
+ if not faces:
219
+ return None, None
220
+
221
  cand = []
222
  for f in faces:
223
  fa = f.get("facial_area", {}) or {}
224
+ x = int(fa.get("x", 0) or 0)
225
+ y = int(fa.get("y", 0) or 0)
226
+ w = int(fa.get("w", 0) or 0)
227
+ h = int(fa.get("h", 0) or 0)
228
  if w > 0 and h > 0 and f.get("face") is not None:
229
  cand.append((f, (x, y, w, h), w * h))
230
+
231
+ if not cand:
232
+ return None, None
233
+
234
  if prev_bbox is not None:
235
+ best = None
236
+ best_i = 0.0
237
  for f, bb, area in cand:
238
  i = _iou(prev_bbox, bb)
239
+ if i > best_i:
240
+ best_i = i
241
+ best = (f, bb)
242
+ if best is not None and best_i >= IOU_TH:
243
+ return best[0], best[1]
244
+
245
  cand.sort(key=lambda x: x[2], reverse=True)
246
  return cand[0][0], cand[0][1]
247
 
248
+
249
  def _reset_state_for_camera(st: AppState) -> AppState:
250
+ st.running = True
251
+ st.finished = False
252
+ st.last_analyze_t = 0.0
253
+ st.track_bbox = None
254
+ st.ema_scores = None
255
+ st.emo_keys = []
256
+ st.stable_emo = "unknown"
257
+ st.candidate_emo = None
258
+ st.candidate_since = None
259
+ st.final_emo = None
260
+ st.final_conf = None
261
+
262
+ # ### [整合需求 新增] ###
263
  st.final_score = 3
264
+ # ######################
265
+
266
  return st
267
 
268
+
269
  def _reset_state_for_upload(st: AppState) -> AppState:
270
+ st.running = False
271
+ st.finished = False
272
+ st.last_analyze_t = 0.0
273
+ st.track_bbox = None
274
+ st.ema_scores = None
275
+ st.emo_keys = []
276
+ st.stable_emo = "unknown"
277
+ st.candidate_emo = None
278
+ st.candidate_since = None
279
+ st.final_emo = None
280
+ st.final_conf = None
281
+
282
+ # ### [整合需求 新增] ###
283
  st.final_score = 3
284
+ # ######################
285
+
286
  return st
287
 
288
 
 
290
  # Camera streaming
291
  # =========================
292
  def on_stream(frame_rgb: np.ndarray, st: AppState):
293
+ # 上傳模式不走攝影機流程
294
  if st.mode != "攝影機辨識":
295
+ return (
296
+ gr.update(), # cam
297
+ gr.update(), # result
298
+ gr.update(), # mood
299
+ gr.update(value=_upload_hint_before()),
300
+ st,
301
+ gr.update(value="重新上傳圖片辨識"),
302
+ )
303
+
304
+ # ✅ 完成/停止狀態:不要再顯示「Click to access…/Record…」
305
  if st.finished or (not st.running):
306
  cam_visible = False
307
+
308
  if st.final_emo:
309
  result_html = _result_card_html(st.final_emo, st.final_conf)
310
  mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
 
313
  result_html = _hint_html("尚未完成辨識。")
314
  mood_val = _score_radio_value(3)
315
  hint_html = _camera_hint_stopped()
 
316
 
317
+ return (
318
+ gr.update(visible=cam_visible),
319
+ gr.update(value=result_html),
320
+ gr.update(value=mood_val),
321
+ gr.update(value=hint_html),
322
+ st,
323
+ gr.update(value="重新攝影機辨識"),
324
+ )
325
+
326
+ # ✅ 還沒拿到 frame:提示使用者按允許/Record(而不是亂寫已開啟)
327
  if frame_rgb is None:
328
+ return (
329
+ gr.update(visible=True),
330
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
331
+ gr.update(value=_score_radio_value(3)),
332
+ gr.update(value=_camera_hint_before_start()),
333
+ st,
334
+ gr.update(value="重新攝影機辨識"),
335
+ )
336
 
337
  now = time.time()
338
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
339
  show = st.stable_emo or "unknown"
340
+ return (
341
+ gr.update(visible=True),
342
+ gr.update(value=_result_card_html(show, st.final_conf)),
343
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
344
+ gr.update(value=_camera_hint_before_start()),
345
+ st,
346
+ gr.update(value="重新攝影機辨識"),
347
+ )
348
 
349
  st.last_analyze_t = now
350
+
351
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
 
 
352
 
353
+ faces = DeepFace.extract_faces(
354
+ img_path=small,
355
+ detector_backend=DETECTOR_BACKEND,
356
+ enforce_detection=False,
357
+ align=ALIGN_FACE,
358
+ )
359
+ pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
360
  if pick is None or bbox is None or pick.get("face") is None:
361
  show = st.stable_emo or "unknown"
362
+ return (
363
+ gr.update(visible=True),
364
+ gr.update(value=_result_card_html(show, st.final_conf)),
365
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
366
+ gr.update(value=_camera_hint_before_start()),
367
+ st,
368
+ gr.update(value="重新攝影機辨識"),
369
+ )
370
 
371
  st.track_bbox = bbox
372
  face = pick["face"]
373
+ if face.dtype != np.uint8:
374
+ face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
375
+
376
+ result = DeepFace.analyze(
377
+ img_path=face,
378
+ actions=["emotion"],
379
+ enforce_detection=False,
380
+ )
381
+ if isinstance(result, list):
382
+ result = result[0]
383
  emo_dict = result.get("emotion", None)
384
 
385
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
386
  show = st.stable_emo or "unknown"
387
+ return (
388
+ gr.update(visible=True),
389
+ gr.update(value=_result_card_html(show, st.final_conf)),
390
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
391
+ gr.update(value=_camera_hint_before_start()),
392
+ st,
393
+ gr.update(value="重新攝影機辨識"),
394
+ )
395
 
396
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
397
  top_conf = float(emo_dict[top_key])
398
 
399
  if top_conf < CONF_TH:
400
  show = st.stable_emo or "unknown"
401
+ return (
402
+ gr.update(visible=True),
403
+ gr.update(value=_result_card_html(show, st.final_conf)),
404
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
405
+ gr.update(value=_camera_hint_before_start()),
406
+ st,
407
+ gr.update(value="重新攝影機辨識"),
408
+ )
409
 
410
  keys = list(emo_dict.keys())
411
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
412
 
413
  if st.ema_scores is None or st.emo_keys != keys:
414
+ st.ema_scores = vec
415
+ st.emo_keys = keys
416
  else:
417
  st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
418
 
 
420
  stable_key = st.emo_keys[stable_idx]
421
  st.stable_emo = stable_key
422
 
423
+ # 延遲確認
424
  if st.candidate_emo != stable_key:
425
+ st.candidate_emo = stable_key
426
+ st.candidate_since = now
427
  else:
428
+ if st.candidate_since is None:
429
+ st.candidate_since = now
430
 
431
+ confirmed = (
432
+ st.candidate_since is not None
433
+ and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
434
+ )
435
 
436
  if confirmed and st.final_emo is None:
437
+ st.final_emo = stable_key
438
+ st.final_conf = top_conf
439
 
440
+ # ### [整合需求 新增] ###
441
+ # 在辨識完成的瞬間,順便存入整數分數
442
  st.final_score = EMO_TO_SCORE.get(stable_key, 3)
443
+ # ######################
444
+
445
+ st.running = False
446
+ st.finished = True
447
+
448
+ return (
449
+ gr.update(visible=False),
450
+ gr.update(value=_result_card_html(stable_key, top_conf)),
451
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
452
+ gr.update(value=_camera_hint_done()),
453
+ st,
454
+ gr.update(value="重新攝影機辨識"),
455
+ )
456
+
457
+ return (
458
+ gr.update(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()),
462
+ st,
463
+ gr.update(value="重新攝影機辨識"),
464
+ )
465
 
466
 
467
  # =========================
 
469
  # =========================
470
  def on_upload(img: np.ndarray, st: AppState):
471
  if st.mode != "上傳圖片辨識":
472
+ return (
473
+ gr.update(), gr.update(), st,
474
+ gr.update(value=_camera_hint_before_start()),
475
+ gr.update(value="重新攝影機辨識")
476
+ )
477
 
478
  st = _reset_state_for_upload(st)
 
 
479
 
480
+ if img is None:
481
+ return (
482
+ gr.update(value=_upload_hint_before()),
483
+ gr.update(value=_score_radio_value(3)),
484
+ st,
485
+ gr.update(value=_upload_hint_before()),
486
+ gr.update(value="重新上傳圖片辨識"),
487
+ )
488
 
489
  small = _downsample_rgb(img, DOWNSAMPLE_W)
490
+ faces = DeepFace.extract_faces(
491
+ img_path=small,
492
+ detector_backend=DETECTOR_BACKEND,
493
+ enforce_detection=False,
494
+ align=ALIGN_FACE,
495
+ )
496
  pick, bbox = _pick_face_by_tracking(faces, None)
 
497
  if pick is None or pick.get("face") is None:
498
+ return (
499
+ gr.update(value=_hint_html("沒有偵測到人臉,請換一張更清楚、臉更大的照片。")),
500
+ gr.update(value=_score_radio_value(3)),
501
+ st,
502
+ gr.update(value=_upload_hint_before()),
503
+ gr.update(value="重新上傳圖片辨識"),
504
+ )
505
 
506
  face = pick["face"]
507
+ if face.dtype != np.uint8:
508
+ face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
509
+
510
+ result = DeepFace.analyze(
511
+ img_path=face,
512
+ actions=["emotion"],
513
+ enforce_detection=False,
514
+ )
515
+ if isinstance(result, list):
516
+ result = result[0]
517
  emo_dict = result.get("emotion", None)
518
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
519
+ return (
520
+ gr.update(value=_hint_html("辨識失敗,請換一張照片再試一次。")),
521
+ gr.update(value=_score_radio_value(3)),
522
+ st,
523
+ gr.update(value=_upload_hint_before()),
524
+ gr.update(value="重新上傳圖片辨識"),
525
+ )
526
 
527
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
528
  top_conf = float(emo_dict[top_key])
529
 
530
+ st.final_emo = top_key
531
+ st.final_conf = top_conf
532
 
533
+ # ### [整合需求 新增] ###
534
+ # 圖片辨識完成,存入分數
535
  st.final_score = EMO_TO_SCORE.get(top_key, 3)
536
+ # ######################
537
+
538
  st.finished = True
539
+
540
+ return (
541
+ gr.update(value=_result_card_html(top_key, top_conf)),
542
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(top_key, 3))),
543
+ st,
544
+ gr.update(value=_upload_hint_done()),
545
+ gr.update(value="重新上傳圖片辨識"),
546
+ )
547
 
548
 
549
  # =========================
 
551
  # =========================
552
  def on_mode_change(mode: str, st: AppState):
553
  st.mode = mode
554
+
555
  if mode == "攝影機辨識":
556
  st = _reset_state_for_camera(st)
557
+ return (
558
+ gr.update(visible=True), # cam
559
+ gr.update(visible=False, value=None), # upload
560
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")), # result
561
+ gr.update(value=_score_radio_value(3)), # mood
562
+ gr.update(value=_camera_hint_before_start()),
563
+ st,
564
+ gr.update(value="重新攝影機辨識"),
565
+ )
566
  else:
567
  st = _reset_state_for_upload(st)
568
+ return (
569
+ gr.update(visible=False), # cam
570
+ gr.update(visible=True, value=None), # upload
571
+ gr.update(value=_upload_hint_before()), # result
572
+ gr.update(value=_score_radio_value(3)), # mood
573
+ gr.update(value=_upload_hint_before()), # hint
574
+ st,
575
+ gr.update(value="重新上傳圖片辨識"),
576
+ )
577
+
578
 
579
  def on_restart(st: AppState):
580
  if st.mode == "攝影機辨識":
581
  st = _reset_state_for_camera(st)
582
+ return (
583
+ gr.update(visible=True), # cam
584
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")), # result
585
+ gr.update(value=_score_radio_value(3)), # mood
586
+ gr.update(value=_camera_hint_before_start()), # hint
587
+ st,
588
+ gr.update(value="重新攝影機辨識"), # btn label
589
+ gr.update(), # upload
590
+ )
591
  else:
592
  st = _reset_state_for_upload(st)
593
+ return (
594
+ gr.update(), # cam
595
+ gr.update(value=_upload_hint_before()), # result
596
+ gr.update(value=_score_radio_value(3)), # mood
597
+ gr.update(value=_upload_hint_before()), # hint
598
+ st,
599
+ gr.update(value="重新上傳圖片辨識"),
600
+ gr.update(value=None), # upload clear
601
+ )
602
+
603
 
604
  def on_stop(st: AppState):
605
  if st.mode == "攝影機辨識":
606
+ st.running = False
607
+ st.finished = True
608
+ # ✅ 停止後也不要再顯示 Click/Record 提示,改成「已停止/可重新」
609
  if st.final_emo is None:
610
+ return (
611
+ gr.update(visible=False),
612
+ gr.update(value=_hint_html("已停止辨識。")),
613
+ gr.update(value=_score_radio_value(3)),
614
+ gr.update(value=_camera_hint_stopped()),
615
+ st,
616
+ gr.update(value="重新攝影機辨識"),
617
+ gr.update(),
618
+ )
619
+
620
+ return (
621
+ gr.update(visible=False),
622
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
623
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
624
+ gr.update(value=_camera_hint_done()),
625
+ st,
626
+ gr.update(value="重新攝影機辨識"),
627
+ gr.update(),
628
+ )
629
+
630
+ # 上傳模式:停止 → 回到可重新上傳
631
  st = _reset_state_for_upload(st)
632
+ return (
633
+ gr.update(),
634
+ gr.update(value=_hint_html("已停止圖片辨識。")),
635
+ gr.update(value=_score_radio_value(3)),
636
+ gr.update(value=_upload_hint_stopped()),
637
+ st,
638
+ gr.update(value="重新上傳圖片辨識"),
639
+ gr.update(value=None),
640
+ )
641
 
642
+
643
+ # =========================
644
+ # Gradio UI
645
+ # =========================
646
+ # ### [整合需求] 下方的 UI Block 可以保留,
647
+ # 之後整合進 app.py 時,app.py 會只 import 上面的函式,不會執行這裡。
648
+ # 這樣隊友還是可以單獨執行這個檔案來測試。
649
+ css = """
650
+ #app_container { max-width: 960px; margin: 0 auto; }
651
+ .gradio-container { background: #f1f5f9 !important; }
652
+ """
653
+
654
+ # 為了方便 app.py 整合,我們把 UI 宣告包成一個函式 render_ui (或是 create_demo)
655
+ # 但為了不改動隊友原本的結構,我們保留下面的寫法,但加上 if __name__ == "__main__"
656
+ # 這樣 app.py import 時不會自動跑出介面
657
 
658
  if __name__ == "__main__":
659
+ with gr.Blocks(title="Emotion Detector") as demo:
660
+ st = gr.State(AppState())
661
+
662
+ gr.Markdown("## 情緒辨識")
663
+ gr.Markdown("請先選擇辨識方式:**攝影機** 或 **上傳圖片**。")
664
+
665
+ mode = gr.Radio(
666
+ choices=["攝影機辨識", "上傳圖片辨識"],
667
+ value="攝影機辨識",
668
+ label="辨識方式",
669
+ )
670
+
671
+ hint = gr.HTML(_camera_hint_before_start())
672
+
673
+ cam = gr.Image(
674
+ sources=["webcam"],
675
+ streaming=True,
676
+ type="numpy",
677
+ label="攝影機",
678
+ visible=True,
679
+ )
680
+
681
+ upload = gr.Image(
682
+ sources=["upload"],
683
+ type="numpy",
684
+ label="上傳圖片",
685
+ visible=False,
686
+ )
687
+
688
+ with gr.Row():
689
+ btn_restart = gr.Button("重新攝影機辨識", variant="primary")
690
+ btn_stop = gr.Button("停止", variant="secondary")
691
+
692
+ # ✅ 你要的排序:結果在上、分數在下
693
+ gr.Markdown("### 辨識結果")
694
+ result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
695
+
696
+ gr.Markdown("### 心情分數(系統判定)")
697
+ mood = gr.Radio(
698
+ choices=SCORE_CHOICES,
699
+ value=_score_radio_value(3),
700
+ label="心情分數",
701
+ interactive=False,
702
+ )
703
+
704
+ mode.change(
705
+ fn=on_mode_change,
706
+ inputs=[mode, st],
707
+ outputs=[cam, upload, result, mood, hint, st, btn_restart],
708
+ show_progress="minimal",
709
+ )
710
+
711
+ cam.stream(
712
+ fn=on_stream,
713
+ inputs=[cam, st],
714
+ outputs=[cam, result, mood, hint, st, btn_restart],
715
+ show_progress="minimal",
716
+ )
717
+
718
+ upload.change(
719
+ fn=on_upload,
720
+ inputs=[upload, st],
721
+ outputs=[result, mood, st, hint, btn_restart],
722
+ show_progress="minimal",
723
+ )
724
+
725
+ btn_restart.click(
726
+ fn=on_restart,
727
+ inputs=[st],
728
+ outputs=[cam, result, mood, hint, st, btn_restart, upload],
729
+ show_progress="minimal",
730
+ )
731
+
732
+ btn_stop.click(
733
+ fn=on_stop,
734
+ inputs=[st],
735
+ outputs=[cam, result, mood, hint, st, btn_restart, upload],
736
+ show_progress="minimal",
737
+ )
738
+
739
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False)