From 52b7b98c9f2424afb3b34fe9342c0beff431b5ed Mon Sep 17 00:00:00 2001 From: Prohurtz <48768484+RedHawk989@users.noreply.github.com> Date: Mon, 19 Jan 2026 18:50:29 -0600 Subject: [PATCH 1/2] add calibration? --- eyetrackvr_backend/calibration.py | 252 ++++++++++++++++++++ eyetrackvr_backend/etvr.py | 49 +++- eyetrackvr_backend/processes/__init__.py | 1 + eyetrackvr_backend/processes/calibration.py | 74 ++++++ eyetrackvr_backend/tracker.py | 41 +++- 5 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 eyetrackvr_backend/calibration.py create mode 100644 eyetrackvr_backend/processes/calibration.py diff --git a/eyetrackvr_backend/calibration.py b/eyetrackvr_backend/calibration.py new file mode 100644 index 0000000..8649898 --- /dev/null +++ b/eyetrackvr_backend/calibration.py @@ -0,0 +1,252 @@ +import numpy as np + + +class CalibrationEllipse: + def __init__(self, n_std_devs=2.5): + self.xs = [] + self.ys = [] + self.n_std_devs = float(n_std_devs) + self.fitted = False + + self.scale_factor = 0.80 + + self.flip_y = False # Set to True if up/down are backwards + self.flip_x = False # Adjust if left/right are backwards + + # Ellipse parameters + self.center = None # Mean pupil position (ellipse center) + self.axes = None # Semi-axes (std_dev based) + self.rotation = None # Rotation angle + self.evecs = None # Eigenvectors + + def add_sample(self, x, y): + self.xs.append(float(x)) + self.ys.append(float(y)) + self.fitted = False + + def set_inset_percent(self, percent_smaller=0.0): + clamped_percent = np.clip(percent_smaller, 0.0, 100.0) + self.scale_factor = 1.0 - (clamped_percent / 100.0) + + def init_from_save(self, evecs, axes): + """Initialize calibration from saved data with validation""" + try: + evecs_array = np.asarray(evecs, dtype=float) + axes_array = np.asarray(axes, dtype=float) + + # Validate evecs shape + if evecs_array.shape != (2, 2): + print( + f"\033[91m[ERROR] Invalid evecs shape in saved data: {evecs_array.shape}. Expected (2, 2).\033[0m" + ) + self.fitted = False + return False + + # Validate axes shape + if axes_array.shape != (2,): + print(f"\033[91m[ERROR] Invalid axes shape in saved data: {axes_array.shape}. Expected (2,).\033[0m") + self.fitted = False + return False + + # Check for zero or invalid values + if np.all(axes_array == 0) or np.any(np.isnan(axes_array)) or np.any(np.isnan(evecs_array)): + print("\033[91m[ERROR] Saved calibration data contains zero or NaN values.\033[0m") + self.fitted = False + return False + + self.evecs = evecs_array + self.axes = axes_array + self.fitted = True + return True + + except (ValueError, TypeError) as e: + print(f"\033[91m[ERROR] Failed to load calibration data: {e}\033[0m") + self.fitted = False + return False + + def fit_ellipse(self): + N = len(self.xs) + if N < 2: + print("Warning: Need >= 2 samples to fit PCA. Fit failed.") + self.fitted = False + return 0, 0 + + points = np.column_stack([self.xs, self.ys]) + self.center = np.mean(points, axis=0) + centered_points = points - self.center + + cov = np.cov(centered_points, rowvar=False) + + try: + evals_cov, evecs_cov = np.linalg.eigh(cov) + except np.linalg.LinAlgError: + self.fitted = False + return 0, 0 + + # Sort eigenvectors by alignment with screen axes (X, Y) + x_alignment = np.abs(evecs_cov[0, :]) # How much each evec points in X direction + + if x_alignment[0] > x_alignment[1]: + # evec 0 is more X-aligned, evec 1 is more Y-aligned + self.evecs = evecs_cov + std_devs = np.sqrt(evals_cov) + else: + # evec 1 is more X-aligned, swap them + self.evecs = evecs_cov[:, [1, 0]] + std_devs = np.sqrt(evals_cov[[1, 0]]) + + # --- FIX STARTS HERE --- + # 1. Ensure the X-aligned eigenvector points Right (Positive X) + if self.evecs[0, 0] < 0: + self.evecs[:, 0] *= -1 + + # 2. Ensure Y-aligned eigenvector maintains a Right-Handed Coordinate System. + # Instead of checking Y-sign independently, check the Determinant. + # In screen coords (Y down), X=(1,0) and Y=(0,1) gives det = 1. + # If det < 0, the axes are mirrored; we flip Y to fix it. + det = (self.evecs[0, 0] * self.evecs[1, 1]) - (self.evecs[0, 1] * self.evecs[1, 0]) + + if det < 0: + self.evecs[:, 1] *= -1 + # --- FIX ENDS HERE --- + + self.axes = std_devs * self.n_std_devs + + if self.axes[0] < 1e-12: + self.axes[0] = 1e-12 + if self.axes[1] < 1e-12: + self.axes[1] = 1e-12 + + major_index = np.argmax(std_devs) + major_vec = self.evecs[:, major_index] + self.rotation = np.arctan2(major_vec[1], major_vec[0]) + + self.fitted = True + return self.evecs, self.axes + + def fit_and_visualize(self): + try: + import matplotlib.pyplot as plt + except ImportError: + print("\033[91m[ERROR] matplotlib is required for visualization.\033[0m") + return + + plt.figure(figsize=(10, 8)) + plt.plot(self.xs, self.ys, "k.", label="Calibration Samples", alpha=0.5, markersize=8) + plt.axis("equal") + plt.grid(True, alpha=0.3) + plt.xlabel("Pupil X (pixels)") + plt.ylabel("Pupil Y (pixels)") + + if not self.fitted: + self.fit_ellipse() + + if self.fitted: + scaled_axes = self.axes * self.scale_factor + + t = np.linspace(0, 2 * np.pi, 200) + local_coords = np.column_stack([scaled_axes[0] * np.cos(t), scaled_axes[1] * np.sin(t)]) + world_coords = (self.evecs @ local_coords.T).T + self.center + + plt.plot( + world_coords[:, 0], + world_coords[:, 1], + "b-", + linewidth=2, + label=f"Calibration Ellipse ({self.scale_factor * 100:.0f}% scale)", + ) + plt.plot(self.center[0], self.center[1], "r+", markersize=15, markeredgewidth=3, label="Ellipse Center (Mean)") + + # Draw principal axes + for i, (axis_len, color, name) in enumerate( + [(scaled_axes[0], "g", "Major"), (scaled_axes[1], "m", "Minor")] + ): + axis_vec = self.evecs[:, i] * axis_len + plt.arrow( + self.center[0], + self.center[1], + axis_vec[0], + axis_vec[1], + head_width=5, + head_length=7, + fc=color, + ec=color, + alpha=0.6, + label=f"{name} Axis", + ) + + plt.title(f"Eye Tracking Calibration Ellipse (PCA, {self.n_std_devs}σ)") + else: + plt.title("Ellipse Fit FAILED (Not enough points)") + + plt.legend() + plt.tight_layout() + plt.show() + + def normalize(self, pupil_pos, target_pos=None, clip=True): + if not self.fitted: + return 0.0, 0.0 + + if self.evecs is None or self.axes is None: + print("\033[91m[ERROR] Calibration data (evecs/axes) is None. Please calibrate.\033[0m") + return 0.0, 0.0 + + if not isinstance(self.evecs, np.ndarray) or self.evecs.shape != (2, 2): + print(f"\033[91m[ERROR] Invalid evecs shape. Expected (2, 2). Please recalibrate.\033[0m") + return 0.0, 0.0 + + if not isinstance(self.axes, np.ndarray) or self.axes.shape != (2,): + print(f"\033[91m[ERROR] Invalid axes shape. Expected (2,). Please recalibrate.\033[0m") + return 0.0, 0.0 + + if np.all(self.axes == 0) or np.any(np.isnan(self.axes)): + print("\033[91m[ERROR] Calibration axes are zero or invalid. Please recalibrate.\033[0m") + return 0.0, 0.0 + + x, y = float(pupil_pos[0]), float(pupil_pos[1]) + p = np.array([x, y], dtype=float) + + if target_pos is None: + reference = self.center + else: + reference = np.asarray(target_pos, dtype=float) + + p_centered = p - reference + + try: + p_rot = self.evecs.T @ p_centered + except (ValueError, TypeError) as e: + print(f"\033[91m[ERROR] Matrix multiplication failed in normalize: {e}. Please recalibrate.\033[0m") + return 0.0, 0.0 + + scaled_axes = self.axes * self.scale_factor + scaled_axes[scaled_axes < 1e-12] = 1e-12 + + norm = p_rot / scaled_axes + + norm_x = -norm[0] if self.flip_x else norm[0] + norm_y = norm[1] if self.flip_y else -norm[1] + + if clip: + norm_x = np.clip(norm_x, -1.0, 1.0) + norm_y = np.clip(norm_y, -1.0, 1.0) + + return float(norm_x), float(norm_y) + + def denormalize(self, norm_x, norm_y, target_pos=None): + if not self.fitted: + print("ERROR: Ellipse not fitted yet.") + return 0.0, 0.0 + + nx = -norm_x if self.flip_x else norm_x + ny = norm_y if self.flip_y else -norm_y + + scaled_axes = self.axes * self.scale_factor + p_rot = np.array([nx, ny]) * scaled_axes + + p_centered = self.evecs @ p_rot + reference = self.center if target_pos is None else np.asarray(target_pos, dtype=float) + p = p_centered + reference + + return float(p[0]), float(p[1]) + diff --git a/eyetrackvr_backend/etvr.py b/eyetrackvr_backend/etvr.py index 241fc6b..ed62592 100644 --- a/eyetrackvr_backend/etvr.py +++ b/eyetrackvr_backend/etvr.py @@ -5,7 +5,7 @@ from .config import ConfigManager from multiprocessing import Manager from .logger import get_logger -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException from .tracker import Tracker logger = get_logger() @@ -89,6 +89,21 @@ def shutdown(self) -> None: self.config.stop() os.kill(os.getpid(), signal.SIGTERM) + def _get_tracker(self, uuid: str) -> Tracker: + for tracker in self.trackers: + if tracker.uuid == uuid: + return tracker + raise HTTPException(status_code=404, detail=f"No tracker found with UUID `{uuid}`") + + def tracker_recenter(self, uuid: str) -> dict: + return self._get_tracker(uuid).recenter() + + def tracker_calibrate(self, uuid: str) -> dict: + return self._get_tracker(uuid).calibrate() + + def tracker_calibration_state(self, uuid: str) -> dict: + return self._get_tracker(uuid).check_state() + def add_routes(self) -> None: logger.debug("Adding routes to ETVR") # region: Image streaming endpoints @@ -159,6 +174,38 @@ def add_routes(self) -> None: """, ) # endregion + # region: Tracker Calibration Endpoints + self.router.add_api_route( + name="Recenter tracker calibration", + path="/etvr/tracker/{uuid}/recenter", + endpoint=self.tracker_recenter, + methods=["GET"], + tags=["Tracker Calibration"], + description=""" + Recenter the calibration for a tracker. + """, + ) + self.router.add_api_route( + name="Toggle tracker calibration", + path="/etvr/tracker/{uuid}/calibrate", + endpoint=self.tracker_calibrate, + methods=["GET"], + tags=["Tracker Calibration"], + description=""" + Toggle calibration mode for a tracker. Calling again stops calibration and fits the ellipse. + """, + ) + self.router.add_api_route( + name="Get tracker calibration state", + path="/etvr/tracker/{uuid}/calibration/state", + endpoint=self.tracker_calibration_state, + methods=["GET"], + tags=["Tracker Calibration"], + description=""" + Return the current calibration state for a tracker. + """, + ) + # endregion # region: Config Endpoints self.router.add_api_route( name="Update Config", diff --git a/eyetrackvr_backend/processes/__init__.py b/eyetrackvr_backend/processes/__init__.py index e04011d..ae5cb06 100644 --- a/eyetrackvr_backend/processes/__init__.py +++ b/eyetrackvr_backend/processes/__init__.py @@ -1,3 +1,4 @@ from .camera import Camera from .eye_processor import EyeProcessor from .osc import VRChatOSC, VRChatOSCReceiver +from .calibration import CalibrationProcessor diff --git a/eyetrackvr_backend/processes/calibration.py b/eyetrackvr_backend/processes/calibration.py new file mode 100644 index 0000000..1e68037 --- /dev/null +++ b/eyetrackvr_backend/processes/calibration.py @@ -0,0 +1,74 @@ +from queue import Queue, Empty, Full +import numpy as np + +from ..types import EyeData, TRACKING_FAILED +from ..utils import WorkerProcess +from ..calibration import CalibrationEllipse + + +class CalibrationProcessor(WorkerProcess): + def __init__( + self, + input_queue: Queue[EyeData], + output_queue: Queue[EyeData], + state, + name: str, + uuid: str, + ): + super().__init__(name=f"Calibration {name}", uuid=uuid) + # Synced variables + self.input_queue = input_queue + self.output_queue = output_queue + self.state = state + # Unsynced variables + self.calibration = CalibrationEllipse() + self.recenter_reference: np.ndarray | None = None + self._prev_calibrating = False + + def startup(self) -> None: + pass + + def run(self) -> None: + try: + eye_data: EyeData = self.input_queue.get(block=True, timeout=0.5) + except Empty: + return + except Exception: + self.logger.exception("Failed to get eye data from queue") + return + + calibrating = bool(self.state.get("calibrating", False)) + if calibrating and not self._prev_calibrating: + self.calibration = CalibrationEllipse() + self.state["calibrated"] = False + self.state["samples"] = 0 + + if not calibrating and self._prev_calibrating: + self.calibration.fit_ellipse() + self.state["calibrated"] = bool(self.calibration.fitted) + self.state["samples"] = len(self.calibration.xs) + + self._prev_calibrating = calibrating + + if self.state.get("recenter_requested", False): + self.recenter_reference = np.array([eye_data.x, eye_data.y], dtype=float) + self.state["recenter_requested"] = False + self.state["recentered"] = True + + if calibrating and eye_data != TRACKING_FAILED: + self.calibration.add_sample(eye_data.x, eye_data.y) + self.state["samples"] = len(self.calibration.xs) + + if self.calibration.fitted: + norm_x, norm_y = self.calibration.normalize((eye_data.x, eye_data.y), target_pos=self.recenter_reference) + eye_data.x = (norm_x + 1.0) / 2.0 + eye_data.y = (norm_y + 1.0) / 2.0 + + try: + self.output_queue.put(eye_data, block=False) + except Full: + pass + + def shutdown(self) -> None: + pass + diff --git a/eyetrackvr_backend/tracker.py b/eyetrackvr_backend/tracker.py index 8288fc1..2beb658 100644 --- a/eyetrackvr_backend/tracker.py +++ b/eyetrackvr_backend/tracker.py @@ -6,7 +6,7 @@ from .config import EyeTrackConfig from .visualizer import Visualizer from multiprocessing.managers import SyncManager -from .processes import EyeProcessor, Camera, VRChatOSC +from .processes import EyeProcessor, Camera, VRChatOSC, CalibrationProcessor # TODO: when we start to integrate babble this should become a common interface that eye trackers and mouth trackers inherit from @@ -19,31 +19,51 @@ def __init__(self, config: EyeTrackConfig, uuid: str, manager: SyncManager, rout # IPC stuff self.manager = manager self.osc_queue: Queue[EyeData] = self.manager.Queue(maxsize=60) + self.calibration_queue: Queue[EyeData] = self.manager.Queue(maxsize=60) self.image_queue: Queue[MatLike] = self.manager.Queue(maxsize=60) # Used purely for visualization in the frontend self.camera_queue: Queue[MatLike] = self.manager.Queue(maxsize=15) self.algo_frame_queue: Queue[MatLike] = self.manager.Queue(maxsize=15) # processes - self.processor = EyeProcessor(self.tracker_config, self.image_queue, self.osc_queue, self.algo_frame_queue) + self.processor = EyeProcessor(self.tracker_config, self.image_queue, self.calibration_queue, self.algo_frame_queue) self.camera = Camera(self.tracker_config, self.image_queue, self.camera_queue) self.osc_sender = VRChatOSC(self.osc_queue, self.tracker_config.name) + self.calibration_state = self.manager.dict( + { + "calibrating": False, + "calibrated": False, + "recenter_requested": False, + "recentered": False, + "samples": 0, + } + ) + self.calibration = CalibrationProcessor( + self.calibration_queue, + self.osc_queue, + self.calibration_state, + self.tracker_config.name, + self.tracker_config.uuid, + ) # Visualization self.camera_visualizer = Visualizer(self.camera_queue) self.algorithm_visualizer = Visualizer(self.algo_frame_queue) def start(self) -> None: self.osc_sender.start() + self.calibration.start() self.processor.start() self.camera.start() def stop(self) -> None: self.camera.stop() self.processor.stop() + self.calibration.stop() self.osc_sender.stop() self.camera_visualizer.stop() self.algorithm_visualizer.stop() # if we dont do this we memory leak :3 clear_queue(self.osc_queue) + clear_queue(self.calibration_queue) clear_queue(self.image_queue) clear_queue(self.camera_queue) clear_queue(self.algo_frame_queue) @@ -51,4 +71,21 @@ def stop(self) -> None: def restart(self) -> None: self.camera.restart() self.osc_sender.restart() + self.calibration.restart() self.processor.restart() + + def recenter(self) -> dict: + self.calibration_state["recenter_requested"] = True + return self.check_state() + + def calibrate(self) -> dict: + self.calibration_state["calibrating"] = not bool(self.calibration_state.get("calibrating", False)) + return self.check_state() + + def check_state(self) -> dict: + return { + "calibrating": bool(self.calibration_state.get("calibrating", False)), + "calibrated": bool(self.calibration_state.get("calibrated", False)), + "recentered": bool(self.calibration_state.get("recentered", False)), + "samples": int(self.calibration_state.get("samples", 0)), + } From 0e0d1017afeb7bf896d981df41c8fbb234d2cfb1 Mon Sep 17 00:00:00 2001 From: Prohurtz <48768484+RedHawk989@users.noreply.github.com> Date: Mon, 19 Jan 2026 21:20:56 -0600 Subject: [PATCH 2/2] port fixed calibration from main app --- eyetrackvr_backend/calibration.py | 278 ++++++++++++------------------ 1 file changed, 109 insertions(+), 169 deletions(-) diff --git a/eyetrackvr_backend/calibration.py b/eyetrackvr_backend/calibration.py index 8649898..428a318 100644 --- a/eyetrackvr_backend/calibration.py +++ b/eyetrackvr_backend/calibration.py @@ -1,4 +1,5 @@ import numpy as np +import matplotlib.pyplot as plt class CalibrationEllipse: @@ -10,14 +11,13 @@ def __init__(self, n_std_devs=2.5): self.scale_factor = 0.80 - self.flip_y = False # Set to True if up/down are backwards - self.flip_x = False # Adjust if left/right are backwards + self.flip_y = False + self.flip_x = True - # Ellipse parameters - self.center = None # Mean pupil position (ellipse center) - self.axes = None # Semi-axes (std_dev based) - self.rotation = None # Rotation angle - self.evecs = None # Eigenvectors + # Parameters + self.center = None + self.axes = None + self.evecs = None def add_sample(self, x, y): self.xs.append(float(x)) @@ -29,224 +29,164 @@ def set_inset_percent(self, percent_smaller=0.0): self.scale_factor = 1.0 - (clamped_percent / 100.0) def init_from_save(self, evecs, axes): - """Initialize calibration from saved data with validation""" + """ + Initialize from save. + NOTE: We ignore the saved 'evecs' rotation to ensure strict axis alignment. + """ try: - evecs_array = np.asarray(evecs, dtype=float) axes_array = np.asarray(axes, dtype=float) - # Validate evecs shape - if evecs_array.shape != (2, 2): - print( - f"\033[91m[ERROR] Invalid evecs shape in saved data: {evecs_array.shape}. Expected (2, 2).\033[0m" - ) - self.fitted = False - return False - - # Validate axes shape if axes_array.shape != (2,): - print(f"\033[91m[ERROR] Invalid axes shape in saved data: {axes_array.shape}. Expected (2,).\033[0m") - self.fitted = False + print(f"[ERROR] Invalid axes shape: {axes_array.shape}.") return False - # Check for zero or invalid values - if np.all(axes_array == 0) or np.any(np.isnan(axes_array)) or np.any(np.isnan(evecs_array)): - print("\033[91m[ERROR] Saved calibration data contains zero or NaN values.\033[0m") - self.fitted = False + if np.all(axes_array == 0) or np.any(np.isnan(axes_array)): + print("[ERROR] Saved data contains zero or NaN values.") return False - self.evecs = evecs_array + # Force Identity Matrix (No Rotation) + self.evecs = np.eye(2) self.axes = axes_array + self.fitted = True return True except (ValueError, TypeError) as e: - print(f"\033[91m[ERROR] Failed to load calibration data: {e}\033[0m") + print(f"[ERROR] Failed to load calibration data: {e}") self.fitted = False return False def fit_ellipse(self): + """ + Fits an axis-aligned ellipse (no rotation) using standard deviation. + """ N = len(self.xs) if N < 2: - print("Warning: Need >= 2 samples to fit PCA. Fit failed.") + print("Warning: Need >= 2 samples to fit.") self.fitted = False return 0, 0 - points = np.column_stack([self.xs, self.ys]) - self.center = np.mean(points, axis=0) - centered_points = points - self.center + # 1. Calculate Center (Mean) + mean_x = np.mean(self.xs) + mean_y = np.mean(self.ys) + self.center = np.array([mean_x, mean_y]) - cov = np.cov(centered_points, rowvar=False) + # 2. Calculate Axis Lengths (Standard Deviation) + std_x = np.std(self.xs) + std_y = np.std(self.ys) - try: - evals_cov, evecs_cov = np.linalg.eigh(cov) - except np.linalg.LinAlgError: - self.fitted = False - return 0, 0 - - # Sort eigenvectors by alignment with screen axes (X, Y) - x_alignment = np.abs(evecs_cov[0, :]) # How much each evec points in X direction - - if x_alignment[0] > x_alignment[1]: - # evec 0 is more X-aligned, evec 1 is more Y-aligned - self.evecs = evecs_cov - std_devs = np.sqrt(evals_cov) - else: - # evec 1 is more X-aligned, swap them - self.evecs = evecs_cov[:, [1, 0]] - std_devs = np.sqrt(evals_cov[[1, 0]]) + # Apply sigma multiplier + radius_x = std_x * self.n_std_devs + radius_y = std_y * self.n_std_devs - # --- FIX STARTS HERE --- - # 1. Ensure the X-aligned eigenvector points Right (Positive X) - if self.evecs[0, 0] < 0: - self.evecs[:, 0] *= -1 + # Safety clamp + if radius_x < 1e-12: + radius_x = 1e-12 + if radius_y < 1e-12: + radius_y = 1e-12 - # 2. Ensure Y-aligned eigenvector maintains a Right-Handed Coordinate System. - # Instead of checking Y-sign independently, check the Determinant. - # In screen coords (Y down), X=(1,0) and Y=(0,1) gives det = 1. - # If det < 0, the axes are mirrored; we flip Y to fix it. - det = (self.evecs[0, 0] * self.evecs[1, 1]) - (self.evecs[0, 1] * self.evecs[1, 0]) + self.axes = np.array([radius_x, radius_y]) - if det < 0: - self.evecs[:, 1] *= -1 - # --- FIX ENDS HERE --- - - self.axes = std_devs * self.n_std_devs - - if self.axes[0] < 1e-12: - self.axes[0] = 1e-12 - if self.axes[1] < 1e-12: - self.axes[1] = 1e-12 - - major_index = np.argmax(std_devs) - major_vec = self.evecs[:, major_index] - self.rotation = np.arctan2(major_vec[1], major_vec[0]) + # 3. Force Identity Matrix (Strict Horizontal/Vertical alignment) + self.evecs = np.eye(2) self.fitted = True return self.evecs, self.axes - def fit_and_visualize(self): - try: - import matplotlib.pyplot as plt - except ImportError: - print("\033[91m[ERROR] matplotlib is required for visualization.\033[0m") - return - - plt.figure(figsize=(10, 8)) - plt.plot(self.xs, self.ys, "k.", label="Calibration Samples", alpha=0.5, markersize=8) - plt.axis("equal") - plt.grid(True, alpha=0.3) - plt.xlabel("Pupil X (pixels)") - plt.ylabel("Pupil Y (pixels)") - + def normalize(self, pupil_pos, target_pos=None, clip=True): if not self.fitted: - self.fit_ellipse() + return 0.0, 0.0 - if self.fitted: - scaled_axes = self.axes * self.scale_factor + x, y = float(pupil_pos[0]), float(pupil_pos[1]) - t = np.linspace(0, 2 * np.pi, 200) - local_coords = np.column_stack([scaled_axes[0] * np.cos(t), scaled_axes[1] * np.sin(t)]) - world_coords = (self.evecs @ local_coords.T).T + self.center - - plt.plot( - world_coords[:, 0], - world_coords[:, 1], - "b-", - linewidth=2, - label=f"Calibration Ellipse ({self.scale_factor * 100:.0f}% scale)", - ) - plt.plot(self.center[0], self.center[1], "r+", markersize=15, markeredgewidth=3, label="Ellipse Center (Mean)") - - # Draw principal axes - for i, (axis_len, color, name) in enumerate( - [(scaled_axes[0], "g", "Major"), (scaled_axes[1], "m", "Minor")] - ): - axis_vec = self.evecs[:, i] * axis_len - plt.arrow( - self.center[0], - self.center[1], - axis_vec[0], - axis_vec[1], - head_width=5, - head_length=7, - fc=color, - ec=color, - alpha=0.6, - label=f"{name} Axis", - ) - - plt.title(f"Eye Tracking Calibration Ellipse (PCA, {self.n_std_devs}σ)") + if target_pos is None: + cx, cy = self.center else: - plt.title("Ellipse Fit FAILED (Not enough points)") + cx, cy = target_pos - plt.legend() - plt.tight_layout() - plt.show() + # Calculate deltas + dx = x - cx + dy = y - cy - def normalize(self, pupil_pos, target_pos=None, clip=True): - if not self.fitted: - return 0.0, 0.0 + # Get calibration radii + rx, ry = self.axes * self.scale_factor - if self.evecs is None or self.axes is None: - print("\033[91m[ERROR] Calibration data (evecs/axes) is None. Please calibrate.\033[0m") - return 0.0, 0.0 + # Normalize + norm_x = dx / rx + norm_y = dy / ry - if not isinstance(self.evecs, np.ndarray) or self.evecs.shape != (2, 2): - print(f"\033[91m[ERROR] Invalid evecs shape. Expected (2, 2). Please recalibrate.\033[0m") - return 0.0, 0.0 + # --- APPLY FLIPS --- + # If flip_x is True: Inverts the sign. + final_x = -norm_x if self.flip_x else norm_x - if not isinstance(self.axes, np.ndarray) or self.axes.shape != (2,): - print(f"\033[91m[ERROR] Invalid axes shape. Expected (2,). Please recalibrate.\033[0m") - return 0.0, 0.0 + # If flip_y is False: Inverts Screen Y (so Up is Positive). + final_y = norm_y if self.flip_y else -norm_y + + if clip: + final_x = np.clip(final_x, -1.0, 1.0) + final_y = np.clip(final_y, -1.0, 1.0) - if np.all(self.axes == 0) or np.any(np.isnan(self.axes)): - print("\033[91m[ERROR] Calibration axes are zero or invalid. Please recalibrate.\033[0m") + return float(final_x), float(final_y) + + def denormalize(self, norm_x, norm_y, target_pos=None): + if not self.fitted: return 0.0, 0.0 - x, y = float(pupil_pos[0]), float(pupil_pos[1]) - p = np.array([x, y], dtype=float) + # 1. Reverse the Output Mapping + nx = -norm_x if self.flip_x else norm_x + ny = norm_y if self.flip_y else -norm_y + # 2. Scale back up + rx, ry = self.axes * self.scale_factor + dx = nx * rx + dy = ny * ry + + # 3. Add Center if target_pos is None: - reference = self.center + cx, cy = self.center else: - reference = np.asarray(target_pos, dtype=float) + cx, cy = target_pos - p_centered = p - reference + return float(cx + dx), float(cy + dy) - try: - p_rot = self.evecs.T @ p_centered - except (ValueError, TypeError) as e: - print(f"\033[91m[ERROR] Matrix multiplication failed in normalize: {e}. Please recalibrate.\033[0m") - return 0.0, 0.0 + def fit_and_visualize(self): + plt.figure(figsize=(10, 8)) - scaled_axes = self.axes * self.scale_factor - scaled_axes[scaled_axes < 1e-12] = 1e-12 + plt.plot(self.xs, self.ys, 'k.', label='Samples', alpha=0.5) + plt.axis('equal') + plt.grid(True, alpha=0.3) - norm = p_rot / scaled_axes + # Invert plot Y axis to match screen coordinates + plt.gca().invert_yaxis() - norm_x = -norm[0] if self.flip_x else norm[0] - norm_y = norm[1] if self.flip_y else -norm[1] + if not self.fitted: + self.fit_ellipse() - if clip: - norm_x = np.clip(norm_x, -1.0, 1.0) - norm_y = np.clip(norm_y, -1.0, 1.0) + if self.fitted: + scaled_axes = self.axes * self.scale_factor + t = np.linspace(0, 2 * np.pi, 200) - return float(norm_x), float(norm_y) + el_x = self.center[0] + scaled_axes[0] * np.cos(t) + el_y = self.center[1] + scaled_axes[1] * np.sin(t) - def denormalize(self, norm_x, norm_y, target_pos=None): - if not self.fitted: - print("ERROR: Ellipse not fitted yet.") - return 0.0, 0.0 + plt.plot(el_x, el_y, 'b-', linewidth=2, label='Axis-Aligned Fit') + plt.plot(self.center[0], self.center[1], 'r+', markersize=15, label='Center') - nx = -norm_x if self.flip_x else norm_x - ny = norm_y if self.flip_y else -norm_y + plt.hlines(self.center[1], + self.center[0] - scaled_axes[0], + self.center[0] + scaled_axes[0], + colors='g', linestyles='-', label='Width (X)') - scaled_axes = self.axes * self.scale_factor - p_rot = np.array([nx, ny]) * scaled_axes + plt.vlines(self.center[0], + self.center[1] - scaled_axes[1], + self.center[1] + scaled_axes[1], + colors='m', linestyles='-', label='Height (Y)') - p_centered = self.evecs @ p_rot - reference = self.center if target_pos is None else np.asarray(target_pos, dtype=float) - p = p_centered + reference + plt.title(f'Axis-Aligned Calibration (FlipX={self.flip_x})') + else: + plt.title("Fit FAILED") - return float(p[0]), float(p[1]) + plt.legend() + plt.tight_layout() + plt.show()