YuAnTsai commited on
Commit
68007df
·
verified ·
1 Parent(s): fff66be

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +486 -1
emotion.py CHANGED
@@ -120,4 +120,489 @@ def _camera_hint_stopped() -> str:
120
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
121
 
122
 
123
- def _result_card_html(emo_key: str, conf: Optional[float])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
121
 
122
 
123
+ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
124
+ zh = EMO_ZH.get(emo_key, emo_key)
125
+ score = EMO_TO_SCORE.get(emo_key, 3)
126
+ conf_txt = "" if conf is None else f"{conf:.1f}%"
127
+ score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
128
+
129
+ # ✅ 強制設定 color: #000000
130
+ return f"""
131
+ <div style="
132
+ border-radius:16px;
133
+ padding:20px;
134
+ border:1px solid #E6E6E6;
135
+ background:#FFFFFF;
136
+ color:#000000;
137
+ ">
138
+ <div style="font-size:14px;color:#000000;margin-bottom:8px;">
139
+ 辨識結果
140
+ </div>
141
+ <div style="
142
+ font-size:42px;
143
+ font-weight:800;
144
+ line-height:1.1;
145
+ margin-bottom:14px;
146
+ color:#000000;
147
+ ">
148
+ {zh}
149
+ </div>
150
+ <div style="font-size:16px;color:#000000;margin-bottom:6px;">
151
+ 心情分數:{score}({score_desc})
152
+ </div>
153
+ <div style="font-size:14px;color:#000000;">
154
+ 信心值:{conf_txt}
155
+ </div>
156
+ </div>
157
+ """
158
+
159
+
160
+ def _score_radio_value(score: int) -> str:
161
+ score = int(score)
162
+ score = min(5, max(1, score))
163
+ return SCORE_LABELS[score]
164
+
165
+
166
+ def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
167
+ if frame is None:
168
+ return None
169
+ h, w = frame.shape[:2]
170
+ if w <= target_w:
171
+ return frame
172
+ scale = target_w / w
173
+ new_h = max(1, int(h * scale))
174
+ img = Image.fromarray(frame)
175
+ img = img.resize((target_w, new_h))
176
+ return np.array(img)
177
+
178
+ def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
179
+ """
180
+ 裁切畫面中心以模擬數位變焦。
181
+ zoom: 放大倍率 (例如 1.5)
182
+ """
183
+ if frame is None or zoom <= 1.0:
184
+ return frame
185
+
186
+ h, w = frame.shape[:2]
187
+
188
+ # 計算新的寬高
189
+ new_w = int(w / zoom)
190
+ new_h = int(h / zoom)
191
+
192
+ # 計算起始點 (讓裁切框置中)
193
+ start_x = (w - new_w) // 2
194
+ start_y = (h - new_h) // 2
195
+
196
+ # 進行裁切
197
+ cropped = frame[start_y : start_y + new_h, start_x : start_x + new_w]
198
+ return cropped
199
+
200
+
201
+ # =========================
202
+ # 引導框(臉要放哪裡)
203
+ # =========================
204
+ GUIDE_W_RATIO = 0.55 # 框寬佔畫面比例(可調)
205
+ GUIDE_H_RATIO = 0.70 # 框高佔畫面比例(可調)
206
+ GUIDE_Y_OFFSET = 0.00 # 框中心往上(-)或往下(+)位移比例(可調,例如 -0.05)
207
+
208
+ def _guide_box(frame: np.ndarray) -> Tuple[int, int, int, int]:
209
+ """回傳引導框 (x1,y1,x2,y2)"""
210
+ h, w = frame.shape[:2]
211
+ gw = int(w * GUIDE_W_RATIO)
212
+ gh = int(h * GUIDE_H_RATIO)
213
+
214
+ cx = w // 2
215
+ cy = int(h * (0.5 + GUIDE_Y_OFFSET))
216
+
217
+ x1 = max(0, cx - gw // 2)
218
+ y1 = max(0, cy - gh // 2)
219
+ x2 = min(w - 1, cx + gw // 2)
220
+ y2 = min(h - 1, cy + gh // 2)
221
+ return x1, y1, x2, y2
222
+
223
+ def _bbox_center_in_box(bbox_xywh: Tuple[float, float, float, float], box_xyxy: Tuple[int, int, int, int]) -> bool:
224
+ """判斷臉框中心是否落在引導框內"""
225
+ x, y, w, h = bbox_xywh
226
+ cx = x + w / 2.0
227
+ cy = y + h / 2.0
228
+ x1, y1, x2, y2 = box_xyxy
229
+ return (x1 <= cx <= x2) and (y1 <= cy <= y2)
230
+
231
+ def _draw_guide_overlay(frame_rgb: np.ndarray, detected_in_box: bool) -> np.ndarray:
232
+ """
233
+ 在畫面上畫引導框:未偵測到(或不在框內)=紅框;偵測到且在框內=綠框
234
+ """
235
+ if frame_rgb is None:
236
+ return frame_rgb
237
+
238
+ img = Image.fromarray(frame_rgb)
239
+ draw = ImageDraw.Draw(img)
240
+
241
+ x1, y1, x2, y2 = _guide_box(frame_rgb)
242
+
243
+ # 顏色:紅 / 綠(RGB)
244
+ color = (0, 200, 0) if detected_in_box else (220, 0, 0)
245
+
246
+ # 線寬(跟解析度稍微連動)
247
+ h, w = frame_rgb.shape[:2]
248
+ thickness = max(3, int(min(w, h) * 0.008))
249
+
250
+ # 畫框(加粗)
251
+ for t in range(thickness):
252
+ draw.rectangle([x1 - t, y1 - t, x2 + t, y2 + t], outline=color)
253
+
254
+ # 提示文字
255
+ msg = "偵測中…" if detected_in_box else "請把臉放進框內"
256
+ tx = x1
257
+ ty = max(0, y1 - int(thickness * 6))
258
+ draw.rectangle([tx, ty, tx + 240, ty + 32], fill=(255, 255, 255))
259
+ draw.text((tx + 6, ty + 6), msg, fill=(0, 0, 0))
260
+
261
+ return np.array(img)
262
+
263
+
264
+ def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
265
+ ax, ay, aw, ah = a
266
+ bx, by, bw, bh = b
267
+ ax2, ay2 = ax + aw, ay + ah
268
+ bx2, by2 = bx + bw, by + bh
269
+
270
+ ix1, iy1 = max(ax, bx), max(ay, by)
271
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
272
+ iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1)
273
+ inter = iw * ih
274
+ if inter <= 0:
275
+ return 0.0
276
+ union = aw * ah + bw * bh - inter
277
+ return inter / max(1e-6, union)
278
+
279
+
280
+ def _pick_face_by_tracking(
281
+ faces: List[Dict],
282
+ prev_bbox: Optional[Tuple[int, int, int, int]]
283
+ ) -> Tuple[Optional[Dict], Optional[Tuple[int, int, int, int]]]:
284
+ if not faces:
285
+ return None, None
286
+
287
+ cand = []
288
+ for f in faces:
289
+ fa = f.get("facial_area", {}) or {}
290
+ x = int(fa.get("x", 0) or 0)
291
+ y = int(fa.get("y", 0) or 0)
292
+ w = int(fa.get("w", 0) or 0)
293
+ h = int(fa.get("h", 0) or 0)
294
+ if w > 0 and h > 0 and f.get("face") is not None:
295
+ cand.append((f, (x, y, w, h), w * h))
296
+
297
+ if not cand:
298
+ return None, None
299
+
300
+ if prev_bbox is not None:
301
+ best = None
302
+ best_i = 0.0
303
+ for f, bb, area in cand:
304
+ i = _iou(prev_bbox, bb)
305
+ if i > best_i:
306
+ best_i = i
307
+ best = (f, bb)
308
+ if best is not None and best_i >= IOU_TH:
309
+ return best[0], best[1]
310
+
311
+ cand.sort(key=lambda x: x[2], reverse=True)
312
+ return cand[0][0], cand[0][1]
313
+
314
+
315
+ def _reset_state_for_camera(st: AppState) -> AppState:
316
+ st.running = True
317
+ st.finished = False
318
+ st.last_analyze_t = 0.0
319
+ st.track_bbox = None
320
+ st.ema_scores = None
321
+ st.emo_keys = []
322
+ st.stable_emo = "unknown"
323
+ st.candidate_emo = None
324
+ st.candidate_since = None
325
+ st.final_emo = None
326
+ st.final_conf = None
327
+ st.final_score = 3
328
+ return st
329
+
330
+
331
+ # =========================
332
+ # Camera streaming
333
+ # =========================
334
+ def on_stream(frame_rgb: np.ndarray, st: AppState):
335
+ if st.finished or (not st.running):
336
+ cam_visible = False
337
+
338
+ if st.final_emo:
339
+ result_html = _result_card_html(st.final_emo, st.final_conf)
340
+ mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
341
+ hint_html = _camera_hint_done()
342
+ else:
343
+ result_html = _hint_html("尚未完成辨識。")
344
+ mood_val = _score_radio_value(3)
345
+ hint_html = _camera_hint_stopped()
346
+
347
+ return (
348
+ gr.update(visible=cam_visible),
349
+ gr.update(value=result_html),
350
+ gr.update(value=mood_val),
351
+ gr.update(value=hint_html),
352
+ st,
353
+ gr.update(value="重新攝影機辨識"),
354
+ )
355
+
356
+ if frame_rgb is None:
357
+ return (
358
+ gr.update(visible=True),
359
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
360
+ gr.update(value=_score_radio_value(3)),
361
+ gr.update(value=_camera_hint_before_start()),
362
+ st,
363
+ gr.update(value="重新攝影機辨識"),
364
+ )
365
+
366
+ frame_rgb = _zoom_center(frame_rgb, ZOOM_FACTOR)
367
+
368
+ # 預設:沒偵測到(或不在引導框內)-> 紅框
369
+ detected_in_box = False
370
+
371
+ now = time.time()
372
+ if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
373
+ show = st.stable_emo or "unknown"
374
+ annotated = _draw_guide_overlay(frame_rgb, detected_in_box)
375
+ return (
376
+ gr.update(visible=True, value=annotated),
377
+ gr.update(value=_result_card_html(show, st.final_conf)),
378
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
379
+ gr.update(value=_camera_hint_before_start()),
380
+ st,
381
+ gr.update(value="重新攝影機辨識"),
382
+ )
383
+
384
+ st.last_analyze_t = now
385
+
386
+ small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
387
+
388
+ faces = DeepFace.extract_faces(
389
+ img_path=small,
390
+ detector_backend=DETECTOR_BACKEND,
391
+ enforce_detection=False,
392
+ align=ALIGN_FACE,
393
+ )
394
+ pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
395
+ if pick is None or bbox is None or pick.get("face") is None:
396
+ show = st.stable_emo or "unknown"
397
+ annotated = _draw_guide_overlay(frame_rgb, detected_in_box)
398
+ return (
399
+ gr.update(visible=True, value=annotated),
400
+ gr.update(value=_result_card_html(show, st.final_conf)),
401
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
402
+ gr.update(value=_camera_hint_before_start()),
403
+ st,
404
+ gr.update(value="重新攝影機辨識"),
405
+ )
406
+
407
+ # bbox 是 small 座標,換算回 frame_rgb 座標後判斷是否在引導框內
408
+ sh, sw = small.shape[:2]
409
+ fh, fw = frame_rgb.shape[:2]
410
+ sx = fw / max(1, sw)
411
+ sy = fh / max(1, sh)
412
+
413
+ x, y, w, h = bbox
414
+ bbox_full = (x * sx, y * sy, w * sx, h * sy)
415
+ guide = _guide_box(frame_rgb)
416
+ detected_in_box = _bbox_center_in_box(bbox_full, guide)
417
+
418
+ st.track_bbox = bbox
419
+ face = pick["face"]
420
+ if face.dtype != np.uint8:
421
+ face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
422
+
423
+ result = DeepFace.analyze(
424
+ img_path=face,
425
+ actions=["emotion"],
426
+ enforce_detection=False,
427
+ )
428
+ if isinstance(result, list):
429
+ result = result[0]
430
+ emo_dict = result.get("emotion", None)
431
+
432
+ if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
433
+ show = st.stable_emo or "unknown"
434
+ annotated = _draw_guide_overlay(frame_rgb, detected_in_box)
435
+ return (
436
+ gr.update(visible=True, value=annotated),
437
+ gr.update(value=_result_card_html(show, st.final_conf)),
438
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
439
+ gr.update(value=_camera_hint_before_start()),
440
+ st,
441
+ gr.update(value="重新攝影機辨識"),
442
+ )
443
+
444
+ top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
445
+ top_conf = float(emo_dict[top_key])
446
+
447
+ if top_conf < CONF_TH:
448
+ show = st.stable_emo or "unknown"
449
+ annotated = _draw_guide_overlay(frame_rgb, detected_in_box)
450
+ return (
451
+ gr.update(visible=True, value=annotated),
452
+ gr.update(value=_result_card_html(show, st.final_conf)),
453
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
454
+ gr.update(value=_camera_hint_before_start()),
455
+ st,
456
+ gr.update(value="重新攝影機辨識"),
457
+ )
458
+
459
+ keys = list(emo_dict.keys())
460
+ vec = np.array([float(emo_dict[k]) for k in keys], dtype=np.float32)
461
+
462
+ if st.ema_scores is None or st.emo_keys != keys:
463
+ st.ema_scores = vec
464
+ st.emo_keys = keys
465
+ else:
466
+ st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
467
+
468
+ stable_idx = int(np.argmax(st.ema_scores))
469
+ stable_key = st.emo_keys[stable_idx]
470
+ st.stable_emo = stable_key
471
+
472
+ # 延遲確認
473
+ if st.candidate_emo != stable_key:
474
+ st.candidate_emo = stable_key
475
+ st.candidate_since = now
476
+ else:
477
+ if st.candidate_since is None:
478
+ st.candidate_since = now
479
+
480
+ confirmed = (
481
+ st.candidate_since is not None
482
+ and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
483
+ )
484
+
485
+ if confirmed and st.final_emo is None:
486
+ st.final_emo = stable_key
487
+ st.final_conf = top_conf
488
+ st.final_score = EMO_TO_SCORE.get(stable_key, 3)
489
+
490
+ st.running = False
491
+ st.finished = True
492
+
493
+ return (
494
+ gr.update(visible=False),
495
+ gr.update(value=_result_card_html(stable_key, top_conf)),
496
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
497
+ gr.update(value=_camera_hint_done()),
498
+ st,
499
+ gr.update(value="重新攝影機辨識"),
500
+ )
501
+
502
+ annotated = _draw_guide_overlay(frame_rgb, detected_in_box)
503
+ return (
504
+ gr.update(visible=True, value=annotated),
505
+ gr.update(value=_result_card_html(stable_key, top_conf)),
506
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
507
+ gr.update(value=_camera_hint_before_start()),
508
+ st,
509
+ gr.update(value="重新攝影機辨識"),
510
+ )
511
+
512
+
513
+ # =========================
514
+ # Mode / Buttons
515
+ # =========================
516
+
517
+ def on_restart(st: AppState):
518
+ st = _reset_state_for_camera(st)
519
+ return (
520
+ gr.update(visible=True),
521
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
522
+ gr.update(value=_score_radio_value(3)),
523
+ gr.update(value=_camera_hint_before_start()),
524
+ st,
525
+ gr.update(value="重新攝影機辨識"),
526
+ )
527
+
528
+
529
+ def on_stop(st: AppState):
530
+ st.running = False
531
+ st.finished = True
532
+ if st.final_emo is None:
533
+ return (
534
+ gr.update(visible=False),
535
+ gr.update(value=_hint_html("已停止辨識。")),
536
+ gr.update(value=_score_radio_value(3)),
537
+ gr.update(value=_camera_hint_stopped()),
538
+ st,
539
+ gr.update(value="重新攝影機辨識"),
540
+ )
541
+
542
+ return (
543
+ gr.update(visible=False),
544
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
545
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
546
+ gr.update(value=_camera_hint_done()),
547
+ st,
548
+ gr.update(value="重新攝影機辨識"),
549
+ )
550
+
551
+
552
+ # =========================
553
+ # Gradio UI
554
+ # =========================
555
+
556
+ if __name__ == "__main__":
557
+ with gr.Blocks(title="Emotion Detector", css=css) as demo:
558
+ st = gr.State(AppState())
559
+
560
+ gr.Markdown("## 情緒辨識")
561
+
562
+ hint = gr.HTML(_camera_hint_before_start())
563
+
564
+ cam = gr.Image(
565
+ sources=["webcam"],
566
+ streaming=True,
567
+ type="numpy",
568
+ label="攝影機",
569
+ visible=True,
570
+ )
571
+
572
+ with gr.Row():
573
+ btn_restart = gr.Button("重新攝影機辨識", variant="primary")
574
+ btn_stop = gr.Button("停止", variant="secondary")
575
+
576
+ gr.Markdown("### 辨識結果")
577
+ result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
578
+
579
+ gr.Markdown("### 心情分數(系統判定)")
580
+ mood = gr.Radio(
581
+ choices=SCORE_CHOICES,
582
+ value=_score_radio_value(3),
583
+ label="心情分數",
584
+ interactive=False,
585
+ )
586
+
587
+ cam.stream(
588
+ fn=on_stream,
589
+ inputs=[cam, st],
590
+ outputs=[cam, result, mood, hint, st, btn_restart],
591
+ show_progress="minimal",
592
+ )
593
+
594
+ btn_restart.click(
595
+ fn=on_restart,
596
+ inputs=[st],
597
+ outputs=[cam, result, mood, hint, st, btn_restart],
598
+ show_progress="minimal",
599
+ )
600
+
601
+ btn_stop.click(
602
+ fn=on_stop,
603
+ inputs=[st],
604
+ outputs=[cam, result, mood, hint, st, btn_restart],
605
+ show_progress="minimal",
606
+ )
607
+
608
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False)