YuAnTsai commited on
Commit
45aa716
·
verified ·
1 Parent(s): 9709828

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +611 -152
emotion.py CHANGED
@@ -1,49 +1,150 @@
1
  import time
2
- import threading
3
  from dataclasses import dataclass, field
4
  from typing import Optional, Dict, Tuple, List
5
 
6
  import gradio as gr
7
  import numpy as np
8
  from PIL import Image, ImageDraw
 
9
  from deepface import DeepFace
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  # =========================
12
- # 1. 核心參數優化
13
  # =========================
14
- ANALYZE_EVERY_SEC = 1.0 # 辨識頻率:1秒1次 (降低 CPU 負荷)
15
- DOWNSAMPLE_W = 320 # 解析度降至 320px (大幅提升速度)
16
- EMA_ALPHA = 0.40 # 平滑係數
17
- CONF_TH = 35.0 # 信心值門檻
18
- SWITCH_CONFIRM_SEC = 2.0 # 情緒穩定多久才算最終結果
19
- ZOOM_FACTOR = 1.25 # 稍微放大臉部區域
20
- DETECTOR_BACKEND = "opencv" # 最速辨識器
21
- ALIGN_FACE = False # 關閉對齊以節省資源
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
- # 全域狀態:防止 DeepFace 重疊執行導致阻塞
24
- _is_processing = False
25
 
26
  # =========================
27
- # 2. 狀態管理
28
  # =========================
29
  @dataclass
30
  class AppState:
31
  running: bool = True
32
  finished: bool = False
33
  last_analyze_t: float = 0.0
 
34
  track_bbox: Optional[Tuple[int, int, int, int]] = None
 
35
  ema_scores: Optional[np.ndarray] = None
36
  emo_keys: List[str] = field(default_factory=list)
 
37
  stable_emo: str = "unknown"
38
  candidate_emo: Optional[str] = None
39
  candidate_since: Optional[float] = None
 
40
  final_emo: Optional[str] = None
41
  final_conf: Optional[float] = None
 
 
 
42
  last_face_t: float = 0.0
43
  face_in_box: bool = False
44
 
 
45
  # =========================
46
- # 3. UI 輔助函數 (修正 AttributeError)
47
  # =========================
48
  def _hint_html(msg: str) -> str:
49
  return f"""
@@ -52,178 +153,536 @@ def _hint_html(msg: str) -> str:
52
  </div>
53
  """
54
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
56
- EMO_ZH = {"angry": "生氣", "disgust": "厭惡", "fear": "害怕", "happy": "開心", "sad": "難過", "surprise": "驚訝", "neutral": "平靜", "unknown": "未知"}
57
- zh = EMO_ZH.get(emo_key, "分析中...")
58
- score_map = {"angry": 1, "disgust": 1, "fear": 1, "sad": 2, "neutral": 3, "surprise": 4, "happy": 5, "unknown": 3}
59
- score = score_map.get(emo_key, 3)
60
- conf_txt = f"{conf:.1f}%" if conf else "--"
61
  return f"""
62
  <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#000000;">
63
- <div style="font-size:14px;color:#666;margin-bottom:8px;">即時辨識結果</div>
64
- <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;">{zh}</div>
65
- <div style="font-size:16px;margin-bottom:6px;">心情分數:{score}</div>
66
- <div style="font-size:14px;color:#999;">信心值:{conf_txt}</div>
67
  </div>
68
  """
69
 
70
- def _score_to_label(score_int: int) -> str:
71
- labels = {1: "1(心情差)", 2: "2(不太好)", 3: "3(普通)", 4: "4(不錯)", 5: "5(超棒)"}
72
- return labels.get(score_int, "3(普通)")
73
 
74
  # =========================
75
- # 4. 影像處理工具
76
  # =========================
77
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
78
- if frame is None: return None
 
79
  h, w = frame.shape[:2]
80
- if w <= target_w: return frame
 
81
  scale = target_w / w
82
- img = Image.fromarray(frame).resize((target_w, int(h * scale)), Image.NEAREST)
 
 
83
  return np.array(img)
84
 
85
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
86
- if frame is None or zoom <= 1.0: return frame
 
87
  h, w = frame.shape[:2]
88
- nw, nh = int(w / zoom), int(h / zoom)
89
- sx, sy = (w - nw) // 2, (h - nh) // 2
90
- return frame[sy:sy + nh, sx:sx + nw]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  # =========================
93
- # 5. 核心串流邏輯 (防止阻塞)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  # =========================
95
  def on_stream(frame_rgb: np.ndarray, st: AppState):
96
- global _is_processing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  now = time.time()
98
 
99
- # 如果已經完成或沒影像,直接結束
100
- if st.finished or not st.running or frame_rgb is None:
101
- return (gr.update(), gr.update(), gr.update(), gr.update(), st)
 
102
 
103
- # A. 基礎影像處理 (輕量級,每影格都做)
104
- processed_frame = _zoom_center(frame_rgb, ZOOM_FACTOR)
 
 
 
 
 
 
105
 
106
- # B. 判斷是否要進行 DeepFace 分析 (防止堆積)
107
- if _is_processing or (now - st.last_analyze_t < ANALYZE_EVERY_SEC):
108
- # 忙碌中或時間未到,只回傳影像,確保 UI 不會卡死
109
- return (processed_frame, gr.update(), gr.update(), gr.update(), st)
 
 
 
 
 
 
 
110
 
111
- # C. 開始分析 (加鎖)
112
- _is_processing = True
113
  try:
114
- st.last_analyze_t = now
115
- small_img = _downsample_rgb(processed_frame, DOWNSAMPLE_W)
116
-
117
- # 使用 DeepFace 分析 (enforce_detection=False 避免沒臉時噴 Error)
118
- results = DeepFace.analyze(
119
- img_path=small_img,
120
- actions=['emotion'],
121
  detector_backend=DETECTOR_BACKEND,
122
  enforce_detection=False,
123
- align=ALIGN_FACE
124
- )
125
-
126
- if results and len(results) > 0:
127
- res = results[0]
128
- # 只有在真的偵測到臉的情況下才更新 (OpenCV 信心值較低)
129
- if res.get('face_confidence', 0) > 0.3:
130
- st.last_face_t = now
131
- emo_dict = res['emotion']
132
- top_key = max(emo_dict, key=emo_dict.get)
133
-
134
- # EMA 平滑
135
- keys = list(emo_dict.keys())
136
- vec = np.array([float(emo_dict[k]) for k in keys])
137
- if st.ema_scores is None:
138
- st.ema_scores, st.emo_keys = vec, keys
139
- else:
140
- st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
141
-
142
- stable_key = st.emo_keys[np.argmax(st.ema_scores)]
143
- st.stable_emo = stable_key
144
-
145
- # 確認邏輯
146
- if st.candidate_emo != stable_key:
147
- st.candidate_emo, st.candidate_since = stable_key, now
148
- elif st.candidate_since and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC:
149
- st.final_emo, st.final_conf = stable_key, float(emo_dict[stable_key])
150
- st.finished = True
151
-
152
- score_val = {"angry": 1, "disgust": 1, "fear": 1, "sad": 2, "neutral": 3, "surprise": 4, "happy": 5, "unknown": 3}.get(stable_key, 3)
153
-
154
- return (
155
- processed_frame,
156
- _result_card_html(stable_key, float(emo_dict[top_key])),
157
- _score_to_label(score_val),
158
- _hint_html("正在辨識情緒,請保持表情穩定..."),
159
- st
160
- )
161
-
162
- except Exception as e:
163
- print(f"DeepFace 分析異常: {e}")
164
- finally:
165
- _is_processing = False
166
-
167
- return (processed_frame, gr.update(), gr.update(), gr.update(), st)
168
-
169
- # =========================
170
- # 6. Gradio 介面佈局
171
- # =========================
172
- with gr.Blocks(title="Emotion AI Detector", css=".gradio-container {background-color: #f9f9f9}") as demo:
173
- st_state = gr.State(AppState())
174
-
175
- gr.Markdown("# 🎭 AI 情緒即時偵測系統")
176
-
177
- with gr.Row():
178
- with gr.Column(scale=2):
179
- # 串流組件
180
- input_cam = gr.Image(sources=["webcam"], streaming=True, type="numpy", label="即時視訊")
181
-
182
- with gr.Row():
183
- btn_reset = gr.Button("🔄 重新辨識", variant="secondary")
184
- btn_stop = gr.Button("⏹ 停止", variant="stop")
185
-
186
- with gr.Column(scale=1):
187
- # 結果顯示組件
188
- hint_box = gr.HTML(_hint_html("請開啟攝影機,並按「Record」開始辨識。"))
189
- result_box = gr.HTML(_result_card_html("unknown", None))
190
- mood_radio = gr.Radio(
191
- choices=["1(心情差)", "2(不太好)", "3(普通)", "4(不錯)", "5(超棒)"],
192
- label="系統判定心情等級",
193
- interactive=False,
194
- value="3(普通)"
195
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
- # --- 事件綁定 ---
198
-
199
- # 串流主邏輯: queue=False 與 show_progress="hidden" 是流暢關鍵
200
- input_cam.stream(
201
- fn=on_stream,
202
- inputs=[input_cam, st_state],
203
- outputs=[input_cam, result_box, mood_radio, hint_box, st_state],
204
- show_progress="hidden",
205
- queue=False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  )
207
 
208
- # 重新辨識按鈕
209
- def restart_app():
210
- return (None, _result_card_html("unknown", None), "3(普通)", _hint_html("系統已重置,請重新開始。"), AppState())
211
-
212
- btn_reset.click(
213
- fn=restart_app,
214
- outputs=[input_cam, result_box, mood_radio, hint_box, st_state]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  )
216
 
217
- # 停止按鈕
218
- def stop_app(curr_st: AppState):
219
- curr_st.running = False
220
- return _hint_html("偵測已停止。"), curr_st
221
-
222
- btn_stop.click(fn=stop_app, inputs=[st_state], outputs=[hint_box, st_state])
223
 
224
  # =========================
225
- # 7. 啟動伺服器
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  # =========================
227
  if __name__ == "__main__":
228
- # 使用 share=False 減少網路延遲造成的卡頓
229
- demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import time
 
2
  from dataclasses import dataclass, field
3
  from typing import Optional, Dict, Tuple, List
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
20
+ DOWNSAMPLE_W = 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
26
+
27
+ DETECTOR_BACKEND = "opencv" # Space 上較順
28
+ ALIGN_FACE = True
29
+
30
+ MIN_FACE_AREA_RATIO = 0.04 # 人臉太小視為沒偵測到
31
+ RECENT_FACE_SEC = 0.25 # ✅ 綠框快取時間:縮短避免空景綠框殘留
32
+
33
+
34
+ # =========================
35
+ # OpenCV 眼睛驗證(降低背景假臉)
36
+ # =========================
37
+ try:
38
+ import cv2
39
+ _HAS_CV2 = True
40
+ _EYE_CASCADE = cv2.CascadeClassifier(
41
+ cv2.data.haarcascades + "haarcascade_eye.xml"
42
+ )
43
+ except Exception:
44
+ _HAS_CV2 = False
45
+ _EYE_CASCADE = None
46
+
47
+
48
+ def _verify_face_has_eyes(face_rgb_uint8: np.ndarray) -> bool:
49
+ """
50
+ 用眼睛 HaarCascade 做簡單驗證:
51
+ - 背景假臉通常偵測不到眼睛
52
+ - 真臉通常能偵測到 >= 1 個眼睛
53
+ """
54
+ if not _HAS_CV2 or _EYE_CASCADE is None:
55
+ # 沒有 cv2 就不做驗證(維持原行為)
56
+ return True
57
+
58
+ if face_rgb_uint8 is None or face_rgb_uint8.size == 0:
59
+ return False
60
+
61
+ # 影像太小就直接判 False
62
+ h, w = face_rgb_uint8.shape[:2]
63
+ if h < 40 or w < 40:
64
+ return False
65
+
66
+ gray = cv2.cvtColor(face_rgb_uint8, cv2.COLOR_RGB2GRAY)
67
+ gray = cv2.equalizeHist(gray)
68
+
69
+ # scaleFactor / minNeighbors 調得偏嚴格 → 更能擋掉背景假臉
70
+ eyes = _EYE_CASCADE.detectMultiScale(
71
+ gray,
72
+ scaleFactor=1.12,
73
+ minNeighbors=6,
74
+ minSize=(12, 12),
75
+ )
76
+ return len(eyes) >= 1
77
+
78
+
79
  # =========================
80
+ # 情緒中文 + 分數映射
81
  # =========================
82
+ EMO_ZH = {
83
+ "angry": "生氣",
84
+ "disgust": "厭惡",
85
+ "fear": "害怕",
86
+ "happy": "開心",
87
+ "sad": "難過",
88
+ "surprise": "驚訝",
89
+ "neutral": "平靜",
90
+ "unknown": "未知",
91
+ }
92
+
93
+ EMO_TO_SCORE = {
94
+ "angry": 1,
95
+ "disgust": 1,
96
+ "fear": 1,
97
+ "sad": 2,
98
+ "neutral": 3,
99
+ "surprise": 4,
100
+ "happy": 5,
101
+ "unknown": 3,
102
+ }
103
+
104
+ SCORE_LABELS = {
105
+ 1: "1(心情差)",
106
+ 2: "2(不太好)",
107
+ 3: "3(普通)",
108
+ 4: "4(不錯)",
109
+ 5: "5(超棒)",
110
+ }
111
+ SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
112
+
113
+ def _score_radio_value(score: int) -> str:
114
+ score = int(score)
115
+ score = min(5, max(1, score))
116
+ return SCORE_LABELS[score]
117
 
 
 
118
 
119
  # =========================
120
+ # 狀態
121
  # =========================
122
  @dataclass
123
  class AppState:
124
  running: bool = True
125
  finished: bool = False
126
  last_analyze_t: float = 0.0
127
+
128
  track_bbox: Optional[Tuple[int, int, int, int]] = None
129
+
130
  ema_scores: Optional[np.ndarray] = None
131
  emo_keys: List[str] = field(default_factory=list)
132
+
133
  stable_emo: str = "unknown"
134
  candidate_emo: Optional[str] = None
135
  candidate_since: Optional[float] = None
136
+
137
  final_emo: Optional[str] = None
138
  final_conf: Optional[float] = None
139
+ final_score: int = 3
140
+
141
+ face_ok: bool = False
142
  last_face_t: float = 0.0
143
  face_in_box: bool = False
144
 
145
+
146
  # =========================
147
+ # UI HTML
148
  # =========================
149
  def _hint_html(msg: str) -> str:
150
  return f"""
 
153
  </div>
154
  """
155
 
156
+ def _camera_hint_before_start() -> str:
157
+ return _hint_html(
158
+ "請先按「Click to access webcam / 允許」開啟攝影機;"
159
+ "接著,請按「錄製(Record)」開始。"
160
+ )
161
+
162
+ def _camera_hint_done() -> str:
163
+ return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
164
+
165
+ def _camera_hint_stopped() -> str:
166
+ return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
167
+
168
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
169
+ zh = EMO_ZH.get(emo_key, emo_key)
170
+ score = EMO_TO_SCORE.get(emo_key, 3)
171
+ conf_txt = "" if conf is None else f"{conf:.1f}%"
172
+ score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
173
+
174
  return f"""
175
  <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#000000;">
176
+ <div style="font-size:14px;color:#000000;margin-bottom:8px;">辨識結果</div>
177
+ <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;color:#000000;">{zh}</div>
178
+ <div style="font-size:16px;color:#000000;margin-bottom:6px;">心情分數:{score}({score_desc})</div>
179
+ <div style="font-size:14px;color:#000000;">信心值:{conf_txt}</div>
180
  </div>
181
  """
182
 
 
 
 
183
 
184
  # =========================
185
+ # 影像工具
186
  # =========================
187
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
188
+ if frame is None:
189
+ return None
190
  h, w = frame.shape[:2]
191
+ if w <= target_w:
192
+ return frame
193
  scale = target_w / w
194
+ new_h = max(1, int(h * scale))
195
+ img = Image.fromarray(frame)
196
+ img = img.resize((target_w, new_h))
197
  return np.array(img)
198
 
199
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
200
+ if frame is None or zoom <= 1.0:
201
+ return frame
202
  h, w = frame.shape[:2]
203
+ new_w = int(w / zoom)
204
+ new_h = int(h / zoom)
205
+ start_x = (w - new_w) // 2
206
+ start_y = (h - new_h) // 2
207
+ return frame[start_y:start_y + new_h, start_x:start_x + new_w]
208
+
209
+
210
+ # =========================
211
+ # 引導框(固定位置)
212
+ # =========================
213
+ GUIDE_W_RATIO = 0.60
214
+ GUIDE_H_RATIO = 0.72
215
+ GUIDE_Y_OFFSET = 0.00
216
+
217
+ def _guide_box_xyxy(frame: np.ndarray) -> Tuple[int, int, int, int]:
218
+ h, w = frame.shape[:2]
219
+ gw = int(w * GUIDE_W_RATIO)
220
+ gh = int(h * GUIDE_H_RATIO)
221
+ cx = w // 2
222
+ cy = int(h * (0.5 + GUIDE_Y_OFFSET))
223
+ x1 = max(0, cx - gw // 2)
224
+ y1 = max(0, cy - gh // 2)
225
+ x2 = min(w - 1, cx + gw // 2)
226
+ y2 = min(h - 1, cy + gh // 2)
227
+ return x1, y1, x2, y2
228
+
229
+ def _bbox_center_in_box(bbox_xywh: Tuple[float, float, float, float], box_xyxy: Tuple[int, int, int, int]) -> bool:
230
+ x, y, w, h = bbox_xywh
231
+ cx = x + w / 2.0
232
+ cy = y + h / 2.0
233
+ x1, y1, x2, y2 = box_xyxy
234
+ return (x1 <= cx <= x2) and (y1 <= cy <= y2)
235
+
236
+ def _draw_guide_overlay(frame_rgb: np.ndarray, green: bool, msg: str) -> np.ndarray:
237
+ img = Image.fromarray(frame_rgb)
238
+ draw = ImageDraw.Draw(img)
239
+
240
+ x1, y1, x2, y2 = _guide_box_xyxy(frame_rgb)
241
+ color = (0, 200, 0) if green else (220, 0, 0)
242
+
243
+ h, w = frame_rgb.shape[:2]
244
+ thickness = max(3, int(min(w, h) * 0.008))
245
+
246
+ for t in range(thickness):
247
+ draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t], outline=color)
248
+
249
+ tx = x1
250
+ ty = max(0, y1 - int(thickness * 7))
251
+ draw.rectangle([tx, ty, min(w - 1, tx + 520), ty + 34], fill=(255, 255, 255))
252
+ draw.text((tx + 8, ty + 7), msg, fill=(0, 0, 0))
253
+
254
+ return np.array(img)
255
+
256
 
257
  # =========================
258
+ # Tracking / IoU
259
+ # =========================
260
+ def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
261
+ ax, ay, aw, ah = a
262
+ bx, by, bw, bh = b
263
+ ax2, ay2 = ax + aw, ay + ah
264
+ bx2, by2 = bx + bw, by + bh
265
+
266
+ ix1, iy1 = max(ax, bx), max(ay, by)
267
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
268
+ iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
269
+ inter = iw * ih
270
+ if inter <= 0:
271
+ return 0.0
272
+ union = aw * ah + bw * bh - inter
273
+ return inter / max(1e-6, union)
274
+
275
+ def _pick_face_by_tracking(
276
+ faces: List[Dict],
277
+ prev_bbox: Optional[Tuple[int, int, int, int]]
278
+ ):
279
+ if not faces:
280
+ return None, None
281
+
282
+ cand = []
283
+ for f in faces:
284
+ fa = f.get("facial_area", {}) or {}
285
+ x = int(fa.get("x", 0) or 0)
286
+ y = int(fa.get("y", 0) or 0)
287
+ w = int(fa.get("w", 0) or 0)
288
+ h = int(fa.get("h", 0) or 0)
289
+ if w > 0 and h > 0 and f.get("face") is not None:
290
+ cand.append((f, (x, y, w, h), w * h))
291
+
292
+ if not cand:
293
+ return None, None
294
+
295
+ if prev_bbox is not None:
296
+ best = None
297
+ best_i = 0.0
298
+ for f, bb, area in cand:
299
+ i = _iou(prev_bbox, bb)
300
+ if i > best_i:
301
+ best_i = i
302
+ best = (f, bb)
303
+ if best is not None and best_i >= IOU_TH:
304
+ return best[0], best[1]
305
+
306
+ cand.sort(key=lambda x: x[2], reverse=True)
307
+ return cand[0][0], cand[0][1]
308
+
309
+
310
+ def _reset_state_for_camera(st: AppState) -> AppState:
311
+ st.running = True
312
+ st.finished = False
313
+ st.last_analyze_t = 0.0
314
+ st.track_bbox = None
315
+ st.ema_scores = None
316
+ st.emo_keys = []
317
+ st.stable_emo = "unknown"
318
+ st.candidate_emo = None
319
+ st.candidate_since = None
320
+ st.final_emo = None
321
+ st.final_conf = None
322
+ st.final_score = 3
323
+ st.face_ok = False
324
+ st.last_face_t = 0.0
325
+ st.face_in_box = False
326
+ return st
327
+
328
+
329
+ def _is_face_big_enough(bbox_xywh: Tuple[float, float, float, float], frame_hw: Tuple[int, int]) -> bool:
330
+ fh, fw = frame_hw
331
+ x, y, w, h = bbox_xywh
332
+ area_ratio = (w * h) / max(1.0, float(fw * fh))
333
+ return area_ratio >= MIN_FACE_AREA_RATIO
334
+
335
+
336
+ # =========================
337
+ # Camera streaming(含紅/綠框 + 眼睛驗證)
338
  # =========================
339
  def on_stream(frame_rgb: np.ndarray, st: AppState):
340
+ if st.finished or (not st.running):
341
+ cam_visible = False
342
+ if st.final_emo:
343
+ return (
344
+ gr.update(visible=cam_visible),
345
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
346
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
347
+ gr.update(value=_camera_hint_done()),
348
+ st,
349
+ gr.update(value="重新攝影機辨識"),
350
+ )
351
+ return (
352
+ gr.update(visible=cam_visible),
353
+ gr.update(value=_hint_html("尚未完成辨識。")),
354
+ gr.update(value=_score_radio_value(3)),
355
+ gr.update(value=_camera_hint_stopped()),
356
+ st,
357
+ gr.update(value="重新攝影機辨識"),
358
+ )
359
+
360
+ if frame_rgb is None:
361
+ return (
362
+ gr.update(visible=True, value=None),
363
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
364
+ gr.update(value=_score_radio_value(3)),
365
+ gr.update(value=_camera_hint_before_start()),
366
+ st,
367
+ gr.update(value="重新攝影機辨識"),
368
+ )
369
+
370
+ frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
371
  now = time.time()
372
 
373
+ # ✅ 快取只留很短時間(避免空景也綠)
374
+ recent_face = st.face_ok and st.face_in_box and (now - st.last_face_t) < RECENT_FACE_SEC
375
+ msg_fast = "已偵測到人臉,開始辨識情緒中…" if recent_face else "未偵測到人臉,請把臉放進框內"
376
+ annotated_fast = _draw_guide_overlay(frame_rgb, green=recent_face, msg=msg_fast)
377
 
378
+ if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
379
+ if recent_face:
380
+ show = st.stable_emo or "unknown"
381
+ result_html = _result_card_html(show, st.final_conf)
382
+ mood_val = _score_radio_value(EMO_TO_SCORE.get(show, 3))
383
+ else:
384
+ result_html = _hint_html("未偵測到人臉,請把臉放進框內(紅框)")
385
+ mood_val = _score_radio_value(3)
386
 
387
+ return (
388
+ gr.update(visible=True, value=annotated_fast),
389
+ gr.update(value=result_html),
390
+ gr.update(value=mood_val),
391
+ gr.update(value=_camera_hint_before_start()),
392
+ st,
393
+ gr.update(value="重新攝影機辨識"),
394
+ )
395
+
396
+ st.last_analyze_t = now
397
+ small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
398
 
 
 
399
  try:
400
+ faces = DeepFace.extract_faces(
401
+ img_path=small,
 
 
 
 
 
402
  detector_backend=DETECTOR_BACKEND,
403
  enforce_detection=False,
404
+ align=ALIGN_FACE,
405
+ )
406
+ except Exception:
407
+ faces = []
408
+
409
+ pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
410
+
411
+ # 沒臉
412
+ if pick is None or bbox is None or pick.get("face") is None:
413
+ st.track_bbox = None
414
+ st.face_ok = False
415
+ st.face_in_box = False
416
+ st.last_face_t = now
417
+ st.stable_emo = "unknown" # ✅ 清掉殘留情緒
418
+
419
+ annotated = _draw_guide_overlay(frame_rgb, green=False, msg="未偵測到人臉,請把臉放進框內")
420
+ return (
421
+ gr.update(visible=True, value=annotated),
422
+ gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內(紅框)")),
423
+ gr.update(value=_score_radio_value(3)),
424
+ gr.update(value=_camera_hint_before_start()),
425
+ st,
426
+ gr.update(value="重新攝影機辨識"),
427
+ )
428
+
429
+ # bbox small -> full
430
+ sh, sw = small.shape[:2]
431
+ fh, fw = frame_rgb.shape[:2]
432
+ sx = fw / max(1, sw)
433
+ sy = fh / max(1, sh)
434
+ x, y, w, h = bbox
435
+ bbox_full = (x * sx, y * sy, w * sx, h * sy)
436
+
437
+ # 太小
438
+ if not _is_face_big_enough(bbox_full, (fh, fw)):
439
+ st.track_bbox = None
440
+ st.face_ok = False
441
+ st.face_in_box = False
442
+ st.last_face_t = now
443
+ st.stable_emo = "unknown"
444
+
445
+ annotated = _draw_guide_overlay(frame_rgb, green=False, msg="人臉太小/太遠,請靠近鏡頭並放進框內")
446
+ return (
447
+ gr.update(visible=True, value=annotated),
448
+ gr.update(value=_hint_html("人臉太小/太遠,請靠近鏡頭並放進框內(紅框)")),
449
+ gr.update(value=_score_radio_value(3)),
450
+ gr.update(value=_camera_hint_before_start()),
451
+ st,
452
+ gr.update(value="重新攝影機辨識"),
453
+ )
454
+
455
+ guide = _guide_box_xyxy(frame_rgb)
456
+ in_box = _bbox_center_in_box(bbox_full, guide)
457
+
458
+ st.track_bbox = bbox
459
+ st.last_face_t = now
460
+ st.face_ok = True
461
+ st.face_in_box = in_box
462
+
463
+ if not in_box:
464
+ st.stable_emo = "unknown"
465
+ annotated = _draw_guide_overlay(frame_rgb, green=False, msg="已偵測到人臉,請把臉移到框內")
466
+ return (
467
+ gr.update(visible=True, value=annotated),
468
+ gr.update(value=_hint_html("已偵測到人臉,但請把臉移到框內(紅框)")),
469
+ gr.update(value=_score_radio_value(3)),
470
+ gr.update(value=_camera_hint_before_start()),
471
+ st,
472
+ gr.update(value="重新攝影機辨識"),
473
+ )
474
+
475
+ # ✅ 眼睛驗證(關鍵)
476
+ face = pick["face"]
477
+ if face.dtype != np.uint8:
478
+ face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
479
+
480
+ if not _verify_face_has_eyes(face):
481
+ # 當作沒臉:避免背景假臉
482
+ st.track_bbox = None
483
+ st.face_ok = False
484
+ st.face_in_box = False
485
+ st.last_face_t = now
486
+ st.stable_emo = "unknown"
487
+
488
+ annotated = _draw_guide_overlay(frame_rgb, green=False, msg="未偵測到真實人臉(找不到眼睛),請對準鏡頭")
489
+ return (
490
+ gr.update(visible=True, value=annotated),
491
+ gr.update(value=_hint_html("未偵測到真實人臉(找不到眼睛),請把臉放進框內(紅框)")),
492
+ gr.update(value=_score_radio_value(3)),
493
+ gr.update(value=_camera_hint_before_start()),
494
+ st,
495
+ gr.update(value="重新攝影機辨識"),
496
+ )
497
+
498
+ # ✅ 到這裡才算「真的有人臉」→ 綠框 + 情緒分析
499
+ annotated = _draw_guide_overlay(frame_rgb, green=True, msg="已偵測到人臉,開始辨識情緒中…")
500
+
501
+ try:
502
+ result = DeepFace.analyze(
503
+ img_path=face,
504
+ actions=["emotion"],
505
+ enforce_detection=False,
506
+ )
507
+ except Exception:
508
+ return (
509
+ gr.update(visible=True, value=annotated),
510
+ gr.update(value=_hint_html("已偵測到人臉,辨識中…(分析暫時失敗,請保持正面/光線亮一點)")),
511
+ gr.update(value=_score_radio_value(3)),
512
+ gr.update(value=_camera_hint_before_start()),
513
+ st,
514
+ gr.update(value="重新攝影機辨識"),
515
+ )
516
+
517
+ if isinstance(result, list):
518
+ result = result[0]
519
+ emo_dict = result.get("emotion", None)
520
+
521
+ if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
522
+ return (
523
+ gr.update(visible=True, value=annotated),
524
+ gr.update(value=_hint_html("已偵測到人臉,辨識中…")),
525
+ gr.update(value=_score_radio_value(3)),
526
+ gr.update(value=_camera_hint_before_start()),
527
+ st,
528
+ gr.update(value="重新攝影機辨識"),
529
+ )
530
+
531
+ top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
532
+ top_conf = float(emo_dict[top_key])
533
 
534
+ if top_conf < CONF_TH:
535
+ return (
536
+ gr.update(visible=True, value=annotated),
537
+ gr.update(value=_hint_html("已偵測到人臉,辨識中…(信心偏低,請面向鏡頭/光線亮一點)")),
538
+ gr.update(value=_score_radio_value(3)),
539
+ gr.update(value=_camera_hint_before_start()),
540
+ st,
541
+ gr.update(value="重新攝影機辨識"),
542
+ )
543
+
544
+ keys = list(emo_dict.keys())
545
+ vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
546
+
547
+ if st.ema_scores is None or st.emo_keys != keys:
548
+ st.ema_scores = vec
549
+ st.emo_keys = keys
550
+ else:
551
+ st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
552
+
553
+ stable_idx = int(np.argmax(st.ema_scores))
554
+ stable_key = st.emo_keys[stable_idx]
555
+ st.stable_emo = stable_key
556
+
557
+ if st.candidate_emo != stable_key:
558
+ st.candidate_emo = stable_key
559
+ st.candidate_since = now
560
+ else:
561
+ if st.candidate_since is None:
562
+ st.candidate_since = now
563
+
564
+ confirmed = (
565
+ st.candidate_since is not None
566
+ and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
567
  )
568
 
569
+ if confirmed and st.final_emo is None:
570
+ st.final_emo = stable_key
571
+ st.final_conf = top_conf
572
+ st.final_score = EMO_TO_SCORE.get(stable_key, 3)
573
+ st.running = False
574
+ st.finished = True
575
+
576
+ return (
577
+ gr.update(visible=False, value=None),
578
+ gr.update(value=_result_card_html(stable_key, top_conf)),
579
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
580
+ gr.update(value=_camera_hint_done()),
581
+ st,
582
+ gr.update(value="重新攝影機辨識"),
583
+ )
584
+
585
+ return (
586
+ gr.update(visible=True, value=annotated),
587
+ gr.update(value=_result_card_html(stable_key, top_conf)),
588
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
589
+ gr.update(value=_camera_hint_before_start()),
590
+ st,
591
+ gr.update(value="重新攝影機辨識"),
592
  )
593
 
 
 
 
 
 
 
594
 
595
  # =========================
596
+ # Buttons
597
+ # =========================
598
+ def on_restart(st: AppState):
599
+ st = _reset_state_for_camera(st)
600
+ return (
601
+ gr.update(visible=True, value=None),
602
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
603
+ gr.update(value=_score_radio_value(3)),
604
+ gr.update(value=_camera_hint_before_start()),
605
+ st,
606
+ gr.update(value="重新攝影機辨識"),
607
+ )
608
+
609
+ def on_stop(st: AppState):
610
+ st.running = False
611
+ st.finished = True
612
+ if st.final_emo is None:
613
+ return (
614
+ gr.update(visible=False, value=None),
615
+ gr.update(value=_hint_html("已停止辨識。")),
616
+ gr.update(value=_score_radio_value(3)),
617
+ gr.update(value=_camera_hint_stopped()),
618
+ st,
619
+ gr.update(value="重新攝影機辨識"),
620
+ )
621
+
622
+ return (
623
+ gr.update(visible=False, value=None),
624
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
625
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
626
+ gr.update(value=_camera_hint_done()),
627
+ st,
628
+ gr.update(value="重新攝影機辨識"),
629
+ )
630
+
631
+
632
+ # =========================
633
+ # Gradio UI
634
  # =========================
635
  if __name__ == "__main__":
636
+ with gr.Blocks(title="Emotion Detector", css=css) as demo:
637
+ st = gr.State(AppState())
638
+
639
+ gr.Markdown("## 第一步:測測你的心情能量")
640
+ gr.Markdown("讓 AI 看看你的表情,自動幫你決定心情分數!(辨識完畢會自動跳轉)")
641
+
642
+ hint = gr.HTML(_camera_hint_before_start())
643
+
644
+ cam = gr.Image(
645
+ sources=["webcam"],
646
+ streaming=True,
647
+ type="numpy",
648
+ label="攝影機畫面",
649
+ visible=True,
650
+ )
651
+
652
+ with gr.Row():
653
+ btn_restart = gr.Button("重新攝影機辨識", variant="primary")
654
+ btn_stop = gr.Button("停止", variant="secondary")
655
+
656
+ gr.Markdown("### 辨識結果")
657
+ result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
658
+
659
+ gr.Markdown("### 心情分數(系統判定)")
660
+ mood = gr.Radio(
661
+ choices=SCORE_CHOICES,
662
+ value=_score_radio_value(3),
663
+ label="心情分數",
664
+ interactive=False,
665
+ )
666
+
667
+ cam.stream(
668
+ fn=on_stream,
669
+ inputs=[cam, st],
670
+ outputs=[cam, result, mood, hint, st, btn_restart],
671
+ show_progress="minimal",
672
+ )
673
+
674
+ btn_restart.click(
675
+ fn=on_restart,
676
+ inputs=[st],
677
+ outputs=[cam, result, mood, hint, st, btn_restart],
678
+ show_progress="minimal",
679
+ )
680
+
681
+ btn_stop.click(
682
+ fn=on_stop,
683
+ inputs=[st],
684
+ outputs=[cam, result, mood, hint, st, btn_restart],
685
+ show_progress="minimal",
686
+ )
687
+
688
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False)