YuAnTsai commited on
Commit
5038320
·
verified ·
1 Parent(s): a88631b

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +362 -108
emotion.py CHANGED
@@ -1,5 +1,5 @@
1
  import time
2
- import cv2 # 新增:用於繪圖
3
  from dataclasses import dataclass, field
4
  from typing import Optional, Dict, Tuple, List
5
  import gradio as gr
@@ -16,12 +16,12 @@ EMA_ALPHA = 0.45
16
  CONF_TH = 40.0
17
  SWITCH_CONFIRM_SEC = 2.5
18
  IOU_TH = 0.25
19
- ZOOM_FACTOR = 1.3 # 微調縮放倍率
20
- DETECTOR_BACKEND = "mtcnn"
21
  ALIGN_FACE = True
22
 
23
  # =========================
24
- # 情緒中文 + 分數映射
25
  # =========================
26
  EMO_ZH = {
27
  "angry": "生氣",
@@ -34,6 +34,7 @@ EMO_ZH = {
34
  "unknown": "未知",
35
  }
36
 
 
37
  EMO_TO_SCORE = {
38
  "angry": 1,
39
  "disgust": 1,
@@ -45,6 +46,9 @@ EMO_TO_SCORE = {
45
  "unknown": 3,
46
  }
47
 
 
 
 
48
  SCORE_LABELS = {
49
  1: "1(心情差)",
50
  2: "2(不太好)",
@@ -52,6 +56,7 @@ SCORE_LABELS = {
52
  4: "4(不錯)",
53
  5: "5(超棒)",
54
  }
 
55
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
56
 
57
  # =========================
@@ -59,24 +64,29 @@ SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
59
  # =========================
60
  @dataclass
61
  class AppState:
62
- mode: str = "攝影機辨識"
63
  running: bool = True
64
  finished: bool = False
65
  last_analyze_t: float = 0.0
 
66
  track_bbox: Optional[Tuple[int, int, int, int]] = None
 
67
  ema_scores: Optional[np.ndarray] = None
68
  emo_keys: List[str] = field(default_factory=list)
 
69
  stable_emo: str = "unknown"
70
  candidate_emo: Optional[str] = None
71
  candidate_since: Optional[float] = None
 
72
  final_emo: Optional[str] = None
73
  final_conf: Optional[float] = None
74
  final_score: int = 3
75
 
76
  # =========================
77
- # 輔助函式
78
  # =========================
79
  def _hint_html(msg: str) -> str:
 
80
  return f"""
81
  <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">
82
  {msg}
@@ -84,35 +94,63 @@ def _hint_html(msg: str) -> str:
84
  """
85
 
86
  def _camera_hint_before_start() -> str:
87
- return _hint_html("請先按「Click to access webcam」開啟攝影機;接著,按「錄製 (Record)」開始。")
 
 
 
88
 
89
  def _camera_hint_done() -> str:
90
- return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行。")
91
 
92
  def _camera_hint_stopped() -> str:
93
- return _hint_html("已停止辨識。")
94
 
95
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
96
  zh = EMO_ZH.get(emo_key, emo_key)
97
  score = EMO_TO_SCORE.get(emo_key, 3)
98
  conf_txt = "" if conf is None else f"{conf:.1f}%"
99
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
 
100
  return f"""
101
- <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#000000;">
102
- <div style="font-size:14px;margin-bottom:8px;">辨識結果</div>
103
- <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;">{zh}</div>
104
- <div style="font-size:16px;margin-bottom:6px;">心情分數:{score}({score_desc})</div>
105
- <div style="font-size:14px;">信心值:{conf_txt}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  </div>
107
  """
108
 
109
  def _score_radio_value(score: int) -> str:
110
- return SCORE_LABELS[min(5, max(1, int(score)))]
 
 
111
 
112
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
113
- if frame is None: return None
 
114
  h, w = frame.shape[:2]
115
- if w <= target_w: return frame
 
116
  scale = target_w / w
117
  new_h = max(1, int(h * scale))
118
  img = Image.fromarray(frame)
@@ -120,168 +158,384 @@ def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
120
  return np.array(img)
121
 
122
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
123
- if frame is None or zoom <= 1.0: return frame
 
 
 
 
 
 
124
  h, w = frame.shape[:2]
125
- new_w, new_h = int(w / zoom), int(h / zoom)
126
- start_x, start_y = (w - new_w) // 2, (h - new_h) // 2
127
- return frame[start_y : start_y + new_h, start_x : start_x + new_w]
 
 
 
 
 
 
 
 
 
128
 
129
  def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
130
  ax, ay, aw, ah = a
131
  bx, by, bw, bh = b
132
- ax2, ay2, bx2, by2 = ax+aw, ay+ah, bx+bw, by+bh
133
- ix1, iy1, ix2, iy2 = max(ax, bx), max(ay, by), min(ax2, bx2), min(ay2, by2)
 
 
134
  iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
135
  inter = iw * ih
136
- if inter <= 0: return 0.0
137
- return inter / (aw * ah + bw * bh - inter)
 
 
138
 
139
- def _pick_face_by_tracking(faces: List[Dict], prev_bbox: Optional[Tuple[int, int, int, int]]):
140
- if not faces: return None, None
 
 
 
 
 
141
  cand = []
142
  for f in faces:
143
- fa = f.get("facial_area", {})
144
- x, y, w, h = fa.get("x",0), fa.get("y",0), fa.get("w",0), fa.get("h",0)
 
 
 
145
  if w > 0 and h > 0 and f.get("face") is not None:
146
  cand.append((f, (x, y, w, h), w * h))
147
- if not cand: return None, None
148
- if prev_bbox:
149
- best, best_i = None, 0.0
 
 
 
 
150
  for f, bb, area in cand:
151
  i = _iou(prev_bbox, bb)
152
- if i > best_i: best_i, best = i, (f, bb)
153
- if best and best_i >= IOU_TH: return best
 
 
 
 
154
  cand.sort(key=lambda x: x[2], reverse=True)
155
  return cand[0][0], cand[0][1]
156
 
157
  def _draw_guide_box(frame: np.ndarray, is_detected: bool):
158
- """ 在影像上繪製導引框(紅框/綠框) """
 
 
 
 
159
  h, w = frame.shape[:2]
160
- # 建立一個佔畫面中間 50% 的框
161
- bw, bh = int(w * 0.5), int(h * 0.6)
162
- x1, y1 = (w - bw) // 2, (h - bh) // 2
163
- x2, y2 = x1 + bw, y1 + bh
 
 
 
164
 
165
- # 顏色 (RGB): 綠色表示偵測中,紅色表示未對準
 
 
166
  color = (0, 255, 0) if is_detected else (255, 0, 0)
167
- label = "Scanning..." if is_detected else "Please align your face"
168
 
169
- # 繪製矩形與文字
170
  cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3)
 
171
  cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, 2)
172
  return frame
173
 
174
  def _reset_state_for_camera(st: AppState) -> AppState:
175
- st.running, st.finished, st.last_analyze_t = True, False, 0.0
176
- st.track_bbox, st.ema_scores, st.emo_keys = None, None, []
177
- st.stable_emo, st.candidate_emo, st.candidate_since = "unknown", None, None
178
- st.final_emo, st.final_conf, st.final_score = None, None, 3
 
 
 
 
 
 
 
 
179
  return st
180
 
181
  # =========================
182
  # Camera streaming
183
  # =========================
184
  def on_stream(frame_rgb: np.ndarray, st: AppState):
185
- if frame_rgb is None:
186
- return (None, gr.update(), gr.update(), gr.update(), st, gr.update())
187
-
188
  if st.finished or (not st.running):
189
- # 結束時顯示結果,隱藏攝像頭
190
- result_html = _result_card_html(st.final_emo, st.final_conf) if st.final_emo else _hint_html("已停止")
191
- mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3)) if st.final_emo else _score_radio_value(3)
192
- return (None, gr.update(value=result_html), gr.update(value=mood_val), gr.update(value=_camera_hint_done()), st, gr.update(value="重新攝影機辨識"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
 
 
 
 
 
 
 
 
 
 
194
  # 1. 數位變焦
195
  frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
196
 
197
- # 2. 為了畫框,進行快速人臉檢查
198
- small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
199
- faces = DeepFace.extract_faces(img_path=small, detector_backend="opencv", enforce_detection=False, align=False)
200
- pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
201
- is_detected = pick is not None and pick.get("face") is not None
 
 
 
 
 
 
202
 
203
- # 3. 繪製導引框
204
  frame_with_box = _draw_guide_box(frame_rgb.copy(), is_detected)
205
- now = time.time()
206
 
207
- # 4. 核心辨識邏輯 (每隔一段時間分析)
208
- if is_detected and (now - st.last_analyze_t >= ANALYZE_EVERY_SEC):
209
- st.last_analyze_t = now
210
- st.track_bbox = bbox
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
 
212
- face = pick["face"]
213
- if face.dtype != np.uint8:
214
- face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
215
-
216
- result = DeepFace.analyze(img_path=face, actions=["emotion"], enforce_detection=False)
217
- if isinstance(result, list): result = result[0]
218
- emo_dict = result.get("emotion", {})
219
 
220
- if emo_dict:
221
- top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
222
- top_conf = float(emo_dict[top_key])
223
-
224
- # EMA 平滑
225
- keys = list(emo_dict.keys())
226
- vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
227
- if st.ema_scores is None or st.emo_keys != keys:
228
- st.ema_scores, st.emo_keys = vec, keys
229
- else:
230
- st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
231
-
232
- stable_key = st.emo_keys[int(np.argmax(st.ema_scores))]
233
- st.stable_emo = stable_key
234
-
235
- # 延遲確認邏輯
236
- if st.candidate_emo != stable_key:
237
- st.candidate_emo, st.candidate_since = stable_key, now
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
 
239
- if st.candidate_since and (now - st.candidate_since >= SWITCH_CONFIRM_SEC):
240
- st.final_emo, st.final_conf, st.running, st.finished = stable_key, top_conf, False, True
241
- return (frame_with_box, 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="重新攝影機辨識"))
242
-
243
- # 持續回傳帶有導引框的影像
244
- show_emo = st.stable_emo or "unknown"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  return (
246
  frame_with_box,
247
- gr.update(value=_result_card_html(show_emo, st.final_conf)),
248
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show_emo, 3))),
249
  gr.update(value=_camera_hint_before_start()),
250
  st,
251
- gr.update()
252
  )
253
 
 
 
 
254
  def on_restart(st: AppState):
255
  st = _reset_state_for_camera(st)
256
- 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="重新攝影機辨識"))
 
 
 
 
 
 
 
257
 
258
  def on_stop(st: AppState):
259
- st.running, st.finished = False, True
260
- 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="重新攝影機辨識"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
  # =========================
263
  # Gradio UI
264
  # =========================
265
  if __name__ == "__main__":
266
- with gr.Blocks(title="Emotion Detector") as demo:
 
 
267
  st = gr.State(AppState())
268
  gr.Markdown("## 情緒辨識")
269
 
270
  hint = gr.HTML(_camera_hint_before_start())
271
- cam = gr.Image(sources=["webcam"], streaming=True, type="numpy", label="攝影機", visible=True)
 
 
 
 
 
 
272
 
273
  with gr.Row():
274
  btn_restart = gr.Button("重新攝影機辨識", variant="primary")
275
  btn_stop = gr.Button("停止", variant="secondary")
276
 
277
  gr.Markdown("### 辨識結果")
278
- result = gr.HTML(_hint_html("等待影像…"))
279
 
280
  gr.Markdown("### 心情分數(系統判定)")
281
- mood = gr.Radio(choices=SCORE_CHOICES, value=_score_radio_value(3), label="心情分數", interactive=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
 
283
- cam.stream(fn=on_stream, inputs=[cam, st], outputs=[cam, result, mood, hint, st, btn_restart], show_progress="minimal")
284
- btn_restart.click(fn=on_restart, inputs=[st], outputs=[cam, result, mood, hint, st, btn_restart])
285
- btn_stop.click(fn=on_stop, inputs=[st], outputs=[cam, result, mood, hint, st, btn_restart])
 
 
 
286
 
287
  demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
 
1
  import time
2
+ import cv2 # 用於繪製導引框
3
  from dataclasses import dataclass, field
4
  from typing import Optional, Dict, Tuple, List
5
  import gradio as gr
 
16
  CONF_TH = 40.0
17
  SWITCH_CONFIRM_SEC = 2.5
18
  IOU_TH = 0.25
19
+ ZOOM_FACTOR = 1.5
20
+ DETECTOR_BACKEND = "mtcnn" # 想快一點可改 "opencv"
21
  ALIGN_FACE = True
22
 
23
  # =========================
24
+ # 情緒中文 + 分數映射(7 -> 5)
25
  # =========================
26
  EMO_ZH = {
27
  "angry": "生氣",
 
34
  "unknown": "未知",
35
  }
36
 
37
+ # 把 7 種情緒塞進 5 分心情分數
38
  EMO_TO_SCORE = {
39
  "angry": 1,
40
  "disgust": 1,
 
46
  "unknown": 3,
47
  }
48
 
49
+ def get_emotion_score(emo: str) -> int:
50
+ return EMO_TO_SCORE.get(emo, 3)
51
+
52
  SCORE_LABELS = {
53
  1: "1(心情差)",
54
  2: "2(不太好)",
 
56
  4: "4(不錯)",
57
  5: "5(超棒)",
58
  }
59
+
60
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
61
 
62
  # =========================
 
64
  # =========================
65
  @dataclass
66
  class AppState:
67
+ mode: str = "攝影機辨識"
68
  running: bool = True
69
  finished: bool = False
70
  last_analyze_t: float = 0.0
71
+ # tracking
72
  track_bbox: Optional[Tuple[int, int, int, int]] = None
73
+ # smoothing
74
  ema_scores: Optional[np.ndarray] = None
75
  emo_keys: List[str] = field(default_factory=list)
76
+ # stable/candidate
77
  stable_emo: str = "unknown"
78
  candidate_emo: Optional[str] = None
79
  candidate_since: Optional[float] = None
80
+ # final
81
  final_emo: Optional[str] = None
82
  final_conf: Optional[float] = None
83
  final_score: int = 3
84
 
85
  # =========================
86
+ # UI HTML (強制黑色文字)
87
  # =========================
88
  def _hint_html(msg: str) -> str:
89
+ # 加入 color:#000000 強制黑色
90
  return f"""
91
  <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">
92
  {msg}
 
94
  """
95
 
96
  def _camera_hint_before_start() -> str:
97
+ return _hint_html(
98
+ "請先按「Click to access webcam / 允許」開啟攝影機;"
99
+ "接著,請按「錄製(Record)」開始。"
100
+ )
101
 
102
  def _camera_hint_done() -> str:
103
+ return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
104
 
105
  def _camera_hint_stopped() -> str:
106
+ return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
107
 
108
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
109
  zh = EMO_ZH.get(emo_key, emo_key)
110
  score = EMO_TO_SCORE.get(emo_key, 3)
111
  conf_txt = "" if conf is None else f"{conf:.1f}%"
112
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
113
+ # ✅ 強制設定 color: #000000
114
  return f"""
115
+ <div style="
116
+ border-radius:16px;
117
+ padding:20px;
118
+ border:1px solid #E6E6E6;
119
+ background:#FFFFFF;
120
+ color:#000000;
121
+ ">
122
+ <div style="font-size:14px;color:#000000;margin-bottom:8px;">
123
+ 辨識結果
124
+ </div>
125
+ <div style="
126
+ font-size:42px;
127
+ font-weight:800;
128
+ line-height:1.1;
129
+ margin-bottom:14px;
130
+ color:#000000;
131
+ ">
132
+ {zh}
133
+ </div>
134
+ <div style="font-size:16px;color:#000000;margin-bottom:6px;">
135
+ 心情分數:{score}({score_desc})
136
+ </div>
137
+ <div style="font-size:14px;color:#000000;">
138
+ 信心值:{conf_txt}
139
+ </div>
140
  </div>
141
  """
142
 
143
  def _score_radio_value(score: int) -> str:
144
+ score = int(score)
145
+ score = min(5, max(1, score))
146
+ return SCORE_LABELS[score]
147
 
148
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
149
+ if frame is None:
150
+ return None
151
  h, w = frame.shape[:2]
152
+ if w <= target_w:
153
+ return frame
154
  scale = target_w / w
155
  new_h = max(1, int(h * scale))
156
  img = Image.fromarray(frame)
 
158
  return np.array(img)
159
 
160
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
161
+ """
162
+ 裁切畫面中心以模擬數位變焦。
163
+ zoom: 放大倍率 (例如 1.5)
164
+ """
165
+ if frame is None or zoom <= 1.0:
166
+ return frame
167
+
168
  h, w = frame.shape[:2]
169
+
170
+ # 計算新的寬高
171
+ new_w = int(w / zoom)
172
+ new_h = int(h / zoom)
173
+
174
+ # 計算起始點 (讓裁切框置中)
175
+ start_x = (w - new_w) // 2
176
+ start_y = (h - new_h) // 2
177
+
178
+ # 進行裁切
179
+ cropped = frame[start_y : start_y + new_h, start_x : start_x + new_w]
180
+ return cropped
181
 
182
  def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
183
  ax, ay, aw, ah = a
184
  bx, by, bw, bh = b
185
+ ax2, ay2 = ax + aw, ay + ah
186
+ bx2, by2 = bx + bw, by + bh
187
+ ix1, iy1 = max(ax, bx), max(ay, by)
188
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
189
  iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
190
  inter = iw * ih
191
+ if inter <= 0:
192
+ return 0.0
193
+ union = aw * ah + bw * bh - inter
194
+ return inter / max(1e-6, union)
195
 
196
+ def _pick_face_by_tracking(
197
+ faces: List[Dict],
198
+ prev_bbox: Optional[Tuple[int, int, int, int]]) -> Tuple[Optional[Dict], Optional[Tuple[int, int, int, int]]]:
199
+
200
+ if not faces:
201
+ return None, None
202
+
203
  cand = []
204
  for f in faces:
205
+ fa = f.get("facial_area", {}) or {}
206
+ x = int(fa.get("x", 0) or 0)
207
+ y = int(fa.get("y", 0) or 0)
208
+ w = int(fa.get("w", 0) or 0)
209
+ h = int(fa.get("h", 0) or 0)
210
  if w > 0 and h > 0 and f.get("face") is not None:
211
  cand.append((f, (x, y, w, h), w * h))
212
+
213
+ if not cand:
214
+ return None, None
215
+
216
+ if prev_bbox is not None:
217
+ best = None
218
+ best_i = 0.0
219
  for f, bb, area in cand:
220
  i = _iou(prev_bbox, bb)
221
+ if i > best_i:
222
+ best_i = i
223
+ best = (f, bb)
224
+ if best is not None and best_i >= IOU_TH:
225
+ return best[0], best[1]
226
+
227
  cand.sort(key=lambda x: x[2], reverse=True)
228
  return cand[0][0], cand[0][1]
229
 
230
  def _draw_guide_box(frame: np.ndarray, is_detected: bool):
231
+ """
232
+ 在畫面上畫一個提示框:
233
+ 未偵測到臉 -> 紅色框
234
+ 偵測到臉部 -> 綠色框
235
+ """
236
  h, w = frame.shape[:2]
237
+ # 定義一個框的大小
238
+ box_w = int(w * 0.5)
239
+ box_h = int(h * 0.6)
240
+ x1 = (w - box_w) // 2
241
+ y1 = (h - box_h) // 2
242
+ x2 = x1 + box_w
243
+ y2 = y1 + box_h
244
 
245
+ # 顏色 BGR (OpenCV 使用 BGR)
246
+ # 但因為 Gradio 傳入的是 RGB,所以我們直接當成 RGB 畫
247
+ # 綠色 (0, 255, 0), 紅色 (255, 0, 0)
248
  color = (0, 255, 0) if is_detected else (255, 0, 0)
249
+ label = "Scanning..." if is_detected else "Align Face Here"
250
 
251
+ # 畫矩形 (粗度 3)
252
  cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3)
253
+ # 寫字
254
  cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, 2)
255
  return frame
256
 
257
  def _reset_state_for_camera(st: AppState) -> AppState:
258
+ st.running = True
259
+ st.finished = False
260
+ st.last_analyze_t = 0.0
261
+ st.track_bbox = None
262
+ st.ema_scores = None
263
+ st.emo_keys = []
264
+ st.stable_emo = "unknown"
265
+ st.candidate_emo = None
266
+ st.candidate_since = None
267
+ st.final_emo = None
268
+ st.final_conf = None
269
+ st.final_score = 3
270
  return st
271
 
272
  # =========================
273
  # Camera streaming
274
  # =========================
275
  def on_stream(frame_rgb: np.ndarray, st: AppState):
 
 
 
276
  if st.finished or (not st.running):
277
+ cam_visible = False
278
+ if st.final_emo:
279
+ result_html = _result_card_html(st.final_emo, st.final_conf)
280
+ mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
281
+ hint_html = _camera_hint_done()
282
+ else:
283
+ result_html = _hint_html("尚未完成辨識。")
284
+ mood_val = _score_radio_value(3)
285
+ hint_html = _camera_hint_stopped()
286
+
287
+ return (
288
+ None,
289
+ gr.update(value=result_html),
290
+ gr.update(value=mood_val),
291
+ gr.update(value=hint_html),
292
+ st,
293
+ gr.update(value="重新攝影機辨識"),
294
+ )
295
 
296
+ if frame_rgb is None:
297
+ return (
298
+ None,
299
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
300
+ gr.update(value=_score_radio_value(3)),
301
+ gr.update(value=_camera_hint_before_start()),
302
+ st,
303
+ gr.update(value="重新攝影機辨識"),
304
+ )
305
+
306
  # 1. 數位變焦
307
  frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
308
 
309
+ # 2. 預檢測 (用於判斷框的顏色)
310
+ now = time.time()
311
+ small_check = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
312
+ faces_check = DeepFace.extract_faces(
313
+ img_path=small_check,
314
+ detector_backend="opencv", # 快速檢查用
315
+ enforce_detection=False,
316
+ align=False,
317
+ )
318
+ pick_check, bbox_check = _pick_face_by_tracking(faces_check, st.track_bbox)
319
+ is_detected = pick_check is not None and pick_check.get("face") is not None
320
 
321
+ # 3. 繪製框框
322
  frame_with_box = _draw_guide_box(frame_rgb.copy(), is_detected)
 
323
 
324
+ # 4. 真正分析邏輯 (時間到了才跑 DeepFace.analyze)
325
+ if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
326
+ show = st.stable_emo or "unknown"
327
+ return (
328
+ frame_with_box,
329
+ gr.update(value=_result_card_html(show, st.final_conf)),
330
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
331
+ gr.update(value=_camera_hint_before_start()),
332
+ st,
333
+ gr.update(value="重新攝影機辨識"),
334
+ )
335
+
336
+ st.last_analyze_t = now
337
+ small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
338
+
339
+ # 使用正式後端偵測
340
+ faces = DeepFace.extract_faces(
341
+ img_path=small,
342
+ detector_backend=DETECTOR_BACKEND,
343
+ enforce_detection=False,
344
+ align=ALIGN_FACE,
345
+ )
346
+
347
+ pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
348
+
349
+ if pick is None or bbox is None or pick.get("face") is None:
350
+ show = st.stable_emo or "unknown"
351
+ return (
352
+ frame_with_box,
353
+ gr.update(value=_result_card_html(show, st.final_conf)),
354
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
355
+ gr.update(value=_camera_hint_before_start()),
356
+ st,
357
+ gr.update(value="重新攝影機辨識"),
358
+ )
359
 
360
+ st.track_bbox = bbox
361
+ face = pick["face"]
362
+
363
+ if face.dtype != np.uint8:
364
+ face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
 
 
365
 
366
+ result = DeepFace.analyze(
367
+ img_path=face,
368
+ actions=["emotion"],
369
+ enforce_detection=False,
370
+ )
371
+
372
+ if isinstance(result, list):
373
+ result = result[0]
374
+
375
+ emo_dict = result.get("emotion", None)
376
+ if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
377
+ show = st.stable_emo or "unknown"
378
+ return (
379
+ frame_with_box,
380
+ gr.update(value=_result_card_html(show, st.final_conf)),
381
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
382
+ gr.update(value=_camera_hint_before_start()),
383
+ st,
384
+ gr.update(value="重新攝影機辨識"),
385
+ )
386
+
387
+ top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
388
+ top_conf = float(emo_dict[top_key])
389
+
390
+ if top_conf < CONF_TH:
391
+ show = st.stable_emo or "unknown"
392
+ return (
393
+ frame_with_box,
394
+ gr.update(value=_result_card_html(show, st.final_conf)),
395
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
396
+ gr.update(value=_camera_hint_before_start()),
397
+ st,
398
+ gr.update(value="重新攝影機辨識"),
399
+ )
400
+
401
+ keys = list(emo_dict.keys())
402
+ vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
403
+
404
+ if st.ema_scores is None or st.emo_keys != keys:
405
+ st.ema_scores = vec
406
+ st.emo_keys = keys
407
+ else:
408
+ st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
409
+
410
+ stable_idx = int(np.argmax(st.ema_scores))
411
+ stable_key = st.emo_keys[stable_idx]
412
+ st.stable_emo = stable_key
413
+
414
+ # 延遲確認
415
+ if st.candidate_emo != stable_key:
416
+ st.candidate_emo = stable_key
417
+ st.candidate_since = now
418
+ else:
419
+ if st.candidate_since is None:
420
+ st.candidate_since = now
421
 
422
+ confirmed = (
423
+ st.candidate_since is not None
424
+ and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
425
+ )
426
+
427
+ if confirmed and st.final_emo is None:
428
+ st.final_emo = stable_key
429
+ st.final_conf = top_conf
430
+ st.final_score = EMO_TO_SCORE.get(stable_key, 3)
431
+ st.running = False
432
+ st.finished = True
433
+ return (
434
+ None,
435
+ gr.update(value=_result_card_html(stable_key, top_conf)),
436
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
437
+ gr.update(value=_camera_hint_done()),
438
+ st,
439
+ gr.update(value="重新攝影機辨識"),
440
+ )
441
+
442
  return (
443
  frame_with_box,
444
+ gr.update(value=_result_card_html(stable_key, top_conf)),
445
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
446
  gr.update(value=_camera_hint_before_start()),
447
  st,
448
+ gr.update(value="重新攝影機辨識"),
449
  )
450
 
451
+ # =========================
452
+ # Mode / Buttons
453
+ # =========================
454
  def on_restart(st: AppState):
455
  st = _reset_state_for_camera(st)
456
+ return (
457
+ gr.update(visible=True),
458
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
459
+ gr.update(value=_score_radio_value(3)),
460
+ gr.update(value=_camera_hint_before_start()),
461
+ st,
462
+ gr.update(value="重新攝影機辨識"),
463
+ )
464
 
465
  def on_stop(st: AppState):
466
+ st.running = False
467
+ st.finished = True
468
+ if st.final_emo is None:
469
+ return (
470
+ gr.update(visible=False),
471
+ gr.update(value=_hint_html("已停止辨識。")),
472
+ gr.update(value=_score_radio_value(3)),
473
+ gr.update(value=_camera_hint_stopped()),
474
+ st,
475
+ gr.update(value="重新攝影機辨識"),
476
+ )
477
+ return (
478
+ gr.update(visible=False),
479
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
480
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
481
+ gr.update(value=_camera_hint_done()),
482
+ st,
483
+ gr.update(value="重新攝影機辨識"),
484
+ )
485
 
486
  # =========================
487
  # Gradio UI
488
  # =========================
489
  if __name__ == "__main__":
490
+ # 若有 CSS 可以寫在這裡,目前維持原本調用的 css 變數
491
+ css = ""
492
+ with gr.Blocks(title="Emotion Detector", css=css) as demo:
493
  st = gr.State(AppState())
494
  gr.Markdown("## 情緒辨識")
495
 
496
  hint = gr.HTML(_camera_hint_before_start())
497
+ cam = gr.Image(
498
+ sources=["webcam"],
499
+ streaming=True,
500
+ type="numpy",
501
+ label="攝影機",
502
+ visible=True,
503
+ )
504
 
505
  with gr.Row():
506
  btn_restart = gr.Button("重新攝影機辨識", variant="primary")
507
  btn_stop = gr.Button("停止", variant="secondary")
508
 
509
  gr.Markdown("### 辨識結果")
510
+ result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
511
 
512
  gr.Markdown("### 心情分數(系統判定)")
513
+ mood = gr.Radio(
514
+ choices=SCORE_CHOICES,
515
+ value=_score_radio_value(3),
516
+ label="心情分數",
517
+ interactive=False,
518
+ )
519
+
520
+ cam.stream(
521
+ fn=on_stream,
522
+ inputs=[cam, st],
523
+ outputs=[cam, result, mood, hint, st, btn_restart],
524
+ show_progress="minimal",
525
+ )
526
+
527
+ btn_restart.click(
528
+ fn=on_restart,
529
+ inputs=[st],
530
+ outputs=[cam, result, mood, hint, st, btn_restart],
531
+ show_progress="minimal",
532
+ )
533
 
534
+ btn_stop.click(
535
+ fn=on_stop,
536
+ inputs=[st],
537
+ outputs=[cam, result, mood, hint, st, btn_restart],
538
+ show_progress="minimal",
539
+ )
540
 
541
  demo.launch(server_name="0.0.0.0", server_port=7860, share=False)