PhilipL commited on
Commit
25f60e9
·
verified ·
1 Parent(s): 0934b27

Upload emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +739 -0
emotion.py ADDED
@@ -0,0 +1,739 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
8
+
9
+ from deepface import DeepFace
10
+
11
+
12
+ # =========================
13
+ # 參數(可微調)
14
+ # =========================
15
+ ANALYZE_EVERY_SEC = 0.8
16
+ DOWNSAMPLE_W = 720
17
+ EMA_ALPHA = 0.45
18
+ CONF_TH = 40.0
19
+ SWITCH_CONFIRM_SEC = 2.5
20
+ IOU_TH = 0.25
21
+
22
+ DETECTOR_BACKEND = "mtcnn" # 想快一點可改 "opencv"
23
+ ALIGN_FACE = True
24
+
25
+
26
+ # =========================
27
+ # 情緒中文 + 分數映射(7 -> 5)
28
+ # =========================
29
+ EMO_ZH = {
30
+ "angry": "生氣",
31
+ "disgust": "厭惡",
32
+ "fear": "害怕",
33
+ "happy": "開心",
34
+ "sad": "難過",
35
+ "surprise": "驚訝",
36
+ "neutral": "平靜",
37
+ "unknown": "未知",
38
+ }
39
+
40
+ # 你要的:把 7 種情緒塞進 5 分心情分數
41
+ EMO_TO_SCORE = {
42
+ "angry": 1,
43
+ "disgust": 1,
44
+ "fear": 1,
45
+ "sad": 2,
46
+ "neutral": 3,
47
+ "surprise": 4,
48
+ "happy": 5,
49
+ "unknown": 3,
50
+ }
51
+
52
+ # ### [整合需求 新增] ###
53
+ # 為了讓主程式 (app.py) 可以呼叫這個轉換邏輯,將其包裝成函式
54
+ def get_emotion_score(emo: str) -> int:
55
+ return EMO_TO_SCORE.get(emo, 3)
56
+ # ######################
57
+
58
+ SCORE_LABELS = {
59
+ 1: "1(心情差)",
60
+ 2: "2(不太好)",
61
+ 3: "3(普通)",
62
+ 4: "4(不錯)",
63
+ 5: "5(超棒)",
64
+ }
65
+ SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
66
+
67
+
68
+ # =========================
69
+ # 狀態
70
+ # =========================
71
+ @dataclass
72
+ class AppState:
73
+ mode: str = "攝影機辨識"
74
+
75
+ running: bool = True
76
+ finished: bool = False
77
+ last_analyze_t: float = 0.0
78
+
79
+ # tracking
80
+ track_bbox: Optional[Tuple[int, int, int, int]] = None
81
+
82
+ # smoothing
83
+ ema_scores: Optional[np.ndarray] = None
84
+ emo_keys: List[str] = field(default_factory=list)
85
+
86
+ # stable/candidate
87
+ stable_emo: str = "unknown"
88
+ candidate_emo: Optional[str] = None
89
+ candidate_since: Optional[float] = None
90
+
91
+ # final
92
+ final_emo: Optional[str] = None
93
+ final_conf: Optional[float] = None
94
+
95
+ # ### [整合需求 新增] ###
96
+ # 用來儲存最後計算出的整數分數 (1~5),讓外部程式容易讀取
97
+ final_score: int = 3
98
+ # ######################
99
+
100
+
101
+ # =========================
102
+ # UI HTML
103
+ # =========================
104
+ def _hint_html(msg: str) -> str:
105
+ return f"""
106
+ <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#111;">
107
+ {msg}
108
+ </div>
109
+ """
110
+
111
+
112
+ def _camera_hint_before_start() -> str:
113
+ return _hint_html(
114
+ "請先按「Click to access webcam / 允許」開啟攝影機;"
115
+ "接著,請按「錄製(Record)」開始。"
116
+ )
117
+
118
+
119
+ def _camera_hint_done() -> str:
120
+ return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
121
+
122
+
123
+ def _camera_hint_stopped() -> str:
124
+ return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
125
+
126
+
127
+ def _upload_hint_before() -> str:
128
+ return _hint_html("請上傳一張清楚的臉部照片(臉越大越準)。")
129
+
130
+
131
+ def _upload_hint_done() -> str:
132
+ return _hint_html("已完成圖片辨識,可按「重新上傳圖片辨識」再次辨識。")
133
+
134
+
135
+ def _upload_hint_stopped() -> str:
136
+ return _hint_html("已停止圖片辨識。可按「重新上傳圖片辨識」再試一次。")
137
+
138
+
139
+ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
140
+ zh = EMO_ZH.get(emo_key, emo_key)
141
+ score = EMO_TO_SCORE.get(emo_key, 3)
142
+ conf_txt = "" if conf is None else f"{conf:.1f}%"
143
+ score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
144
+
145
+ # ✅ 結果字體用黑色,避免你說的灰字看不清楚
146
+ return f"""
147
+ <div style="
148
+ border-radius:16px;
149
+ padding:20px;
150
+ border:1px solid #E6E6E6;
151
+ background:#FFFFFF;
152
+ color:#111;
153
+ ">
154
+ <div style="font-size:14px;color:#444;margin-bottom:8px;">
155
+ 辨識結果
156
+ </div>
157
+
158
+ <div style="
159
+ font-size:42px;
160
+ font-weight:800;
161
+ line-height:1.1;
162
+ margin-bottom:14px;
163
+ color:#000;
164
+ ">
165
+ {zh}
166
+ </div>
167
+
168
+ <div style="font-size:16px;color:#111;margin-bottom:6px;">
169
+ 心情分數:<b>{score}</b>({score_desc})
170
+ </div>
171
+
172
+ <div style="font-size:14px;color:#111;">
173
+ 信心值:{conf_txt}
174
+ </div>
175
+ </div>
176
+ """
177
+
178
+
179
+ def _score_radio_value(score: int) -> str:
180
+ score = int(score)
181
+ score = min(5, max(1, score))
182
+ return SCORE_LABELS[score]
183
+
184
+
185
+ def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
186
+ if frame is None:
187
+ return None
188
+ h, w = frame.shape[:2]
189
+ if w <= target_w:
190
+ return frame
191
+ scale = target_w / w
192
+ new_h = max(1, int(h * scale))
193
+ img = Image.fromarray(frame)
194
+ img = img.resize((target_w, new_h))
195
+ return np.array(img)
196
+
197
+
198
+ def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
199
+ ax, ay, aw, ah = a
200
+ bx, by, bw, bh = b
201
+ ax2, ay2 = ax + aw, ay + ah
202
+ bx2, by2 = bx + bw, by + bh
203
+
204
+ ix1, iy1 = max(ax, bx), max(ay, by)
205
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
206
+ iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
207
+ inter = iw * ih
208
+ if inter <= 0:
209
+ return 0.0
210
+ union = aw * ah + bw * bh - inter
211
+ return inter / max(1e-6, union)
212
+
213
+
214
+ def _pick_face_by_tracking(
215
+ faces: List[Dict],
216
+ prev_bbox: Optional[Tuple[int, int, int, int]]
217
+ ) -> Tuple[Optional[Dict], Optional[Tuple[int, int, int, int]]]:
218
+ if not faces:
219
+ return None, None
220
+
221
+ cand = []
222
+ for f in faces:
223
+ fa = f.get("facial_area", {}) or {}
224
+ x = int(fa.get("x", 0) or 0)
225
+ y = int(fa.get("y", 0) or 0)
226
+ w = int(fa.get("w", 0) or 0)
227
+ h = int(fa.get("h", 0) or 0)
228
+ if w > 0 and h > 0 and f.get("face") is not None:
229
+ cand.append((f, (x, y, w, h), w * h))
230
+
231
+ if not cand:
232
+ return None, None
233
+
234
+ if prev_bbox is not None:
235
+ best = None
236
+ best_i = 0.0
237
+ for f, bb, area in cand:
238
+ i = _iou(prev_bbox, bb)
239
+ if i > best_i:
240
+ best_i = i
241
+ best = (f, bb)
242
+ if best is not None and best_i >= IOU_TH:
243
+ return best[0], best[1]
244
+
245
+ cand.sort(key=lambda x: x[2], reverse=True)
246
+ return cand[0][0], cand[0][1]
247
+
248
+
249
+ def _reset_state_for_camera(st: AppState) -> AppState:
250
+ st.running = True
251
+ st.finished = False
252
+ st.last_analyze_t = 0.0
253
+ st.track_bbox = None
254
+ st.ema_scores = None
255
+ st.emo_keys = []
256
+ st.stable_emo = "unknown"
257
+ st.candidate_emo = None
258
+ st.candidate_since = None
259
+ st.final_emo = None
260
+ st.final_conf = None
261
+
262
+ # ### [整合需求 新增] ###
263
+ st.final_score = 3
264
+ # ######################
265
+
266
+ return st
267
+
268
+
269
+ def _reset_state_for_upload(st: AppState) -> AppState:
270
+ st.running = False
271
+ st.finished = False
272
+ st.last_analyze_t = 0.0
273
+ st.track_bbox = None
274
+ st.ema_scores = None
275
+ st.emo_keys = []
276
+ st.stable_emo = "unknown"
277
+ st.candidate_emo = None
278
+ st.candidate_since = None
279
+ st.final_emo = None
280
+ st.final_conf = None
281
+
282
+ # ### [整合需求 新增] ###
283
+ st.final_score = 3
284
+ # ######################
285
+
286
+ return st
287
+
288
+
289
+ # =========================
290
+ # Camera streaming
291
+ # =========================
292
+ def on_stream(frame_rgb: np.ndarray, st: AppState):
293
+ # 上傳模式不走攝影機流程
294
+ if st.mode != "攝影機辨識":
295
+ return (
296
+ gr.update(), # cam
297
+ gr.update(), # result
298
+ gr.update(), # mood
299
+ gr.update(value=_upload_hint_before()),
300
+ st,
301
+ gr.update(value="重新上傳圖片辨識"),
302
+ )
303
+
304
+ # ✅ 完成/停止狀態:不要再顯示「Click to access…/Record…」
305
+ if st.finished or (not st.running):
306
+ cam_visible = False
307
+
308
+ if st.final_emo:
309
+ result_html = _result_card_html(st.final_emo, st.final_conf)
310
+ mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
311
+ hint_html = _camera_hint_done()
312
+ else:
313
+ result_html = _hint_html("尚未完成辨識。")
314
+ mood_val = _score_radio_value(3)
315
+ hint_html = _camera_hint_stopped()
316
+
317
+ return (
318
+ gr.update(visible=cam_visible),
319
+ gr.update(value=result_html),
320
+ gr.update(value=mood_val),
321
+ gr.update(value=hint_html),
322
+ st,
323
+ gr.update(value="重新攝影機辨識"),
324
+ )
325
+
326
+ # ✅ 還沒拿到 frame:提示使用者按允許/Record(而不是亂寫已開啟)
327
+ if frame_rgb is None:
328
+ return (
329
+ gr.update(visible=True),
330
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
331
+ gr.update(value=_score_radio_value(3)),
332
+ gr.update(value=_camera_hint_before_start()),
333
+ st,
334
+ gr.update(value="重新攝影機辨識"),
335
+ )
336
+
337
+ now = time.time()
338
+ if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
339
+ show = st.stable_emo or "unknown"
340
+ return (
341
+ gr.update(visible=True),
342
+ gr.update(value=_result_card_html(show, st.final_conf)),
343
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
344
+ gr.update(value=_camera_hint_before_start()),
345
+ st,
346
+ gr.update(value="重新攝影機辨識"),
347
+ )
348
+
349
+ st.last_analyze_t = now
350
+
351
+ small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
352
+
353
+ faces = DeepFace.extract_faces(
354
+ img_path=small,
355
+ detector_backend=DETECTOR_BACKEND,
356
+ enforce_detection=False,
357
+ align=ALIGN_FACE,
358
+ )
359
+ pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
360
+ if pick is None or bbox is None or pick.get("face") is None:
361
+ show = st.stable_emo or "unknown"
362
+ return (
363
+ gr.update(visible=True),
364
+ gr.update(value=_result_card_html(show, st.final_conf)),
365
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
366
+ gr.update(value=_camera_hint_before_start()),
367
+ st,
368
+ gr.update(value="重新攝影機辨識"),
369
+ )
370
+
371
+ st.track_bbox = bbox
372
+ face = pick["face"]
373
+ if face.dtype != np.uint8:
374
+ face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
375
+
376
+ result = DeepFace.analyze(
377
+ img_path=face,
378
+ actions=["emotion"],
379
+ enforce_detection=False,
380
+ )
381
+ if isinstance(result, list):
382
+ result = result[0]
383
+ emo_dict = result.get("emotion", None)
384
+
385
+ if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
386
+ show = st.stable_emo or "unknown"
387
+ return (
388
+ gr.update(visible=True),
389
+ gr.update(value=_result_card_html(show, st.final_conf)),
390
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
391
+ gr.update(value=_camera_hint_before_start()),
392
+ st,
393
+ gr.update(value="重新攝影機辨識"),
394
+ )
395
+
396
+ top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
397
+ top_conf = float(emo_dict[top_key])
398
+
399
+ if top_conf < CONF_TH:
400
+ show = st.stable_emo or "unknown"
401
+ return (
402
+ gr.update(visible=True),
403
+ gr.update(value=_result_card_html(show, st.final_conf)),
404
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
405
+ gr.update(value=_camera_hint_before_start()),
406
+ st,
407
+ gr.update(value="重新攝影機辨識"),
408
+ )
409
+
410
+ keys = list(emo_dict.keys())
411
+ vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
412
+
413
+ if st.ema_scores is None or st.emo_keys != keys:
414
+ st.ema_scores = vec
415
+ st.emo_keys = keys
416
+ else:
417
+ st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
418
+
419
+ stable_idx = int(np.argmax(st.ema_scores))
420
+ stable_key = st.emo_keys[stable_idx]
421
+ st.stable_emo = stable_key
422
+
423
+ # 延遲確認
424
+ if st.candidate_emo != stable_key:
425
+ st.candidate_emo = stable_key
426
+ st.candidate_since = now
427
+ else:
428
+ if st.candidate_since is None:
429
+ st.candidate_since = now
430
+
431
+ confirmed = (
432
+ st.candidate_since is not None
433
+ and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
434
+ )
435
+
436
+ if confirmed and st.final_emo is None:
437
+ st.final_emo = stable_key
438
+ st.final_conf = top_conf
439
+
440
+ # ### [整合需求 新增] ###
441
+ # 在辨識完成的瞬間,順便存入整數分數
442
+ st.final_score = EMO_TO_SCORE.get(stable_key, 3)
443
+ # ######################
444
+
445
+ st.running = False
446
+ st.finished = True
447
+
448
+ return (
449
+ gr.update(visible=False),
450
+ gr.update(value=_result_card_html(stable_key, top_conf)),
451
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
452
+ gr.update(value=_camera_hint_done()),
453
+ st,
454
+ gr.update(value="重新攝影機辨識"),
455
+ )
456
+
457
+ return (
458
+ gr.update(visible=True),
459
+ gr.update(value=_result_card_html(stable_key, top_conf)),
460
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
461
+ gr.update(value=_camera_hint_before_start()),
462
+ st,
463
+ gr.update(value="重新攝影機辨識"),
464
+ )
465
+
466
+
467
+ # =========================
468
+ # Upload image
469
+ # =========================
470
+ def on_upload(img: np.ndarray, st: AppState):
471
+ if st.mode != "上傳圖片辨識":
472
+ return (
473
+ gr.update(), gr.update(), st,
474
+ gr.update(value=_camera_hint_before_start()),
475
+ gr.update(value="重新攝影機辨識")
476
+ )
477
+
478
+ st = _reset_state_for_upload(st)
479
+
480
+ if img is None:
481
+ return (
482
+ gr.update(value=_upload_hint_before()),
483
+ gr.update(value=_score_radio_value(3)),
484
+ st,
485
+ gr.update(value=_upload_hint_before()),
486
+ gr.update(value="重新上傳圖片辨識"),
487
+ )
488
+
489
+ small = _downsample_rgb(img, DOWNSAMPLE_W)
490
+ faces = DeepFace.extract_faces(
491
+ img_path=small,
492
+ detector_backend=DETECTOR_BACKEND,
493
+ enforce_detection=False,
494
+ align=ALIGN_FACE,
495
+ )
496
+ pick, bbox = _pick_face_by_tracking(faces, None)
497
+ if pick is None or pick.get("face") is None:
498
+ return (
499
+ gr.update(value=_hint_html("沒有偵測到人臉,請換一張更清楚、臉更大的照片。")),
500
+ gr.update(value=_score_radio_value(3)),
501
+ st,
502
+ gr.update(value=_upload_hint_before()),
503
+ gr.update(value="重新上傳圖片辨識"),
504
+ )
505
+
506
+ face = pick["face"]
507
+ if face.dtype != np.uint8:
508
+ face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
509
+
510
+ result = DeepFace.analyze(
511
+ img_path=face,
512
+ actions=["emotion"],
513
+ enforce_detection=False,
514
+ )
515
+ if isinstance(result, list):
516
+ result = result[0]
517
+ emo_dict = result.get("emotion", None)
518
+ if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
519
+ return (
520
+ gr.update(value=_hint_html("辨識失敗,請換一張照片再試一次。")),
521
+ gr.update(value=_score_radio_value(3)),
522
+ st,
523
+ gr.update(value=_upload_hint_before()),
524
+ gr.update(value="重新上傳圖片辨識"),
525
+ )
526
+
527
+ top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
528
+ top_conf = float(emo_dict[top_key])
529
+
530
+ st.final_emo = top_key
531
+ st.final_conf = top_conf
532
+
533
+ # ### [整合需求 新增] ###
534
+ # 圖片辨識完成,存入分數
535
+ st.final_score = EMO_TO_SCORE.get(top_key, 3)
536
+ # ######################
537
+
538
+ st.finished = True
539
+
540
+ return (
541
+ gr.update(value=_result_card_html(top_key, top_conf)),
542
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(top_key, 3))),
543
+ st,
544
+ gr.update(value=_upload_hint_done()),
545
+ gr.update(value="重新上傳圖片辨識"),
546
+ )
547
+
548
+
549
+ # =========================
550
+ # Mode / Buttons
551
+ # =========================
552
+ def on_mode_change(mode: str, st: AppState):
553
+ st.mode = mode
554
+
555
+ if mode == "攝影機辨識":
556
+ st = _reset_state_for_camera(st)
557
+ return (
558
+ gr.update(visible=True), # cam
559
+ gr.update(visible=False, value=None), # upload
560
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")), # result
561
+ gr.update(value=_score_radio_value(3)), # mood
562
+ gr.update(value=_camera_hint_before_start()),
563
+ st,
564
+ gr.update(value="重新攝影機辨識"),
565
+ )
566
+ else:
567
+ st = _reset_state_for_upload(st)
568
+ return (
569
+ gr.update(visible=False), # cam
570
+ gr.update(visible=True, value=None), # upload
571
+ gr.update(value=_upload_hint_before()), # result
572
+ gr.update(value=_score_radio_value(3)), # mood
573
+ gr.update(value=_upload_hint_before()), # hint
574
+ st,
575
+ gr.update(value="重新上傳圖片辨識"),
576
+ )
577
+
578
+
579
+ def on_restart(st: AppState):
580
+ if st.mode == "攝影機辨識":
581
+ st = _reset_state_for_camera(st)
582
+ return (
583
+ gr.update(visible=True), # cam
584
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")), # result
585
+ gr.update(value=_score_radio_value(3)), # mood
586
+ gr.update(value=_camera_hint_before_start()), # hint
587
+ st,
588
+ gr.update(value="重新攝影機辨識"), # btn label
589
+ gr.update(), # upload
590
+ )
591
+ else:
592
+ st = _reset_state_for_upload(st)
593
+ return (
594
+ gr.update(), # cam
595
+ gr.update(value=_upload_hint_before()), # result
596
+ gr.update(value=_score_radio_value(3)), # mood
597
+ gr.update(value=_upload_hint_before()), # hint
598
+ st,
599
+ gr.update(value="重新上傳圖片辨識"),
600
+ gr.update(value=None), # upload clear
601
+ )
602
+
603
+
604
+ def on_stop(st: AppState):
605
+ if st.mode == "攝影機辨識":
606
+ st.running = False
607
+ st.finished = True
608
+ # ✅ 停止後也不要再顯示 Click/Record 提示,改成「已停止/可重新」
609
+ if st.final_emo is None:
610
+ return (
611
+ gr.update(visible=False),
612
+ gr.update(value=_hint_html("已停止辨識。")),
613
+ gr.update(value=_score_radio_value(3)),
614
+ gr.update(value=_camera_hint_stopped()),
615
+ st,
616
+ gr.update(value="重新攝影機辨識"),
617
+ gr.update(),
618
+ )
619
+
620
+ return (
621
+ gr.update(visible=False),
622
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
623
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
624
+ gr.update(value=_camera_hint_done()),
625
+ st,
626
+ gr.update(value="重新攝影機辨識"),
627
+ gr.update(),
628
+ )
629
+
630
+ # 上傳模式:停止 → 回到可重新上傳
631
+ st = _reset_state_for_upload(st)
632
+ return (
633
+ gr.update(),
634
+ gr.update(value=_hint_html("已停止圖片辨識。")),
635
+ gr.update(value=_score_radio_value(3)),
636
+ gr.update(value=_upload_hint_stopped()),
637
+ st,
638
+ gr.update(value="重新上傳圖片辨識"),
639
+ gr.update(value=None),
640
+ )
641
+
642
+
643
+ # =========================
644
+ # Gradio UI
645
+ # =========================
646
+ # ### [整合需求] 下方的 UI Block 可以保留,
647
+ # 之後整合進 app.py 時,app.py 會只 import 上面的函式,不會執行這裡。
648
+ # 這樣隊友還是可以單獨執行這個檔案來測試。
649
+ css = """
650
+ #app_container { max-width: 960px; margin: 0 auto; }
651
+ .gradio-container { background: #f1f5f9 !important; }
652
+ """
653
+
654
+ # 為了方便 app.py 整合,我們把 UI 宣告包���一個函式 render_ui (或是 create_demo)
655
+ # 但為了不改動隊友原本的結構,我們保留下面的寫法,但加上 if __name__ == "__main__"
656
+ # 這樣 app.py import 時不會自動跑出介面
657
+
658
+ if __name__ == "__main__":
659
+ with gr.Blocks(title="Emotion Detector") as demo:
660
+ st = gr.State(AppState())
661
+
662
+ gr.Markdown("## 情緒辨識")
663
+ gr.Markdown("請先選擇辨識方式:**攝影機** 或 **上傳圖片**。")
664
+
665
+ mode = gr.Radio(
666
+ choices=["攝影機辨識", "上傳圖片辨識"],
667
+ value="攝影機辨識",
668
+ label="辨識方式",
669
+ )
670
+
671
+ hint = gr.HTML(_camera_hint_before_start())
672
+
673
+ cam = gr.Image(
674
+ sources=["webcam"],
675
+ streaming=True,
676
+ type="numpy",
677
+ label="攝影機",
678
+ visible=True,
679
+ )
680
+
681
+ upload = gr.Image(
682
+ sources=["upload"],
683
+ type="numpy",
684
+ label="上傳圖片",
685
+ visible=False,
686
+ )
687
+
688
+ with gr.Row():
689
+ btn_restart = gr.Button("重新攝影機辨識", variant="primary")
690
+ btn_stop = gr.Button("停止", variant="secondary")
691
+
692
+ # ✅ 你要的排序:結果在上、分數在下
693
+ gr.Markdown("### 辨識結果")
694
+ result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
695
+
696
+ gr.Markdown("### 心情分數(系統判定)")
697
+ mood = gr.Radio(
698
+ choices=SCORE_CHOICES,
699
+ value=_score_radio_value(3),
700
+ label="心情分數",
701
+ interactive=False,
702
+ )
703
+
704
+ mode.change(
705
+ fn=on_mode_change,
706
+ inputs=[mode, st],
707
+ outputs=[cam, upload, result, mood, hint, st, btn_restart],
708
+ show_progress="minimal",
709
+ )
710
+
711
+ cam.stream(
712
+ fn=on_stream,
713
+ inputs=[cam, st],
714
+ outputs=[cam, result, mood, hint, st, btn_restart],
715
+ show_progress="minimal",
716
+ )
717
+
718
+ upload.change(
719
+ fn=on_upload,
720
+ inputs=[upload, st],
721
+ outputs=[result, mood, st, hint, btn_restart],
722
+ show_progress="minimal",
723
+ )
724
+
725
+ btn_restart.click(
726
+ fn=on_restart,
727
+ inputs=[st],
728
+ outputs=[cam, result, mood, hint, st, btn_restart, upload],
729
+ show_progress="minimal",
730
+ )
731
+
732
+ btn_stop.click(
733
+ fn=on_stop,
734
+ inputs=[st],
735
+ outputs=[cam, result, mood, hint, st, btn_restart, upload],
736
+ show_progress="minimal",
737
+ )
738
+
739
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False)