InsightUX / browser /calibrate.py
Trishti's picture
Upload 13 files
b05d283 verified
Raw History Blame Contribute Delete
28.6 kB
import cv2
import numpy as np
import time
import os
import random
import pyautogui
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
from preprocessing.preprocessing_pipeline import (
create_face_mesh,
estimate_camera_matrix,
estimate_head_pose,
compute_iris_radius,
compute_ear,
step1_normalize,
step2_illumination,
LEFT_EYE_INDICES,
LEFT_EAR_INDICES,
LEFT_IRIS_INDICES,
RIGHT_EYE_INDICES,
RIGHT_EAR_INDICES,
RIGHT_IRIS_INDICES,
)
from inference_pipeline import InsightUXPipeline
# =============================================================================
# FIX A — POSE NORMALIZATION
# solvePnP returns degrees (typically ±30 deg at a laptop, now that the
# FACE_3D_MODEL Y/Z sign bug in preprocessing_pipeline.py is fixed).
# The CNN stream_b was trained expecting values near [-1, +1].
# Dividing by 30 maps ±30 deg -> ±1, matching training scale.
# Keep POSE_NORM_SCALE identical in main_webcam_pipeline.py.
# =============================================================================
POSE_NORM_SCALE = 30.0
def normalize_pose(head_pose):
return np.array([
head_pose.pitch / POSE_NORM_SCALE,
head_pose.yaw / POSE_NORM_SCALE,
head_pose.roll / POSE_NORM_SCALE,
], dtype=np.float32)
# =============================================================================
# FIX B — HEAD-PITCH COMPENSATION
# =============================================================================
HEAD_PITCH_COMPENSATION = 0.0
def compensate_pitch(raw_pitch, head_pitch_deg):
return raw_pitch - np.radians(head_pitch_deg) * HEAD_PITCH_COMPENSATION
# =============================================================================
# FIX C — HEAD-YAW COMPENSATION
# =============================================================================
HEAD_YAW_COMPENSATION = 0.0 # disabled
def compensate_yaw(raw_yaw, head_yaw_deg):
return raw_yaw - np.radians(head_yaw_deg) * HEAD_YAW_COMPENSATION
# =============================================================================
# LIVE QUALITY THRESHOLDS (used during actual point collection, not just
# the pre-calibration gate)
# =============================================================================
LIGHT_MIN_BRIGHTNESS = 60
LIGHT_MAX_BRIGHTNESS = 200
BLINK_EAR_THRESHOLD = 0.20
MIN_SAMPLES_OK = 15 # below this, warn the point may be unreliable
# =============================================================================
# PRE-CALIBRATION CHECKS
# =============================================================================
def check_lighting(frame):
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
brightness = np.mean(gray)
if brightness < LIGHT_MIN_BRIGHTNESS:
return False, "Too dark - increase lighting"
elif brightness > LIGHT_MAX_BRIGHTNESS:
return False, "Too bright - reduce lighting"
return True, None
def check_face_center(lms, frame_shape):
H, W = frame_shape[:2]
xs = [lm.x * W for lm in lms]
ys = [lm.y * H for lm in lms]
cx = np.mean(xs)
cy = np.mean(ys)
if cx < W * 0.35:
return False, "Move face RIGHT in frame"
elif cx > W * 0.65:
return False, "Move face LEFT in frame"
if cy < H * 0.35:
return False, "Sit closer or lower your camera"
elif cy > H * 0.65:
return False, "Sit further or raise your camera"
return True, None
def get_full_feedback(frame, lms, frame_shape, yaw, roll):
msgs = []
ok = True
H, W = frame_shape[:2]
nose_y = lms[1].y * H
forehead_y = lms[10].y * H
chin_y = lms[152].y * H
midpoint_y = (forehead_y + chin_y) / 2.0
face_h = chin_y - forehead_y
if face_h > 1:
pitch_ratio = (nose_y - midpoint_y) / face_h
if pitch_ratio < -0.10:
msgs.append("Lift your head UP")
ok = False
elif pitch_ratio > 0.15:
msgs.append("Tilt your head DOWN slightly")
ok = False
if yaw < -15:
msgs.append("Turn face slightly RIGHT")
ok = False
elif yaw > 15:
msgs.append("Turn face slightly LEFT")
ok = False
if roll < -10:
msgs.append("Tilt head slightly RIGHT")
ok = False
elif roll > 10:
msgs.append("Tilt head slightly LEFT")
ok = False
light_ok, light_msg = check_lighting(frame)
if not light_ok:
msgs.append(light_msg)
ok = False
center_ok, center_msg = check_face_center(lms, frame_shape)
if not center_ok:
msgs.append(center_msg)
ok = False
if ok:
msgs.append("Perfect! Hold still...")
return ok, msgs
# =============================================================================
# FACE ORIENTATION GATE
# =============================================================================
def face_orientation_gate(face_mesh, cap, cam_matrix_ref):
print("\nChecking face setup before calibration...")
print("Position your face straight, centred, at normal laptop distance.")
HOLD_SECONDS = 2.0
good_since = None
cam_matrix_loc = cam_matrix_ref[0]
cv2.namedWindow("Face Check", cv2.WINDOW_NORMAL)
cv2.resizeWindow("Face Check", 640, 420)
while True:
ret, frame = cap.read()
if not ret:
continue
if cam_matrix_loc is None:
cam_matrix_loc = estimate_camera_matrix(frame.shape)
cam_matrix_ref[0] = cam_matrix_loc
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
results = face_mesh.process(rgb)
display = frame.copy()
H, W = display.shape[:2]
if not results.multi_face_landmarks:
good_since = None
cv2.putText(display, "No face detected - look at camera",
(20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
else:
lms = results.multi_face_landmarks[0].landmark
head_pose = estimate_head_pose(lms, frame.shape, cam_matrix_loc)
if head_pose is None:
good_since = None
cv2.putText(display, "Pose failed - move slightly",
(20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
else:
is_good, msgs = get_full_feedback(
frame, lms, frame.shape, head_pose.yaw, head_pose.roll
)
if is_good:
if good_since is None:
good_since = time.time()
elapsed = time.time() - good_since
remaining = max(0, HOLD_SECONDS - elapsed)
bar_w = int((elapsed / HOLD_SECONDS) * (W - 40))
bar_w = min(bar_w, W - 40)
cv2.rectangle(display, (20, H-50), (W-20, H-25), (40, 40, 40), -1)
cv2.rectangle(display, (20, H-50), (20+bar_w, H-25), (0, 220, 0), -1)
cv2.putText(display, f"Hold still... {remaining:.1f}s",
(20, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
if elapsed >= HOLD_SECONDS:
cv2.putText(display, "Starting calibration!",
(20, 80), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 255, 0), 2)
cv2.imshow("Face Check", display)
cv2.waitKey(800)
cv2.destroyWindow("Face Check")
return
else:
good_since = None
for i, msg in enumerate(msgs):
cv2.putText(display, msg, (20, 40 + i * 35),
cv2.FONT_HERSHEY_SIMPLEX, 0.75, (0, 165, 255), 2)
cv2.putText(display,
f"Yaw:{head_pose.yaw:+.1f} Roll:{head_pose.roll:+.1f}",
(20, H - 65), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (160, 160, 160), 1)
cv2.imshow("Face Check", display)
if cv2.waitKey(1) & 0xFF == 27:
cv2.destroyWindow("Face Check")
return
# =============================================================================
# FINE-TUNING TARGET ANGLES
# =============================================================================
def screen_to_gaze_angles(sx, sy, screen_w, screen_h):
nx = (sx - screen_w / 2) / (screen_w / 2)
ny = (sy - screen_h / 2) / (screen_h / 2)
pitch = float(np.arctan(ny * 0.4))
yaw = float(np.arctan(nx * 0.6))
return pitch, yaw
def finetune_on_calibration_v4(
onnx_path,
left_patches, right_patches, head_poses, screen_points,
screen_w, screen_h,
ckpt_path="checkpoints/best_model_v4.pt",
out_onnx_path=None,
steps=300, lr=5e-4,
):
try:
import torch
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from models.model_v4 import GazeCNNv4
except ImportError as e:
print(f"[Fine-tune] Skipped: {e}")
return False
if not os.path.exists(ckpt_path):
print(f"[Fine-tune] Skipped: checkpoint not found at {ckpt_path}")
return False
if out_onnx_path is None:
out_onnx_path = onnx_path
device = torch.device("cpu")
model = GazeCNNv4().to(device)
ckpt = torch.load(ckpt_path, map_location=device)
model.load_state_dict(ckpt["model_state_dict"])
for p in model.parameters():
p.requires_grad = False
for p in model.fc_a.parameters(): p.requires_grad = True
for p in model.stream_b.parameters(): p.requires_grad = True
for p in model.fusion.parameters(): p.requires_grad = True
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"[Fine-tune] Trainable: {trainable:,} parameters")
optimizer = torch.optim.Adam(
filter(lambda p: p.requires_grad, model.parameters()),
lr=lr, weight_decay=1e-4
)
def angular_loss(pred, target):
def to_vec(a):
p, y = a[:, 0], a[:, 1]
v = torch.stack([torch.cos(p)*torch.sin(y),
torch.sin(p),
torch.cos(p)*torch.cos(y)], dim=1)
return v / (v.norm(dim=1, keepdim=True) + 1e-8)
cos_sim = (to_vec(pred) * to_vec(target)).sum(dim=1)
return (1 - cos_sim).mean()
patches_l, poses_t, gazes = [], [], []
for l, r, pose, sp in zip(left_patches, right_patches, head_poses, screen_points):
def norm(x): return (x.astype(np.float32) / 255.0 - 0.5) / 0.5
patches_l.append(np.stack([norm(l), norm(r)], axis=0))
poses_t.append(pose)
gazes.append(screen_to_gaze_angles(sp[0], sp[1], screen_w, screen_h))
patches_t = torch.tensor(np.array(patches_l), dtype=torch.float32)
poses_t = torch.tensor(np.array(poses_t), dtype=torch.float32)
gazes_t = torch.tensor(np.array(gazes), dtype=torch.float32)
model.eval()
with torch.no_grad():
feat_a_raw = model.pool(model.backbone(patches_t)).flatten(1)
model.fc_a.train()
model.stream_b.train()
model.fusion.train()
print(f"[Fine-tune] {steps} steps on {len(patches_l)} samples (backbone cached)...")
for step in range(steps):
optimizer.zero_grad()
feat_a = model.fc_a(feat_a_raw)
feat_b = model.stream_b(poses_t)
fused = torch.cat([feat_a, feat_b], dim=1)
pred = model.fusion(fused)
loss = angular_loss(pred, gazes_t)
loss.backward()
optimizer.step()
if (step + 1) % 50 == 0:
print(f" Step {step+1}/{steps} | Loss: {loss.item():.5f}")
model.eval()
dp = torch.zeros(1, 2, 36, 60)
dpose = torch.zeros(1, 3)
torch.onnx.export(
model, (dp, dpose), out_onnx_path,
input_names = ["eye_patch_binocular", "head_pose"],
output_names = ["gaze"],
dynamic_axes = {"eye_patch_binocular": {0: "batch"},
"head_pose": {0: "batch"}, "gaze": {0: "batch"}},
opset_version=14,
dynamo=False,
)
print(f"[Fine-tune] Adapted ONNX saved: {out_onnx_path}")
return True
# =============================================================================
# OUTLIER FILTERING
# =============================================================================
def filter_outlier_points(gaze_vectors, screen_points, lefts, rights, poses,
head_pitches_deg, sigma=2.0):
gv = np.array(gaze_vectors)
med_p = np.median(gv[:, 0])
med_y = np.median(gv[:, 1])
std_p = max(np.std(gv[:, 0]), 1e-6)
std_y = max(np.std(gv[:, 1]), 1e-6)
keep = (np.abs(gv[:, 0] - med_p) / std_p <= sigma)
keep &= (np.abs(gv[:, 1] - med_y) / std_y <= sigma)
n_removed = int((~keep).sum())
if n_removed:
print(f"[Outlier filter] Removed {n_removed} point(s) (>{sigma}σ)")
idx = np.where(keep)[0].tolist()
return (
[gaze_vectors[i] for i in idx],
[screen_points[i] for i in idx],
[lefts[i] for i in idx],
[rights[i] for i in idx],
[poses[i] for i in idx],
[head_pitches_deg[i] for i in idx],
)
# =============================================================================
# CONFIG
# =============================================================================
ONNX_PATH = "models/gaze_cnn_v4.onnx"
CKPT_PATH = "checkpoints/best_model_v4.pt"
SCREEN_W, SCREEN_H = pyautogui.size()
points = [
(0.02, 0.02), (0.35, 0.02), (0.65, 0.02), (0.98, 0.02),
(0.02, 0.35), (0.35, 0.35), (0.65, 0.35), (0.98, 0.35),
(0.02, 0.65), (0.35, 0.65), (0.65, 0.65), (0.98, 0.65),
(0.02, 0.98), (0.35, 0.98), (0.65, 0.98), (0.98, 0.98),
]
def get_duration(py):
# Top AND bottom rows get more time. Bottom rows specifically need it:
# looking down partially occludes the iris under the eyelid, which
# means more frames get blink-skipped there than in the middle rows,
# so the same 3s window yields fewer usable samples unless we give it
# more time to compensate.
return 5 if (py <= 0.4 or py >= 0.6) else 3
random.Random(7).shuffle(points)
FINETUNE_SAMPLES_PER_POINT = 5
# =============================================================================
# MAIN CALIBRATION
# =============================================================================
pipeline = InsightUXPipeline(ONNX_PATH)
face_mesh = create_face_mesh(static_image_mode=False)
cap = cv2.VideoCapture(0)
cam_matrix = None
gaze_vectors = []
screen_points = []
head_pitches_deg = []
rep_lefts = []
rep_rights = []
rep_poses = []
rep_hp_deg = []
ft_lefts = []
ft_rights = []
ft_poses = []
ft_sp = []
# Session-wide diagnostics, so we can warn about systemic issues at the end
# rather than just per-point.
session_brightness = []
session_blink_skips = 0
session_light_skips = 0
points_with_low_yield = []
cam_matrix_ref = [None]
face_orientation_gate(face_mesh, cap, cam_matrix_ref)
if cam_matrix_ref[0] is not None:
cam_matrix = cam_matrix_ref[0]
cv2.namedWindow("Calibration", cv2.WINDOW_NORMAL)
cv2.setWindowProperty("Calibration", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN)
total = len(points)
print(f"Calibration started - {total} points")
for idx, (px, py) in enumerate(points):
sx, sy = int(px * SCREEN_W), int(py * SCREEN_H)
duration = get_duration(py)
settle_s = 1.5 if idx == 0 else 0.4
settle_start = time.time()
while time.time() - settle_start < settle_s:
ret, frame = cap.read()
if not ret:
continue
screen = np.zeros((SCREEN_H, SCREEN_W, 3), dtype=np.uint8)
cv2.circle(screen, (sx, sy), 18, (80, 80, 80), -1)
msg = "Get ready..." if idx == 0 else "Settling..."
cv2.putText(screen, msg, (50, 50),
cv2.FONT_HERSHEY_SIMPLEX, 1, (180, 180, 180), 2)
cv2.imshow("Calibration", screen)
cv2.waitKey(1)
samples = []
l_list = []
r_list = []
p_list = []
hp_list = []
point_blink_skips = 0
point_light_skips = 0
start = time.time()
while time.time() - start < duration:
ret, frame = cap.read()
if not ret:
continue
if cam_matrix is None:
cam_matrix = estimate_camera_matrix(frame.shape)
# LIVE LIGHTING CHECK — skip this frame and tell the person, instead
# of silently feeding a too-dark/too-bright frame into the model.
light_ok, light_msg = check_lighting(frame)
gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
session_brightness.append(float(np.mean(gray_frame)))
if not light_ok:
point_light_skips += 1
screen = np.zeros((SCREEN_H, SCREEN_W, 3), dtype=np.uint8)
cv2.circle(screen, (sx, sy), 18, (0, 140, 255), 2)
cv2.putText(screen, f"Point {idx+1}/{total} - {light_msg}",
(50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 140, 255), 2)
cv2.imshow("Calibration", screen)
cv2.waitKey(1)
continue
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
results = face_mesh.process(rgb)
if not results.multi_face_landmarks:
continue
lms = results.multi_face_landmarks[0].landmark
head_pose = estimate_head_pose(lms, frame.shape, cam_matrix)
if head_pose is None:
continue
# LIVE BLINK CHECK — average EAR across both eyes. Below threshold
# means eyes are closed/closing; that frame's eye patches carry no
# real gaze signal and would just add noise to this point's median.
ear_l = compute_ear(lms, LEFT_EAR_INDICES, frame.shape)
ear_r = compute_ear(lms, RIGHT_EAR_INDICES, frame.shape)
avg_ear = (ear_l + ear_r) / 2.0
if avg_ear < BLINK_EAR_THRESHOLD:
point_blink_skips += 1
screen = np.zeros((SCREEN_H, SCREEN_W, 3), dtype=np.uint8)
cv2.circle(screen, (sx, sy), 18, (180, 180, 0), 2)
cv2.putText(screen, f"Point {idx+1}/{total} - Blink detected, skipping frame",
(50, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (180, 180, 0), 2)
cv2.imshow("Calibration", screen)
cv2.waitKey(1)
continue
# FIX A: normalize pose to [-1, +1] before CNN
pose_vec = normalize_pose(head_pose)
def process_eye(eye_idx, ear_idx, iris_idx):
s1 = step1_normalize(frame, lms, head_pose, eye_idx, ear_idx, iris_idx)
if not s1.is_open:
return None
ir = compute_iris_radius(lms, iris_idx, frame.shape)
s2 = step2_illumination(s1, ir)
return s2.blended if s2.is_usable else None
l = process_eye(LEFT_EYE_INDICES, LEFT_EAR_INDICES, LEFT_IRIS_INDICES)
r = process_eye(RIGHT_EYE_INDICES, RIGHT_EAR_INDICES, RIGHT_IRIS_INDICES)
if l is None and r is None:
continue
if l is None: l = r
if r is None: r = l
_, _, raw_pitch, raw_yaw = pipeline.predict_gaze_vector(l, pose_vec, r)
pitch = compensate_pitch(raw_pitch, head_pose.pitch)
yaw = compensate_yaw(raw_yaw, head_pose.yaw)
samples.append([pitch, yaw])
l_list.append(l)
r_list.append(r)
p_list.append(pose_vec)
hp_list.append(head_pose.pitch)
elapsed = time.time() - start
screen = np.zeros((SCREEN_H, SCREEN_W, 3), dtype=np.uint8)
angle = int(360 * elapsed / duration)
cv2.ellipse(screen, (sx, sy), (28, 28), -90, 0, angle, (0, 180, 0), 3)
cv2.circle(screen, (sx, sy), 18, (0, 255, 0), -1)
cv2.putText(screen, f"Point {idx+1}/{total} - Look at the dot",
(50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2)
if point_blink_skips or point_light_skips:
cv2.putText(screen, f"skipped: {point_blink_skips} blink, {point_light_skips} lighting",
(50, 85), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (140, 140, 140), 1)
cv2.imshow("Calibration", screen)
if cv2.waitKey(1) & 0xFF == 27:
break
session_blink_skips += point_blink_skips
session_light_skips += point_light_skips
if not samples:
print(f"Point {idx+1}: no samples, skipping.")
points_with_low_yield.append(idx + 1)
continue
if len(samples) < MIN_SAMPLES_OK:
print(f" ⚠ Point {idx+1}: only {len(samples)} valid samples "
f"({point_blink_skips} blink-skipped, {point_light_skips} light-skipped) — may be unreliable")
points_with_low_yield.append(idx + 1)
avg = np.median(samples, axis=0)
avg_hp_deg = float(np.median(hp_list))
gaze_vectors.append(list(avg))
screen_points.append([sx, sy])
head_pitches_deg.append(avg_hp_deg)
mid = len(l_list) // 2
rep_lefts.append(l_list[mid])
rep_rights.append(r_list[mid])
rep_poses.append(p_list[mid])
rep_hp_deg.append(hp_list[mid])
n_avail = len(l_list)
n_take = min(FINETUNE_SAMPLES_PER_POINT, n_avail)
indices = np.linspace(0, n_avail - 1, n_take, dtype=int)
for i in indices:
ft_lefts.append(l_list[i])
ft_rights.append(r_list[i])
ft_poses.append(p_list[i])
ft_sp.append([sx, sy])
print(f"Point {idx+1:2d}/{total} | pitch={avg[0]:.4f} yaw={avg[1]:.4f} "
f"| head_pitch={avg_hp_deg:.1f}deg | {len(indices)} frames kept "
f"| skipped: {point_blink_skips} blink, {point_light_skips} lighting")
cap.release()
cv2.destroyAllWindows()
if len(gaze_vectors) < 4:
print(f"ERROR: only {len(gaze_vectors)} points collected, need at least 4.")
exit(1)
# =============================================================================
# OUTLIER FILTER
# =============================================================================
(gaze_vectors, screen_points,
rep_lefts, rep_rights, rep_poses,
rep_hp_deg) = filter_outlier_points(
gaze_vectors, screen_points,
rep_lefts, rep_rights, rep_poses,
head_pitches_deg, sigma=3.0
)
n_pts = len(screen_points)
if n_pts < 4:
print("ERROR: too many outliers removed, need at least 4 clean points.")
exit(1)
ft_lefts_clean = []
ft_rights_clean = []
ft_poses_clean = []
ft_sp_clean = []
for i in range(n_pts):
for _ in range(FINETUNE_SAMPLES_PER_POINT):
ft_lefts_clean.append(rep_lefts[i])
ft_rights_clean.append(rep_rights[i])
ft_poses_clean.append(rep_poses[i])
ft_sp_clean.append(screen_points[i])
print(f"\n--- Fine-tuning CNN on your eyes ({len(ft_lefts_clean)} frames) ---")
finetuned = finetune_on_calibration_v4(
onnx_path = ONNX_PATH,
left_patches = ft_lefts_clean,
right_patches = ft_rights_clean,
head_poses = ft_poses_clean,
screen_points = ft_sp_clean,
screen_w = SCREEN_W,
screen_h = SCREEN_H,
ckpt_path = CKPT_PATH,
out_onnx_path = ONNX_PATH,
steps = 80,
lr = 1e-4,
)
print("\n--- Fitting RBF calibration ---")
pipeline = InsightUXPipeline(ONNX_PATH)
adapted_gaze_vectors = []
for i in range(n_pts):
l = rep_lefts[i]
r = rep_rights[i]
pose = rep_poses[i]
hp = rep_hp_deg[i]
hy = float(pose[1]) * POSE_NORM_SCALE
_, _, raw_pitch, raw_yaw = pipeline.predict_gaze_vector(l, pose, r)
pitch = compensate_pitch(raw_pitch, hp)
yaw = compensate_yaw(raw_yaw, hy)
adapted_gaze_vectors.append([pitch, yaw])
assert len(adapted_gaze_vectors) == len(screen_points), \
f"Length mismatch: {len(adapted_gaze_vectors)} gaze vs {len(screen_points)} screen"
pipeline.calibration.calibrate(
np.array(adapted_gaze_vectors),
np.array(screen_points)
)
pipeline.calibration.save("calibration.pkl")
import pickle
baseline_pitch = float(np.median(rep_hp_deg)) if len(rep_hp_deg) else 0.0
baseline_yaw = float(np.median([float(p[1]) * POSE_NORM_SCALE for p in rep_poses])) if len(rep_poses) else 0.0
baseline_roll = float(np.median([float(p[2]) * POSE_NORM_SCALE for p in rep_poses])) if len(rep_poses) else 0.0
with open("baseline_pose.pkl", "wb") as f:
pickle.dump({"pitch": baseline_pitch, "yaw": baseline_yaw, "roll": baseline_roll}, f)
print(f"Saved baseline_pose.pkl: pitch={baseline_pitch:+.2f}, yaw={baseline_yaw:+.2f}, roll={baseline_roll:+.2f}")
print(f"\nCalibration complete! {n_pts}/{total} points used.")
if finetuned:
print("CNN fine-tuned on your eyes + RBF calibration fitted.")
else:
print("RBF calibration fitted (CNN fine-tuning skipped).")
# =============================================================================
# SESSION DIAGNOSTIC SUMMARY — the "tell the person if something's off" ask.
# Printed once, in plain language, instead of them having to interpret
# per-point numbers themselves.
# =============================================================================
print("\n================ CALIBRATION QUALITY SUMMARY ================")
if session_brightness:
avg_bright = float(np.mean(session_brightness))
pct_dark = 100 * sum(1 for b in session_brightness if b < LIGHT_MIN_BRIGHTNESS) / len(session_brightness)
pct_bright = 100 * sum(1 for b in session_brightness if b > LIGHT_MAX_BRIGHTNESS) / len(session_brightness)
print(f"Average brightness: {avg_bright:.0f} (comfortable range: {LIGHT_MIN_BRIGHTNESS}-{LIGHT_MAX_BRIGHTNESS})")
if pct_dark > 10:
print(f"⚠ Lighting was too DARK for {pct_dark:.0f}% of frames. "
f"Add a light source facing your face, or face a window, before recalibrating.")
if pct_bright > 10:
print(f"⚠ Lighting was too BRIGHT for {pct_bright:.0f}% of frames "
f"(backlight or a light directly behind you?). Try facing away from strong light sources.")
if pct_dark <= 10 and pct_bright <= 10:
print("Lighting was consistently good throughout.")
if session_blink_skips > 0:
print(f"Blinking accounted for {session_blink_skips} skipped frames across the session "
f"— normal, this is expected and was handled automatically.")
if points_with_low_yield:
print(f"⚠ These points had low sample counts and may be less accurate: "
f"{', '.join(str(p) for p in points_with_low_yield)}. "
f"If tracking feels off in that part of the screen, consider recalibrating.")
else:
print("All points collected a healthy number of samples.")
print("===============================================================")
# --- Diagnostic: verify vertical pitch separation ---
print("\n--- Pitch by screen row (should increase top -> bottom) ---")
gv_arr = np.array(adapted_gaze_vectors)
sp_arr = np.array(screen_points)
thresholds = [(0, SCREEN_H*0.25, "Top (y<25%) "),
(SCREEN_H*0.25, SCREEN_H*0.5, "Mid-hi (25-50%) "),
(SCREEN_H*0.5, SCREEN_H*0.75, "Mid-lo (50-75%) "),
(SCREEN_H*0.75, SCREEN_H+1, "Bottom (y>75%) ")]
for lo, hi, label in thresholds:
mask = (sp_arr[:, 1] >= lo) & (sp_arr[:, 1] < hi)
if mask.any():
print(f" {label}: avg pitch = {gv_arr[mask, 0].mean():.4f}")
print("\n--- Yaw by screen column (should increase left -> right) ---")
col_thresholds = [(0, SCREEN_W*0.25, "Left (x<25%) "),
(SCREEN_W*0.25, SCREEN_W*0.5, "Mid-lf (25-50%) "),
(SCREEN_W*0.5, SCREEN_W*0.75, "Mid-rt (50-75%) "),
(SCREEN_W*0.75, SCREEN_W+1, "Right (x>75%) ")]
for lo, hi, label in col_thresholds:
mask = (sp_arr[:, 0] >= lo) & (sp_arr[:, 0] < hi)
if mask.any():
print(f" {label}: avg yaw = {gv_arr[mask, 1].mean():.4f}")