"""Eyetracking Calibration(s) class constructor.""" # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. from copy import deepcopy import numpy as np from ...io.eyelink._utils import _parse_calibration from ...utils import _check_fname, _validate_type, fill_doc, logger from ...viz.utils import plt_show @fill_doc class Calibration(dict): """Eye-tracking calibration info. This data structure behaves like a dictionary. It contains information regarding a calibration that was conducted during an eye-tracking recording. .. note:: When possible, a Calibration instance should be created with a helper function, such as :func:`~mne.preprocessing.eyetracking.read_eyelink_calibration`. Parameters ---------- onset : float The onset of the calibration in seconds. If the calibration was performed before the recording started, the the onset can be negative. model : str A string, which is the model of the eye-tracking calibration that was applied. For example ``'H3'`` for a horizontal only 3-point calibration, or ``'HV3'`` for a horizontal and vertical 3-point calibration. eye : str The eye that was calibrated. For example, ``'left'``, or ``'right'``. avg_error : float The average error in degrees between the calibration positions and the actual gaze position. max_error : float The maximum error in degrees that occurred between the calibration positions and the actual gaze position. positions : array-like of float, shape ``(n_calibration_points, 2)`` The x and y coordinates of the calibration points. offsets : array-like of float, shape ``(n_calibration_points,)`` The error in degrees between the calibration position and the actual gaze position for each calibration point. gaze : array-like of float, shape ``(n_calibration_points, 2)`` The x and y coordinates of the actual gaze position for each calibration point. screen_size : array-like of shape ``(2,)`` The width and height (in meters) of the screen that the eyetracking data was collected with. For example ``(.531, .298)`` for a monitor with a display area of 531 x 298 mm. screen_distance : float The distance (in meters) from the participant's eyes to the screen. screen_resolution : array-like of shape ``(2,)`` The resolution (in pixels) of the screen that the eyetracking data was collected with. For example, ``(1920, 1080)`` for a 1920x1080 resolution display. """ def __init__( self, *, onset, model, eye, avg_error, max_error, positions, offsets, gaze, screen_size=None, screen_distance=None, screen_resolution=None, ): super().__init__( onset=onset, model=model, eye=eye, avg_error=avg_error, max_error=max_error, screen_size=screen_size, screen_distance=screen_distance, screen_resolution=screen_resolution, positions=positions, offsets=offsets, gaze=gaze, ) def __repr__(self): """Return a summary of the Calibration object.""" return ( f"Calibration |\n" f" onset: {self['onset']} seconds\n" f" model: {self['model']}\n" f" eye: {self['eye']}\n" f" average error: {self['avg_error']} degrees\n" f" max error: {self['max_error']} degrees\n" f" screen size: {self['screen_size']} meters\n" f" screen distance: {self['screen_distance']} meters\n" f" screen resolution: {self['screen_resolution']} pixels\n" ) def copy(self): """Copy the instance. Returns ------- cal : instance of Calibration The copied Calibration. """ return deepcopy(self) def plot(self, show_offsets=True, axes=None, show=True): """Visualize calibration. Parameters ---------- show_offsets : bool Whether to display the offset (in visual degrees) of each calibration point or not. Defaults to ``True``. axes : instance of matplotlib.axes.Axes | None Axes to draw the calibration positions to. If ``None`` (default), a new axes will be created. show : bool Whether to show the figure or not. Defaults to ``True``. Returns ------- fig : instance of matplotlib.figure.Figure The resulting figure object for the calibration plot. """ import matplotlib.pyplot as plt msg = "positions and gaze keys must both be 2D numpy arrays." assert isinstance(self["positions"], np.ndarray), msg assert isinstance(self["gaze"], np.ndarray), msg if axes is not None: from matplotlib.axes import Axes _validate_type(axes, Axes, "axes") ax = axes fig = ax.get_figure() else: # create new figure and axes fig, ax = plt.subplots(layout="constrained") px, py = self["positions"].T gaze_x, gaze_y = self["gaze"].T ax.set_title(f"Calibration ({self['eye']} eye)") ax.set_xlabel("x (pixels)") ax.set_ylabel("y (pixels)") # Display avg_error and max_error in the top left corner text = ( f"avg_error: {self['avg_error']} deg.\nmax_error: {self['max_error']} deg." ) ax.text( 0, 1.01, text, transform=ax.transAxes, verticalalignment="baseline", fontsize=8, ) # Invert y-axis because the origin is in the top left corner ax.invert_yaxis() ax.scatter(px, py, color="gray") ax.scatter(gaze_x, gaze_y, color="red", alpha=0.5) if show_offsets: for i in range(len(px)): x_offset = 0.01 * gaze_x[i] # 1% to the right of the gazepoint text = ax.text( x=gaze_x[i] + x_offset, y=gaze_y[i], s=self["offsets"][i], fontsize=8, ha="left", va="center", ) plt_show(show) return fig @fill_doc def read_eyelink_calibration( fname, screen_size=None, screen_distance=None, screen_resolution=None ): """Return info on calibrations collected in an eyelink file. Parameters ---------- fname : path-like Path to the eyelink file (.asc). screen_size : array-like of shape ``(2,)`` The width and height (in meters) of the screen that the eyetracking data was collected with. For example ``(.531, .298)`` for a monitor with a display area of 531 x 298 mm. Defaults to ``None``. screen_distance : float The distance (in meters) from the participant's eyes to the screen. Defaults to ``None``. screen_resolution : array-like of shape ``(2,)`` The resolution (in pixels) of the screen that the eyetracking data was collected with. For example, ``(1920, 1080)`` for a 1920x1080 resolution display. Defaults to ``None``. Returns ------- calibrations : list A list of :class:`~mne.preprocessing.eyetracking.Calibration` instances, one for each eye of every calibration that was performed during the recording session. """ fname = _check_fname(fname, overwrite="read", must_exist=True, name="fname") logger.info(f"Reading calibration data from {fname}") lines = fname.read_text(encoding="ASCII").splitlines() return _parse_calibration(lines, screen_size, screen_distance, screen_resolution)