PhilipL commited on
Commit
efe6d56
·
verified ·
1 Parent(s): b72859d

Upload emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +622 -0
emotion.py ADDED
@@ -0,0 +1,622 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import threading
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional, List, Tuple, Dict
5
+
6
+ import gradio as gr
7
+ import numpy as np
8
+ from PIL import Image
9
+
10
+ import cv2
11
+ import mediapipe as mp
12
+ from mediapipe.tasks import python
13
+ from mediapipe.tasks.python import vision
14
+
15
+ from deepface import DeepFace
16
+
17
+
18
+ # =========================
19
+ # 參數(可微調)
20
+ # =========================
21
+ ANALYZE_EVERY_SEC = 1.5 # ⭐ 建議 1.5~2.0:HF CPU 比較順
22
+ EMA_ALPHA = 0.45
23
+ CONF_TH = 40.0 # DeepFace emotion confidence 門檻
24
+ SWITCH_CONFIRM_SEC = 2.5 # 連續穩定多久才算完成
25
+ ZOOM_FACTOR = 1.5 # ✅ 保留:只用在「顯示」
26
+ UI_TARGET_W = 720 # ✅ 回傳畫面縮小寬度,提升順暢度
27
+
28
+ # MediaPipe face detector 門檻(太高很容易漏)
29
+ MP_MIN_DET_CONF = 0.3
30
+
31
+ # 模型路徑(確認 repo 中模型真的在這裡)
32
+ MP_MODEL_PATH = "blaze_face_short_range.tflite"
33
+
34
+
35
+ # =========================
36
+ # 情緒中文 + 分數映射(7 -> 5)
37
+ # =========================
38
+ EMO_ZH = {
39
+ "angry": "生氣",
40
+ "disgust": "厭惡",
41
+ "fear": "害怕",
42
+ "happy": "開心",
43
+ "sad": "難過",
44
+ "surprise": "驚訝",
45
+ "neutral": "平靜",
46
+ "unknown": "未知",
47
+ }
48
+
49
+ EMO_TO_SCORE = {
50
+ "angry": 1,
51
+ "disgust": 1,
52
+ "fear": 1,
53
+ "sad": 2,
54
+ "neutral": 3,
55
+ "surprise": 4,
56
+ "happy": 5,
57
+ "unknown": 3,
58
+ }
59
+
60
+ SCORE_LABELS = {
61
+ 1: "1(心情差)",
62
+ 2: "2(不太好)",
63
+ 3: "3(普通)",
64
+ 4: "4(不錯)",
65
+ 5: "5(超棒)",
66
+ }
67
+ SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
68
+
69
+
70
+ # =========================
71
+ # 狀態
72
+ # =========================
73
+ @dataclass
74
+ class AppState:
75
+ mode: str = "攝影機辨識"
76
+
77
+ running: bool = True
78
+ finished: bool = False
79
+ last_analyze_t: float = 0.0
80
+
81
+ # smoothing
82
+ ema_scores: Optional[np.ndarray] = None
83
+ emo_keys: List[str] = field(default_factory=list)
84
+
85
+ # stable/candidate
86
+ stable_emo: str = "unknown"
87
+ candidate_emo: Optional[str] = None
88
+ candidate_since: Optional[float] = None
89
+
90
+ # final
91
+ final_emo: Optional[str] = None
92
+ final_conf: Optional[float] = None
93
+ final_score: int = 3
94
+
95
+
96
+ # =========================
97
+ # UI HTML
98
+ # =========================
99
+ def _hint_html(msg: str) -> str:
100
+ return f"""
101
+ <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">
102
+ {msg}
103
+ </div>
104
+ """
105
+
106
+ def _camera_hint_before_start() -> str:
107
+ return _hint_html(
108
+ "請先按「Click to access webcam / 允許」開啟攝影機;"
109
+ "接著,請按「錄製(Record)」開始。"
110
+ )
111
+
112
+ def _camera_hint_need_face() -> str:
113
+ return _hint_html(
114
+ "⚠️ 目前未偵測到人臉,請將臉部移至畫面中央、靠近鏡頭,並避免瀏海遮住眉眼,才可進行情緒辨識。"
115
+ )
116
+
117
+ def _camera_hint_done() -> str:
118
+ return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
119
+
120
+ def _camera_hint_stopped() -> str:
121
+ return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
122
+
123
+ def _score_radio_value(score: int) -> str:
124
+ score = int(score)
125
+ score = min(5, max(1, score))
126
+ return SCORE_LABELS[score]
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
+ return f"""
135
+ <div style="
136
+ border-radius:16px;
137
+ padding:20px;
138
+ border:1px solid #E6E6E6;
139
+ background:#FFFFFF;
140
+ color:#000000;
141
+ ">
142
+ <div style="font-size:14px;color:#000000;margin-bottom:8px;">
143
+ 辨識結果
144
+ </div>
145
+
146
+ <div style="
147
+ font-size:42px;
148
+ font-weight:800;
149
+ line-height:1.1;
150
+ margin-bottom:14px;
151
+ color:#000000;
152
+ ">
153
+ {zh}
154
+ </div>
155
+
156
+ <div style="font-size:16px;color:#000000;margin-bottom:6px;">
157
+ 心情分數:{score}({score_desc})
158
+ </div>
159
+
160
+ <div style="font-size:14px;color:#000000;">
161
+ 信心值:{conf_txt}
162
+ </div>
163
+ </div>
164
+ """
165
+
166
+
167
+ # =========================
168
+ # 影像處理(保命 + 顯示優化)
169
+ # =========================
170
+ def _to_rgb_uint8(frame: np.ndarray) -> np.ndarray:
171
+ """統一成 MediaPipe Tasks 最穩格式:RGB、uint8、0~255、contiguous。"""
172
+ if frame is None:
173
+ return frame
174
+
175
+ # RGBA -> RGB
176
+ if frame.ndim == 3 and frame.shape[2] == 4:
177
+ frame = frame[:, :, :3]
178
+
179
+ # 灰階 -> RGB
180
+ if frame.ndim == 2:
181
+ frame = np.stack([frame, frame, frame], axis=-1)
182
+
183
+ # dtype / range 修正
184
+ if frame.dtype != np.uint8:
185
+ mx = float(np.max(frame)) if frame.size else 0.0
186
+ if mx <= 1.5: # 多半是 0~1
187
+ frame = (frame * 255.0).clip(0, 255).astype(np.uint8)
188
+ else: # 多半是 0~255 但 float
189
+ frame = np.clip(frame, 0, 255).astype(np.uint8)
190
+
191
+ return np.ascontiguousarray(frame)
192
+
193
+ def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
194
+ if frame is None or zoom <= 1.0:
195
+ return frame
196
+ h, w = frame.shape[:2]
197
+ new_w = int(w / zoom)
198
+ new_h = int(h / zoom)
199
+ start_x = (w - new_w) // 2
200
+ start_y = (h - new_h) // 2
201
+ cropped = frame[start_y : start_y + new_h, start_x : start_x + new_w]
202
+ return np.ascontiguousarray(cropped)
203
+
204
+ def _resize_for_ui(frame_rgb: np.ndarray, target_w: int = UI_TARGET_W) -> np.ndarray:
205
+ """回傳給 Gradio 的畫面縮到固定寬度,減少傳輸/渲染負擔。"""
206
+ if frame_rgb is None:
207
+ return frame_rgb
208
+ h, w = frame_rgb.shape[:2]
209
+ if w <= target_w:
210
+ return frame_rgb
211
+ scale = target_w / w
212
+ new_h = max(1, int(h * scale))
213
+ return cv2.resize(frame_rgb, (target_w, new_h), interpolation=cv2.INTER_AREA)
214
+
215
+
216
+ # =========================
217
+ # MediaPipe Tasks Face Detector
218
+ # =========================
219
+ BaseOptions = python.BaseOptions
220
+ FaceDetector = vision.FaceDetector
221
+ FaceDetectorOptions = vision.FaceDetectorOptions
222
+ VisionRunningMode = vision.RunningMode
223
+
224
+ _face_detector = FaceDetector.create_from_options(
225
+ FaceDetectorOptions(
226
+ base_options=BaseOptions(model_asset_path=MP_MODEL_PATH),
227
+ running_mode=VisionRunningMode.IMAGE,
228
+ min_detection_confidence=MP_MIN_DET_CONF,
229
+ )
230
+ )
231
+
232
+ def mp_tasks_detect_and_draw(frame_rgb: np.ndarray):
233
+ """
234
+ - 最大臉策略(bbox 面積最大)
235
+ - 有臉:畫白框 + 紅點 keypoints
236
+ - 回 bbox_px (x, y, w, h) 供 ROI 裁切
237
+ """
238
+ frame_rgb = _to_rgb_uint8(frame_rgb)
239
+ h, w = frame_rgb.shape[:2]
240
+
241
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=frame_rgb)
242
+ result = _face_detector.detect(mp_image)
243
+
244
+ if not result.detections:
245
+ return frame_rgb, None
246
+
247
+ best_det = None
248
+ best_area = -1.0
249
+ best_bbox = None
250
+
251
+ # 最大臉:bbox 面積最大
252
+ for det in result.detections:
253
+ bbox = det.bounding_box
254
+ area = float(bbox.width) * float(bbox.height)
255
+ if area > best_area:
256
+ best_area = area
257
+ best_det = det
258
+ best_bbox = bbox
259
+
260
+ if best_det is None or best_bbox is None:
261
+ return frame_rgb, None
262
+
263
+ lx = int(best_bbox.origin_x)
264
+ ly = int(best_bbox.origin_y)
265
+ bw = int(best_bbox.width)
266
+ bh = int(best_bbox.height)
267
+
268
+ # clamp
269
+ lx = max(0, min(w - 1, lx))
270
+ ly = max(0, min(h - 1, ly))
271
+ bw = max(1, min(w - lx, bw))
272
+ bh = max(1, min(h - ly, bh))
273
+
274
+ bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
275
+
276
+ # 白框
277
+ cv2.rectangle(bgr, (lx, ly), (lx + bw, ly + bh), (255, 255, 255), 3)
278
+
279
+ # 紅點:五官特徵點(相對座標)
280
+ for kp in best_det.keypoints:
281
+ cx = int(kp.x * w)
282
+ cy = int(kp.y * h)
283
+ cv2.circle(bgr, (cx, cy), 6, (0, 0, 255), -1)
284
+
285
+ drawn = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
286
+ return drawn, (lx, ly, bw, bh)
287
+
288
+
289
+ # =========================
290
+ # 背景推論(避免卡頓)
291
+ # =========================
292
+ _ANALYZE_LOCK = threading.Lock()
293
+ _ANALYZE_THREAD: Optional[threading.Thread] = None
294
+ _PENDING_FACE: Optional[np.ndarray] = None
295
+ _LAST_ANALYZE: Dict[str, Optional[float]] = {"ts": 0.0, "conf": None, "emo": "unknown"}
296
+
297
+ def _analyze_worker():
298
+ """背景 thread:取最新 face_roi 做 DeepFace.analyze,更新 _LAST_ANALYZE。"""
299
+ global _PENDING_FACE, _ANALYZE_THREAD, _LAST_ANALYZE
300
+
301
+ while True:
302
+ with _ANALYZE_LOCK:
303
+ face = _PENDING_FACE
304
+ _PENDING_FACE = None
305
+
306
+ if face is None:
307
+ break
308
+
309
+ try:
310
+ r = DeepFace.analyze(
311
+ img_path=face,
312
+ actions=["emotion"],
313
+ enforce_detection=False,
314
+ detector_backend="skip", # ⭐ 關鍵:你已經裁好 ROI
315
+ )
316
+ if isinstance(r, list):
317
+ r = r[0]
318
+ emo_dict = r.get("emotion", None)
319
+
320
+ if isinstance(emo_dict, dict) and len(emo_dict) > 0:
321
+ top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
322
+ top_conf = float(emo_dict[top_key])
323
+ _LAST_ANALYZE = {"ts": time.time(), "emo": top_key, "conf": top_conf}
324
+ except Exception:
325
+ # 失敗就保持上一次結果
326
+ pass
327
+
328
+ _ANALYZE_THREAD = None
329
+
330
+
331
+ def _enqueue_analyze(face_roi: np.ndarray):
332
+ """把最新 ROI 丟給背景 thread,若 thread 沒在跑就啟動。"""
333
+ global _PENDING_FACE, _ANALYZE_THREAD
334
+
335
+ with _ANALYZE_LOCK:
336
+ _PENDING_FACE = face_roi
337
+
338
+ if _ANALYZE_THREAD is None:
339
+ _ANALYZE_THREAD = threading.Thread(target=_analyze_worker, daemon=True)
340
+ _ANALYZE_THREAD.start()
341
+
342
+
343
+ # =========================
344
+ # state helper
345
+ # =========================
346
+ def _reset_state_for_camera(st: AppState) -> AppState:
347
+ st.running = True
348
+ st.finished = False
349
+ st.last_analyze_t = 0.0
350
+
351
+ st.ema_scores = None
352
+ st.emo_keys = []
353
+
354
+ st.stable_emo = "unknown"
355
+ st.candidate_emo = None
356
+ st.candidate_since = None
357
+
358
+ st.final_emo = None
359
+ st.final_conf = None
360
+ st.final_score = 3
361
+ return st
362
+
363
+
364
+ # =========================
365
+ # streaming callback
366
+ # =========================
367
+ def on_stream(frame_rgb: np.ndarray, st: AppState):
368
+ # stopped / finished
369
+ if st.finished or (not st.running):
370
+ cam_visible = False
371
+
372
+ if st.final_emo:
373
+ result_html = _result_card_html(st.final_emo, st.final_conf)
374
+ mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
375
+ hint_html = _camera_hint_done()
376
+ else:
377
+ result_html = _hint_html("尚未完成辨識。")
378
+ mood_val = _score_radio_value(3)
379
+ hint_html = _camera_hint_stopped()
380
+
381
+ return (
382
+ gr.update(value=None, visible=cam_visible),
383
+ gr.update(value=result_html),
384
+ gr.update(value=mood_val),
385
+ gr.update(value=hint_html),
386
+ st,
387
+ gr.update(value="重新攝影機辨識"),
388
+ )
389
+
390
+ # no frame yet
391
+ if frame_rgb is None:
392
+ return (
393
+ gr.update(value=None, visible=True),
394
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
395
+ gr.update(value=_score_radio_value(3)),
396
+ gr.update(value=_camera_hint_before_start()),
397
+ st,
398
+ gr.update(value="重新攝影機辨識"),
399
+ )
400
+
401
+ # 統一格式
402
+ frame_rgb = _to_rgb_uint8(frame_rgb)
403
+
404
+ # ✅ 偵測用原圖(穩)
405
+ frame_drawn, bbox_px = mp_tasks_detect_and_draw(frame_rgb)
406
+
407
+ # ✅ 顯示用 zoom(你要保留 zoom 畫面)
408
+ frame_show = _zoom_center(frame_drawn if bbox_px is not None else frame_rgb, ZOOM_FACTOR)
409
+ frame_show = _resize_for_ui(frame_show, UI_TARGET_W)
410
+
411
+ # 沒有人臉:不畫框/點 + 提示
412
+ if bbox_px is None:
413
+ return (
414
+ gr.update(value=frame_show, visible=True),
415
+ gr.update(value=_hint_html("尚未偵測到人臉")),
416
+ gr.update(value=_score_radio_value(3)),
417
+ gr.update(value=_camera_hint_need_face()),
418
+ st,
419
+ gr.update(value="重新攝影機辨識"),
420
+ )
421
+
422
+ now = time.time()
423
+
424
+ # 到分析時間:只排隊給背景 thread(不在 on_stream 裡跑重推論)
425
+ if now - st.last_analyze_t >= ANALYZE_EVERY_SEC:
426
+ st.last_analyze_t = now
427
+
428
+ x, y, bw, bh = bbox_px
429
+ pad_x = int(bw * 0.10)
430
+ pad_y = int(bh * 0.10)
431
+ H, W = frame_rgb.shape[:2]
432
+ x1 = max(0, x - pad_x)
433
+ y1 = max(0, y - pad_y)
434
+ x2 = min(W, x + bw + pad_x)
435
+ y2 = min(H, y + bh + pad_y)
436
+ face_roi = frame_rgb[y1:y2, x1:x2].astype(np.uint8)
437
+
438
+ _enqueue_analyze(face_roi)
439
+
440
+ # 取「最後一次背景推論結果」
441
+ top_key = _LAST_ANALYZE.get("emo", "unknown") or "unknown"
442
+ top_conf = _LAST_ANALYZE.get("conf", None)
443
+
444
+ # 還沒有推論到結果:先用目前穩定值顯示
445
+ if top_conf is None:
446
+ show = st.stable_emo or "unknown"
447
+ return (
448
+ gr.update(value=frame_show, visible=True),
449
+ gr.update(value=_result_card_html(show, st.final_conf)),
450
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
451
+ gr.update(value=_camera_hint_before_start()),
452
+ st,
453
+ gr.update(value="重新攝影機辨識"),
454
+ )
455
+
456
+ # 低信心就不切換,顯示目前穩定值
457
+ if top_conf < CONF_TH:
458
+ show = st.stable_emo or "unknown"
459
+ return (
460
+ gr.update(value=frame_show, visible=True),
461
+ gr.update(value=_result_card_html(show, st.final_conf)),
462
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
463
+ gr.update(value=_camera_hint_before_start()),
464
+ st,
465
+ gr.update(value="重新攝影機辨識"),
466
+ )
467
+
468
+ # === EMA smoothing ===
469
+ # DeepFace emotion dict 的 keys 順序可能固定,但我們這裡用單一 top_conf/top_key 也能做穩定
470
+ # 仍然保留你原本的「看整個分佈」的 smoothing:用 top_key/top_conf 建一個簡化 dict
471
+ # 為了兼容你原本寫法,我們用 7 類別做一個向量(缺的填 0)
472
+ all_keys = ["angry", "disgust", "fear", "happy", "sad", "surprise", "neutral"]
473
+ vec = np.zeros((len(all_keys),), dtype=np.float32)
474
+ if top_key in all_keys:
475
+ vec[all_keys.index(top_key)] = float(top_conf)
476
+
477
+ if st.ema_scores is None or st.emo_keys != all_keys:
478
+ st.ema_scores = vec
479
+ st.emo_keys = all_keys
480
+ else:
481
+ st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
482
+
483
+ stable_idx = int(np.argmax(st.ema_scores))
484
+ stable_key = st.emo_keys[stable_idx] if st.emo_keys else "unknown"
485
+ st.stable_emo = stable_key
486
+
487
+ # 延遲確認,避免跳動
488
+ if st.candidate_emo != stable_key:
489
+ st.candidate_emo = stable_key
490
+ st.candidate_since = now
491
+ else:
492
+ if st.candidate_since is None:
493
+ st.candidate_since = now
494
+
495
+ confirmed = (
496
+ st.candidate_since is not None
497
+ and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
498
+ )
499
+
500
+ # ✅ 保留你原本「辨識完成後自動跳轉/停止」邏輯
501
+ if confirmed and st.final_emo is None:
502
+ st.final_emo = stable_key
503
+ st.final_conf = float(top_conf) if top_conf is not None else None
504
+ st.final_score = EMO_TO_SCORE.get(stable_key, 3)
505
+
506
+ st.running = False
507
+ st.finished = True
508
+
509
+ return (
510
+ gr.update(value=None, visible=False),
511
+ gr.update(value=_result_card_html(stable_key, st.final_conf)),
512
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
513
+ gr.update(value=_camera_hint_done()),
514
+ st,
515
+ gr.update(value="重新攝影機辨識"),
516
+ )
517
+
518
+ return (
519
+ gr.update(value=frame_show, visible=True),
520
+ gr.update(value=_result_card_html(stable_key, float(top_conf))),
521
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
522
+ gr.update(value=_camera_hint_before_start()),
523
+ st,
524
+ gr.update(value="重新攝影機辨識"),
525
+ )
526
+
527
+
528
+ # =========================
529
+ # Buttons
530
+ # =========================
531
+ def on_restart(st: AppState):
532
+ st = _reset_state_for_camera(st)
533
+ return (
534
+ gr.update(value=None, visible=True),
535
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
536
+ gr.update(value=_score_radio_value(3)),
537
+ gr.update(value=_camera_hint_before_start()),
538
+ st,
539
+ gr.update(value="重新攝影機辨識"),
540
+ )
541
+
542
+ def on_stop(st: AppState):
543
+ st.running = False
544
+ st.finished = True
545
+
546
+ if st.final_emo is None:
547
+ return (
548
+ gr.update(value=None, visible=False),
549
+ gr.update(value=_hint_html("已停止辨識。")),
550
+ gr.update(value=_score_radio_value(3)),
551
+ gr.update(value=_camera_hint_stopped()),
552
+ st,
553
+ gr.update(value="重新攝影機辨識"),
554
+ )
555
+
556
+ return (
557
+ gr.update(value=None, visible=False),
558
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
559
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
560
+ gr.update(value=_camera_hint_done()),
561
+ st,
562
+ gr.update(value="重新攝影機辨識"),
563
+ )
564
+
565
+
566
+ # =========================
567
+ # Gradio UI
568
+ # =========================
569
+ css = ""
570
+
571
+ if __name__ == "__main__":
572
+ with gr.Blocks(title="Emotion Detector", css=css) as demo:
573
+ st = gr.State(AppState())
574
+
575
+ gr.Markdown("## 情緒辨識")
576
+ hint = gr.HTML(_camera_hint_before_start())
577
+
578
+ cam = gr.Image(
579
+ sources=["webcam"],
580
+ streaming=True,
581
+ type="numpy",
582
+ label="攝影機畫面",
583
+ visible=True,
584
+ )
585
+
586
+ with gr.Row():
587
+ btn_restart = gr.Button("重新攝影機辨識", variant="primary")
588
+ btn_stop = gr.Button("停止", variant="secondary")
589
+
590
+ gr.Markdown("### 辨識結果")
591
+ result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
592
+
593
+ gr.Markdown("### 心情分數(系統判定)")
594
+ mood = gr.Radio(
595
+ choices=SCORE_CHOICES,
596
+ value=_score_radio_value(3),
597
+ label="心情分數",
598
+ interactive=False,
599
+ )
600
+
601
+ cam.stream(
602
+ fn=on_stream,
603
+ inputs=[cam, st],
604
+ outputs=[cam, result, mood, hint, st, btn_restart],
605
+ show_progress="minimal",
606
+ )
607
+
608
+ btn_restart.click(
609
+ fn=on_restart,
610
+ inputs=[st],
611
+ outputs=[cam, result, mood, hint, st, btn_restart],
612
+ show_progress="minimal",
613
+ )
614
+
615
+ btn_stop.click(
616
+ fn=on_stop,
617
+ inputs=[st],
618
+ outputs=[cam, result, mood, hint, st, btn_restart],
619
+ show_progress="minimal",
620
+ )
621
+
622
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False)