focus_Guard_test / models /gaze_eye_fusion.py
k22056537
feat: UI nav, onboarding, L2CS weights path + torch.load; trim dev files
37a8ba6
# 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