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) |