Download validate.py from InsightUX/Insight_UX_1.0: direct link, hf CLI and curl.
- Browser
- Download file 11 kB
-
https://huggingface.co/InsightUX/Insight_UX_1.0/resolve/main/validate.py
- Command line
-
hf download hf://InsightUX/Insight_UX_1.0/validate.py
-
curl -L -o validate.py https://huggingface.co/InsightUX/Insight_UX_1.0/resolve/main/validate.py
11 kB
| """ | |
| 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 | |
| # Windows consoles default to a non-UTF-8 codepage (cp1252) — a stray | |
| # unicode character in any print() would otherwise crash the whole process | |
| # with UnicodeEncodeError. See calibrate.py for where this was hit for real. | |
| 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") | |
| # Same RESOURCE_DIR/DATA_DIR split as calibrate.py and browser_session.py — | |
| # bundled read-only assets vs. per-user writable data, once frozen. | |
| 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 | |
| # See calibrate.py's identical check — browser_session.py's run_validation() | |
| # sets this to the active InsightUX profile's own folder so validation reads | |
| # THAT profile's calibration.pkl, not the global/another profile's. Unset | |
| # (standalone `python validate.py`) reproduces exactly today's behavior. | |
| _USER_DATA_DIR = os.environ.get("INSIGHTUX_USER_DATA_DIR") | |
| if _USER_DATA_DIR: | |
| DATA_DIR = _USER_DATA_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") | |
| # Mirrors calibrate.py's _current_onnx_path()/browser_session.py's | |
| # _user_onnx_path(): validate against the same model this profile's real | |
| # tracking would actually use — its own fine-tuned model if it has one, | |
| # the stock bundled model otherwise. | |
| _USER_ONNX_OUT_PATH = os.environ.get("INSIGHTUX_USER_ONNX_OUT") | |
| if _USER_ONNX_OUT_PATH and os.path.exists(_USER_ONNX_OUT_PATH): | |
| ONNX_PATH = _USER_ONNX_OUT_PATH | |
| SCREEN_W, SCREEN_H = pyautogui.size() | |
| PATCH_SOURCE = "blended" # MUST match calibrate.py and main_webcam_pipeline.py | |
| # FIX A / FIX B — MUST match calibrate.py and browser_session.py exactly, | |
| # or this validation measures a different pipeline than the one calibrated. | |
| POSE_NORM_SCALE = 30.0 | |
| HEAD_PITCH_COMPENSATION = 0.0 # reverted — 0.35 made accuracy worse, not better | |
| 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 targets between the calibration grid (fair generalization test) | |
| 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 # seconds collected per target | |
| # MUST match browser_session.py, or validation measures a different pipeline | |
| # than the one you actually run. | |
| 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 = [] # (true_x, true_y, pred_x, pred_y) | |
| for idx, (px, py) in enumerate(TEST_POINTS): | |
| tx, ty = int(px * SCREEN_W), int(py * SCREEN_H) | |
| preds = [] | |
| last_pred = None | |
| # Fresh smoother per target: each target is an independent fixation, | |
| # and angles from the previous target must not bleed into this one. | |
| 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) # FIX A | |
| 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) # FIX B | |
| # Same input smoothing as browser_session.py — the RBF | |
| # amplifies input noise, so it must be cleaned first. | |
| 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() |