| """ |
| validate.py |
| Measures how accurate the tracker is after calibration, with real numbers. |
| |
| Run AFTER calibrate.py (it loads calibration.pkl). |
| python validate.py |
| |
| It flashes 9 targets at positions BETWEEN your calibration points (so this is a |
| fair generalization test, not the dots the RBF was fit on). For each target it |
| collects gaze for a couple of seconds, takes the median predicted screen point, |
| and compares to the true target. |
| |
| Prints: |
| - mean pixel error |
| - mean error as a percent of screen diagonal |
| - zone hit rate: did the gaze land in the correct third of the screen |
| (a 3x3 grid). This is the closest proxy to your AOI hit rate. |
| |
| PATCH_SOURCE must match calibrate.py and run_session.py. |
| """ |
|
|
| import os |
| import sys |
| import cv2 |
| import numpy as np |
| import time |
| import pyautogui |
|
|
| |
| |
| |
| if hasattr(sys.stdout, "reconfigure"): |
| sys.stdout.reconfigure(encoding="utf-8", errors="replace") |
| if hasattr(sys.stderr, "reconfigure"): |
| sys.stderr.reconfigure(encoding="utf-8", errors="replace") |
|
|
| |
| |
| if getattr(sys, "frozen", False): |
| RESOURCE_DIR = sys._MEIPASS |
| DATA_DIR = os.path.dirname(sys.executable) |
| else: |
| RESOURCE_DIR = os.path.dirname(os.path.abspath(__file__)) |
| DATA_DIR = RESOURCE_DIR |
|
|
| 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, GazeAngleSmoother |
|
|
|
|
| ONNX_PATH = os.path.join(RESOURCE_DIR, "models", "gaze_cnn_v4.onnx") |
| CALIBRATION_PATH = os.path.join(DATA_DIR, "calibration.pkl") |
|
|
| SCREEN_W, SCREEN_H = pyautogui.size() |
| PATCH_SOURCE = "blended" |
|
|
| |
| |
| POSE_NORM_SCALE = 30.0 |
| HEAD_PITCH_COMPENSATION = 0.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) |
|
|
| def compensate_pitch(raw_pitch, head_pitch_deg): |
| return raw_pitch - np.radians(head_pitch_deg) * HEAD_PITCH_COMPENSATION |
|
|
| |
| TEST_POINTS = [ |
| (0.25, 0.25), (0.50, 0.25), (0.75, 0.25), |
| (0.25, 0.50), (0.50, 0.50), (0.75, 0.50), |
| (0.25, 0.75), (0.50, 0.75), (0.75, 0.75), |
| ] |
| DURATION = 2.5 |
|
|
| |
| |
| ANGLE_SMOOTH_WINDOW = 10 |
|
|
|
|
| def zone(sx, sy): |
| col = 0 if sx < SCREEN_W / 3 else (1 if sx < 2 * SCREEN_W / 3 else 2) |
| row = 0 if sy < SCREEN_H / 3 else (1 if sy < 2 * SCREEN_H / 3 else 2) |
| return row, col |
|
|
|
|
| def get_patch(frame, lms, head_pose, 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 |
| if PATCH_SOURCE == "norm": |
| return s1.norm_crop |
| ir = compute_iris_radius(lms, iris_idx, frame.shape) |
| s2 = step2_illumination(s1, ir) |
| return s2.blended if s2.is_usable else None |
|
|
|
|
| def main(): |
| pipeline = InsightUXPipeline(ONNX_PATH, CALIBRATION_PATH) |
| face_mesh = create_face_mesh(static_image_mode=False) |
| cap = cv2.VideoCapture(0) |
| cam_matrix = None |
|
|
| cv2.namedWindow("Validate", cv2.WINDOW_NORMAL) |
| cv2.setWindowProperty("Validate", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN) |
|
|
| print("Validation: look at each red dot until it turns green.") |
| print("Cyan dot = your live, single-frame prediction (will jitter, that's normal).") |
| print("Magenta ring = the running median - this is what actually gets scored.") |
| results = [] |
|
|
| for idx, (px, py) in enumerate(TEST_POINTS): |
| tx, ty = int(px * SCREEN_W), int(py * SCREEN_H) |
| preds = [] |
| last_pred = None |
| |
| |
| angle_smoother = GazeAngleSmoother(window=ANGLE_SMOOTH_WINDOW) |
| 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) |
|
|
| rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) |
| res = face_mesh.process(rgb) |
| if res.multi_face_landmarks: |
| lms = res.multi_face_landmarks[0].landmark |
| head_pose = estimate_head_pose(lms, frame.shape, cam_matrix) |
| if head_pose is not None: |
| pose_vec = normalize_pose(head_pose) |
| lp = get_patch(frame, lms, head_pose, |
| LEFT_EYE_INDICES, LEFT_EAR_INDICES, LEFT_IRIS_INDICES) |
| rp = get_patch(frame, lms, head_pose, |
| RIGHT_EYE_INDICES, RIGHT_EAR_INDICES, RIGHT_IRIS_INDICES) |
| if lp is not None or rp is not None: |
| if lp is None: lp = rp |
| if rp is None: rp = lp |
| _, _, raw_pitch, raw_yaw = pipeline.predict_gaze_vector(lp, pose_vec, rp) |
| pitch = compensate_pitch(raw_pitch, head_pose.pitch) |
| |
| |
| ear_now = 0.5 * (compute_ear(lms, LEFT_EAR_INDICES, frame.shape) + |
| compute_ear(lms, RIGHT_EAR_INDICES, frame.shape)) |
| pitch, yaw_s, ear_s = angle_smoother(pitch, raw_yaw, ear_now) |
| sx, sy = pipeline.calibration.predict(pitch, yaw_s, ear_s) |
| sx = max(0.0, min(sx, SCREEN_W)) |
| sy = max(0.0, min(sy, SCREEN_H)) |
| preds.append([sx, sy]) |
| last_pred = (sx, sy) |
|
|
| screen = np.zeros((SCREEN_H, SCREEN_W, 3), dtype=np.uint8) |
| ready = len(preds) > 10 |
| color = (0, 255, 0) if ready else (0, 0, 255) |
| cv2.circle(screen, (tx, ty), 20, color, -1) |
| cv2.putText(screen, f"Target {idx+1}/{len(TEST_POINTS)}", |
| (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2) |
|
|
| if last_pred is not None: |
| lx, ly = int(last_pred[0]), int(last_pred[1]) |
| cv2.line(screen, (tx, ty), (lx, ly), (120, 120, 0), 1) |
| cv2.circle(screen, (lx, ly), 9, (255, 255, 0), -1) |
|
|
| if len(preds) >= 5: |
| mx_, my_ = np.median(np.array(preds), axis=0) |
| mxi, myi = int(mx_), int(my_) |
| cv2.circle(screen, (mxi, myi), 16, (255, 0, 255), 2) |
| live_err = float(np.hypot(tx - mx_, ty - my_)) |
| cv2.putText(screen, f"running error: {live_err:.0f}px", |
| (50, 95), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 255, 255), 2) |
|
|
| cv2.putText(screen, "cyan = live magenta ring = running median (scored)", |
| (50, SCREEN_H - 30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (170, 170, 170), 1) |
|
|
| cv2.imshow("Validate", screen) |
| if cv2.waitKey(1) & 0xFF == 27: |
| cap.release(); cv2.destroyAllWindows(); return |
|
|
| if len(preds) >= 5: |
| mx, my = np.median(np.array(preds), axis=0) |
| results.append((tx, ty, float(mx), float(my))) |
| err = float(np.hypot(tx - mx, ty - my)) |
| print(f"Target {idx+1}: true=({tx},{ty}) pred=({mx:.0f},{my:.0f}) error={err:.0f}px") |
|
|
| freeze = np.zeros((SCREEN_H, SCREEN_W, 3), dtype=np.uint8) |
| cv2.circle(freeze, (tx, ty), 20, (0, 255, 0), -1) |
| cv2.circle(freeze, (int(mx), int(my)), 16, (255, 0, 255), 2) |
| cv2.line(freeze, (tx, ty), (int(mx), int(my)), (255, 0, 255), 2) |
| cv2.putText(freeze, f"Target {idx+1}/{len(TEST_POINTS)} error: {err:.0f}px", |
| (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2) |
| cv2.imshow("Validate", freeze) |
| cv2.waitKey(700) |
| else: |
| print(f"Target {idx+1}: too few samples, skipped") |
|
|
| cap.release() |
| cv2.destroyAllWindows() |
|
|
| if not results: |
| print("No valid targets. Check lighting and camera.") |
| return |
|
|
| errs = [np.hypot(tx - mx, ty - my) for (tx, ty, mx, my) in results] |
| diag = np.hypot(SCREEN_W, SCREEN_H) |
| hits = sum(1 for (tx, ty, mx, my) in results if zone(tx, ty) == zone(mx, my)) |
|
|
| print("\n================ VALIDATION RESULT ================") |
| print(f"Targets measured : {len(results)}/{len(TEST_POINTS)}") |
| print(f"Mean pixel error : {np.mean(errs):.0f} px") |
| print(f"Median pixel error : {np.median(errs):.0f} px") |
| print(f"Mean error vs screen : {100*np.mean(errs)/diag:.1f}% of diagonal") |
| print(f"Zone hit rate (3x3) : {hits}/{len(results)} ({100*hits/len(results):.0f}%)") |
| print("===================================================") |
| print("Zone hit rate is the closest proxy to AOI accuracy. Aim for a coarser") |
| print("AOI layout than 3x3 if you need a higher number for the demo.") |
|
|
|
|
| if __name__ == "__main__": |
| main() |