""" Custom PyTorch MLP trained on MediaPipe 468-point facial landmarks. 468 landmarks × 3 coords (x,y,z) = 1404-dimensional feature vector. Used by the analyzer for fast, lightweight emotion classification that runs in the main thread every frame (unlike DeepFace which is slow). """ import os import numpy as np MODEL_PATH = "models/expression_mlp.pth" EMOTIONS = ["angry", "disgust", "fear", "happy", "neutral", "sad", "surprise"] INPUT_DIM = 1404 # 468 × 3 def extract_landmark_features(face_landmarks) -> np.ndarray: """ Convert MediaPipe face landmarks to a scale- and position-invariant 1404-dim feature vector suitable for the MLP. """ coords = np.array( [[lm.x, lm.y, lm.z] for lm in face_landmarks.landmark[:468]], dtype=np.float32, ) # Normalize x,y to [0,1] relative to the face bounding box min_xy = coords[:, :2].min(axis=0) max_xy = coords[:, :2].max(axis=0) scale = (max_xy - min_xy).max() + 1e-6 coords[:, 0] = (coords[:, 0] - min_xy[0]) / scale coords[:, 1] = (coords[:, 1] - min_xy[1]) / scale # z is already relative in MediaPipe output return coords.flatten() class ExpressionMLP: """Lazy-loads torch only when the model file exists.""" def __init__(self, model_path: str = MODEL_PATH): self._model = None self._labels = EMOTIONS self.available = False if not os.path.exists(model_path): print(f"[expression_model] no model at {model_path} — run train_expression_model.py first") return try: import torch import torch.nn as nn class _MLP(nn.Module): def __init__(self, n_in, n_cls): super().__init__() self.net = nn.Sequential( nn.Linear(n_in, 512), nn.BatchNorm1d(512), nn.GELU(), nn.Dropout(0.30), nn.Linear(512, 256), nn.BatchNorm1d(256), nn.GELU(), nn.Dropout(0.20), nn.Linear(256, 128), nn.GELU(), nn.Linear(128, n_cls), ) def forward(self, x): return self.net(x) ckpt = torch.load(model_path, map_location="cpu", weights_only=False) self._labels = ckpt.get("labels", EMOTIONS) net = _MLP(INPUT_DIM, len(self._labels)) net.load_state_dict(ckpt["model"]) net.eval() self._model = net self._torch = torch self.available = True print(f"[expression_model] loaded — classes: {self._labels}") except Exception as e: print(f"[expression_model] load failed: {e}") def predict(self, face_landmarks) -> tuple[str, dict[str, float]]: """Returns (dominant_emotion, scores_dict). Call only if self.available.""" feats = extract_landmark_features(face_landmarks) x = self._torch.tensor(feats, dtype=self._torch.float32).unsqueeze(0) with self._torch.no_grad(): probs = self._torch.softmax(self._model(x), dim=1).squeeze().numpy() scores = {lbl: float(p) for lbl, p in zip(self._labels, probs)} dominant = max(scores, key=scores.get) return dominant, scores