Download browser/calibrate.py from Trishti/InsightUX: direct link, hf CLI and curl.
- Browser
- Download file 28.6 kB
-
https://huggingface.co/Trishti/InsightUX/resolve/main/browser/calibrate.py
- Command line
-
hf download hf://Trishti/InsightUX/browser/calibrate.py
-
curl -L -o calibrate.py https://huggingface.co/Trishti/InsightUX/resolve/main/browser/calibrate.py
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}") |