"""Calibration analysis for eye tracking systems.
This module analyzes eye tracker calibration accuracy by testing gaze estimation
at the original calibration points to assess calibration quality.
"""
import matplotlib.pyplot as plt
import numpy as np
from pyetsimul.log import error, info, table
from ..core import Eye, EyeTracker
from ..geometry.conversions import calculate_angular_error_degrees
from ..types import Point3D, Position3D
from ..visualization.interactive_gaze_plot import create_interactive_gaze_plot
from .analysis_utils import calculate_error_statistics
from .calibration_utils import pprint_polynomial_parameters
[docs]
class CalibrationResults:
"""Calibration accuracy results with on-demand visualization."""
[docs]
def __init__(self, errors: dict[str, dict[str, float]], plot_data: dict | None = None) -> None:
"""Initialize calibration results.
Args:
errors: Dictionary containing error statistics in different units
plot_data: Internal data needed for on-demand plot creation (None if all points failed)
"""
self.errors = errors
self._plot_data = plot_data
def __str__(self) -> str:
"""Basic string representation of calibration results."""
mean_error_mm = self.errors["mm"]["mean"]
mean_error_deg = self.errors["deg"]["mean"]
return f"CalibrationResults(mean_error={mean_error_deg:.3f}° / {mean_error_mm:.2f}mm)"
[docs]
def pprint(self, title: str = "Calibration Accuracy") -> None:
"""Print formatted calibration error statistics."""
info(f"\n{title}:")
headers = ["Statistic", "Error (degrees)", "Error (mm)"]
data = [
["Mean", f"{self.errors['deg']['mean']:.4f}", f"{self.errors['mm']['mean']:.4f}"],
["Std", f"{self.errors['deg']['std']:.4f}", f"{self.errors['mm']['std']:.4f}"],
["Median", f"{self.errors['deg']['median']:.4f}", f"{self.errors['mm']['median']:.4f}"],
["Max", f"{self.errors['deg']['max']:.4f}", f"{self.errors['mm']['max']:.4f}"],
]
table(data, headers=headers, tablefmt="grid")
[docs]
def interactive_plot(self, show: bool = True) -> plt.Figure:
"""Create the interactive calibration plot on demand.
No figure is created until this method is called, preventing figures from
lurking in matplotlib's global figure manager and appearing unexpectedly.
Args:
show: If True (default), display the figure with plt.show() (blocks until closed).
If False, return the figure for saving (fig.savefig()) without displaying.
The figure is removed from matplotlib's manager to prevent it from
appearing unexpectedly in later plt.show() calls.
Returns:
The matplotlib Figure.
"""
if self._plot_data is None:
raise ValueError("No valid calibration data available for plotting.")
et = self._plot_data["et"]
eye = self._plot_data["eye"]
return create_interactive_gaze_plot(
[eye],
[et.estimate_gaze_at],
et.calib_points,
et.plane_info,
et.cameras,
et.lights,
et.use_legacy_look_at,
show=show,
)
[docs]
def accuracy_at_calibration_points(et: EyeTracker, eye: Eye) -> CalibrationResults:
"""Computes gaze error at calibration points to assess calibration quality.
Evaluates calibration accuracy by testing gaze prediction at original calibration targets.
Provides comprehensive error analysis with both spatial and angular metrics.
To visualize the results, call calib_results.interactive_plot() on the returned object.
Args:
et: Eye tracker structure
eye: Pre-configured Eye object (required)
Returns:
CalibrationResults object with error statistics, printing, and on-demand visualization
"""
# Ensure eye tracker is calibrated before running analysis
if not et.algorithm_state.is_calibrated:
raise ValueError(
"Eye tracker must be calibrated before running accuracy analysis. Call et.run_calibration(eye) first."
)
# Get calibration points and plane info
calib_points = et.calib_points
n_points = len(calib_points)
# Get the eye tracker's plane info for coordinate system
plane_info = et.plane_info
info(f"Analyzing calibration accuracy at {n_points} points...")
# Output eye measurements
apex_pos = eye.cornea.get_apex_position()
apecornea_surface_x_dist = np.linalg.norm(apex_pos - eye.cornea.center)
cornea_pupil_dist = np.linalg.norm(eye.cornea.center - eye.pupil.pos_pupil)
info(f"Corneal radius: {apecornea_surface_x_dist:.3g} mm")
info(f"Pupil radius: {cornea_pupil_dist:.3g} mm")
# Initialize result arrays
actual_points = []
predicted_points = []
u = np.zeros(n_points)
v = np.zeros(n_points)
errs_deg = np.zeros(n_points)
# Print polynomial parameters
pprint_polynomial_parameters(et)
# Test calibrated polynomial by predicting each calibration point with fresh measurements
# Use calibrated polynomial to test each calibration point with fresh measurements
calibration_fit_results = et.test_calibration_fit(eye)
# Collect data for tabulate
table_data = []
for i, (target_position, predicted_gaze) in enumerate(calibration_fit_results):
# Extract 2D coordinates using plane info for coordinate system consistency
actual_coord1, actual_coord2 = plane_info.extract_2d_coords(target_position)
actual_point = Point3D(actual_coord1, actual_coord2, 0.0) # 2D coordinates in plane
actual_points.append(actual_point)
if predicted_gaze is not None and predicted_gaze.gaze_point is not None:
predicted_points.append(predicted_gaze.gaze_point)
# Extract predicted coordinates using plane info
predicted_pos = Position3D(
predicted_gaze.gaze_point.x, predicted_gaze.gaze_point.y, predicted_gaze.gaze_point.z
)
predicted_coord1, predicted_coord2 = plane_info.extract_2d_coords(predicted_pos)
# Calculate error vectors using plane coordinates
u[i] = predicted_coord1 - actual_coord1
v[i] = predicted_coord2 - actual_coord2
# Compute error in degrees using full 3D coordinates (convert to Point3D)
target_point = Point3D(target_position.x, target_position.y, target_position.z)
predicted_point = Point3D(predicted_pos.x, predicted_pos.y, predicted_pos.z)
errs_deg[i] = calculate_angular_error_degrees(target_point, predicted_point, eye.position)
# Collect data for table
error_mm = np.sqrt(u[i] ** 2 + v[i] ** 2)
table_data.append([
i + 1,
f"({actual_coord1:6.1f}, {actual_coord2:6.1f})",
f"({predicted_coord1:6.1f}, {predicted_coord2:6.1f})",
f"{error_mm:8.2f}",
f"{errs_deg[i]:8.4f}",
])
else:
predicted_points.append(Point3D(np.nan, np.nan, np.nan))
u[i] = np.nan
v[i] = np.nan
errs_deg[i] = np.nan
table_data.append([
i + 1,
f"({actual_coord1:6.1f}, {actual_coord2:6.1f})",
"FAILED",
"--",
"--",
])
# Print the results table
headers = ["Point", "Target (mm)", "Predicted (mm)", "Error (mm)", "Error (°)"]
info("\nCalibration Point Analysis:")
table(table_data, headers=headers, tablefmt="grid")
# Calculate error statistics only for valid points
valid_mask = ~(np.isnan(u) | np.isnan(v) | np.isnan(errs_deg))
n_valid = np.sum(valid_mask)
n_total = len(u)
if n_valid > 0:
errors = calculate_error_statistics(
u[valid_mask].reshape(1, -1),
v[valid_mask].reshape(1, -1),
errs_deg[valid_mask].reshape(1, -1),
)
# Display statistics
info(f"\nCalibration Analysis Results ({n_valid}/{n_total} points successful):")
info(f"Mean error {errors['deg']['mean']:.4f}° ({errors['mm']['mean']:.3g} mm)")
info(f"Standard deviation {errors['deg']['std']:.4f}° ({errors['mm']['std']:.3g} mm)")
info(f"Maximum error {errors['deg']['max']:.4f}° ({errors['mm']['max']:.3g} mm)")
# Store minimal data for on-demand plot creation via interactive_plot()
plot_data = {"et": et, "eye": eye}
else:
error(f"\nCalibration Analysis Results: ALL {n_total} POINTS FAILED")
errors = {
"mm": {"max": np.nan, "mean": np.nan, "std": np.nan, "median": np.nan},
"deg": {"max": np.nan, "mean": np.nan, "std": np.nan, "median": np.nan},
}
plot_data = None
return CalibrationResults(errors, plot_data=plot_data)