Spaces:
Sleeping
Sleeping
| # 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 | |
| _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 | |
| # Gaze score: 1.0 anywhere on screen, gentle falloff near edges, | |
| # 0.0 when clearly off screen. | |
| if not on_screen: | |
| gaze_score = 0.0 | |
| else: | |
| dx = max(0.0, abs(gx - 0.5) - 0.4) | |
| dy = max(0.0, abs(gy - 0.5) - 0.4) | |
| dist = math.sqrt(dx ** 2 + dy ** 2) | |
| gaze_score = max(0.0, 1.0 - dist * 2.5) | |
| # 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 | |