File size: 2,817 Bytes
6065cea
 
 
 
 
 
 
 
 
 
9d50060
77ec8df
9d50060
 
 
6065cea
 
 
 
9d50060
6065cea
 
 
 
 
 
 
 
9d50060
 
6065cea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d50060
 
 
 
 
 
6065cea
77ec8df
 
 
 
 
 
 
6065cea
9d50060
 
 
 
 
6065cea
 
9d50060
 
6065cea
 
 
 
 
 
 
 
 
9d50060
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# 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