YuAnTsai commited on
Commit
5af3dbc
·
verified ·
1 Parent(s): b1cb4b8

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +487 -171
emotion.py CHANGED
@@ -4,267 +4,583 @@ from typing import Optional, Dict, Tuple, List
4
 
5
  import gradio as gr
6
  import numpy as np
7
- import cv2
8
- import mediapipe as mp
9
- from mediapipe.tasks import python
10
- from mediapipe.tasks.vision import FaceDetector, FaceDetectorOptions, RunningMode
11
 
12
  from deepface import DeepFace
13
 
 
14
  # =========================
15
- # 參數與 MediaPipe 初始化
16
  # =========================
17
- ANALYZE_EVERY_SEC = 0.6
18
  DOWNSAMPLE_W = 720
19
  EMA_ALPHA = 0.45
20
  CONF_TH = 40.0
21
- SWITCH_CONFIRM_SEC = 2.0
22
  IOU_TH = 0.25
23
- ZOOM_FACTOR = 1.3
24
-
25
- # MediaPipe 設定 (請確保 model 資料夾內有此檔案)
26
- MODEL_PATH = 'blaze_face_short_range.tflite'
27
- base_options = python.BaseOptions(model_asset_path=MODEL_PATH)
28
- options = FaceDetectorOptions(
29
- base_options=base_options,
30
- running_mode=RunningMode.IMAGE
31
- )
32
- # 建立全域偵測器
33
- mp_detector = FaceDetector.create_from_options(options)
34
 
35
  # =========================
36
- # 情緒映射與標籤
37
  # =========================
38
  EMO_ZH = {
39
- "angry": "生氣", "disgust": "厭惡", "fear": "害怕",
40
- "happy": "開心", "sad": "難過", "surprise": "驚訝",
41
- "neutral": "平靜", "unknown": "未知",
 
 
 
 
 
42
  }
43
 
 
44
  EMO_TO_SCORE = {
45
- "angry": 1, "disgust": 1, "fear": 1,
46
- "sad": 2, "neutral": 3, "surprise": 4,
47
- "happy": 5, "unknown": 3,
 
 
 
 
 
48
  }
49
 
 
 
 
50
  SCORE_LABELS = {
51
- 1: "1(心情差)", 2: "2(不太好)", 3: "3(普通)",
52
- 4: "4(不錯)", 5: "5(超棒)",
 
 
 
53
  }
54
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
55
 
 
 
 
 
56
  @dataclass
57
  class AppState:
 
 
58
  running: bool = True
59
  finished: bool = False
60
  last_analyze_t: float = 0.0
 
 
61
  track_bbox: Optional[Tuple[int, int, int, int]] = None
 
 
 
 
62
  ema_scores: Optional[np.ndarray] = None
63
  emo_keys: List[str] = field(default_factory=list)
 
 
64
  stable_emo: str = "unknown"
65
  candidate_emo: Optional[str] = None
66
  candidate_since: Optional[float] = None
 
 
67
  final_emo: Optional[str] = None
68
  final_conf: Optional[float] = None
69
- final_score: int = 3
 
 
70
 
71
  # =========================
72
- # UI 工具與輔助函式
73
  # =========================
74
  def _hint_html(msg: str) -> str:
75
- return f'<div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">{msg}</div>'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
78
  zh = EMO_ZH.get(emo_key, emo_key)
79
  score = EMO_TO_SCORE.get(emo_key, 3)
80
  conf_txt = "" if conf is None else f"{conf:.1f}%"
81
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
 
82
  return f"""
83
- <div style="border-radius:16px;padding:20px;border:1px solid #E6E6E6;background:#FFFFFF;color:#000000;">
84
- <div style="font-size:14px;margin-bottom:8px;">即時辨識結果</div>
85
- <div style="font-size:42px;font-weight:800;line-height:1.1;margin-bottom:14px;">{zh}</div>
86
- <div style="font-size:16px;margin-bottom:6px;">心情分數:{score}({score_desc})</div>
87
- <div style="font-size:14px;">信心值:{conf_txt}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  </div>
89
  """
90
 
 
91
  def _score_radio_value(score: int) -> str:
92
- return SCORE_LABELS[min(5, max(1, int(score)))]
 
 
93
 
94
- def _iou(a, b) -> float:
95
- ax, ay, aw, ah = a
96
- bx, by, bw, bh = b
97
- ix, iy = max(ax, bx), max(ay, by)
98
- iw, ih = max(0, min(ax+aw, bx+bw)-ix), max(0, min(ay+ah, by+bh)-iy)
99
- area_i = iw * ih
100
- if area_i <= 0: return 0.0
101
- return area_i / (aw*ah + bw*bh - area_i)
102
 
103
- def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
104
- if frame is None or zoom <= 1.0: return frame
 
105
  h, w = frame.shape[:2]
106
- nw, nh = int(w / zoom), int(h / zoom)
107
- sx, sy = (w - nw) // 2, (h - nh) // 2
108
- return frame[sy:sy+nh, sx:sx+nw]
 
 
 
 
109
 
110
- # =========================
111
- # MediaPipe 偵測邏輯 (整合繪圖)
112
- # =========================
113
- def get_face_mediapipe(frame: np.ndarray, prev_bbox: Optional[Tuple[int, int, int, int]]):
114
- h, w = frame.shape[:2]
115
- draw_frame = frame.copy() # 建立一個複本用來畫圖
116
- mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=frame)
117
- res = mp_detector.detect(mp_image)
118
-
119
- if not res.detections:
120
- return None, None, draw_frame
121
-
122
- candidates = []
123
- for det in res.detections:
124
- b = det.bounding_box
125
- nx, ny = max(0, b.origin_x), max(0, b.origin_y)
126
- nw, nh = min(b.width, w - nx), min(b.height, h - ny)
127
-
128
- # 繪製白色正方形外框 (Thickness=5)
129
- cv2.rectangle(draw_frame, (nx, ny), (nx + nw, ny + nh), (255, 255, 255), 5)
130
 
131
- # 繪製紅色五官小圓點
132
- if det.keypoints:
133
- for kp in det.keypoints:
134
- kx, ky = int(kp.x * w), int(kp.y * h)
135
- cv2.circle(draw_frame, (kx, ky), 6, (0, 0, 255), -1)
 
 
136
 
137
- if nw > 10 and nh > 10:
138
- candidates.append(((nx, ny, nw, nh), frame[ny:ny+nh, nx:nx+nw]))
139
 
140
- if not candidates:
141
- return None, None, draw_frame
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
- # 優先匹配上一幀位���
144
- best_match = None
145
- if prev_bbox:
146
- best_match = max(candidates, key=lambda c: _iou(prev_bbox, c[0]), default=(None, None))
147
- if best_match[0] and _iou(prev_bbox, best_match[0]) < IOU_TH:
148
- best_match = None
149
 
150
- if best_match is None:
151
- best_match = max(candidates, key=lambda c: c[0][2] * c[0][3])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
- return best_match[0], best_match[1], draw_frame
154
 
155
  # =========================
156
- # Gradio 事件處理
157
  # =========================
158
  def on_stream(frame_rgb: np.ndarray, st: AppState):
159
- if not st.running or st.finished:
160
- res_html = _result_card_html(st.final_emo, st.final_conf) if st.final_emo else _hint_html("辨識停止")
161
- return gr.update(visible=False), res_html, _score_radio_value(st.final_score), _hint_html("已完成"), st, gr.update()
162
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  if frame_rgb is None:
164
- return gr.update(), gr.update(), gr.update(), _hint_html("等待攝影機..."), st, gr.update()
165
-
 
 
 
 
 
 
 
 
166
  frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
 
 
 
 
167
  now = time.time()
168
-
169
- # 頻率控制
170
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
171
- return gr.update(), gr.update(), gr.update(), gr.update(), st, gr.update()
172
-
 
 
 
 
 
 
 
 
 
173
  st.last_analyze_t = now
174
 
175
- # 使用 MediaPipe 找臉並繪圖
176
- bbox, face_crop, frame_with_drawings = get_face_mediapipe(frame_rgb, st.track_bbox)
177
-
178
- if face_crop is None:
179
- return gr.update(value=frame_with_drawings), gr.update(), gr.update(), _hint_html("未偵測到人臉"), st, gr.update()
 
 
 
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  st.track_bbox = bbox
 
 
182
 
183
- # DeepFace 情緒分析
184
- try:
185
- res = DeepFace.analyze(face_crop, actions=['emotion'], enforce_detection=False, silent=True)
186
- emo_dict = res[0]['emotion'] if isinstance(res, list) else res['emotion']
187
- except:
188
- return gr.update(value=frame_with_drawings), gr.update(), gr.update(), gr.update(), st, gr.update()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
191
  top_conf = float(emo_dict[top_key])
192
 
193
- # 平滑處理 (EMA)
194
- if top_conf >= CONF_TH:
195
- keys = list(emo_dict.keys())
196
- vals = np.array([float(emo_dict[k]) for k in keys])
197
- if st.ema_scores is None or st.emo_keys != keys:
198
- st.ema_scores, st.emo_keys = vals, keys
199
- else:
200
- st.ema_scores = EMA_ALPHA * vals + (1.0 - EMA_ALPHA) * st.ema_scores
201
-
202
- st.stable_emo = st.emo_keys[int(np.argmax(st.ema_scores))]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
 
204
- # 穩定切換邏輯
205
- if st.candidate_emo != st.stable_emo:
206
- st.candidate_emo, st.candidate_since = st.stable_emo, now
207
- elif st.candidate_since and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC:
208
- st.final_emo, st.final_conf = st.stable_emo, top_conf
209
- st.final_score = EMO_TO_SCORE.get(st.stable_emo, 3)
210
- st.finished, st.running = True, False
 
 
 
 
 
 
 
 
 
211
 
212
  return (
213
- gr.update(value=frame_with_drawings, visible=not st.finished),
214
- gr.update(value=_result_card_html(st.stable_emo, top_conf)),
215
- gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.stable_emo, 3))),
216
- gr.update(value=_hint_html("正在辨識中,請保持表情穩定...")),
217
  st,
218
- gr.update()
219
  )
220
 
 
 
 
 
 
221
  def on_restart(st: AppState):
222
- new_st = AppState()
223
  return (
224
- gr.update(visible=True),
225
- _hint_html("等待影像..."),
226
- _score_radio_value(3),
227
- _hint_html("請點擊 Record 開始"),
228
- new_st,
229
- gr.update()
230
  )
231
 
 
232
  def on_stop(st: AppState):
233
- st.running, st.finished = False, True
234
- return gr.update(visible=False), gr.update(), gr.update(), _hint_html("已手動停止"), st, gr.update()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
 
236
  # =========================
237
- # Gradio UI 介面
238
  # =========================
239
- css = ".gradio-container { background-color: #f7f9fc; }"
240
-
241
- with gr.Blocks(title="AI 情緒偵測系統", css=css) as demo:
242
- state = gr.State(AppState())
243
-
244
- gr.Markdown("# 🎭 AI 情緒辨識評分系統")
245
- status_hint = gr.HTML(_hint_html("請開啟攝影機並點擊錄製"))
246
-
247
- with gr.Row():
248
- with gr.Column(scale=1):
249
- webcam = gr.Image(sources=["webcam"], streaming=True, type="numpy", label="攝影機畫面")
250
- with gr.Row():
251
- btn_re = gr.Button("🔄 重新辨識", variant="primary")
252
- btn_stop = gr.Button("🛑 停止", variant="secondary")
253
-
254
- with gr.Column(scale=1):
255
- res_display = gr.HTML(_result_card_html("unknown", 0))
256
- gr.Markdown("### 系統判定心情分數")
257
- mood_radio = gr.Radio(choices=SCORE_CHOICES, value=SCORE_LABELS[3], interactive=False, label="")
258
-
259
- # 事件綁定
260
- webcam.stream(
261
- fn=on_stream,
262
- inputs=[webcam, state],
263
- outputs=[webcam, res_display, mood_radio, status_hint, state, btn_re]
264
- )
265
-
266
- btn_re.click(fn=on_restart, inputs=[state], outputs=[webcam, res_display, mood_radio, status_hint, state, btn_re])
267
- btn_stop.click(fn=on_stop, inputs=[state], outputs=[webcam, res_display, mood_radio, status_hint, state, btn_re])
268
 
269
  if __name__ == "__main__":
270
- demo.launch(server_name="0.0.0.0", server_port=7860)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  import gradio as gr
6
  import numpy as np
7
+ from PIL import Image
8
+
9
+ import cv2 # ✅ 新增:用來畫框
 
10
 
11
  from deepface import DeepFace
12
 
13
+
14
  # =========================
15
+ # 參數(可微調)
16
  # =========================
17
+ ANALYZE_EVERY_SEC = 0.8
18
  DOWNSAMPLE_W = 720
19
  EMA_ALPHA = 0.45
20
  CONF_TH = 40.0
21
+ SWITCH_CONFIRM_SEC = 2.5
22
  IOU_TH = 0.25
23
+ ZOOM_FACTOR = 1.5
24
+
25
+ DETECTOR_BACKEND = "mtcnn" # 想快一點可改 "opencv"
26
+ ALIGN_FACE = True
27
+
28
+ # ✅ 新增:bbox 過期時間(秒),避免框卡死
29
+ BBOX_TTL_SEC = 2.0
30
+
 
 
 
31
 
32
  # =========================
33
+ # 情緒中文 + 分數映射(7 -> 5)
34
  # =========================
35
  EMO_ZH = {
36
+ "angry": "生氣",
37
+ "disgust": "厭惡",
38
+ "fear": "害怕",
39
+ "happy": "開心",
40
+ "sad": "難過",
41
+ "surprise": "驚訝",
42
+ "neutral": "平靜",
43
+ "unknown": "未知",
44
  }
45
 
46
+ # 你要的:把 7 種情緒塞進 5 分心情分數
47
  EMO_TO_SCORE = {
48
+ "angry": 1,
49
+ "disgust": 1,
50
+ "fear": 1,
51
+ "sad": 2,
52
+ "neutral": 3,
53
+ "surprise": 4,
54
+ "happy": 5,
55
+ "unknown": 3,
56
  }
57
 
58
+ def get_emotion_score(emo: str) -> int:
59
+ return EMO_TO_SCORE.get(emo, 3)
60
+
61
  SCORE_LABELS = {
62
+ 1: "1(心情差)",
63
+ 2: "2(不太好)",
64
+ 3: "3(普通)",
65
+ 4: "4(不錯)",
66
+ 5: "5(超棒)",
67
  }
68
  SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
69
 
70
+
71
+ # =========================
72
+ # 狀態
73
+ # =========================
74
  @dataclass
75
  class AppState:
76
+ mode: str = "攝影機辨識"
77
+
78
  running: bool = True
79
  finished: bool = False
80
  last_analyze_t: float = 0.0
81
+
82
+ # tracking(bbox 在 small 座標系)
83
  track_bbox: Optional[Tuple[int, int, int, int]] = None
84
+ track_small_shape: Optional[Tuple[int, int]] = None # ✅ 新增:(h, w) of small
85
+ track_bbox_time: float = 0.0 # ✅ 新增:bbox 更新時間
86
+
87
+ # smoothing
88
  ema_scores: Optional[np.ndarray] = None
89
  emo_keys: List[str] = field(default_factory=list)
90
+
91
+ # stable/candidate
92
  stable_emo: str = "unknown"
93
  candidate_emo: Optional[str] = None
94
  candidate_since: Optional[float] = None
95
+
96
+ # final
97
  final_emo: Optional[str] = None
98
  final_conf: Optional[float] = None
99
+
100
+ final_score: int = 3
101
+
102
 
103
  # =========================
104
+ # UI HTML (強制黑色文字)
105
  # =========================
106
  def _hint_html(msg: str) -> str:
107
+ return f"""
108
+ <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">
109
+ {msg}
110
+ </div>
111
+ """
112
+
113
+
114
+ def _camera_hint_before_start() -> str:
115
+ return _hint_html(
116
+ "請先按「Click to access webcam / 允許」開啟攝影機;"
117
+ "接著,請按「錄製(Record)」開始。"
118
+ )
119
+
120
+
121
+ def _camera_hint_done() -> str:
122
+ return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
123
+
124
+
125
+ def _camera_hint_stopped() -> str:
126
+ return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
127
+
128
 
129
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
130
  zh = EMO_ZH.get(emo_key, emo_key)
131
  score = EMO_TO_SCORE.get(emo_key, 3)
132
  conf_txt = "" if conf is None else f"{conf:.1f}%"
133
  score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
134
+
135
  return f"""
136
+ <div style="
137
+ border-radius:16px;
138
+ padding:20px;
139
+ border:1px solid #E6E6E6;
140
+ background:#FFFFFF;
141
+ color:#000000;
142
+ ">
143
+ <div style="font-size:14px;color:#000000;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:#000000;
153
+ ">
154
+ {zh}
155
+ </div>
156
+
157
+ <div style="font-size:16px;color:#000000;margin-bottom:6px;">
158
+ 心情分數:{score}({score_desc})
159
+ </div>
160
+
161
+ <div style="font-size:14px;color:#000000;">
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
+ def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
187
+ if frame is None or zoom <= 1.0:
188
+ return frame
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
+ h, w = frame.shape[:2]
191
+ new_w = int(w / zoom)
192
+ new_h = int(h / zoom)
193
+ start_x = (w - new_w) // 2
194
+ start_y = (h - new_h) // 2
195
+ cropped = frame[start_y : start_y + new_h, start_x : start_x + new_w]
196
+ return cropped
197
 
 
 
198
 
199
+ def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
200
+ ax, ay, aw, ah = a
201
+ bx, by, bw, bh = b
202
+ ax2, ay2 = ax + aw, ay + ah
203
+ bx2, by2 = bx + bw, by + bh
204
+
205
+ ix1, iy1 = max(ax, bx), max(ay, by)
206
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
207
+ iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
208
+ inter = iw * ih
209
+ if inter <= 0:
210
+ return 0.0
211
+ union = aw * ah + bw * bh - inter
212
+ return inter / max(1e-6, union)
213
+
214
+
215
+ def _pick_face_by_tracking(
216
+ faces: List[Dict],
217
+ prev_bbox: Optional[Tuple[int, int, int, int]]
218
+ ) -> Tuple[Optional[Dict], Optional[Tuple[int, int, int, int]]]:
219
+ if not faces:
220
+ return None, None
221
+
222
+ cand = []
223
+ for f in faces:
224
+ fa = f.get("facial_area", {}) or {}
225
+ x = int(fa.get("x", 0) or 0)
226
+ y = int(fa.get("y", 0) or 0)
227
+ w = int(fa.get("w", 0) or 0)
228
+ h = int(fa.get("h", 0) or 0)
229
+ if w > 0 and h > 0 and f.get("face") is not None:
230
+ cand.append((f, (x, y, w, h), w * h))
231
+
232
+ if not cand:
233
+ return None, None
234
+
235
+ if prev_bbox is not None:
236
+ best = None
237
+ best_i = 0.0
238
+ for f, bb, area in cand:
239
+ i = _iou(prev_bbox, bb)
240
+ if i > best_i:
241
+ best_i = i
242
+ best = (f, bb)
243
+ if best is not None and best_i >= IOU_TH:
244
+ return best[0], best[1]
245
+
246
+ cand.sort(key=lambda x: x[2], reverse=True)
247
+ return cand[0][0], cand[0][1]
248
+
249
+
250
+ # ✅ 新增:每一幀都畫上 bbox(如果 bbox 沒過期)
251
+ def _draw_bbox_always(frame_rgb: np.ndarray, st: AppState, ttl_sec: float = BBOX_TTL_SEC) -> np.ndarray:
252
+ if frame_rgb is None:
253
+ return frame_rgb
254
 
255
+ if st.track_bbox is None or st.track_small_shape is None:
256
+ return frame_rgb
 
 
 
 
257
 
258
+ now = time.time()
259
+ if st.track_bbox_time and (now - st.track_bbox_time) > ttl_sec:
260
+ return frame_rgb
261
+
262
+ fh, fw = frame_rgb.shape[:2]
263
+ sh, sw = st.track_small_shape
264
+ if sw <= 0 or sh <= 0:
265
+ return frame_rgb
266
+
267
+ x, y, w, h = st.track_bbox # small 座標系
268
+ sx = fw / sw
269
+ sy = fh / sh
270
+
271
+ x1 = int(x * sx)
272
+ y1 = int(y * sy)
273
+ x2 = int((x + w) * sx)
274
+ y2 = int((y + h) * sy)
275
+
276
+ x1 = max(0, min(fw - 1, x1))
277
+ y1 = max(0, min(fh - 1, y1))
278
+ x2 = max(0, min(fw - 1, x2))
279
+ y2 = max(0, min(fh - 1, y2))
280
+
281
+ bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
282
+ cv2.rectangle(bgr, (x1, y1), (x2, y2), (255, 255, 255), 3) # 白框
283
+ return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
284
+
285
+
286
+ def _reset_state_for_camera(st: AppState) -> AppState:
287
+ st.running = True
288
+ st.finished = False
289
+ st.last_analyze_t = 0.0
290
+ st.track_bbox = None
291
+ st.track_small_shape = None
292
+ st.track_bbox_time = 0.0
293
+ st.ema_scores = None
294
+ st.emo_keys = []
295
+ st.stable_emo = "unknown"
296
+ st.candidate_emo = None
297
+ st.candidate_since = None
298
+ st.final_emo = None
299
+ st.final_conf = None
300
+ st.final_score = 3
301
+ return st
302
 
 
303
 
304
  # =========================
305
+ # Camera streaming
306
  # =========================
307
  def on_stream(frame_rgb: np.ndarray, st: AppState):
308
+ # --- stopped/finished ---
309
+ if st.finished or (not st.running):
310
+ cam_visible = False
311
+
312
+ if st.final_emo:
313
+ result_html = _result_card_html(st.final_emo, st.final_conf)
314
+ mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
315
+ hint_html = _camera_hint_done()
316
+ else:
317
+ result_html = _hint_html("尚未完成辨識。")
318
+ mood_val = _score_radio_value(3)
319
+ hint_html = _camera_hint_stopped()
320
+
321
+ return (
322
+ gr.update(value=None, visible=cam_visible), # ✅ cam
323
+ gr.update(value=result_html),
324
+ gr.update(value=mood_val),
325
+ gr.update(value=hint_html),
326
+ st,
327
+ gr.update(value="重新攝影機辨識"),
328
+ )
329
+
330
+ # --- no frame yet ---
331
  if frame_rgb is None:
332
+ return (
333
+ gr.update(value=None, visible=True), # ✅ cam
334
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
335
+ gr.update(value=_score_radio_value(3)),
336
+ gr.update(value=_camera_hint_before_start()),
337
+ st,
338
+ gr.update(value="重新攝影機辨識"),
339
+ )
340
+
341
+ # zoom
342
  frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
343
+
344
+ # ✅ 每一幀先畫「上一個 bbox」,讓框一直存在
345
+ frame_rgb = _draw_bbox_always(frame_rgb, st, ttl_sec=BBOX_TTL_SEC)
346
+
347
  now = time.time()
348
+
349
+ # --- not time to analyze: return frame with bbox ---
350
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
351
+ show = st.stable_emo or "unknown"
352
+ return (
353
+ gr.update(value=frame_rgb, visible=True), # ✅ cam:回傳影像
354
+ gr.update(value=_result_card_html(show, st.final_conf)),
355
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
356
+ gr.update(value=_camera_hint_before_start()),
357
+ st,
358
+ gr.update(value="重新攝影機辨識"),
359
+ )
360
+
361
+ # --- analyze tick ---
362
  st.last_analyze_t = now
363
 
364
+ small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
365
+
366
+ faces = DeepFace.extract_faces(
367
+ img_path=small,
368
+ detector_backend=DETECTOR_BACKEND,
369
+ enforce_detection=False,
370
+ align=ALIGN_FACE,
371
+ )
372
 
373
+ pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
374
+
375
+ # 若偵測不到:仍回傳「上一個框」(已經畫過),不要閃爍
376
+ if pick is None or bbox is None or pick.get("face") is None:
377
+ show = st.stable_emo or "unknown"
378
+ return (
379
+ gr.update(value=frame_rgb, visible=True), # ✅ cam
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
+ # ✅ 更新追蹤 bbox(small 座標系)
388
  st.track_bbox = bbox
389
+ st.track_small_shape = small.shape[:2]
390
+ st.track_bbox_time = now
391
 
392
+ # ✅ 立刻再畫一次最新 bbox(讓框馬上跟上)
393
+ frame_rgb = _draw_bbox_always(frame_rgb, st, ttl_sec=BBOX_TTL_SEC)
394
+
395
+ # ROI face for emotion
396
+ face = pick["face"]
397
+ if face.dtype != np.uint8:
398
+ face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
399
+
400
+ result = DeepFace.analyze(
401
+ img_path=face,
402
+ actions=["emotion"],
403
+ enforce_detection=False,
404
+ )
405
+ if isinstance(result, list):
406
+ result = result[0]
407
+ emo_dict = result.get("emotion", None)
408
+
409
+ if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
410
+ show = st.stable_emo or "unknown"
411
+ return (
412
+ gr.update(value=frame_rgb, visible=True),
413
+ gr.update(value=_result_card_html(show, st.final_conf)),
414
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
415
+ gr.update(value=_camera_hint_before_start()),
416
+ st,
417
+ gr.update(value="重新攝影機辨識"),
418
+ )
419
 
420
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
421
  top_conf = float(emo_dict[top_key])
422
 
423
+ if top_conf < CONF_TH:
424
+ show = st.stable_emo or "unknown"
425
+ return (
426
+ gr.update(value=frame_rgb, visible=True),
427
+ gr.update(value=_result_card_html(show, st.final_conf)),
428
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
429
+ gr.update(value=_camera_hint_before_start()),
430
+ st,
431
+ gr.update(value="重新攝影機辨識"),
432
+ )
433
+
434
+ keys = list(emo_dict.keys())
435
+ vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
436
+
437
+ if st.ema_scores is None or st.emo_keys != keys:
438
+ st.ema_scores = vec
439
+ st.emo_keys = keys
440
+ else:
441
+ st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
442
+
443
+ stable_idx = int(np.argmax(st.ema_scores))
444
+ stable_key = st.emo_keys[stable_idx]
445
+ st.stable_emo = stable_key
446
+
447
+ # 延遲確認
448
+ if st.candidate_emo != stable_key:
449
+ st.candidate_emo = stable_key
450
+ st.candidate_since = now
451
+ else:
452
+ if st.candidate_since is None:
453
+ st.candidate_since = now
454
+
455
+ confirmed = (
456
+ st.candidate_since is not None
457
+ and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
458
+ )
459
 
460
+ if confirmed and st.final_emo is None:
461
+ st.final_emo = stable_key
462
+ st.final_conf = top_conf
463
+ st.final_score = EMO_TO_SCORE.get(stable_key, 3)
464
+
465
+ st.running = False
466
+ st.finished = True
467
+
468
+ return (
469
+ gr.update(value=None, visible=False), # ✅ cam hide
470
+ gr.update(value=_result_card_html(stable_key, top_conf)),
471
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
472
+ gr.update(value=_camera_hint_done()),
473
+ st,
474
+ gr.update(value="重新攝影機辨識"),
475
+ )
476
 
477
  return (
478
+ gr.update(value=frame_rgb, visible=True), # ✅ cam
479
+ gr.update(value=_result_card_html(stable_key, top_conf)),
480
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
481
+ gr.update(value=_camera_hint_before_start()),
482
  st,
483
+ gr.update(value="重新攝影機辨識"),
484
  )
485
 
486
+
487
+ # =========================
488
+ # Mode / Buttons
489
+ # =========================
490
+
491
  def on_restart(st: AppState):
492
+ st = _reset_state_for_camera(st)
493
  return (
494
+ gr.update(value=None, visible=True), # ✅ cam
495
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
496
+ gr.update(value=_score_radio_value(3)),
497
+ gr.update(value=_camera_hint_before_start()),
498
+ st,
499
+ gr.update(value="重新攝影機辨識"),
500
  )
501
 
502
+
503
  def on_stop(st: AppState):
504
+ st.running = False
505
+ st.finished = True
506
+ if st.final_emo is None:
507
+ return (
508
+ gr.update(value=None, visible=False), # ✅ cam
509
+ gr.update(value=_hint_html("已停止辨識。")),
510
+ gr.update(value=_score_radio_value(3)),
511
+ gr.update(value=_camera_hint_stopped()),
512
+ st,
513
+ gr.update(value="重新攝影機辨識"),
514
+ )
515
+
516
+ return (
517
+ gr.update(value=None, visible=False), # ✅ cam
518
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
519
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
520
+ gr.update(value=_camera_hint_done()),
521
+ st,
522
+ gr.update(value="重新攝影機辨識"),
523
+ )
524
+
525
 
526
  # =========================
527
+ # Gradio UI
528
  # =========================
529
+
530
+ # 你原本這裡的 css 變數在 pasted 檔裡沒顯示出來
531
+ # 如果你原檔有 css = "...", 請保持原樣;沒有的話請自行加上或移除 css 參數
532
+ css = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
533
 
534
  if __name__ == "__main__":
535
+ with gr.Blocks(title="Emotion Detector", css=css) as demo:
536
+ st = gr.State(AppState())
537
+
538
+ gr.Markdown("## 情緒辨識")
539
+
540
+ hint = gr.HTML(_camera_hint_before_start())
541
+
542
+ cam = gr.Image(
543
+ sources=["webcam"],
544
+ streaming=True,
545
+ type="numpy",
546
+ label="攝影機",
547
+ visible=True,
548
+ )
549
+
550
+ with gr.Row():
551
+ btn_restart = gr.Button("重新攝影機辨識", variant="primary")
552
+ btn_stop = gr.Button("停止", variant="secondary")
553
+
554
+ gr.Markdown("### 辨識結果")
555
+ result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
556
+
557
+ gr.Markdown("### 心情分數(系統判定)")
558
+ mood = gr.Radio(
559
+ choices=SCORE_CHOICES,
560
+ value=_score_radio_value(3),
561
+ label="心情分數",
562
+ interactive=False,
563
+ )
564
+
565
+ cam.stream(
566
+ fn=on_stream,
567
+ inputs=[cam, st],
568
+ outputs=[cam, result, mood, hint, st, btn_restart],
569
+ show_progress="minimal",
570
+ )
571
+
572
+ btn_restart.click(
573
+ fn=on_restart,
574
+ inputs=[st],
575
+ outputs=[cam, result, mood, hint, st, btn_restart],
576
+ show_progress="minimal",
577
+ )
578
+
579
+ btn_stop.click(
580
+ fn=on_stop,
581
+ inputs=[st],
582
+ outputs=[cam, result, mood, hint, st, btn_restart],
583
+ show_progress="minimal",
584
+ )
585
+
586
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False)