""" 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)