YuAnTsai commited on
Commit
a303742
·
verified ·
1 Parent(s): 7223a4b

Update emotion.py

Browse files
Files changed (1) hide show
  1. emotion.py +99 -45
emotion.py CHANGED
@@ -23,6 +23,12 @@ ZOOM_FACTOR = 1.5
23
  DETECTOR_BACKEND = "mtcnn" # 想快一點可改 "opencv"
24
  ALIGN_FACE = True
25
 
 
 
 
 
 
 
26
 
27
  # =========================
28
  # 情緒中文 + 分數映射(7 -> 5)
@@ -38,7 +44,6 @@ EMO_ZH = {
38
  "unknown": "未知",
39
  }
40
 
41
- # 你要的:把 7 種情緒塞進 5 分心情分數
42
  EMO_TO_SCORE = {
43
  "angry": 1,
44
  "disgust": 1,
@@ -92,13 +97,13 @@ class AppState:
92
 
93
  final_score: int = 3
94
 
95
- # ✅ 新增:最近有沒有臉(避免節流期間一直紅框)
96
  last_face_t: float = 0.0
97
  face_in_box: bool = False
98
 
99
 
100
  # =========================
101
- # UI HTML (強制黑色文字)
102
  # =========================
103
  def _hint_html(msg: str) -> str:
104
  return f"""
@@ -107,22 +112,18 @@ def _hint_html(msg: str) -> str:
107
  </div>
108
  """
109
 
110
-
111
  def _camera_hint_before_start() -> str:
112
  return _hint_html(
113
  "請先按「Click to access webcam / 允許」開啟攝影機;"
114
  "接著,請按「錄製(Record)」開始。"
115
  )
116
 
117
-
118
  def _camera_hint_done() -> str:
119
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
120
 
121
-
122
  def _camera_hint_stopped() -> str:
123
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
124
 
125
-
126
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
127
  zh = EMO_ZH.get(emo_key, emo_key)
128
  score = EMO_TO_SCORE.get(emo_key, 3)
@@ -135,7 +136,7 @@ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
135
  padding:20px;
136
  border:1px solid #E6E6E6;
137
  background:#FFFFFF;
138
- color:#000000;
139
  ">
140
  <div style="font-size:14px;color:#000000;margin-bottom:8px;">
141
  辨識結果
@@ -158,13 +159,15 @@ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
158
  </div>
159
  """
160
 
161
-
162
  def _score_radio_value(score: int) -> str:
163
  score = int(score)
164
  score = min(5, max(1, score))
165
  return SCORE_LABELS[score]
166
 
167
 
 
 
 
168
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
169
  if frame is None:
170
  return None
@@ -177,11 +180,9 @@ def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
177
  img = img.resize((target_w, new_h))
178
  return np.array(img)
179
 
180
-
181
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
182
  if frame is None or zoom <= 1.0:
183
  return frame
184
-
185
  h, w = frame.shape[:2]
186
  new_w = int(w / zoom)
187
  new_h = int(h / zoom)
@@ -192,7 +193,7 @@ def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
192
 
193
 
194
  # =========================
195
- # 引導框(臉要放哪裡)
196
  # =========================
197
  GUIDE_W_RATIO = 0.55
198
  GUIDE_H_RATIO = 0.70
@@ -202,10 +203,8 @@ def _guide_box(frame: np.ndarray) -> Tuple[int, int, int, int]:
202
  h, w = frame.shape[:2]
203
  gw = int(w * GUIDE_W_RATIO)
204
  gh = int(h * GUIDE_H_RATIO)
205
-
206
  cx = w // 2
207
  cy = int(h * (0.5 + GUIDE_Y_OFFSET))
208
-
209
  x1 = max(0, cx - gw // 2)
210
  y1 = max(0, cy - gh // 2)
211
  x2 = min(w - 1, cx + gw // 2)
@@ -227,8 +226,6 @@ def _draw_guide_overlay(frame_rgb: np.ndarray, detected_in_box: bool) -> np.ndar
227
  draw = ImageDraw.Draw(img)
228
 
229
  x1, y1, x2, y2 = _guide_box(frame_rgb)
230
-
231
- # 紅/綠框
232
  color = (0, 200, 0) if detected_in_box else (220, 0, 0)
233
 
234
  h, w = frame_rgb.shape[:2]
@@ -264,7 +261,6 @@ def _iou(a: Tuple[int, int, int, int], b: Tuple[int, int, int, int]) -> float:
264
  union = aw * ah + bw * bh - inter
265
  return inter / max(1e-6, union)
266
 
267
-
268
  def _pick_face_by_tracking(
269
  faces: List[Dict],
270
  prev_bbox: Optional[Tuple[int, int, int, int]]
@@ -314,12 +310,40 @@ def _reset_state_for_camera(st: AppState) -> AppState:
314
  st.final_conf = None
315
  st.final_score = 3
316
 
317
- # ✅ 新增欄位也要 reset
318
  st.last_face_t = 0.0
319
  st.face_in_box = False
320
  return st
321
 
322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  # =========================
324
  # Camera streaming
325
  # =========================
@@ -359,16 +383,12 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
359
 
360
  now = time.time()
361
 
362
- # ✅ 節流時沿用最近一次「臉是否在框內」狀態(避免一直紅框)
363
  detected_in_box_recent = st.face_in_box and (now - st.last_face_t) < 1.0
364
 
365
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
366
  annotated = _draw_guide_overlay(frame_rgb, detected_in_box_recent)
367
- if detected_in_box_recent:
368
- result_html = _hint_html("已偵測到人臉,開始辨識情緒中…")
369
- else:
370
- result_html = _hint_html("未偵測到人臉,請把臉放進框內")
371
-
372
  return (
373
  gr.update(visible=True, value=annotated),
374
  gr.update(value=result_html),
@@ -382,16 +402,20 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
382
 
383
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
384
 
385
- faces = DeepFace.extract_faces(
386
- img_path=small,
387
- detector_backend=DETECTOR_BACKEND,
388
- enforce_detection=False,
389
- align=ALIGN_FACE,
390
- )
 
 
 
 
391
 
392
  pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
393
 
394
- # ✅ (A) 完全沒臉:紅框 + 不顯示情緒(避免「沒臉也顯示害怕」)
395
  if pick is None or bbox is None or pick.get("face") is None:
396
  st.track_bbox = None
397
  st.face_in_box = False
@@ -408,7 +432,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
408
  gr.update(value="重新攝影機辨識"),
409
  )
410
 
411
- # bbox 是 small 座標,換算回 frame_rgb
412
  sh, sw = small.shape[:2]
413
  fh, fw = frame_rgb.shape[:2]
414
  sx = fw / max(1, sw)
@@ -417,15 +441,31 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
417
  x, y, w, h = bbox
418
  bbox_full = (x * sx, y * sy, w * sx, h * sy)
419
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  guide = _guide_box(frame_rgb)
421
  detected_in_box = _bbox_center_in_box(bbox_full, guide)
422
 
423
- # ✅ 更新「臉狀態」
424
  st.last_face_t = now
425
  st.face_in_box = detected_in_box
426
  st.track_bbox = bbox
427
 
428
- # ✅ (B) 有臉但不在框內:紅框,不做情緒辨識
429
  if not detected_in_box:
430
  annotated = _draw_guide_overlay(frame_rgb, False)
431
  return (
@@ -437,21 +477,38 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
437
  gr.update(value="重新攝影機辨識"),
438
  )
439
 
440
- # ✅ (C) 有臉且在框內:綠框,才開始 analyze
441
  face = pick["face"]
442
  if face.dtype != np.uint8:
443
  face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
444
 
445
- result = DeepFace.analyze(
446
- img_path=face,
447
- actions=["emotion"],
448
- enforce_detection=False,
449
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
  if isinstance(result, list):
451
  result = result[0]
452
  emo_dict = result.get("emotion", None)
453
 
454
- # 若分析結果不完整:仍顯示綠框 + 辨識中提示
455
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
456
  annotated = _draw_guide_overlay(frame_rgb, True)
457
  return (
@@ -466,7 +523,6 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
466
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
467
  top_conf = float(emo_dict[top_key])
468
 
469
- # 信心不足:仍綠框 + 辨識中
470
  if top_conf < CONF_TH:
471
  annotated = _draw_guide_overlay(frame_rgb, True)
472
  return (
@@ -491,7 +547,6 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
491
  stable_key = st.emo_keys[stable_idx]
492
  st.stable_emo = stable_key
493
 
494
- # 延遲確認
495
  if st.candidate_emo != stable_key:
496
  st.candidate_emo = stable_key
497
  st.candidate_since = now
@@ -533,7 +588,7 @@ def on_stream(frame_rgb: np.ndarray, st: AppState):
533
 
534
 
535
  # =========================
536
- # Mode / Buttons
537
  # =========================
538
  def on_restart(st: AppState):
539
  st = _reset_state_for_camera(st)
@@ -546,7 +601,6 @@ def on_restart(st: AppState):
546
  gr.update(value="重新攝影機辨識"),
547
  )
548
 
549
-
550
  def on_stop(st: AppState):
551
  st.running = False
552
  st.finished = True
 
23
  DETECTOR_BACKEND = "mtcnn" # 想快一點可改 "opencv"
24
  ALIGN_FACE = True
25
 
26
+ # ✅ 新增:防止背景誤判為臉(你要的核心)
27
+ # 臉框面積佔整張畫面比例下限(太小通常是誤判)
28
+ MIN_FACE_AREA_RATIO = 0.05 # 5%(可調:0.03~0.08)
29
+ # 偵測器信心值下限(MTCNN/RetinaFace 通常有 confidence)
30
+ MIN_DET_CONF = 0.90 # 可調:0.80~0.95
31
+
32
 
33
  # =========================
34
  # 情緒中文 + 分數映射(7 -> 5)
 
44
  "unknown": "未知",
45
  }
46
 
 
47
  EMO_TO_SCORE = {
48
  "angry": 1,
49
  "disgust": 1,
 
97
 
98
  final_score: int = 3
99
 
100
+ # 最近有沒有臉(避免節流期間一直紅框)
101
  last_face_t: float = 0.0
102
  face_in_box: bool = False
103
 
104
 
105
  # =========================
106
+ # UI HTML
107
  # =========================
108
  def _hint_html(msg: str) -> str:
109
  return f"""
 
112
  </div>
113
  """
114
 
 
115
  def _camera_hint_before_start() -> str:
116
  return _hint_html(
117
  "請先按「Click to access webcam / 允許」開啟攝影機;"
118
  "接著,請按「錄製(Record)」開始。"
119
  )
120
 
 
121
  def _camera_hint_done() -> str:
122
  return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
123
 
 
124
  def _camera_hint_stopped() -> str:
125
  return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
126
 
 
127
  def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
128
  zh = EMO_ZH.get(emo_key, emo_key)
129
  score = EMO_TO_SCORE.get(emo_key, 3)
 
136
  padding:20px;
137
  border:1px solid #E6E6E6;
138
  background:#FFFFFF;
139
+ color:#000000;
140
  ">
141
  <div style="font-size:14px;color:#000000;margin-bottom:8px;">
142
  辨識結果
 
159
  </div>
160
  """
161
 
 
162
  def _score_radio_value(score: int) -> str:
163
  score = int(score)
164
  score = min(5, max(1, score))
165
  return SCORE_LABELS[score]
166
 
167
 
168
+ # =========================
169
+ # 影像工具
170
+ # =========================
171
  def _downsample_rgb(frame: np.ndarray, target_w: int) -> np.ndarray:
172
  if frame is None:
173
  return None
 
180
  img = img.resize((target_w, new_h))
181
  return np.array(img)
182
 
 
183
  def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
184
  if frame is None or zoom <= 1.0:
185
  return frame
 
186
  h, w = frame.shape[:2]
187
  new_w = int(w / zoom)
188
  new_h = int(h / zoom)
 
193
 
194
 
195
  # =========================
196
+ # 引導框
197
  # =========================
198
  GUIDE_W_RATIO = 0.55
199
  GUIDE_H_RATIO = 0.70
 
203
  h, w = frame.shape[:2]
204
  gw = int(w * GUIDE_W_RATIO)
205
  gh = int(h * GUIDE_H_RATIO)
 
206
  cx = w // 2
207
  cy = int(h * (0.5 + GUIDE_Y_OFFSET))
 
208
  x1 = max(0, cx - gw // 2)
209
  y1 = max(0, cy - gh // 2)
210
  x2 = min(w - 1, cx + gw // 2)
 
226
  draw = ImageDraw.Draw(img)
227
 
228
  x1, y1, x2, y2 = _guide_box(frame_rgb)
 
 
229
  color = (0, 200, 0) if detected_in_box else (220, 0, 0)
230
 
231
  h, w = frame_rgb.shape[:2]
 
261
  union = aw * ah + bw * bh - inter
262
  return inter / max(1e-6, union)
263
 
 
264
  def _pick_face_by_tracking(
265
  faces: List[Dict],
266
  prev_bbox: Optional[Tuple[int, int, int, int]]
 
310
  st.final_conf = None
311
  st.final_score = 3
312
 
 
313
  st.last_face_t = 0.0
314
  st.face_in_box = False
315
  return st
316
 
317
 
318
+ # =========================
319
+ # ✅ 判斷「這個 face box 算不算真的臉」
320
+ # =========================
321
+ def _is_real_face(
322
+ pick: Dict,
323
+ bbox_full_xywh: Tuple[float, float, float, float],
324
+ frame_hw: Tuple[int, int],
325
+ ) -> bool:
326
+ fh, fw = frame_hw
327
+ x, y, w, h = bbox_full_xywh
328
+
329
+ # 1) 面積門檻(避免小塊背景誤判)
330
+ area_ratio = (w * h) / max(1.0, float(fw * fh))
331
+ if area_ratio < MIN_FACE_AREA_RATIO:
332
+ return False
333
+
334
+ # 2) 偵測 confidence(如果偵測器有提供)
335
+ # DeepFace.extract_faces 的 dict 常見會有 "confidence"(依 backend 而異)
336
+ det_conf = pick.get("confidence", None)
337
+ if det_conf is not None:
338
+ try:
339
+ if float(det_conf) < MIN_DET_CONF:
340
+ return False
341
+ except Exception:
342
+ pass
343
+
344
+ return True
345
+
346
+
347
  # =========================
348
  # Camera streaming
349
  # =========================
 
383
 
384
  now = time.time()
385
 
386
+ # 節流:沿用最近一次「真的有人臉在框內」狀態
387
  detected_in_box_recent = st.face_in_box and (now - st.last_face_t) < 1.0
388
 
389
  if now - st.last_analyze_t < ANALYZE_EVERY_SEC:
390
  annotated = _draw_guide_overlay(frame_rgb, detected_in_box_recent)
391
+ result_html = _hint_html("已偵測到人臉,開始辨識情緒中…" if detected_in_box_recent else "未偵測到人臉,請把臉放進框內")
 
 
 
 
392
  return (
393
  gr.update(visible=True, value=annotated),
394
  gr.update(value=result_html),
 
402
 
403
  small = _downsample_rgb(frame_rgb, DOWNSAMPLE_W)
404
 
405
+ # ✅ 把「沒臉」當作 faces=[](避免 backend 誤回傳奇怪結果)
406
+ try:
407
+ faces = DeepFace.extract_faces(
408
+ img_path=small,
409
+ detector_backend=DETECTOR_BACKEND,
410
+ enforce_detection=True, # ✅ 變嚴格:沒偵測到臉就丟例外
411
+ align=ALIGN_FACE,
412
+ )
413
+ except Exception:
414
+ faces = []
415
 
416
  pick, bbox = _pick_face_by_tracking(faces, st.track_bbox)
417
 
418
+ # (A) 完全沒臉:紅框 + 清掉狀態
419
  if pick is None or bbox is None or pick.get("face") is None:
420
  st.track_bbox = None
421
  st.face_in_box = False
 
432
  gr.update(value="重新攝影機辨識"),
433
  )
434
 
435
+ # bbox small -> frame
436
  sh, sw = small.shape[:2]
437
  fh, fw = frame_rgb.shape[:2]
438
  sx = fw / max(1, sw)
 
441
  x, y, w, h = bbox
442
  bbox_full = (x * sx, y * sy, w * sx, h * sy)
443
 
444
+ # ✅ 新增:先檢查是不是「真的臉」(面積+confidence)
445
+ if not _is_real_face(pick, bbox_full, (fh, fw)):
446
+ st.track_bbox = None
447
+ st.face_in_box = False
448
+ st.last_face_t = now
449
+ st.stable_emo = "unknown"
450
+
451
+ annotated = _draw_guide_overlay(frame_rgb, False)
452
+ return (
453
+ gr.update(visible=True, value=annotated),
454
+ gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
455
+ gr.update(value=_score_radio_value(3)),
456
+ gr.update(value=_camera_hint_before_start()),
457
+ st,
458
+ gr.update(value="重新攝影機辨識"),
459
+ )
460
+
461
  guide = _guide_box(frame_rgb)
462
  detected_in_box = _bbox_center_in_box(bbox_full, guide)
463
 
 
464
  st.last_face_t = now
465
  st.face_in_box = detected_in_box
466
  st.track_bbox = bbox
467
 
468
+ # (B) 有臉但不在框內:紅框,不做情緒辨識
469
  if not detected_in_box:
470
  annotated = _draw_guide_overlay(frame_rgb, False)
471
  return (
 
477
  gr.update(value="重新攝影機辨識"),
478
  )
479
 
480
+ # (C) 有臉且在框內:綠框,才開始 analyze
481
  face = pick["face"]
482
  if face.dtype != np.uint8:
483
  face = np.clip(face * 255.0, 0, 255).astype(np.uint8)
484
 
485
+ # ✅ analyze 也變嚴格:如果這塊其實不是臉,就當沒臉(避免誤判一路走到底)
486
+ try:
487
+ result = DeepFace.analyze(
488
+ img_path=face,
489
+ actions=["emotion"],
490
+ enforce_detection=True,
491
+ )
492
+ except Exception:
493
+ st.track_bbox = None
494
+ st.face_in_box = False
495
+ st.last_face_t = now
496
+ st.stable_emo = "unknown"
497
+
498
+ annotated = _draw_guide_overlay(frame_rgb, False)
499
+ return (
500
+ gr.update(visible=True, value=annotated),
501
+ gr.update(value=_hint_html("未偵測到人臉,請把臉放進框內")),
502
+ gr.update(value=_score_radio_value(3)),
503
+ gr.update(value=_camera_hint_before_start()),
504
+ st,
505
+ gr.update(value="重新攝影機辨識"),
506
+ )
507
+
508
  if isinstance(result, list):
509
  result = result[0]
510
  emo_dict = result.get("emotion", None)
511
 
 
512
  if not isinstance(emo_dict, dict) or len(emo_dict) == 0:
513
  annotated = _draw_guide_overlay(frame_rgb, True)
514
  return (
 
523
  top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
524
  top_conf = float(emo_dict[top_key])
525
 
 
526
  if top_conf < CONF_TH:
527
  annotated = _draw_guide_overlay(frame_rgb, True)
528
  return (
 
547
  stable_key = st.emo_keys[stable_idx]
548
  st.stable_emo = stable_key
549
 
 
550
  if st.candidate_emo != stable_key:
551
  st.candidate_emo = stable_key
552
  st.candidate_since = now
 
588
 
589
 
590
  # =========================
591
+ # Buttons
592
  # =========================
593
  def on_restart(st: AppState):
594
  st = _reset_state_for_camera(st)
 
601
  gr.update(value="重新攝影機辨識"),
602
  )
603
 
 
604
  def on_stop(st: AppState):
605
  st.running = False
606
  st.finished = True