File size: 17,290 Bytes
58dd7d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
import numpy as np
import onnxruntime as ort
from scipy.interpolate import Rbf
from collections import deque


# =============================================================================
# GAZE ANGLE SMOOTHER  β€”  APPLY BEFORE calibration.predict(), NOT AFTER
#
# This is the single most important piece of the inference path, and its
# absence was the root cause of the "z-scoring made everything worse" episode.
#
# WHY IT MATTERS
# The RBF maps (pitch, yaw) -> screen. Because the calibration z-scores each
# axis by its own spread, and the vertical (pitch) axis has a very small
# spread, the RBF has a STEEP gradient along pitch: a tiny change in pitch
# swings the prediction a long way vertically. That steepness is necessary
# (otherwise the RBF cannot resolve vertical position at all) β€” but it means
# the RBF AMPLIFIES whatever noise is on its input.
#
# Feeding it a single raw frame therefore amplifies that frame's noise.
# Smoothing the OUTPUT afterwards (One Euro on screen coords) cannot undo
# this: by then the noise has already been multiplied through a nonlinear map.
# The noise must be removed BEFORE the query.
#
# Measured effect in simulation (z-scored RBF, realistic noise):
#     no input smoothing  -> err_x 113-348px, err_y ~330px   (unusable)
#     10-frame smoothing  ->  err_x  ~38px,   err_y ~179px   (usable)
#
# A rolling MEDIAN (not mean) is used so a single blink-tail or landmark
# glitch frame cannot drag the estimate.
#
# WINDOW is a latency/accuracy trade: at ~30fps, 10 frames is ~0.33s, which
# is roughly one fixation. Larger windows keep improving accuracy slightly but
# make the live dot feel laggy.
# =============================================================================

class GazeAngleSmoother:
    def __init__(self, window=10):
        self.window = window
        self._pitch = deque(maxlen=window)
        self._yaw   = deque(maxlen=window)
        self._ear   = deque(maxlen=window)

    def __call__(self, pitch, yaw, ear=0.0):
        self._pitch.append(float(pitch))
        self._yaw.append(float(yaw))
        self._ear.append(float(ear))
        return (float(np.median(self._pitch)),
                float(np.median(self._yaw)),
                float(np.median(self._ear)))

    def reset(self):
        self._pitch.clear()
        self._yaw.clear()
        self._ear.clear()


# =============================================================================
# ONNX MODEL (FIXED FOR BINOCULAR v4)
# =============================================================================

class GazeONNXModel:
    def __init__(self, onnx_path: str):
        self.session = ort.InferenceSession(
            onnx_path, providers=["CPUExecutionProvider"]
        )

        input_names = [inp.name for inp in self.session.get_inputs()]
        self.is_binocular = "eye_patch_binocular" in input_names

        print(f"ONNX loaded: {onnx_path}")
        print(f"Mode: {'binocular (v4)' if self.is_binocular else 'monocular'}")

        if self.is_binocular:
            dummy_patch = np.zeros((1, 2, 36, 60), dtype=np.float32)
            dummy_pose  = np.zeros((1, 3), dtype=np.float32)
            self.session.run(
                ["gaze"],
                {"eye_patch_binocular": dummy_patch, "head_pose": dummy_pose}
            )
        else:
            dummy_patch = np.zeros((1, 1, 36, 60), dtype=np.float32)
            dummy_pose  = np.zeros((1, 3), dtype=np.float32)
            self.session.run(
                ["gaze"],
                {"eye_patch": dummy_patch, "head_pose": dummy_pose}
            )

    def predict(self, left_patch, right_patch, head_pose):
        def norm(img):
            return (img.astype(np.float32) / 255.0 - 0.5) / 0.5

        pose = head_pose.astype(np.float32)[np.newaxis, :]

        if self.is_binocular:
            patch = np.stack([norm(left_patch), norm(right_patch)], axis=0)
            patch = patch[np.newaxis, :, :, :]

            out = self.session.run(
                ["gaze"],
                {"eye_patch_binocular": patch, "head_pose": pose}
            )[0][0]

        else:
            patch = norm(left_patch)[np.newaxis, np.newaxis, :, :]

            out = self.session.run(
                ["gaze"],
                {"eye_patch": patch, "head_pose": pose}
            )[0][0]

        return float(out[0]), float(out[1])  # pitch, yaw


# =============================================================================
# RBF CALIBRATION (with extrapolation clamp)
# =============================================================================

class RBFGazeCalibration:
    def __init__(self, screen_w=1493, screen_h=933,
                 function="multiquadric", smooth=0.1):
        self.screen_w = screen_w
        self.screen_h = screen_h
        self.function = function
        self.smooth   = smooth
        self._rbf_x   = None
        self._rbf_y   = None
        self._calibrated = False

        # Range of (pitch, yaw) actually seen during calibration. Multiquadric
        # RBF surfaces are only well-behaved INSIDE the convex hull of their
        # training points. Outside it, the surface is dominated by whichever
        # basis functions happen to be nearest, and the gradient direction is
        # not guaranteed to continue the trend. Clamping pitch/yaw to a small
        # margin beyond the calibrated range, before ever calling the RBF,
        # keeps every query inside (or just at the edge of) the well-behaved
        # interpolation region instead of letting it wander into extrapolation
        # territory.
        self._pitch_range = None
        self._yaw_range   = None

        # Output-side safety net, computed from where your calibration dots
        # actually were, not the full screen. Loosened from the original
        # 6%/50px to 10%/70px β€” the tighter clamp was creating a visible
        # dead zone near the bottom/right edges where predictions couldn't
        # reach even when the underlying gaze estimate was pointing there.
        # Combined with pushing the calibration grid itself to 0.02/0.98
        # (see calibrate.py), this should shrink that gap meaningfully
        # without reopening the reversal-error risk the clamp exists for.
        self._screen_x_range = None
        self._screen_y_range = None

        self._pitch_center = 0.0
        self._yaw_center    = 0.0
        self._pitch_scale   = 1.0
        self._yaw_scale     = 1.0
        self._vert_center   = 0.0
        self._vert_scale    = 1.0
        self._vert_feature  = "pitch"   # or "ear" β€” chosen by measurement


    def calibrate(self, pitch_yaw, screen_points, ear=None, screen_size=None):
        """
        pitch_yaw : (n, 2) array of [pitch, yaw] per calibration point
        screen_points : (n, 2) array of [sx, sy]
        ear : (n,) optional array of eye-aperture (EAR) per point.
        screen_size : (W, H) TRUE screen size in pixels. MUST be passed.

        WHY screen_size IS NOT OPTIONAL IN PRACTICE:
        This class used to fall back to hardcoded defaults of 1493x933 (a
        stale leftover from an old monitor), and InsightUXPipeline never
        passed anything, so EVERY prediction was silently hard-clamped to
        1493x933 in predict():
            sx = max(0, min(sx, self.screen_w))
        On a 1920x1200 screen that made the rightmost 427px (22% of width)
        and the bottom 267px (22% of height) literally unreachable β€” the
        gaze estimate was fine, the clamp threw it away. Symptom: the dot
        stops dead at an invisible line near the right and bottom edges.
        The true size is now recorded here and persisted in calibration.pkl.

        VERTICAL FEATURE SELECTION.

        Measurement showed the CNN's pitch output correlates with screen-Y at
        only r ~= 0.41 (it explains ~16% of vertical variance) while yaw
        correlates with screen-X at r ~= 0.99. The model is close to blind
        vertically, and no mapping can invent information its input lacks.

        Eye aperture is a physically independent vertical cue: looking down
        lowers the eyelid, so EAR shrinks. It is already computed for blink
        detection and costs nothing extra.

        Rather than assume EAR is better, this MEASURES both candidates
        against the true screen-Y and uses whichever actually correlates
        more strongly. If EAR turns out to be useless, nothing changes and
        we fall back to pitch β€” so this cannot make things worse.
        """
        if screen_size is not None:
            self.screen_w = int(screen_size[0])
            self.screen_h = int(screen_size[1])
        print(f"  screen size recorded: {self.screen_w} x {self.screen_h}")

        pitch = pitch_yaw[:, 0]
        yaw   = pitch_yaw[:, 1]
        sy    = screen_points[:, 1]

        def _r(a, b):
            a = np.asarray(a, float); b = np.asarray(b, float)
            if a.std() < 1e-12 or b.std() < 1e-12:
                return 0.0
            return float(np.corrcoef(a, b)[0, 1])

        r_pitch_y = _r(pitch, sy)
        r_ear_y   = _r(ear, sy) if ear is not None else 0.0

        # Require EAR to be meaningfully better, not just noise-better.
        use_ear = (ear is not None) and (abs(r_ear_y) > abs(r_pitch_y) + 0.10)

        if use_ear:
            vert = np.asarray(ear, dtype=float)
            self._vert_feature = "ear"
            print(f"  vertical feature: EYE APERTURE (r={r_ear_y:+.3f}) "
                  f"β€” beats CNN pitch (r={r_pitch_y:+.3f})")
        else:
            vert = pitch
            self._vert_feature = "pitch"
            if ear is not None:
                print(f"  vertical feature: CNN PITCH (r={r_pitch_y:+.3f}) "
                      f"β€” eye aperture was not better (r={r_ear_y:+.3f})")

        self._pitch_center = float(np.mean(pitch))
        self._yaw_center   = float(np.mean(yaw))
        self._pitch_scale  = float(np.std(pitch)) or 1e-3
        self._yaw_scale    = float(np.std(yaw)) or 1e-3
        self._vert_center  = float(np.mean(vert))
        self._vert_scale   = float(np.std(vert)) or 1e-3

        pitch_n = (pitch - self._pitch_center) / self._pitch_scale
        yaw_n   = (yaw   - self._yaw_center)   / self._yaw_scale
        vert_n  = (vert  - self._vert_center)  / self._vert_scale

        # Horizontal mapping is healthy β€” leave it on (pitch, yaw).
        self._rbf_x = Rbf(pitch_n, yaw_n, screen_points[:, 0],
                          function=self.function, smooth=self.smooth)
        # Vertical mapping uses whichever vertical cue actually tracks Y.
        self._rbf_y = Rbf(vert_n, yaw_n, screen_points[:, 1],
                          function=self.function, smooth=self.smooth)

        # NOTE β€” a leave-one-out "gain correction" (fitting a linear stretch
        # to counteract edge under-reach) used to live here. It was REMOVED
        # after simulation showed it roughly DOUBLES vertical error
        # (306px -> 519px). The reason is straightforward in hindsight: when
        # an axis is noise-dominated, the leave-one-out residuals it fits on
        # are mostly noise, and a linear stretch fitted to noise just
        # amplifies that noise. Do not reintroduce it without re-running that
        # experiment.

        margin_p = 0.10 * (pitch.max() - pitch.min() + 1e-6)
        margin_y = 0.10 * (yaw.max()   - yaw.min()   + 1e-6)
        self._pitch_range = (float(pitch.min() - margin_p), float(pitch.max() + margin_p))
        self._yaw_range   = (float(yaw.min()   - margin_y), float(yaw.max()   + margin_y))

        sx_vals = screen_points[:, 0]
        sy_vals = screen_points[:, 1]
        margin_sx = max(70.0, 0.10 * (sx_vals.max() - sx_vals.min() + 1e-6))
        margin_sy = max(70.0, 0.10 * (sy_vals.max() - sy_vals.min() + 1e-6))
        self._screen_x_range = (float(sx_vals.min() - margin_sx), float(sx_vals.max() + margin_sx))
        self._screen_y_range = (float(sy_vals.min() - margin_sy), float(sy_vals.max() + margin_sy))

        self._calibrated = True
        print(f"RBF calibrated on {len(pitch_yaw)} points")
        print(f"  pitch clamp range: {self._pitch_range[0]:.4f} to {self._pitch_range[1]:.4f}")
        print(f"  yaw clamp range:   {self._yaw_range[0]:.4f} to {self._yaw_range[1]:.4f}")
        print(f"  screen x clamp:    {self._screen_x_range[0]:.0f} to {self._screen_x_range[1]:.0f}")
        print(f"  screen y clamp:    {self._screen_y_range[0]:.0f} to {self._screen_y_range[1]:.0f}")

    def predict(self, pitch, yaw, ear=0.0):
        if not self._calibrated:
            raise RuntimeError("Calibration not done")

        if self._pitch_range is not None:
            pitch = float(np.clip(pitch, self._pitch_range[0], self._pitch_range[1]))
        if self._yaw_range is not None:
            yaw = float(np.clip(yaw, self._yaw_range[0], self._yaw_range[1]))

        pitch_n = (pitch - self._pitch_center) / self._pitch_scale
        yaw_n   = (yaw   - self._yaw_center)   / self._yaw_scale

        # Vertical uses whichever cue calibration measured as stronger.
        vert = float(ear) if self._vert_feature == "ear" else float(pitch)
        vert_n = (vert - self._vert_center) / self._vert_scale

        sx = float(self._rbf_x(pitch_n, yaw_n))
        sy = float(self._rbf_y(vert_n, yaw_n))

        if self._screen_x_range is not None:
            sx = float(np.clip(sx, self._screen_x_range[0], self._screen_x_range[1]))
        if self._screen_y_range is not None:
            sy = float(np.clip(sy, self._screen_y_range[0], self._screen_y_range[1]))

        sx = max(0, min(sx, self.screen_w))
        sy = max(0, min(sy, self.screen_h))

        return sx, sy

    def save(self, path):
        import pickle
        with open(path, "wb") as f:
            pickle.dump({
                "x": self._rbf_x,
                "y": self._rbf_y,
                "pitch_range": self._pitch_range,
                "yaw_range": self._yaw_range,
                "screen_x_range": self._screen_x_range,
                "screen_y_range": self._screen_y_range,
                "pitch_center": self._pitch_center,
                "yaw_center": self._yaw_center,
                "pitch_scale": self._pitch_scale,
                "yaw_scale": self._yaw_scale,
                "vert_center": self._vert_center,
                "vert_scale": self._vert_scale,
                "vert_feature": self._vert_feature,
                "screen_w": self.screen_w,
                "screen_h": self.screen_h,
            }, f)

    def load(self, path):
        import pickle
        with open(path, "rb") as f:
            data = pickle.load(f)
        self._rbf_x = data["x"]
        self._rbf_y = data["y"]
        self._pitch_range = data.get("pitch_range")
        self._yaw_range   = data.get("yaw_range")
        self._screen_x_range = data.get("screen_x_range")
        self._screen_y_range = data.get("screen_y_range")
        # .get() with fallback so an OLDER calibration.pkl (saved before
        # this normalization fix) still loads without crashing β€” it just
        # won't have the fix applied until you recalibrate.
        self._pitch_center = data.get("pitch_center", 0.0)
        self._yaw_center   = data.get("yaw_center", 0.0)
        self._pitch_scale  = data.get("pitch_scale", 1.0)
        self._yaw_scale    = data.get("yaw_scale", 1.0)
        self._vert_center  = data.get("vert_center", 0.0)
        self._vert_scale   = data.get("vert_scale", 1.0)
        self._vert_feature = data.get("vert_feature", "pitch")
        # Restore the screen size this calibration was actually built for.
        # An OLD calibration.pkl won't have it β€” warn loudly rather than
        # silently clamping to the stale 1493x933 defaults again.
        if "screen_w" in data and "screen_h" in data:
            self.screen_w = int(data["screen_w"])
            self.screen_h = int(data["screen_h"])
        else:
            print("[RBFGazeCalibration] WARNING: this calibration.pkl predates the "
                  "screen-size fix and has no screen dimensions stored. Predictions "
                  "will be clamped to the stale default "
                  f"{self.screen_w}x{self.screen_h}. Re-run calibrate.py.")
        self._calibrated = True


# =============================================================================
# FULL PIPELINE
# =============================================================================

class InsightUXPipeline:
    def __init__(self, onnx_path, calibration_path=None):
        self.gaze_model = GazeONNXModel(onnx_path)
        self.calibration = RBFGazeCalibration()

        if calibration_path:
            self.calibration.load(calibration_path)

    def predict_gaze_vector(self, left_patch, head_pose, right_patch=None):
        if right_patch is None:
            right_patch = left_patch

        pitch, yaw = self.gaze_model.predict(left_patch, right_patch, head_pose)

        gvx = -np.cos(pitch) * np.sin(yaw)
        gvy = -np.sin(pitch)

        return float(gvx), float(gvy), float(pitch), float(yaw)

    def predict_screen(self, left_patch, head_pose, right_patch=None, ear=0.0):
        _, _, pitch, yaw = self.predict_gaze_vector(left_patch, head_pose, right_patch)
        return self.calibration.predict(pitch, yaw, ear)