body-debt / face_scan.py
Papajams's picture
Initial Body Debt Gradio app for Build Small hackathon
b11e593 verified
Raw
History Blame Contribute Delete
3.88 kB
"""
Face scan stress feature extraction using MediaPipe FaceMesh.
Ported from src/lib/ai/face-mesh.ts
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Optional
import numpy as np
LANDMARKS = {
"LEFT_EYE_OUTER": 33,
"LEFT_EYE_INNER": 133,
"LEFT_EYE_TOP": 159,
"LEFT_EYE_BOTTOM": 145,
"RIGHT_EYE_OUTER": 263,
"RIGHT_EYE_INNER": 362,
"RIGHT_EYE_TOP": 386,
"RIGHT_EYE_BOTTOM": 374,
"LEFT_EYEBROW_INNER": 107,
"LEFT_EYEBROW_OUTER": 70,
"RIGHT_EYEBROW_INNER": 336,
"RIGHT_EYEBROW_OUTER": 300,
"MOUTH_TOP": 13,
"MOUTH_BOTTOM": 14,
"MOUTH_LEFT": 61,
"MOUTH_RIGHT": 291,
}
@dataclass
class StressFeatures:
left_eye_aspect: float
right_eye_aspect: float
brow_tension: float
mouth_tension: float
eye_symmetry: float
mouth_opening: float
timestamp: float
def _distance(p1, p2) -> float:
return math.sqrt(
(p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2 + (p2[2] - p1[2]) ** 2
)
def _ear(outer, inner, top, bottom) -> float:
v = _distance(top, bottom)
h = _distance(outer, inner)
return v / h if h > 0 else 0
def extract_stress_features(landmarks: list) -> Optional[StressFeatures]:
"""Extract 7 stress features from 478 MediaPipe face landmarks."""
if not landmarks or len(landmarks) < 468:
return None
def p(idx):
lm = landmarks[idx]
return (lm.x, lm.y, lm.z)
left_ear = _ear(
p(LANDMARKS["LEFT_EYE_OUTER"]),
p(LANDMARKS["LEFT_EYE_INNER"]),
p(LANDMARKS["LEFT_EYE_TOP"]),
p(LANDMARKS["LEFT_EYE_BOTTOM"]),
)
right_ear = _ear(
p(LANDMARKS["RIGHT_EYE_OUTER"]),
p(LANDMARKS["RIGHT_EYE_INNER"]),
p(LANDMARKS["RIGHT_EYE_TOP"]),
p(LANDMARKS["RIGHT_EYE_BOTTOM"]),
)
brow_tension = (
_distance(p(LANDMARKS["LEFT_EYEBROW_INNER"]), p(LANDMARKS["LEFT_EYE_TOP"]))
+ _distance(p(LANDMARKS["RIGHT_EYEBROW_INNER"]), p(LANDMARKS["RIGHT_EYE_TOP"]))
) / 2
mouth_width = _distance(p(LANDMARKS["MOUTH_LEFT"]), p(LANDMARKS["MOUTH_RIGHT"]))
mouth_height = _distance(p(LANDMARKS["MOUTH_TOP"]), p(LANDMARKS["MOUTH_BOTTOM"]))
mouth_tension = mouth_width / mouth_height if mouth_height > 0 else 1.0
eye_symmetry = abs(left_ear - right_ear) / ((left_ear + right_ear) / 2 + 0.001)
mouth_opening = mouth_height / mouth_width if mouth_width > 0 else 0.1
import time
return StressFeatures(
left_eye_aspect=left_ear,
right_eye_aspect=right_ear,
brow_tension=brow_tension,
mouth_tension=mouth_tension,
eye_symmetry=eye_symmetry,
mouth_opening=mouth_opening,
timestamp=time.time(),
)
def features_to_array(features: StressFeatures) -> np.ndarray:
"""Convert StressFeatures to a 7-element numpy array for the ONNX model."""
return np.array(
[
features.left_eye_aspect,
features.right_eye_aspect,
features.brow_tension,
features.mouth_tension,
features.eye_symmetry,
features.mouth_opening,
features.timestamp % 86400 / 86400, # normalized time-of-day
],
dtype=np.float32,
)
def run_face_scan(image: np.ndarray) -> Optional[StressFeatures]:
"""Run MediaPipe FaceMesh on a BGR image and extract stress features."""
import mediapipe as mp
mp_face_mesh = mp.solutions.face_mesh
with mp_face_mesh.FaceMesh(
static_image_mode=True,
max_num_faces=1,
refine_landmarks=True,
min_detection_confidence=0.5,
) as face_mesh:
results = face_mesh.process(image)
if not results.multi_face_landmarks:
return None
landmarks = results.multi_face_landmarks[0].landmark
return extract_stress_features(landmarks)