PhilipL commited on
Commit
1eb777e
·
verified ·
1 Parent(s): 324b70f

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +259 -61
emotion.py CHANGED
@@ -19,13 +19,13 @@ CONF_TH = 40.0
19
  SWITCH_CONFIRM_SEC = 2.5
20
  IOU_TH = 0.25
21
 
22
- # 建議改為 "opencv" 穩定性較高
23
  DETECTOR_BACKEND = "opencv"
24
  ALIGN_FACE = True
25
 
26
 
27
  # =========================
28
- # 情緒中文 + 分數映射
29
  # =========================
30
  EMO_ZH = {
31
  "angry": "生氣",
@@ -38,6 +38,7 @@ EMO_ZH = {
38
  "unknown": "未知",
39
  }
40
 
 
41
  EMO_TO_SCORE = {
42
  "angry": 1,
43
  "disgust": 1,
@@ -49,10 +50,11 @@ EMO_TO_SCORE = {
49
  "unknown": 3,
50
  }
51
 
52
- # 供外部主程式呼叫的介面
 
53
  def get_emotion_score(emo: str) -> int:
54
  return EMO_TO_SCORE.get(emo, 3)
55
-
56
 
57
  SCORE_LABELS = {
58
  1: "1(心情差)",
@@ -65,10 +67,12 @@ SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
65
 
66
 
67
  # =========================
68
- # 狀態 (已移除 mode 欄位)
69
  # =========================
70
  @dataclass
71
  class AppState:
 
 
72
  running: bool = True
73
  finished: bool = False
74
  last_analyze_t: float = 0.0
@@ -89,92 +93,165 @@ class AppState:
89
  final_emo: Optional[str] = None
90
  final_conf: Optional[float] = None
91
 
92
- # 儲存整數分數
 
93
  final_score: int = 3
 
94
 
95
 
96
  # =========================
97
  # UI HTML
98
  # =========================
99
  def _hint_html(msg: str) -> str:
100
- return f"""<div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#111;">{msg}</div>"""
 
 
 
 
 
101
 
102
  def _camera_hint_before_start() -> str:
103
- return _hint_html("請先按「Click to access webcam / 允許」開啟攝影機;接著,請按「錄製(Record)」開始。")
 
 
 
 
104
 
105
  def _camera_hint_done() -> str:
106
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
107
 
 
108
  def _camera_hint_stopped() -> str:
109
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
110
 
 
111
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
112
  zh = EMO_ZH.get(emo_key, emo_key)
113
  score = EMO_TO_SCORE.get(emo_key, 3)
114
  conf_txt = "" if conf is None else f"{conf:.1f}%"
115
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
116
 
 
117
  return f"""
118
- <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#111;">
119
- <div style="font-size:14px;color:#444;margin-bottom:8px;">辨識結果</div>
120
- <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;color:#000;">{zh}</div>
121
- <div style="font-size:16px;color:#111;margin-bottom:6px;">心情分數:<b>{score}</b>({score_desc})</div>
122
- <div style="font-size:14px;color:#111;">信心值:{conf_txt}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  </div>
124
  """
125
 
 
126
  def _score_radio_value(score: int) -> str:
127
  score = int(score)
128
  score = min(5, max(1, score))
129
  return SCORE_LABELS[score]
130
 
 
131
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
132
- if frame is None: return None
 
133
  h, w = frame.shape[:2]
134
- if w <= target_w: return frame
 
135
  scale = target_w / w
136
  new_h = max(1, int(h * scale))
137
  img = Image.fromarray(frame)
138
  img = img.resize((target_w, new_h))
139
  return np.array(img)
140
 
141
- def _iou(a, b) -> float:
 
142
  ax, ay, aw, ah = a
143
  bx, by, bw, bh = b
144
  ax2, ay2 = ax + aw, ay + ah
145
  bx2, by2 = bx + bw, by + bh
 
146
  ix1, iy1 = max(ax, bx), max(ay, by)
147
  ix2, iy2 = min(ax2, bx2), min(ay2, by2)
148
  iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
149
  inter = iw * ih
150
- if inter <= 0: return 0.0
 
151
  union = aw * ah + bw * bh - inter
152
  return inter / max(1e-6, union)
153
 
154
- def _pick_face_by_tracking(faces, prev_bbox):
155
- if not faces: return None, None
 
 
 
 
 
 
156
  cand = []
157
  for f in faces:
158
  fa = f.get("facial_area", {}) or {}
159
- x, y, w, h = int(fa.get("x",0)), int(fa.get("y",0)), int(fa.get("w",0)), int(fa.get("h",0))
 
 
 
160
  if w > 0 and h > 0 and f.get("face") is not None:
161
  cand.append((f, (x, y, w, h), w * h))
162
- if not cand: return None, None
 
 
 
163
  if prev_bbox is not None:
164
- best, best_i = None, 0.0
 
165
  for f, bb, area in cand:
166
  i = _iou(prev_bbox, bb)
167
- if i > best_i: best_i = i; best = (f, bb)
168
- if best is not None and best_i >= IOU_TH: return best[0], best[1]
 
 
 
 
169
  cand.sort(key=lambda x: x[2], reverse=True)
170
  return cand[0][0], cand[0][1]
171
 
 
172
  def _reset_state_for_camera(st: AppState) -> AppState:
173
- st.running = True; st.finished = False; st.last_analyze_t = 0.0
174
- st.track_bbox = None; st.ema_scores = None; st.emo_keys = []
175
- st.stable_emo = "unknown"; st.candidate_emo = None; st.candidate_since = None
176
- st.final_emo = None; st.final_conf = None
 
 
 
 
 
 
 
 
 
177
  st.final_score = 3
 
 
178
  return st
179
 
180
 
@@ -182,10 +259,10 @@ def _reset_state_for_camera(st: AppState) -> AppState:
182
  # Camera streaming
183
  # =========================
184
  def on_stream(frame_rgb: np.ndarray, st: AppState):
185
- # 移除模式檢查,直接執行
186
-
187
  if st.finished or (not st.running):
188
  cam_visible = False
 
189
  if st.final_emo:
190
  result_html = _result_card_html(st.final_emo, st.final_conf)
191
  mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
@@ -194,49 +271,106 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
194
  result_html = _hint_html("尚未完成辨識。")
195
  mood_val = _score_radio_value(3)
196
  hint_html = _camera_hint_stopped()
197
- 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="重新攝影機辨識")
198
 
 
 
 
 
 
 
 
 
 
 
199
  if frame_rgb is None:
200
- 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="重新攝影機辨識")
 
 
 
 
 
 
 
201
 
202
  now = time.time()
203
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
204
  show = st.stable_emo or "unknown"
205
- 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="重新攝影機辨識")
 
 
 
 
 
 
 
206
 
207
  st.last_analyze_t = now
 
208
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
209
- faces = DeepFace.extract_faces(img_path=small, detector_backend=DETECTOR_BACKEND, enforce_detection=False, align=ALIGN_FACE)
210
- pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
211
 
 
 
 
 
 
 
 
212
  if pick is None or bbox is None or pick.get("face") is None:
213
  show = st.stable_emo or "unknown"
214
- 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="重新攝影機辨識")
 
 
 
 
 
 
 
215
 
216
  st.track_bbox = bbox
217
  face = pick["face"]
218
- if face.dtype != np.uint8: face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
219
-
220
- result = DeepFace.analyze(img_path=face, actions=["emotion"], enforce_detection=False)
221
- if isinstance(result, list): result = result[0]
 
 
 
 
 
 
222
  emo_dict = result.get("emotion", None)
223
 
224
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
225
  show = st.stable_emo or "unknown"
226
- 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="重新攝影機辨識")
 
 
 
 
 
 
 
227
 
228
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
229
  top_conf = float(emo_dict[top_key])
230
 
231
  if top_conf < CONF_TH:
232
  show = st.stable_emo or "unknown"
233
- 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="重新攝影機辨識")
 
 
 
 
 
 
 
234
 
235
  keys = list(emo_dict.keys())
236
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
237
 
238
  if st.ema_scores is None or st.emo_keys != keys:
239
- st.ema_scores = vec; st.emo_keys = keys
 
240
  else:
241
  st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
242
 
@@ -244,47 +378,110 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
244
  stable_key = st.emo_keys[stable_idx]
245
  st.stable_emo = stable_key
246
 
 
247
  if st.candidate_emo != stable_key:
248
- st.candidate_emo = stable_key; st.candidate_since = now
 
249
  else:
250
- if st.candidate_since is None: st.candidate_since = now
 
251
 
252
- confirmed = (st.candidate_since is not None and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC)
 
 
 
253
 
254
  if confirmed and st.final_emo is None:
255
- st.final_emo = stable_key; st.final_conf = top_conf
 
 
 
 
256
  st.final_score = EMO_TO_SCORE.get(stable_key, 3)
257
- st.running = False; st.finished = True
258
- 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="重新攝影機辨識")
 
 
 
 
 
 
 
 
 
 
 
259
 
260
- 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="重新攝影機辨識")
 
 
 
 
 
 
 
261
 
262
 
263
  # =========================
264
- # Buttons Logic (Removed Upload)
265
  # =========================
266
-
267
  def on_restart(st: AppState):
268
  st = _reset_state_for_camera(st)
269
- # 回傳值已減少 (移除最後一個 upload update)
270
- 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="重新攝影機辨識")
 
 
 
 
 
 
 
271
 
272
  def on_stop(st: AppState):
273
- st.running = False; st.finished = True
 
 
274
  if st.final_emo is None:
275
- 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="重新攝影機辨識")
276
- 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="重新攝影機辨識")
 
 
 
 
 
 
277
 
278
- css = """#app_container { max-width: 960px; margin: 0 auto; } .gradio-container { background: #f1f5f9 !important; }"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
  if __name__ == "__main__":
281
- with gr.Blocks(title="Emotion Detector", css=css) as demo:
282
  st = gr.State(AppState())
283
 
284
  gr.Markdown("## 情緒辨識")
285
 
286
- # 移除了模式選擇 Radio
287
-
288
  hint = gr.HTML(_camera_hint_before_start())
289
 
290
  cam = gr.Image(
@@ -299,6 +496,7 @@ if __name__ == "__main__":
299
  btn_restart = gr.Button("重新攝影機辨識", variant="primary")
300
  btn_stop = gr.Button("停止", variant="secondary")
301
 
 
302
  gr.Markdown("### 辨識結果")
303
  result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
304
 
 
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
+ # 情緒中文 + 分數映射(7 -> 5)
29
  # =========================
30
  EMO_ZH = {
31
  "angry": "生氣",
 
38
  "unknown": "未知",
39
  }
40
 
41
+ # 你要的:把 7 種情緒塞進 5 分心情分數
42
  EMO_TO_SCORE = {
43
  "angry": 1,
44
  "disgust": 1,
 
50
  "unknown": 3,
51
  }
52
 
53
+ # ### [整合需求 新增] ###
54
+ # 為了讓主程式 (app.py) 可以呼叫這個轉換邏輯,將其包裝成函式
55
  def get_emotion_score(emo: str) -> int:
56
  return EMO_TO_SCORE.get(emo, 3)
57
+ # ######################
58
 
59
  SCORE_LABELS = {
60
  1: "1(心情差)",
 
67
 
68
 
69
  # =========================
70
+ # 狀態
71
  # =========================
72
  @dataclass
73
  class AppState:
74
+ mode: str = "攝影機辨識" # Default mode
75
+
76
  running: bool = True
77
  finished: bool = False
78
  last_analyze_t: float = 0.0
 
93
  final_emo: Optional[str] = None
94
  final_conf: Optional[float] = None
95
 
96
+ # ### [整合需求 新增] ###
97
+ # 用來儲存最後計算出的整數分數 (1~5),讓外部程式容易讀取
98
  final_score: int = 3
99
+ # ######################
100
 
101
 
102
  # =========================
103
  # UI HTML
104
  # =========================
105
  def _hint_html(msg: str) -> str:
106
+ return f"""
107
+ <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#111;">
108
+ {msg}
109
+ </div>
110
+ """
111
+
112
 
113
  def _camera_hint_before_start() -> str:
114
+ return _hint_html(
115
+ "請先按「Click to access webcam / 允許」開啟攝影機;"
116
+ "接著,請按「錄製(Record)」開始。"
117
+ )
118
+
119
 
120
  def _camera_hint_done() -> str:
121
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
122
 
123
+
124
  def _camera_hint_stopped() -> str:
125
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
126
 
127
+
128
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
129
  zh = EMO_ZH.get(emo_key, emo_key)
130
  score = EMO_TO_SCORE.get(emo_key, 3)
131
  conf_txt = "" if conf is None else f"{conf:.1f}%"
132
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
133
 
134
+ # ✅ 結果字體用黑色,避免你說的灰字看不清楚
135
  return f"""
136
+ <div style="
137
+ border-radius:16px;
138
+ padding:20px;
139
+ border:1px solid #E6E6E6;
140
+ background:#FFFFFF;
141
+ color:#111;
142
+ ">
143
+ <div style="font-size:14px;color:#444;margin-bottom:8px;">
144
+ 辨識結果
145
+ </div>
146
+
147
+ <div style="
148
+ font-size:42px;
149
+ font-weight:800;
150
+ line-height:1.1;
151
+ margin-bottom:14px;
152
+ color:#000;
153
+ ">
154
+ {zh}
155
+ </div>
156
+
157
+ <div style="font-size:16px;color:#111;margin-bottom:6px;">
158
+ 心情分數:<b>{score}</b>({score_desc})
159
+ </div>
160
+
161
+ <div style="font-size:14px;color:#111;">
162
+ 信心值:{conf_txt}
163
+ </div>
164
  </div>
165
  """
166
 
167
+
168
  def _score_radio_value(score: int) -> str:
169
  score = int(score)
170
  score = min(5, max(1, score))
171
  return SCORE_LABELS[score]
172
 
173
+
174
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
175
+ if frame is None:
176
+ return None
177
  h, w = frame.shape[:2]
178
+ if w <= target_w:
179
+ return frame
180
  scale = target_w / w
181
  new_h = max(1, int(h * scale))
182
  img = Image.fromarray(frame)
183
  img = img.resize((target_w, new_h))
184
  return np.array(img)
185
 
186
+
187
+ def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
188
  ax, ay, aw, ah = a
189
  bx, by, bw, bh = b
190
  ax2, ay2 = ax + aw, ay + ah
191
  bx2, by2 = bx + bw, by + bh
192
+
193
  ix1, iy1 = max(ax, bx), max(ay, by)
194
  ix2, iy2 = min(ax2, bx2), min(ay2, by2)
195
  iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
196
  inter = iw * ih
197
+ if inter <= 0:
198
+ return 0.0
199
  union = aw * ah + bw * bh - inter
200
  return inter / max(1e-6, union)
201
 
202
+
203
+ def _pick_face_by_tracking(
204
+ faces: List[Dict],
205
+ prev_bbox: Optional[Tuple[int, int, int, int]]
206
+ ) -> Tuple[Optional[Dict], Optional[Tuple[int, int, int, int]]]:
207
+ if not faces:
208
+ return None, None
209
+
210
  cand = []
211
  for f in faces:
212
  fa = f.get("facial_area", {}) or {}
213
+ x = int(fa.get("x", 0) or 0)
214
+ y = int(fa.get("y", 0) or 0)
215
+ w = int(fa.get("w", 0) or 0)
216
+ h = int(fa.get("h", 0) or 0)
217
  if w > 0 and h > 0 and f.get("face") is not None:
218
  cand.append((f, (x, y, w, h), w * h))
219
+
220
+ if not cand:
221
+ return None, None
222
+
223
  if prev_bbox is not None:
224
+ best = None
225
+ best_i = 0.0
226
  for f, bb, area in cand:
227
  i = _iou(prev_bbox, bb)
228
+ if i > best_i:
229
+ best_i = i
230
+ best = (f, bb)
231
+ if best is not None and best_i >= IOU_TH:
232
+ return best[0], best[1]
233
+
234
  cand.sort(key=lambda x: x[2], reverse=True)
235
  return cand[0][0], cand[0][1]
236
 
237
+
238
  def _reset_state_for_camera(st: AppState) -> AppState:
239
+ st.running = True
240
+ st.finished = False
241
+ st.last_analyze_t = 0.0
242
+ st.track_bbox = None
243
+ st.ema_scores = None
244
+ st.emo_keys = []
245
+ st.stable_emo = "unknown"
246
+ st.candidate_emo = None
247
+ st.candidate_since = None
248
+ st.final_emo = None
249
+ st.final_conf = None
250
+
251
+ # ### [整合需求 新增] ###
252
  st.final_score = 3
253
+ # ######################
254
+
255
  return st
256
 
257
 
 
259
  # Camera streaming
260
  # =========================
261
  def on_stream(frame_rgb: np.ndarray, st: AppState):
262
+ # ✅ 完成/停止狀態:不要再顯示「Click to access…/Record…」
 
263
  if st.finished or (not st.running):
264
  cam_visible = False
265
+
266
  if st.final_emo:
267
  result_html = _result_card_html(st.final_emo, st.final_conf)
268
  mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
 
271
  result_html = _hint_html("尚未完成辨識。")
272
  mood_val = _score_radio_value(3)
273
  hint_html = _camera_hint_stopped()
 
274
 
275
+ return (
276
+ gr.update(visible=cam_visible),
277
+ gr.update(value=result_html),
278
+ gr.update(value=mood_val),
279
+ gr.update(value=hint_html),
280
+ st,
281
+ gr.update(value="重新攝影機辨識"),
282
+ )
283
+
284
+ # ✅ 還沒拿到 frame:提示使用者按允許/Record(而不是亂寫已開啟)
285
  if frame_rgb is None:
286
+ return (
287
+ gr.update(visible=True),
288
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
289
+ gr.update(value=_score_radio_value(3)),
290
+ gr.update(value=_camera_hint_before_start()),
291
+ st,
292
+ gr.update(value="重新攝影機辨識"),
293
+ )
294
 
295
  now = time.time()
296
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
297
  show = st.stable_emo or "unknown"
298
+ return (
299
+ gr.update(visible=True),
300
+ gr.update(value=_result_card_html(show, st.final_conf)),
301
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
302
+ gr.update(value=_camera_hint_before_start()),
303
+ st,
304
+ gr.update(value="重新攝影機辨識"),
305
+ )
306
 
307
  st.last_analyze_t = now
308
+
309
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
 
 
310
 
311
+ faces = DeepFace.extract_faces(
312
+ img_path=small,
313
+ detector_backend=DETECTOR_BACKEND,
314
+ enforce_detection=False,
315
+ align=ALIGN_FACE,
316
+ )
317
+ pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
318
  if pick is None or bbox is None or pick.get("face") is None:
319
  show = st.stable_emo or "unknown"
320
+ return (
321
+ gr.update(visible=True),
322
+ gr.update(value=_result_card_html(show, st.final_conf)),
323
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
324
+ gr.update(value=_camera_hint_before_start()),
325
+ st,
326
+ gr.update(value="重新攝影機辨識"),
327
+ )
328
 
329
  st.track_bbox = bbox
330
  face = pick["face"]
331
+ if face.dtype != np.uint8:
332
+ face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
333
+
334
+ result = DeepFace.analyze(
335
+ img_path=face,
336
+ actions=["emotion"],
337
+ enforce_detection=False,
338
+ )
339
+ if isinstance(result, list):
340
+ result = result[0]
341
  emo_dict = result.get("emotion", None)
342
 
343
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
344
  show = st.stable_emo or "unknown"
345
+ return (
346
+ gr.update(visible=True),
347
+ gr.update(value=_result_card_html(show, st.final_conf)),
348
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
349
+ gr.update(value=_camera_hint_before_start()),
350
+ st,
351
+ gr.update(value="重新攝影機辨識"),
352
+ )
353
 
354
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
355
  top_conf = float(emo_dict[top_key])
356
 
357
  if top_conf < CONF_TH:
358
  show = st.stable_emo or "unknown"
359
+ return (
360
+ gr.update(visible=True),
361
+ gr.update(value=_result_card_html(show, st.final_conf)),
362
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
363
+ gr.update(value=_camera_hint_before_start()),
364
+ st,
365
+ gr.update(value="重新攝影機辨識"),
366
+ )
367
 
368
  keys = list(emo_dict.keys())
369
  vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
370
 
371
  if st.ema_scores is None or st.emo_keys != keys:
372
+ st.ema_scores = vec
373
+ st.emo_keys = keys
374
  else:
375
  st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
376
 
 
378
  stable_key = st.emo_keys[stable_idx]
379
  st.stable_emo = stable_key
380
 
381
+ # 延遲確認
382
  if st.candidate_emo != stable_key:
383
+ st.candidate_emo = stable_key
384
+ st.candidate_since = now
385
  else:
386
+ if st.candidate_since is None:
387
+ st.candidate_since = now
388
 
389
+ confirmed = (
390
+ st.candidate_since is not None
391
+ and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
392
+ )
393
 
394
  if confirmed and st.final_emo is None:
395
+ st.final_emo = stable_key
396
+ st.final_conf = top_conf
397
+
398
+ # ### [整合需求 新增] ###
399
+ # 在辨識完成的瞬間,順便存入整數分數
400
  st.final_score = EMO_TO_SCORE.get(stable_key, 3)
401
+ # ######################
402
+
403
+ st.running = False
404
+ st.finished = True
405
+
406
+ return (
407
+ gr.update(visible=False),
408
+ gr.update(value=_result_card_html(stable_key, top_conf)),
409
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
410
+ gr.update(value=_camera_hint_done()),
411
+ st,
412
+ gr.update(value="重新攝影機辨識"),
413
+ )
414
 
415
+ return (
416
+ gr.update(visible=True),
417
+ gr.update(value=_result_card_html(stable_key, top_conf)),
418
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
419
+ gr.update(value=_camera_hint_before_start()),
420
+ st,
421
+ gr.update(value="重新攝影機辨識"),
422
+ )
423
 
424
 
425
  # =========================
426
+ # Mode / Buttons
427
  # =========================
 
428
  def on_restart(st: AppState):
429
  st = _reset_state_for_camera(st)
430
+ return (
431
+ gr.update(visible=True), # cam
432
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")), # result
433
+ gr.update(value=_score_radio_value(3)), # mood
434
+ gr.update(value=_camera_hint_before_start()), # hint
435
+ st,
436
+ gr.update(value="重新攝影機辨識"), # btn label
437
+ )
438
+
439
 
440
  def on_stop(st: AppState):
441
+ st.running = False
442
+ st.finished = True
443
+ # ✅ 停止後也不要再顯示 Click/Record 提示,改成「已停止/可重新」
444
  if st.final_emo is None:
445
+ return (
446
+ gr.update(visible=False),
447
+ gr.update(value=_hint_html("已停止辨識。")),
448
+ gr.update(value=_score_radio_value(3)),
449
+ gr.update(value=_camera_hint_stopped()),
450
+ st,
451
+ gr.update(value="重新攝影機辨識"),
452
+ )
453
 
454
+ return (
455
+ gr.update(visible=False),
456
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
457
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
458
+ gr.update(value=_camera_hint_done()),
459
+ st,
460
+ gr.update(value="重新攝影機辨識"),
461
+ )
462
+
463
+
464
+ # =========================
465
+ # Gradio UI
466
+ # =========================
467
+ # ### [整合需求] 下方的 UI Block 可以保留,
468
+ # 之後整合進 app.py 時,app.py 會只 import 上面的函式,不會執行這裡。
469
+ # 這樣隊友還是可以單獨執行這個檔案來測試。
470
+ css = """
471
+ #app_container { max-width: 960px; margin: 0 auto; }
472
+ .gradio-container { background: #f1f5f9 !important; }
473
+ """
474
+
475
+ # 為了方便 app.py 整合,我們把 UI 宣告包成一個函式 render_ui (或是 create_demo)
476
+ # 但為了不改動隊友原本的結構,我們保留下面的寫法,但加上 if __name__ == "__main__"
477
+ # 這樣 app.py import 時不會自動跑出介面
478
 
479
  if __name__ == "__main__":
480
+ with gr.Blocks(title="Emotion Detector") as demo:
481
  st = gr.State(AppState())
482
 
483
  gr.Markdown("## 情緒辨識")
484
 
 
 
485
  hint = gr.HTML(_camera_hint_before_start())
486
 
487
  cam = gr.Image(
 
496
  btn_restart = gr.Button("重新攝影機辨識", variant="primary")
497
  btn_stop = gr.Button("停止", variant="secondary")
498
 
499
+ # ✅ 你要的排序:結果在上、分數在下
500
  gr.Markdown("### 辨識結果")
501
  result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
502