# Fuses calibrated gaze position with eye openness (EAR) for focus detection. # Takes L2CS gaze angles + MediaPipe landmarks, outputs screen coords + focus decision. import math import numpy as np from .gaze_calibration import GazeCalibration from .eye_scorer import compute_avg_ear _EAR_BLINK = 0.18 _ON_SCREEN_MARGIN = 0.15 _GAZE_MAX_DIST = 0.6 _SUSTAINED_CLOSE_FRAMES = 4 # ~250ms at 15fps — ignore brief blinks class GazeEyeFusion: def __init__(self, calibration, ear_weight=0.25, gaze_weight=0.75, focus_threshold=0.42): if not calibration.is_fitted: raise ValueError("Calibration must be fitted first") self._cal = calibration self._ear_w = ear_weight self._gaze_w = gaze_weight self._threshold = focus_threshold self._smooth_x = 0.5 self._smooth_y = 0.5 self._alpha = 0.35 self._closed_streak = 0 def update(self, yaw_rad, pitch_rad, landmarks): gx, gy = self._cal.predict(yaw_rad, pitch_rad) # EMA smooth the gaze position self._smooth_x += self._alpha * (gx - self._smooth_x) self._smooth_y += self._alpha * (gy - self._smooth_y) gx, gy = self._smooth_x, self._smooth_y on_screen = ( -_ON_SCREEN_MARGIN <= gx <= 1.0 + _ON_SCREEN_MARGIN and -_ON_SCREEN_MARGIN <= gy <= 1.0 + _ON_SCREEN_MARGIN ) ear = None ear_score = 1.0 if landmarks is not None: ear = compute_avg_ear(landmarks) if ear < _EAR_BLINK: ear_score = 0.0 self._closed_streak += 1 else: ear_score = min(ear / 0.30, 1.0) self._closed_streak = 0 # Continuous gaze score: 1.0 at screen center, cosine falloff toward edges # and beyond — no hard cliff at the screen boundary. dx = max(0.0, abs(gx - 0.5) - 0.5) dy = max(0.0, abs(gy - 0.5) - 0.5) dist = math.sqrt(dx ** 2 + dy ** 2) t = min(dist / _GAZE_MAX_DIST, 1.0) gaze_score = 0.5 * (1.0 + math.cos(math.pi * t)) # Sustained eye closure veto — ignore brief blinks (< 4 frames) if self._closed_streak >= _SUSTAINED_CLOSE_FRAMES: score = 0.0 else: score = float(np.clip(self._gaze_w * gaze_score + self._ear_w * ear_score, 0, 1)) return { "gaze_x": round(float(np.clip(gx, 0, 1)), 4), "gaze_y": round(float(np.clip(gy, 0, 1)), 4), "on_screen": on_screen, "ear": round(ear, 4) if ear is not None else None, "focus_score": round(score, 4), "focused": score >= self._threshold, } def reset(self): self._smooth_x = 0.5 self._smooth_y = 0.5 self._closed_streak = 0