From 5fb94fd89e8cd0795b4dfaed2b1db5862d2f7382 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Sat, 3 Jun 2023 15:47:44 -0400 Subject: [PATCH 01/52] ENH: Create a Calibrations class for eyetracking and reader function for eyelink calibration This commit creates a Calibrations class to store eyetracking calibration info. Calibrations subclasses list and contains a list of Calibration instances. Calibration instances sublcass OrderedDict. --- mne/io/__init__.py | 2 +- mne/io/eyelink/__init__.py | 5 +- mne/io/eyelink/_utils.py | 130 +++++++++++++++++ mne/io/eyelink/calibration.py | 203 +++++++++++++++++++++++++++ mne/io/eyelink/eyelink.py | 71 +++++++++- mne/io/eyelink/tests/test_eyelink.py | 65 ++++++++- 6 files changed, 471 insertions(+), 5 deletions(-) create mode 100644 mne/io/eyelink/_utils.py create mode 100644 mne/io/eyelink/calibration.py diff --git a/mne/io/__init__.py b/mne/io/__init__.py index e51df7c9183..69550e4fcaa 100644 --- a/mne/io/__init__.py +++ b/mne/io/__init__.py @@ -68,7 +68,7 @@ from .fieldtrip import read_raw_fieldtrip, read_epochs_fieldtrip, read_evoked_fieldtrip from .nihon import read_raw_nihon from ._read_raw import read_raw -from .eyelink import read_raw_eyelink +from .eyelink import read_raw_eyelink, read_eyelink_calibration # for backward compatibility diff --git a/mne/io/eyelink/__init__.py b/mne/io/eyelink/__init__.py index 77ee7ebc9ef..6136544bb3b 100644 --- a/mne/io/eyelink/__init__.py +++ b/mne/io/eyelink/__init__.py @@ -1,7 +1,8 @@ """Module for loading Eye-Tracker data.""" -# Author: Dominik Welke +# Authors: Dominik Welke +# Scott Huberty # # License: BSD-3-Clause -from .eyelink import read_raw_eyelink +from .eyelink import read_raw_eyelink, read_eyelink_calibration diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py new file mode 100644 index 00000000000..c9e5de62048 --- /dev/null +++ b/mne/io/eyelink/_utils.py @@ -0,0 +1,130 @@ +"""Helper functions for reading eyelink ASCII files.""" +# Authors: Scott Huberty +# License: BSD-3-Clause + +import re +import numpy as np + +from .calibration import Calibration, Calibrations + + +def _find_recording_start(lines): + """Return the first START line in an eyelink ASCII file. + + Parameters + ---------- + lines: A list of strings, which are The lines in an eyelink ASCII file. + + Returns + ------- + The line that contains the info on the start of the recording. + """ + for line in lines: + if line.startswith("START"): + return line + raise ValueError("Could not find the start of the recording.") + + +def _parse_validation_line(line): + """Parse a single line of eyelink validation data. + + Parameters + ---------- + line: A string containing a line of validation data from an eyelink + ASCII file. + + Returns + ------- + A dictionary containing the validation data. + """ + keys = ["point_x", "point_y", "offset", "diff_x", "diff_y"] + dtype = [(key, "f8") for key in keys] + parsed_data = np.empty(1, dtype=dtype) + + tokens = line.split() + xy = tokens[-6].strip("[]").split(",") # e.g. '960, 540' + xy_diff = tokens[-2].strip("[]").split(",") # e.g. '-1.5, -2.8' + vals = [float(v) for v in [*xy, tokens[-4], *xy_diff]] + + for key, data in zip(keys, vals): + parsed_data[0][key] = data + + return parsed_data + + +def _parse_calibration( + lines, screen_size=None, screen_distance=None, screen_resolution=None +): + """Parse the lines in the given list and returns a Calibrations instance. + + Parameters + ---------- + lines: A list of strings, which are The lines in an eyelink ASCII file. + + Returns + ------- + A Calibrations instance containing one or more Calibration instances, + one for each calibration that was recorded in the eyelink ASCII file + data. + """ + regex = re.compile(r"\d+") # for finding numeric characters + calibrations = Calibrations() + rec_start = float(_find_recording_start(lines).split()[1]) + + for line_number, line in enumerate(lines): + if ( + "!CAL VALIDATION " in line and "ABORTED" not in line + ): # Start of a calibration + tokens = line.split() + this_eye = tokens[6].lower() + assert this_eye in ["left", "right"] + if "LR" not in line or ("LR" in line and this_eye == "left"): + # for binocular calibrations, there are two '!CAL VALIDATION' lines + # Create a single calibration instance for both eyes. + calibration = Calibration( + screen_size=screen_size, + screen_distance=screen_distance, + screen_resolution=screen_resolution, + ) + calibration["model"] = tokens[4] # e.g. 'HV13' + assert calibration["model"].startswith("H") + calibration["eye"] = "both" if "LR" in line else this_eye + timestamp = float(tokens[1]) + onset = timestamp - rec_start + calibration["onset"] = 0 if onset < 0 else onset + + avg_error = float(line.split("avg.")[0].split()[-1]) # e.g. 0.3 + max_error = float(line.split("max")[0].split()[-1]) # e.g. 0.9 + if calibration["eye"] == "both": + if not isinstance(calibration["points"], dict): + # don't overwrite dict if it was set in previous line + calibration["points"] = {"left": [], "right": []} + calibration["avg_error"][this_eye] = avg_error + calibration["max_error"][this_eye] = max_error + else: + calibration["avg_error"] = avg_error + calibration["max_error"] = max_error + + n_points = int(regex.search(calibration["model"]).group()) # e.g. 9 + n_points *= 2 if "LR" in line else 1 # one point per eye if "LR" + # The next n_point lines contain the validation data + for validation_index in range(n_points): + subline = lines[line_number + validation_index + 1] + subline_eye = subline.split("at")[0].split()[-1].lower() + if subline_eye != this_eye: + continue # skip the validation lines for the other eye + point_info = _parse_validation_line(subline) + if calibration["eye"] == "both": + calibration["points"][this_eye].append(point_info) + else: + calibration["points"].append(point_info) + # Convert the list of validation data into a numpy array + if calibration["eye"] == "both": + calibration["points"][this_eye] = np.concatenate( + calibration["points"][this_eye], axis=0 + ) + else: + calibration["points"] = np.concatenate(calibration["points"], axis=0) + + calibrations.append(calibration) + return calibrations diff --git a/mne/io/eyelink/calibration.py b/mne/io/eyelink/calibration.py new file mode 100644 index 00000000000..82854579f9f --- /dev/null +++ b/mne/io/eyelink/calibration.py @@ -0,0 +1,203 @@ +"""Eyetracking Calibration(s) class constructor.""" + +# Authors: Scott Huberty +# License: BSD-3-Clause + +from collections import OrderedDict +from ...utils import fill_doc + + +@fill_doc +class Calibrations(list): + """A list of Calibration objects. + + Parameters + ---------- + onset: float + The onset of the calibration in seconds. If the calibration was + performed before the recording started, then the onset should be + set to 0 seconds. + model: str + A string, which is the model of the eyetracker. 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', + 'right', or 'both'. + avg_error: float + The average error in degrees between the calibration points and the + actual gaze position. If 'eye' is 'both', then a dict can be passed + with the average error for each eye. For example, {'left': 0.5, 'right': 0.6}. + max_error: float + The maximum error in degrees that occurred between the calibration + points and the actual gaze position. If 'eye' is 'both', then a dict + can be passed with the maximum error for each eye. For example, + {'left': 0.5, 'right': 0.6}. + points: ndarray + a 2D numpy array, which are the data for each calibration point. + Each row contains the x and y pixel-coordinates of the actual gaze position + to the calibration point, the error in degrees between the calibration point + and the actual gaze position, and the difference in x and y pixel coordinates + between the calibration point and the actual gaze position. If 'eye' is 'both', + then a dict can be passed with a separate 2D numpy array for each eye. + screen_size : tuple + 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 cm. + screen_distance : float + The distance (in meters) from the participant's eyes to the screen. + screen_resolution : tuple + The resolution (in pixels) of the screen that the eyetracking data + was collected with. For example, (1920, 1080) for a 1920x1080 + resolution display. + + Returns + ------- + calibrations: Calibrations + A Calibrations instance, which is a list of Calibration objects. + """ + + def __init__( + self, + onset=None, + model=None, + eye=None, + avg_error=None, + max_error=None, + points=None, + screen_size=None, + screen_distance=None, + screen_resolution=None, + ): + super().__init__() + if any( + arg is not None + for arg in ( + onset, + model, + eye, + avg_error, + max_error, + points, + screen_size, + screen_distance, + screen_resolution, + ) + ): + calibration = Calibration( + onset=onset, + model=model, + eye=eye, + avg_error=avg_error, + max_error=max_error, + points=points, + screen_size=screen_size, + screen_distance=screen_distance, + screen_resolution=screen_resolution, + ) + self.append(calibration) + + def __repr__(self): + """Return the number of calibration objects in this instance.""" + num_calibrations = len(self) + return f"Calibrations | {num_calibrations} calibration(s)" + + +@fill_doc +class Calibration(OrderedDict): + """A dictionary containing calibration data. + + Parameters + ---------- + onset: float + The onset of the calibration in seconds. If the calibration was + performed before the recording started, then the onset should be + set to 0 seconds. + model: str + A string, which is the model of the eyetracker. 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', + 'right', or 'both'. + avg_error: float + The average error in degrees between the calibration points and the + actual gaze position. If 'eye' is 'both', then a dict can be passed + with the average error for each eye. For example, {'left': 0.5, 'right': 0.6}. + max_error: float + The maximum error in degrees that occurred between the calibration + points and the actual gaze position. If 'eye' is 'both', then a dict + can be passed with the maximum error for each eye. For example, + {'left': 0.5, 'right': 0.6}. + points: ndarray + a 2D numpy array, which are the data for each calibration point. + Each row contains the x and y pixel-coordinates of the actual gaze position + to the calibration point, the error in degrees between the calibration point + and the actual gaze position, and the difference in x and y pixel coordinates + between the calibration point and the actual gaze position. If 'eye' is 'both', + then a dict can be passed with a separate 2D numpy array for each eye. + screen_size : tuple + 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 cm. + screen_distance : float + The distance (in meters) from the participant's eyes to the screen. + screen_resolution : tuple + 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=None, + model=None, + avg_error=None, + max_error=None, + points=None, + eye=None, + screen_size=None, + screen_distance=None, + screen_resolution=None, + **kwargs, + ): + super().__init__(**kwargs) + self["onset"] = onset + self["model"] = {} if model is None else model + self["eye"] = {} if eye is None else eye + self["avg_error"] = {} if avg_error is None else avg_error + self["max_error"] = {} if max_error is None else max_error + self["points"] = [] if points is None else points + self["screen_size"] = screen_size + self["screen_distance"] = screen_distance + self["screen_resolution"] = screen_resolution + + def __repr__(self): + """Return a summary of the Calibration object.""" + onset = self.get("onset", "N/A") + model = self.get("model", "N/A") + eye = self.get("eye", "N/A") + avg_error = self.get("avg_error", "N/A") + max_error = self.get("max_error", "N/A") + screen_size = self.get("screen_size", "N/A") + screen_distance = self.get("screen_distance", "N/A") + screen_resolution = self.get("screen_resolution", "N/A") + return ( + f"Calibration |\n" + f" onset: {onset} seconds\n" + f" model: {model}\n" + f" eye: {eye}\n" + f" average error: {avg_error} degrees\n" + f" max error: {max_error} degrees\n" + f" screen size: {screen_size} meters\n" + f" screen distance: {screen_distance} meters\n" + f" screen resolution: {screen_resolution} pixels\n" + ) + + def __getattr__(self, name): + """Allow dot indexing of dict keys.""" + if name in self: + return self[name] + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{name}'" + ) diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index e01f46a30b7..e4f2a8a25a8 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -1,3 +1,5 @@ +"""SR Research Eyelink Load Function.""" + # Authors: Dominik Welke # Scott Huberty # Christian O'Reilly @@ -8,6 +10,7 @@ from pathlib import Path import numpy as np +from ._utils import _parse_calibration from ..constants import FIFF from ..base import BaseRaw from ..meas_info import create_info @@ -284,6 +287,43 @@ def _find_overlaps(df, max_time=0.05): return ovrlp.drop(columns=tmp_cols).reset_index(drop=True) +@fill_doc +def read_eyelink_calibration( + filename, screen_size=None, screen_distance=None, screen_resolution=None +): + """Return info on calibrations collected in an eyelink file. + + Parameters + ---------- + filename : str + Path to the eyelink file (.asc). + screen_size : tuple + 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 cm. Defaults to None. + screen_distance : float + The distance (in meters) from the participant's eyes to the screen. + Defaults to None. + screen_resolution : tuple + 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 : instance of Calibrations + """ + fname = Path(filename) + if not fname.exists(): + raise FileNotFoundError(f"Could not find file {filename}") + logger.info("Reading calibration data from {}".format(fname)) + with fname.open() as file: + lines = file.readlines() + return _parse_calibration( + lines, screen_size, screen_distance, screen_resolution + ) + + @fill_doc def read_raw_eyelink( fname, @@ -294,6 +334,10 @@ def read_raw_eyelink( find_overlaps=False, overlap_threshold=0.05, gap_description="bad_rec_gap", + return_calibration=False, + screen_size=None, + screen_distance=None, + screen_resolution=None, ): """Reader for an Eyelink .asc file. @@ -328,6 +372,24 @@ def read_raw_eyelink( the annotation that will span across the gap period between the blocks. Uses 'bad_rec_gap' by default so that these time periods will be considered bad by MNE and excluded from operations like epoching. + return_calibration : bool (default False) + If True, returns a tuple of (raw, calibrations) where calibrations is + an object that contains information about the eye calibration for the + file. + screen_size : tuple + Only set if 'return_calibration' is set to True. + 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 cm. Defaults to None. + screen_distance : float + Only set if 'return_calibration' is set to True. + The distance from the participant's eyes to the screen in meters. + Defaults to None. + screen_resolution : tuple + Only set if 'return_calibration' is set to True. + 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 ------- @@ -347,7 +409,7 @@ def read_raw_eyelink( " files to .asc format." ) - return RawEyelink( + raw_eyelink = RawEyelink( fname, preload=preload, verbose=verbose, @@ -357,6 +419,13 @@ def read_raw_eyelink( overlap_threshold=overlap_threshold, gap_desc=gap_description, ) + if return_calibration: + calibrations = read_eyelink_calibration( + fname, screen_size, screen_distance, screen_resolution + ) + return raw_eyelink, calibrations + else: + return raw_eyelink @fill_doc diff --git a/mne/io/eyelink/tests/test_eyelink.py b/mne/io/eyelink/tests/test_eyelink.py index 51d64ea5ed5..f68873b12f6 100644 --- a/mne/io/eyelink/tests/test_eyelink.py +++ b/mne/io/eyelink/tests/test_eyelink.py @@ -3,7 +3,7 @@ import numpy as np from mne.datasets.testing import data_path, requires_testing_data -from mne.io import read_raw_eyelink +from mne.io import read_raw_eyelink, read_eyelink_calibration from mne.io.constants import FIFF from mne.io.pick import _DATA_CH_TYPES_SPLIT from mne.utils import _check_pandas_installed, requires_pandas @@ -85,6 +85,69 @@ def test_eyelink(fname, create_annotations, find_overlaps): assert df["description"].iloc[i] == f"{label}_both" +@requires_testing_data +@pytest.mark.parametrize("fname", [(fname)]) +def test_read_calibration(fname): + """Test reading calibration data from an eyelink asc file.""" + calibrations = read_eyelink_calibration(fname) + calibration = calibrations[0] + expected_x_left = np.array( + [ + 960.0, + 960.0, + 960.0, + 115.0, + 1804.0, + 216.0, + 1703.0, + 216.0, + 1703.0, + 537.0, + 1382.0, + 537.0, + 1382.0, + ] + ) + expected_y_right = np.array( + [ + 540.0, + 92.0, + 987.0, + 540.0, + 540.0, + 145.0, + 145.0, + 934.0, + 934.0, + 316.0, + 316.0, + 763.0, + 763.0, + ] + ) + expected_diff_y_left = np.array( + [-4.1, 16.0, -14.2, -14.8, 1.0, -15.4, -1.4, 6.9, -28.1, 7.6, 2.1, -2.0, 8.4] + ) + expected_offset_right = np.array( + [0.36, 0.5, 0.2, 0.1, 0.3, 0.38, 0.13, 0.33, 0.22, 0.18, 0.34, 0.52, 0.21] + ) + + assert calibration["model"] == "HV13" + assert calibration["eye"] == "both" + assert calibration["avg_error"]["left"] == 0.30 + assert calibration["max_error"]["left"] == 0.90 + assert calibration["avg_error"]["right"] == 0.31 + assert calibration["max_error"]["right"] == 0.52 + assert calibration["points"]["left"]["point_x"] == pytest.approx(expected_x_left) + assert calibration["points"]["right"]["point_y"] == pytest.approx(expected_y_right) + assert calibration["points"]["left"]["diff_y"] == pytest.approx( + expected_diff_y_left + ) + assert calibration["points"]["right"]["offset"] == pytest.approx( + expected_offset_right + ) + + @requires_testing_data @requires_pandas @pytest.mark.parametrize("fname_href", [(fname_href)]) From 444f6d8e4449f983a3b47f66f6230b639b103973 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 5 Jun 2023 09:18:41 -0400 Subject: [PATCH 02/52] FIX, DOC: please review. add read_eyelink_calibration to mne namespace Added read_eyelink_calibration function to mne namespace, because IMO thematically it fits in with other file I/O funcs like mne.read_epochs_eeglab, mne.read_evoked_besa, that are not mne.io.read_raw_[] funcs. Added read_eyelink_calibration to mne.docs.file_io.rst --- doc/file_io.rst | 1 + mne/__init__.py | 1 + 2 files changed, 2 insertions(+) diff --git a/doc/file_io.rst b/doc/file_io.rst index 4ddcf7d1d01..466edc1f854 100644 --- a/doc/file_io.rst +++ b/doc/file_io.rst @@ -27,6 +27,7 @@ File I/O read_evoked_besa read_evoked_fieldtrip read_evokeds_mff + read_eyelink_calibration read_freesurfer_lut read_forward_solution read_label diff --git a/mne/__init__.py b/mne/__init__.py index da3a1093630..9e15e2de98e 100644 --- a/mne/__init__.py +++ b/mne/__init__.py @@ -223,6 +223,7 @@ read_evoked_besa, read_evoked_fieldtrip, read_evokeds_mff, + read_eyelink_calibration, ) from .rank import compute_rank From 48a946ca06e7e992e8a916aab3108af678817a4c Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 5 Jun 2023 10:32:13 -0400 Subject: [PATCH 03/52] ENH, STY: Refactor read_eyelink_calibration and update test After some deliberation, I believe that it is better to always return a Calibration instance for a single eye. This means that, for a recording in binocular mode, 2 Calibration instances will be returned for each "calibration" run during the session. For example if a single calibration was collected in binocular mode, the new API is just: Calibrations[0] for the left eye calibration, and Calibrations[1] for the right eye calibration. The two Calibration instances can be linked because they have the same exact onset. my reasoning is: Conceptual: even in binocular mode, a SINGLE calibration is done for each eye (though simultaneously). The calibration info for each eye is returned. API: Users can expect a consistent structure in the Calibration class. For example, calibrations[0]["avg_error"] will alwasy be a single float (where before, if binocular, it was a dict). Code Design: This makes the routines of read_eyelink_calibration much simpler, as we don't need a lot of conditional logic that depends on the recording mode being binocular or monocular. --- mne/io/eyelink/_utils.py | 47 +++++++++-------------- mne/io/eyelink/calibration.py | 10 ++--- mne/io/eyelink/tests/test_eyelink.py | 57 +++++++++++++++++----------- 3 files changed, 56 insertions(+), 58 deletions(-) diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index c9e5de62048..3394dc93de2 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -75,56 +75,43 @@ def _parse_calibration( if ( "!CAL VALIDATION " in line and "ABORTED" not in line ): # Start of a calibration + calibration = Calibration( + screen_size=screen_size, + screen_distance=screen_distance, + screen_resolution=screen_resolution, + ) tokens = line.split() this_eye = tokens[6].lower() - assert this_eye in ["left", "right"] - if "LR" not in line or ("LR" in line and this_eye == "left"): - # for binocular calibrations, there are two '!CAL VALIDATION' lines - # Create a single calibration instance for both eyes. - calibration = Calibration( - screen_size=screen_size, - screen_distance=screen_distance, - screen_resolution=screen_resolution, - ) + assert this_eye in ["left", "right"], this_eye calibration["model"] = tokens[4] # e.g. 'HV13' assert calibration["model"].startswith("H") - calibration["eye"] = "both" if "LR" in line else this_eye + calibration["eye"] = this_eye timestamp = float(tokens[1]) - onset = timestamp - rec_start + onset = (timestamp - rec_start) / 1000.0 # in seconds calibration["onset"] = 0 if onset < 0 else onset avg_error = float(line.split("avg.")[0].split()[-1]) # e.g. 0.3 max_error = float(line.split("max")[0].split()[-1]) # e.g. 0.9 - if calibration["eye"] == "both": - if not isinstance(calibration["points"], dict): - # don't overwrite dict if it was set in previous line - calibration["points"] = {"left": [], "right": []} - calibration["avg_error"][this_eye] = avg_error - calibration["max_error"][this_eye] = max_error - else: - calibration["avg_error"] = avg_error - calibration["max_error"] = max_error + calibration["avg_error"] = avg_error + calibration["max_error"] = max_error n_points = int(regex.search(calibration["model"]).group()) # e.g. 9 n_points *= 2 if "LR" in line else 1 # one point per eye if "LR" # The next n_point lines contain the validation data for validation_index in range(n_points): subline = lines[line_number + validation_index + 1] + if "!CAL VALIDATION" in subline: + continue # for bino mode, skip the second eye's validation summary subline_eye = subline.split("at")[0].split()[-1].lower() + assert subline_eye in ["left", "right"], subline_eye if subline_eye != this_eye: continue # skip the validation lines for the other eye point_info = _parse_validation_line(subline) - if calibration["eye"] == "both": - calibration["points"][this_eye].append(point_info) + if not calibration["points"]: + calibration["points"] = [point_info] else: calibration["points"].append(point_info) # Convert the list of validation data into a numpy array - if calibration["eye"] == "both": - calibration["points"][this_eye] = np.concatenate( - calibration["points"][this_eye], axis=0 - ) - else: - calibration["points"] = np.concatenate(calibration["points"], axis=0) - - calibrations.append(calibration) + calibration["points"] = np.concatenate(calibration["points"], axis=0) + calibrations.append(calibration) return calibrations diff --git a/mne/io/eyelink/calibration.py b/mne/io/eyelink/calibration.py index 82854579f9f..b5540d4b86d 100644 --- a/mne/io/eyelink/calibration.py +++ b/mne/io/eyelink/calibration.py @@ -163,11 +163,11 @@ def __init__( ): super().__init__(**kwargs) self["onset"] = onset - self["model"] = {} if model is None else model - self["eye"] = {} if eye is None else eye - self["avg_error"] = {} if avg_error is None else avg_error - self["max_error"] = {} if max_error is None else max_error - self["points"] = [] if points is None else points + self["model"] = model + self["eye"] = eye + self["avg_error"] = avg_error + self["max_error"] = max_error + self["points"] = points self["screen_size"] = screen_size self["screen_distance"] = screen_distance self["screen_resolution"] = screen_resolution diff --git a/mne/io/eyelink/tests/test_eyelink.py b/mne/io/eyelink/tests/test_eyelink.py index f68873b12f6..c348daf56e3 100644 --- a/mne/io/eyelink/tests/test_eyelink.py +++ b/mne/io/eyelink/tests/test_eyelink.py @@ -3,7 +3,7 @@ import numpy as np from mne.datasets.testing import data_path, requires_testing_data -from mne.io import read_raw_eyelink, read_eyelink_calibration +from mne.io import read_raw_eyelink, read_eyelink_calibration, BaseRaw from mne.io.constants import FIFF from mne.io.pick import _DATA_CH_TYPES_SPLIT from mne.utils import _check_pandas_installed, requires_pandas @@ -26,18 +26,29 @@ def test_eyetrack_not_data_ch(): @requires_testing_data @requires_pandas @pytest.mark.parametrize( - "fname, create_annotations, find_overlaps", + "fname, create_annotations, find_overlaps, return_calibration", [ - (fname, False, False), - (fname, True, False), - (fname, True, True), - (fname, ["fixations", "saccades", "blinks"], True), + (fname, False, False, False), + (fname, False, False, True), + (fname, True, False, False), + (fname, True, True, False), + (fname, ["fixations", "saccades", "blinks"], True, False), ], ) -def test_eyelink(fname, create_annotations, find_overlaps): +def test_eyelink(fname, create_annotations, find_overlaps, return_calibration): """Test reading eyelink asc files.""" + if return_calibration: + raw, calibrations = read_raw_eyelink( + fname, return_calibration=return_calibration + ) + assert len(calibrations) == 2 + assert isinstance(raw, BaseRaw) + return raw = read_raw_eyelink( - fname, create_annotations=create_annotations, find_overlaps=find_overlaps + fname, + create_annotations=create_annotations, + find_overlaps=find_overlaps, + return_calibration=return_calibration, ) # First, tests that shouldn't change based on function arguments @@ -90,7 +101,6 @@ def test_eyelink(fname, create_annotations, find_overlaps): def test_read_calibration(fname): """Test reading calibration data from an eyelink asc file.""" calibrations = read_eyelink_calibration(fname) - calibration = calibrations[0] expected_x_left = np.array( [ 960.0, @@ -132,20 +142,21 @@ def test_read_calibration(fname): [0.36, 0.5, 0.2, 0.1, 0.3, 0.38, 0.13, 0.33, 0.22, 0.18, 0.34, 0.52, 0.21] ) - assert calibration["model"] == "HV13" - assert calibration["eye"] == "both" - assert calibration["avg_error"]["left"] == 0.30 - assert calibration["max_error"]["left"] == 0.90 - assert calibration["avg_error"]["right"] == 0.31 - assert calibration["max_error"]["right"] == 0.52 - assert calibration["points"]["left"]["point_x"] == pytest.approx(expected_x_left) - assert calibration["points"]["right"]["point_y"] == pytest.approx(expected_y_right) - assert calibration["points"]["left"]["diff_y"] == pytest.approx( - expected_diff_y_left - ) - assert calibration["points"]["right"]["offset"] == pytest.approx( - expected_offset_right - ) + assert len(calibrations) == 2 # calibration[0] is left, calibration[1] is right + assert calibrations[0]["onset"] == 0 + assert calibrations[1]["onset"] == 0 + assert calibrations[0]["model"] == "HV13" + assert calibrations[1]["model"] == "HV13" + assert calibrations[0]["eye"] == "left" + assert calibrations[1]["eye"] == "right" + assert calibrations[0]["avg_error"] == 0.30 + assert calibrations[0]["max_error"] == 0.90 + assert calibrations[1]["avg_error"] == 0.31 + assert calibrations[1]["max_error"] == 0.52 + assert calibrations[0]["points"]["point_x"] == pytest.approx(expected_x_left) + assert calibrations[1]["points"]["point_y"] == pytest.approx(expected_y_right) + assert calibrations[0]["points"]["diff_y"] == pytest.approx(expected_diff_y_left) + assert calibrations[1]["points"]["offset"] == pytest.approx(expected_offset_right) @requires_testing_data From 000bc5c94fae48d9341bebdc6a8731ebf1d328a6 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 5 Jun 2023 11:29:20 -0400 Subject: [PATCH 04/52] ENH, STY: Remove Calibrations class in favor of simple list. Please Review. Instead of appending Calibration instances to a special Calibrations class, just append them to a list object. added Calibration class to mne namespace added Calibration class to doc/fileio.rst --- doc/file_io.rst | 1 + mne/__init__.py | 1 + mne/io/__init__.py | 2 +- mne/io/eyelink/__init__.py | 1 + mne/io/eyelink/_utils.py | 8 +- mne/io/eyelink/calibration.py | 113 +++++------------------ mne/io/eyelink/eyelink.py | 6 +- mne/io/eyelink/tests/test_calibration.py | 51 ++++++++++ 8 files changed, 83 insertions(+), 100 deletions(-) create mode 100644 mne/io/eyelink/tests/test_calibration.py diff --git a/doc/file_io.rst b/doc/file_io.rst index 466edc1f854..f7052d84436 100644 --- a/doc/file_io.rst +++ b/doc/file_io.rst @@ -6,6 +6,7 @@ File I/O .. autosummary:: :toctree: generated + Calibration channel_type channel_indices_by_type get_head_surf diff --git a/mne/__init__.py b/mne/__init__.py index 9e15e2de98e..9778243b33d 100644 --- a/mne/__init__.py +++ b/mne/__init__.py @@ -224,6 +224,7 @@ read_evoked_fieldtrip, read_evokeds_mff, read_eyelink_calibration, + Calibration, ) from .rank import compute_rank diff --git a/mne/io/__init__.py b/mne/io/__init__.py index 69550e4fcaa..df9f2ac5b22 100644 --- a/mne/io/__init__.py +++ b/mne/io/__init__.py @@ -68,7 +68,7 @@ from .fieldtrip import read_raw_fieldtrip, read_epochs_fieldtrip, read_evoked_fieldtrip from .nihon import read_raw_nihon from ._read_raw import read_raw -from .eyelink import read_raw_eyelink, read_eyelink_calibration +from .eyelink import read_raw_eyelink, read_eyelink_calibration, Calibration # for backward compatibility diff --git a/mne/io/eyelink/__init__.py b/mne/io/eyelink/__init__.py index 6136544bb3b..c3934ea7eed 100644 --- a/mne/io/eyelink/__init__.py +++ b/mne/io/eyelink/__init__.py @@ -6,3 +6,4 @@ # License: BSD-3-Clause from .eyelink import read_raw_eyelink, read_eyelink_calibration +from .calibration import Calibration diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index 3394dc93de2..9440dd69501 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -5,7 +5,7 @@ import re import numpy as np -from .calibration import Calibration, Calibrations +from .calibration import Calibration def _find_recording_start(lines): @@ -55,7 +55,7 @@ def _parse_validation_line(line): def _parse_calibration( lines, screen_size=None, screen_distance=None, screen_resolution=None ): - """Parse the lines in the given list and returns a Calibrations instance. + """Parse the lines in the given list and returns a list of Calibration instances. Parameters ---------- @@ -63,12 +63,12 @@ def _parse_calibration( Returns ------- - A Calibrations instance containing one or more Calibration instances, + A list containing one or more Calibration instances, one for each calibration that was recorded in the eyelink ASCII file data. """ regex = re.compile(r"\d+") # for finding numeric characters - calibrations = Calibrations() + calibrations = list() rec_start = float(_find_recording_start(lines).split()[1]) for line_number, line in enumerate(lines): diff --git a/mne/io/eyelink/calibration.py b/mne/io/eyelink/calibration.py index b5540d4b86d..a1299d24357 100644 --- a/mne/io/eyelink/calibration.py +++ b/mne/io/eyelink/calibration.py @@ -8,8 +8,8 @@ @fill_doc -class Calibrations(list): - """A list of Calibration objects. +class Calibration(OrderedDict): + """A dictionary containing calibration data. Parameters ---------- @@ -26,24 +26,20 @@ class Calibrations(list): 'right', or 'both'. avg_error: float The average error in degrees between the calibration points and the - actual gaze position. If 'eye' is 'both', then a dict can be passed - with the average error for each eye. For example, {'left': 0.5, 'right': 0.6}. + actual gaze position. max_error: float The maximum error in degrees that occurred between the calibration - points and the actual gaze position. If 'eye' is 'both', then a dict - can be passed with the maximum error for each eye. For example, - {'left': 0.5, 'right': 0.6}. + points and the actual gaze position. points: ndarray a 2D numpy array, which are the data for each calibration point. Each row contains the x and y pixel-coordinates of the actual gaze position to the calibration point, the error in degrees between the calibration point and the actual gaze position, and the difference in x and y pixel coordinates - between the calibration point and the actual gaze position. If 'eye' is 'both', - then a dict can be passed with a separate 2D numpy array for each eye. + between the calibration point and the actual gaze position. screen_size : tuple 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 cm. + 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 : tuple @@ -51,101 +47,34 @@ class Calibrations(list): was collected with. For example, (1920, 1080) for a 1920x1080 resolution display. - Returns - ------- - calibrations: Calibrations - A Calibrations instance, which is a list of Calibration objects. - """ - - def __init__( - self, - onset=None, - model=None, - eye=None, - avg_error=None, - max_error=None, - points=None, - screen_size=None, - screen_distance=None, - screen_resolution=None, - ): - super().__init__() - if any( - arg is not None - for arg in ( - onset, - model, - eye, - avg_error, - max_error, - points, - screen_size, - screen_distance, - screen_resolution, - ) - ): - calibration = Calibration( - onset=onset, - model=model, - eye=eye, - avg_error=avg_error, - max_error=max_error, - points=points, - screen_size=screen_size, - screen_distance=screen_distance, - screen_resolution=screen_resolution, - ) - self.append(calibration) - - def __repr__(self): - """Return the number of calibration objects in this instance.""" - num_calibrations = len(self) - return f"Calibrations | {num_calibrations} calibration(s)" - - -@fill_doc -class Calibration(OrderedDict): - """A dictionary containing calibration data. - - Parameters + Attributes ---------- onset: float The onset of the calibration in seconds. If the calibration was - performed before the recording started, then the onset should be - set to 0 seconds. + performed before the recording started. model: str - A string, which is the model of the eyetracker. For example H3 for - a horizontal only 3-point calibration, or HV3 for a horizontal and - vertical 3-point calibration. + A string, which is the model of the calibration that was administerd. 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', - 'right', or 'both'. + the eye that was calibrated. For example, 'left', or 'right'. avg_error: float - The average error in degrees between the calibration points and the - actual gaze position. If 'eye' is 'both', then a dict can be passed - with the average error for each eye. For example, {'left': 0.5, 'right': 0.6}. + The average error in degrees between the calibration points and the actual gaze + position. max_error: float - The maximum error in degrees that occurred between the calibration - points and the actual gaze position. If 'eye' is 'both', then a dict - can be passed with the maximum error for each eye. For example, - {'left': 0.5, 'right': 0.6}. + The maximum error in degrees that occurred between the calibration points and + the actual gaze position. points: ndarray a 2D numpy array, which are the data for each calibration point. - Each row contains the x and y pixel-coordinates of the actual gaze position - to the calibration point, the error in degrees between the calibration point - and the actual gaze position, and the difference in x and y pixel coordinates - between the calibration point and the actual gaze position. If 'eye' is 'both', - then a dict can be passed with a separate 2D numpy array for each eye. screen_size : tuple - 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 cm. + 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 : tuple - The resolution (in pixels) of the screen that the eyetracking data - was collected with. For example, (1920, 1080) for a 1920x1080 - resolution display. + The resolution (in pixels) of the screen that the eyetracking data was + collected with. """ def __init__( diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index e4f2a8a25a8..b1e2326f61b 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -311,7 +311,7 @@ def read_eyelink_calibration( Returns ------- - calibrations : instance of Calibrations + calibrations : a list of Calibration instances """ fname = Path(filename) if not fname.exists(): @@ -374,8 +374,8 @@ def read_raw_eyelink( be considered bad by MNE and excluded from operations like epoching. return_calibration : bool (default False) If True, returns a tuple of (raw, calibrations) where calibrations is - an object that contains information about the eye calibration for the - file. + a list of Calibration instances, each containing information about a + single calibration collected during the recording. screen_size : tuple Only set if 'return_calibration' is set to True. The width and height (in meters) of the screen that the eyetracking diff --git a/mne/io/eyelink/tests/test_calibration.py b/mne/io/eyelink/tests/test_calibration.py new file mode 100644 index 00000000000..5acc893113a --- /dev/null +++ b/mne/io/eyelink/tests/test_calibration.py @@ -0,0 +1,51 @@ +import pytest +from ..calibration import Calibration + + +@pytest.mark.parametrize( + ( + "onset, model, eye, avg_error, max_error, points, screen_size, screen_distance," + " screen_resolution" + ), + [ + (0, "H3", "right", 0.5, 1.0, 3, (0.531, 0.298), 0.065, (1920, 1080)), + (None, None, None, None, None, None, None, None, None), + ], +) +def test_create_calibration( + onset, + model, + eye, + avg_error, + max_error, + points, + screen_size, + screen_distance, + screen_resolution, +): + """Test creating a Calibration object.""" + kwargs = dict( + onset=onset, + model=model, + eye=eye, + avg_error=avg_error, + max_error=max_error, + points=points, + screen_size=screen_size, + screen_distance=screen_distance, + screen_resolution=screen_resolution, + ) + cal = Calibration(**kwargs) + if all([kwarg is None for kwarg in kwargs]): + for kwarg in kwargs: + assert cal[kwarg] is None + else: + assert cal["onset"] == onset + assert cal["model"] == model + assert cal["eye"] == eye + assert cal["avg_error"] == avg_error + assert cal["max_error"] == max_error + assert cal["points"] == points + assert cal["screen_size"] == screen_size + assert cal["screen_distance"] == screen_distance + assert cal["screen_resolution"] == screen_resolution From 1863b24a648d03261469137a1797929060daac17 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 5 Jun 2023 13:02:18 -0400 Subject: [PATCH 05/52] ENH: Add plot method and a test Added a plot method to the Calibration class, and added a test for this method to mne.io.eyelink.tests.test_eyelink --- mne/io/eyelink/calibration.py | 34 ++++++++++++++++++++++++++++ mne/io/eyelink/tests/test_eyelink.py | 23 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/mne/io/eyelink/calibration.py b/mne/io/eyelink/calibration.py index a1299d24357..fe62a28c405 100644 --- a/mne/io/eyelink/calibration.py +++ b/mne/io/eyelink/calibration.py @@ -4,6 +4,9 @@ # License: BSD-3-Clause from collections import OrderedDict + +import matplotlib.pyplot as plt + from ...utils import fill_doc @@ -130,3 +133,34 @@ def __getattr__(self, name): raise AttributeError( f"'{self.__class__.__name__}' object has no attribute '{name}'" ) + + def plot(self, title=None, show=True): + """Visualize calibration. + + Parameters + ---------- + title : str + The title to be displayed. Defaults to None, which uses a generic title. + show : bool + Whether to show the figure or not. + + Returns + ------- + fig : instance of matplotlib.figure.Figure + The resulting figure object for the calibration plot. + """ + fig, ax = plt.subplots() + px, py = self["points"]["point_x"], self["points"]["point_y"] + dx, dy = self["points"]["diff_x"], self["points"]["diff_y"] + + if title is None: + ax.set_title(f"Calibration ({self['eye']} eye)") + else: + ax.set_title(title) + ax.set_xlabel("x (pixels)") + ax.set_ylabel("y (pixels)") + + ax.scatter(px, py, color="gray") + ax.scatter(px - dx, py - dy, color="red") + fig.show() if show else None + return fig diff --git a/mne/io/eyelink/tests/test_eyelink.py b/mne/io/eyelink/tests/test_eyelink.py index c348daf56e3..2eb252781ee 100644 --- a/mne/io/eyelink/tests/test_eyelink.py +++ b/mne/io/eyelink/tests/test_eyelink.py @@ -1,5 +1,6 @@ import pytest +import matplotlib.pyplot as plt import numpy as np from mne.datasets.testing import data_path, requires_testing_data @@ -159,6 +160,28 @@ def test_read_calibration(fname): assert calibrations[1]["points"]["offset"] == pytest.approx(expected_offset_right) +@requires_testing_data +@pytest.mark.parametrize("fname", [(fname)]) +def test_plot_calibration(fname): + """Test plotting calibration data.""" + calibrations = read_eyelink_calibration(fname) + cal_left = calibrations[0] + fig = cal_left.plot(show=False) + ax = fig.axes[0] + + scatter1 = ax.collections[0] + scatter2 = ax.collections[1] + px, py = cal_left.points["point_x"], cal_left.points["point_y"] + dx, dy = cal_left.points["diff_x"], cal_left.points["diff_y"] + + assert ax.title.get_text() == f"Calibration ({cal_left.eye} eye)" + assert len(ax.collections) == 2 # Two scatter plots + + assert np.allclose(scatter1.get_offsets(), np.column_stack((px, py))) + assert np.allclose(scatter2.get_offsets(), np.column_stack((px - dx, py - dy))) + plt.close(fig) + + @requires_testing_data @requires_pandas @pytest.mark.parametrize("fname_href", [(fname_href)]) From e1115c1a63ce8cd038c8bf90bc8dcb9ce1f44175 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 5 Jun 2023 14:16:19 -0400 Subject: [PATCH 06/52] FIX: Incorporate Britta's suggested revisions. That were suggested via the github browser. --- mne/io/eyelink/_utils.py | 2 +- mne/io/eyelink/calibration.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index 9440dd69501..d2f1343917a 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -9,7 +9,7 @@ def _find_recording_start(lines): - """Return the first START line in an eyelink ASCII file. + """Return the first START line in an SR Research EyeLink ASCII file. Parameters ---------- diff --git a/mne/io/eyelink/calibration.py b/mne/io/eyelink/calibration.py index fe62a28c405..7f58c38e4c2 100644 --- a/mne/io/eyelink/calibration.py +++ b/mne/io/eyelink/calibration.py @@ -34,11 +34,11 @@ class Calibration(OrderedDict): The maximum error in degrees that occurred between the calibration points and the actual gaze position. points: ndarray - a 2D numpy array, which are the data for each calibration point. - Each row contains the x and y pixel-coordinates of the actual gaze position - to the calibration point, the error in degrees between the calibration point - and the actual gaze position, and the difference in x and y pixel coordinates - between the calibration point and the actual gaze position. + a 2D numpy array containing the data for each calibration point. + Each row contains the x and y pixel-coordinates of the calibration point, + the error in degrees between the calibration point and the actual gaze position, + and the difference in x and y pixel coordinates between the calibration point + and the actual gaze position. screen_size : tuple The width and height (in meters) of the screen that the eyetracking data was collected with. For example (.531, .298) for a monitor with From ea8088583e03b4d80692f869b176db336e56874a Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 5 Jun 2023 15:11:56 -0400 Subject: [PATCH 07/52] ENH, STY: refactor code to create Calibration.points array This code simplifies the routine a bit, and makes it easier for a user to create a Calibration object manually (Now they would just pass in a list of tuples, instead of having to create a structured array).. Even though I think it is unlikely that someone will create one of these Calibration instances manually, it's good to make it easy if they do.. --- mne/io/eyelink/_utils.py | 22 ++----- mne/io/eyelink/calibration.py | 81 ++++++++++++++++++++++-- mne/io/eyelink/tests/test_calibration.py | 34 ++++++---- 3 files changed, 101 insertions(+), 36 deletions(-) diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index d2f1343917a..eebde520e73 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -3,7 +3,6 @@ # License: BSD-3-Clause import re -import numpy as np from .calibration import Calibration @@ -35,21 +34,14 @@ def _parse_validation_line(line): Returns ------- - A dictionary containing the validation data. + A list of tuples containing the validation data. """ - keys = ["point_x", "point_y", "offset", "diff_x", "diff_y"] - dtype = [(key, "f8") for key in keys] - parsed_data = np.empty(1, dtype=dtype) - tokens = line.split() xy = tokens[-6].strip("[]").split(",") # e.g. '960, 540' xy_diff = tokens[-2].strip("[]").split(",") # e.g. '-1.5, -2.8' - vals = [float(v) for v in [*xy, tokens[-4], *xy_diff]] - - for key, data in zip(keys, vals): - parsed_data[0][key] = data + vals = tuple([float(v) for v in [*xy, tokens[-4], *xy_diff]]) - return parsed_data + return vals def _parse_calibration( @@ -98,6 +90,7 @@ def _parse_calibration( n_points = int(regex.search(calibration["model"]).group()) # e.g. 9 n_points *= 2 if "LR" in line else 1 # one point per eye if "LR" # The next n_point lines contain the validation data + points = [] for validation_index in range(n_points): subline = lines[line_number + validation_index + 1] if "!CAL VALIDATION" in subline: @@ -107,11 +100,8 @@ def _parse_calibration( if subline_eye != this_eye: continue # skip the validation lines for the other eye point_info = _parse_validation_line(subline) - if not calibration["points"]: - calibration["points"] = [point_info] - else: - calibration["points"].append(point_info) + points.append(point_info) # Convert the list of validation data into a numpy array - calibration["points"] = np.concatenate(calibration["points"], axis=0) + calibration.set_calibration_array(points) calibrations.append(calibration) return calibrations diff --git a/mne/io/eyelink/calibration.py b/mne/io/eyelink/calibration.py index 7f58c38e4c2..bb395ea1d59 100644 --- a/mne/io/eyelink/calibration.py +++ b/mne/io/eyelink/calibration.py @@ -6,6 +6,7 @@ from collections import OrderedDict import matplotlib.pyplot as plt +import numpy as np from ...utils import fill_doc @@ -33,12 +34,19 @@ class Calibration(OrderedDict): max_error: float The maximum error in degrees that occurred between the calibration points and the actual gaze position. - points: ndarray - a 2D numpy array containing the data for each calibration point. - Each row contains the x and y pixel-coordinates of the calibration point, - the error in degrees between the calibration point and the actual gaze position, - and the difference in x and y pixel coordinates between the calibration point - and the actual gaze position. + points: list of tuples + The data for each individual calibration point. Each tuple should represent + 1 calibration point. The elements within each tuple should be as follows: + - (point_x, point_y, offset, diff_x, diff_y) + where: + - point_x: the x pixel-coordinate of the calibration point + - point_y: the y pixel-coordinate of the calibration point + - offset: the error in degrees between the calibration point and the + actual gaze position + - diff_x: the difference in x pixel coordinates between the calibration + point and the actual gaze position + - diff_y: the difference in y pixel coordinates between the calibration + point and the actual gaze position screen_size : tuple The width and height (in meters) of the screen that the eyetracking data was collected with. For example (.531, .298) for a monitor with @@ -99,7 +107,10 @@ def __init__( self["eye"] = eye self["avg_error"] = avg_error self["max_error"] = max_error - self["points"] = points + if points is not None and isinstance(points, list): + self.set_calibration_array(points) + else: + self["points"] = points self["screen_size"] = screen_size self["screen_distance"] = screen_distance self["screen_resolution"] = screen_resolution @@ -134,6 +145,52 @@ def __getattr__(self, name): f"'{self.__class__.__name__}' object has no attribute '{name}'" ) + def set_calibration_array(self, data): + """ + Convert a list of tuples to a structured array with calibration field names. + + Parameters + ---------- + data : list of tuples + The calibration data to be converted. Each tuple should represent + 1 calibration point. The elements within each tuple should be as follows: + - (point_x, point_y, offset, diff_x, diff_y) + where: + - point_x: the x pixel-coordinate of the calibration point + - point_y: the y pixel-coordinate of the calibration point + - offset: the error in degrees between the calibration point and the + actual gaze position + - diff_x: the difference in x pixel coordinates between the calibration + point and the actual gaze position + - diff_y: the difference in y pixel coordinates between the calibration + point and the actual gaze position + + Returns + ------- + self, with the points attribute set as a structured numpy array + + Examples + -------- + Below is an example of a list of tuples that can be passed to this method: + >>> data = [(960., 540., 0.23, 9.9, -4.1), + (960., 92., 0.38, -7.8, 16.), + ...] + """ + field_names = ["point_x", "point_y", "offset", "diff_x", "diff_y"] + dtype = [(name, float) for name in field_names] + if isinstance(data, list): + if not all([len(elem) == len(field_names) for elem in data]): + raise ValueError( + f"Each tuple in the data list must have have 5 elements: " + f"Got {data}" + ) + structured_array = np.array(data, dtype=dtype) + self["points"] = structured_array + else: + raise TypeError( + f"Data must be a list. got {data} which is of type {type(data)}" + ) + def plot(self, title=None, show=True): """Visualize calibration. @@ -149,6 +206,16 @@ def plot(self, title=None, show=True): fig : instance of matplotlib.figure.Figure The resulting figure object for the calibration plot. """ + if not len(self["points"]): + raise ValueError( + "No calibration data to plot. Use set_calibration_array()" + " to set calibration data." + ) + if not isinstance(self["points"], np.ndarray): + raise TypeError( + "Calibration points must be a numpy array. Use " + "set_calibration_array() to set calibration data." + ) fig, ax = plt.subplots() px, py = self["points"]["point_x"], self["points"]["point_y"] dx, dy = self["points"]["diff_x"], self["points"]["diff_y"] diff --git a/mne/io/eyelink/tests/test_calibration.py b/mne/io/eyelink/tests/test_calibration.py index 5acc893113a..b54aad6a00e 100644 --- a/mne/io/eyelink/tests/test_calibration.py +++ b/mne/io/eyelink/tests/test_calibration.py @@ -1,14 +1,23 @@ import pytest + +import numpy as np + from ..calibration import Calibration +test_points = [(960.0, 540.0, 0.23, 9.9, -4.1), (960.0, 92.0, 0.38, -7.8, 16.0)] +field_names = ["point_x", "point_y", "offset", "diff_x", "diff_y"] +dtypes = [(name, float) for name in field_names] +test_array = np.array(test_points, dtype=dtypes) + + @pytest.mark.parametrize( ( "onset, model, eye, avg_error, max_error, points, screen_size, screen_distance," " screen_resolution" ), [ - (0, "H3", "right", 0.5, 1.0, 3, (0.531, 0.298), 0.065, (1920, 1080)), + (0, "H3", "right", 0.5, 1.0, test_points, (0.531, 0.298), 0.065, (1920, 1080)), (None, None, None, None, None, None, None, None, None), ], ) @@ -36,16 +45,15 @@ def test_create_calibration( screen_resolution=screen_resolution, ) cal = Calibration(**kwargs) - if all([kwarg is None for kwarg in kwargs]): - for kwarg in kwargs: - assert cal[kwarg] is None + assert cal["onset"] == onset + assert cal["model"] == model + assert cal["eye"] == eye + assert cal["avg_error"] == avg_error + assert cal["max_error"] == max_error + if points is not None: + assert np.array_equal(cal["points"], test_array) else: - assert cal["onset"] == onset - assert cal["model"] == model - assert cal["eye"] == eye - assert cal["avg_error"] == avg_error - assert cal["max_error"] == max_error - assert cal["points"] == points - assert cal["screen_size"] == screen_size - assert cal["screen_distance"] == screen_distance - assert cal["screen_resolution"] == screen_resolution + assert cal["points"] is None + assert cal["screen_size"] == screen_size + assert cal["screen_distance"] == screen_distance + assert cal["screen_resolution"] == screen_resolution From 7fac97ee2515f29766c900e088db18ef82386d3e Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 5 Jun 2023 15:37:30 -0400 Subject: [PATCH 08/52] FIX: nest matplotlib import under plot method --- mne/io/eyelink/calibration.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mne/io/eyelink/calibration.py b/mne/io/eyelink/calibration.py index bb395ea1d59..caf7dfaba8c 100644 --- a/mne/io/eyelink/calibration.py +++ b/mne/io/eyelink/calibration.py @@ -5,7 +5,6 @@ from collections import OrderedDict -import matplotlib.pyplot as plt import numpy as np from ...utils import fill_doc @@ -206,6 +205,8 @@ def plot(self, title=None, show=True): fig : instance of matplotlib.figure.Figure The resulting figure object for the calibration plot. """ + import matplotlib.pyplot as plt + if not len(self["points"]): raise ValueError( "No calibration data to plot. Use set_calibration_array()" From 71ccbac2b567b30e5f28cd3006a117aae51bc4a4 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 5 Jun 2023 16:02:57 -0400 Subject: [PATCH 09/52] FIX, DOC: Fixes to docstring that were raised by test_docstring_parameters --- mne/io/eyelink/calibration.py | 47 +++++++++++++++++++---------------- mne/io/eyelink/eyelink.py | 4 ++- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/mne/io/eyelink/calibration.py b/mne/io/eyelink/calibration.py index caf7dfaba8c..ec9754e564e 100644 --- a/mne/io/eyelink/calibration.py +++ b/mne/io/eyelink/calibration.py @@ -16,26 +16,27 @@ class Calibration(OrderedDict): Parameters ---------- - onset: float + onset : float The onset of the calibration in seconds. If the calibration was performed before the recording started, then the onset should be set to 0 seconds. - model: str + model : str A string, which is the model of the eyetracker. For example H3 for a horizontal only 3-point calibration, or HV3 for a horizontal and vertical 3-point calibration. - eye: str + eye : str the eye that was calibrated. For example, 'left', 'right', or 'both'. - avg_error: float + avg_error : float The average error in degrees between the calibration points and the actual gaze position. - max_error: float + max_error : float The maximum error in degrees that occurred between the calibration points and the actual gaze position. - points: list of tuples - The data for each individual calibration point. Each tuple should represent - 1 calibration point. The elements within each tuple should be as follows: + points : list + List of tuples, contaiing the data for each individual calibration point. + Each tuple should represent data for 1 calibration point. The elements + within each tuple should be as follows: - (point_x, point_y, offset, diff_x, diff_y) where: - point_x: the x pixel-coordinate of the calibration point @@ -59,22 +60,22 @@ class Calibration(OrderedDict): Attributes ---------- - onset: float + onset : float The onset of the calibration in seconds. If the calibration was performed before the recording started. - model: str + model : str A string, which is the model of the calibration that was administerd. 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 + eye : str + The eye that was calibrated. For example, 'left', or 'right'. + avg_error : float The average error in degrees between the calibration points and the actual gaze position. - max_error: float + max_error : float The maximum error in degrees that occurred between the calibration points and the actual gaze position. - points: ndarray + points : ndarray a 2D numpy array, which are the data for each calibration point. screen_size : tuple The width and height (in meters) of the screen that the eyetracking data was @@ -91,16 +92,15 @@ def __init__( self, onset=None, model=None, + eye=None, avg_error=None, max_error=None, points=None, - eye=None, screen_size=None, screen_distance=None, screen_resolution=None, - **kwargs, ): - super().__init__(**kwargs) + super().__init__() self["onset"] = onset self["model"] = model self["eye"] = eye @@ -150,9 +150,10 @@ def set_calibration_array(self, data): Parameters ---------- - data : list of tuples - The calibration data to be converted. Each tuple should represent - 1 calibration point. The elements within each tuple should be as follows: + data : list + List of tuples, containing the data for each individual calibration point. + Each tuple should represent data for 1 calibration point. The elements + within each tuple should be as follows: - (point_x, point_y, offset, diff_x, diff_y) where: - point_x: the x pixel-coordinate of the calibration point @@ -166,7 +167,9 @@ def set_calibration_array(self, data): Returns ------- - self, with the points attribute set as a structured numpy array + self: instance of Calibration + The Calibration instance with the points attribute set as a structured numpy + array Examples -------- diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index b1e2326f61b..d84db8167b1 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -311,7 +311,9 @@ def read_eyelink_calibration( Returns ------- - calibrations : a list of Calibration instances + calibrations : list + a list of Calibration instances, one for each eye of every + calibration that was performed during the recording session. """ fname = Path(filename) if not fname.exists(): From 2bfff19e9c21d482c4fc86d1d31f0fc103643e5b Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 5 Jun 2023 16:33:39 -0400 Subject: [PATCH 10/52] FIX, DOC: More docstring error fixes --- doc/file_io.rst | 11 +++++++++-- mne/io/eyelink/calibration.py | 8 +++----- mne/io/eyelink/eyelink.py | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/doc/file_io.rst b/doc/file_io.rst index f7052d84436..f5a73e518bc 100644 --- a/doc/file_io.rst +++ b/doc/file_io.rst @@ -6,7 +6,6 @@ File I/O .. autosummary:: :toctree: generated - Calibration channel_type channel_indices_by_type get_head_surf @@ -28,7 +27,6 @@ File I/O read_evoked_besa read_evoked_fieldtrip read_evokeds_mff - read_eyelink_calibration read_freesurfer_lut read_forward_solution read_label @@ -64,3 +62,12 @@ Base class: :template: autosummary/class_no_members.rst BaseEpochs + +Eye-tracking: + +.. autosummary:: + :toctree: generated + :no-inherited-members: + Calibration + read_eyelink_calibration + \ No newline at end of file diff --git a/mne/io/eyelink/calibration.py b/mne/io/eyelink/calibration.py index ec9754e564e..10a7033ab9c 100644 --- a/mne/io/eyelink/calibration.py +++ b/mne/io/eyelink/calibration.py @@ -25,7 +25,7 @@ class Calibration(OrderedDict): 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', + The eye that was calibrated. For example, 'left', 'right', or 'both'. avg_error : float The average error in degrees between the calibration points and the @@ -169,14 +169,12 @@ def set_calibration_array(self, data): ------- self: instance of Calibration The Calibration instance with the points attribute set as a structured numpy - array + array. Examples -------- Below is an example of a list of tuples that can be passed to this method: - >>> data = [(960., 540., 0.23, 9.9, -4.1), - (960., 92., 0.38, -7.8, 16.), - ...] + >>> data = [(960., 540., 0.23, 9.9, -4.1), (960., 92., 0.38, -7.8, 16.)] """ field_names = ["point_x", "point_y", "offset", "diff_x", "diff_y"] dtype = [(name, float) for name in field_names] diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index d84db8167b1..f3da3c6a932 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -312,7 +312,7 @@ def read_eyelink_calibration( Returns ------- calibrations : list - a list of Calibration instances, one for each eye of every + A list of Calibration instances, one for each eye of every calibration that was performed during the recording session. """ fname = Path(filename) From ec13a2b5ac6cca203f3103d5f1e67193f2258326 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 6 Jun 2023 09:32:54 -0400 Subject: [PATCH 11/52] ENH, DOC: Nest Calibration class under mne.preprocessing.eyetracking Removed read_eyelink_calibration, Calibration, from mne namespace moved Calibration.py to mne.preprocessing.eyetracking read_eyelink_calibration is accessed via mne.io namespace DOC: added Calibration to doc.preprocessing.rst DOC: added read_eyelink_calibration to reading_raw_data.rst --- doc/file_io.rst | 8 -------- doc/preprocessing.rst | 1 + doc/reading_raw_data.rst | 1 + mne/__init__.py | 2 -- mne/io/__init__.py | 2 +- mne/io/eyelink/__init__.py | 1 - mne/io/eyelink/_utils.py | 2 +- mne/preprocessing/eyetracking/__init__.py | 1 + .../eyelink => preprocessing/eyetracking}/calibration.py | 0 mne/preprocessing/eyetracking/tests/__init__.py | 0 .../eyetracking}/tests/test_calibration.py | 0 11 files changed, 5 insertions(+), 13 deletions(-) rename mne/{io/eyelink => preprocessing/eyetracking}/calibration.py (100%) create mode 100644 mne/preprocessing/eyetracking/tests/__init__.py rename mne/{io/eyelink => preprocessing/eyetracking}/tests/test_calibration.py (100%) diff --git a/doc/file_io.rst b/doc/file_io.rst index f5a73e518bc..1c6d42e00d4 100644 --- a/doc/file_io.rst +++ b/doc/file_io.rst @@ -62,12 +62,4 @@ Base class: :template: autosummary/class_no_members.rst BaseEpochs - -Eye-tracking: - -.. autosummary:: - :toctree: generated - :no-inherited-members: - Calibration - read_eyelink_calibration \ No newline at end of file diff --git a/doc/preprocessing.rst b/doc/preprocessing.rst index 0ed960be4b9..920d001059f 100644 --- a/doc/preprocessing.rst +++ b/doc/preprocessing.rst @@ -153,6 +153,7 @@ Projections: .. autosummary:: :toctree: generated/ + Calibration set_channel_types_eyetrack EEG referencing: diff --git a/doc/reading_raw_data.rst b/doc/reading_raw_data.rst index c9316ffa9b0..88dab12d8be 100644 --- a/doc/reading_raw_data.rst +++ b/doc/reading_raw_data.rst @@ -21,6 +21,7 @@ Reading raw data read_raw_curry read_raw_edf read_raw_eyelink + read_eyelink_calibration read_raw_bdf read_raw_gdf read_raw_kit diff --git a/mne/__init__.py b/mne/__init__.py index 9778243b33d..da3a1093630 100644 --- a/mne/__init__.py +++ b/mne/__init__.py @@ -223,8 +223,6 @@ read_evoked_besa, read_evoked_fieldtrip, read_evokeds_mff, - read_eyelink_calibration, - Calibration, ) from .rank import compute_rank diff --git a/mne/io/__init__.py b/mne/io/__init__.py index df9f2ac5b22..69550e4fcaa 100644 --- a/mne/io/__init__.py +++ b/mne/io/__init__.py @@ -68,7 +68,7 @@ from .fieldtrip import read_raw_fieldtrip, read_epochs_fieldtrip, read_evoked_fieldtrip from .nihon import read_raw_nihon from ._read_raw import read_raw -from .eyelink import read_raw_eyelink, read_eyelink_calibration, Calibration +from .eyelink import read_raw_eyelink, read_eyelink_calibration # for backward compatibility diff --git a/mne/io/eyelink/__init__.py b/mne/io/eyelink/__init__.py index c3934ea7eed..6136544bb3b 100644 --- a/mne/io/eyelink/__init__.py +++ b/mne/io/eyelink/__init__.py @@ -6,4 +6,3 @@ # License: BSD-3-Clause from .eyelink import read_raw_eyelink, read_eyelink_calibration -from .calibration import Calibration diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index eebde520e73..a8491ed0529 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -4,7 +4,7 @@ import re -from .calibration import Calibration +from ...preprocessing.eyetracking.calibration import Calibration def _find_recording_start(lines): diff --git a/mne/preprocessing/eyetracking/__init__.py b/mne/preprocessing/eyetracking/__init__.py index 7c7f5f42765..66e7056012a 100644 --- a/mne/preprocessing/eyetracking/__init__.py +++ b/mne/preprocessing/eyetracking/__init__.py @@ -5,3 +5,4 @@ # License: BSD-3-Clause from .eyetracking import set_channel_types_eyetrack +from .calibration import Calibration diff --git a/mne/io/eyelink/calibration.py b/mne/preprocessing/eyetracking/calibration.py similarity index 100% rename from mne/io/eyelink/calibration.py rename to mne/preprocessing/eyetracking/calibration.py diff --git a/mne/preprocessing/eyetracking/tests/__init__.py b/mne/preprocessing/eyetracking/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/mne/io/eyelink/tests/test_calibration.py b/mne/preprocessing/eyetracking/tests/test_calibration.py similarity index 100% rename from mne/io/eyelink/tests/test_calibration.py rename to mne/preprocessing/eyetracking/tests/test_calibration.py From 1d09b00437a0b256d85d7f167f770f01810f385c Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Tue, 6 Jun 2023 09:56:53 -0400 Subject: [PATCH 12/52] Update mne/io/eyelink/_utils.py Co-authored-by: Eric Larson --- mne/io/eyelink/_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index a8491ed0529..9586b24750a 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -59,6 +59,7 @@ def _parse_calibration( one for each calibration that was recorded in the eyelink ASCII file data. """ + from ...preprocessing.eyetracking.calibration import Calibration regex = re.compile(r"\d+") # for finding numeric characters calibrations = list() rec_start = float(_find_recording_start(lines).split()[1]) From dd25b355802417f7afd61c0939910309296b83bf Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Tue, 6 Jun 2023 09:56:59 -0400 Subject: [PATCH 13/52] Update mne/io/eyelink/_utils.py Co-authored-by: Eric Larson --- mne/io/eyelink/_utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index 9586b24750a..7b6993cac8b 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -4,8 +4,6 @@ import re -from ...preprocessing.eyetracking.calibration import Calibration - def _find_recording_start(lines): """Return the first START line in an SR Research EyeLink ASCII file. From ca01ed8e71a54a8853dcaa0dbe764b1d6e4d3348 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 6 Jun 2023 09:59:47 -0400 Subject: [PATCH 14/52] FIX: black error --- mne/io/eyelink/_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index 7b6993cac8b..ecd354b474f 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -58,6 +58,7 @@ def _parse_calibration( data. """ from ...preprocessing.eyetracking.calibration import Calibration + regex = re.compile(r"\d+") # for finding numeric characters calibrations = list() rec_start = float(_find_recording_start(lines).split()[1]) From f484759ded6a3d47fab5caef808a7cf2bda97bc4 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 6 Jun 2023 14:34:40 -0400 Subject: [PATCH 15/52] FIX, DOC: More docstring error fixes, hopefully made minor docstring revisions to Calibration class added "None. Remove all items from od." to nitpick ignore in conf.py added "a shallow copy of od" to nitpick ignore in conf.py added mne.preprocessing.eyetracking.Calibration as False to numpydoc_show_inherited_class_members --- doc/conf.py | 4 +++ mne/preprocessing/eyetracking/calibration.py | 27 ++++++++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index e6c7b55de81..c5bf52216c1 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -186,6 +186,7 @@ numpydoc_show_inherited_class_members = { "mne.SourceSpaces": False, "mne.Forward": False, + "mne.preprocessing.eyetracking.Calibration": False, } numpydoc_attributes_as_param_list = True numpydoc_xref_param_type = True @@ -723,6 +724,8 @@ def append_attr_meth_examples(app, what, name, obj, options, lines): nitpicky = True nitpick_ignore = [ ("py:class", "None. Remove all items from D."), + ("py:class", "None. Remove all items from od."), + ("py:class", "a shallow copy of od"), ("py:class", "a set-like object providing a view on D's items"), ("py:class", "a set-like object providing a view on D's keys"), ( @@ -735,6 +738,7 @@ def append_attr_meth_examples(app, what, name, obj, options, lines): ("py:class", "(k, v), remove and return some (key, value) pair as a"), ("py:class", "_FuncT"), # type hint used in @verbose decorator ("py:class", "mne.utils._logging._FuncT"), + ("py:class", "None. Remove all items from od."), ] nitpick_ignore_regex = [ ("py:.*", r"mne\.io\.BaseRaw.*"), diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index 10a7033ab9c..aa75c4e0e51 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -4,6 +4,7 @@ # License: BSD-3-Clause from collections import OrderedDict +from copy import deepcopy import numpy as np @@ -21,12 +22,11 @@ class Calibration(OrderedDict): performed before the recording started, then the onset should be set to 0 seconds. model : str - A string, which is the model of the eyetracker. For example H3 for - a horizontal only 3-point calibration, or HV3 for a horizontal and - vertical 3-point calibration. + 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', - 'right', or 'both'. + The eye that was calibrated. For example, 'left', or 'right'. avg_error : float The average error in degrees between the calibration points and the actual gaze position. @@ -34,7 +34,7 @@ class Calibration(OrderedDict): The maximum error in degrees that occurred between the calibration points and the actual gaze position. points : list - List of tuples, contaiing the data for each individual calibration point. + List of tuples, containing the data for each individual calibration point. Each tuple should represent data for 1 calibration point. The elements within each tuple should be as follows: - (point_x, point_y, offset, diff_x, diff_y) @@ -47,6 +47,7 @@ class Calibration(OrderedDict): point and the actual gaze position - diff_y: the difference in y pixel coordinates between the calibration point and the actual gaze position + screen_size : tuple The width and height (in meters) of the screen that the eyetracking data was collected with. For example (.531, .298) for a monitor with @@ -62,7 +63,7 @@ class Calibration(OrderedDict): ---------- onset : float The onset of the calibration in seconds. If the calibration was - performed before the recording started. + performed before the recording started, the onset will be 0 seconds. model : str A string, which is the model of the calibration that was administerd. For example 'H3' for a horizontal only 3-point calibration, or 'HV3' for a @@ -76,7 +77,7 @@ class Calibration(OrderedDict): The maximum error in degrees that occurred between the calibration points and the actual gaze position. points : ndarray - a 2D numpy array, which are the data for each calibration point. + a 1D structured numpy array, which contains the data for each calibration point. screen_size : tuple 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 @@ -144,6 +145,16 @@ def __getattr__(self, name): f"'{self.__class__.__name__}' object has no attribute '{name}'" ) + def copy(self): + """Copy the instance. + + Returns + ------- + info : instance of Calibration + The copied Calibration. + """ + return deepcopy(self) + def set_calibration_array(self, data): """ Convert a list of tuples to a structured array with calibration field names. From 90fbdd273ee2c07d9780e1b3a1fdf3b02ff521c4 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 6 Jun 2023 17:30:01 -0400 Subject: [PATCH 16/52] FIX, DOC: more sphinx build fixes added move_to_end to numpydoc_validation_exclude in conf file made additional revisions to Calibration docstring --- doc/conf.py | 1 + mne/preprocessing/eyetracking/calibration.py | 35 ++++++++++++-------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index c5bf52216c1..6ed5e9d6d2b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -399,6 +399,7 @@ r"\.fromkeys", r"\.items", r"\.keys", + r"\.move_to_end", r"\.pop", r"\.popitem", r"\.setdefault", diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index aa75c4e0e51..f8c02b79994 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -13,7 +13,14 @@ @fill_doc class Calibration(OrderedDict): - """A dictionary containing calibration data. + """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, this class should be instantiated via an available function, + such as :func:`mne.io.read_eyelink_calibration`. Parameters ---------- @@ -23,7 +30,7 @@ class Calibration(OrderedDict): set to 0 seconds. 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 + 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'. @@ -36,9 +43,8 @@ class Calibration(OrderedDict): points : list List of tuples, containing the data for each individual calibration point. Each tuple should represent data for 1 calibration point. The elements - within each tuple should be as follows: - - (point_x, point_y, offset, diff_x, diff_y) - where: + within each tuple should be (point_x, point_y, offset, diff_x, diff_y), where: + - point_x: the x pixel-coordinate of the calibration point - point_y: the y pixel-coordinate of the calibration point - offset: the error in degrees between the calibration point and the @@ -65,7 +71,7 @@ class Calibration(OrderedDict): The onset of the calibration in seconds. If the calibration was performed before the recording started, the onset will be 0 seconds. model : str - A string, which is the model of the calibration that was administerd. For + A string, which is the model of the 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 @@ -157,16 +163,18 @@ def copy(self): def set_calibration_array(self, data): """ - Convert a list of tuples to a structured array with calibration field names. + Create a Numpy Array containing data regarding each calibration point. + + This method takes a list of tuples and converts it into a structured numpy + array, with field names 'point_x', 'point_y', 'offset', 'diff_x', and 'diff_y'. Parameters ---------- data : list List of tuples, containing the data for each individual calibration point. - Each tuple should represent data for 1 calibration point. The elements - within each tuple should be as follows: - - (point_x, point_y, offset, diff_x, diff_y) - where: + Each tuple should represent data for 1 calibration point. The elements within + each tuple should be (point_x, point_y, offset, diff_x, diff_y), where: + - point_x: the x pixel-coordinate of the calibration point - point_y: the y pixel-coordinate of the calibration point - offset: the error in degrees between the calibration point and the @@ -179,8 +187,9 @@ def set_calibration_array(self, data): Returns ------- self: instance of Calibration - The Calibration instance with the points attribute set as a structured numpy - array. + The Calibration instance, with the points attribute containing a structured + numpy array, with field names 'point_x', 'point_y', 'offset', 'diff_x', and + 'diff_y'. Examples -------- From 604e981bcd1f896ae91f859cb316baed95b41e97 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 6 Jun 2023 18:52:31 -0400 Subject: [PATCH 17/52] ENH: additional parameters to calibration.plot method Display the avg_offset and max_offset at the top of canvas if show_offsets is True, display offset for each point add param inver_y_axis, to invert y_axis. Defaults to True because in most monitors, pixel origin zero is at the top left corner of the screen. Thus, greater y coordinates are actually at the lower end of the screen. --- mne/preprocessing/eyetracking/calibration.py | 40 +++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index f8c02b79994..693941da6af 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -211,13 +211,20 @@ def set_calibration_array(self, data): f"Data must be a list. got {data} which is of type {type(data)}" ) - def plot(self, title=None, show=True): + def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): """Visualize calibration. Parameters ---------- title : str The title to be displayed. Defaults to None, which uses a generic title. + show_offsets : bool + Whether to display the offset (in visual degrees) of each calibration + point or not. Defaults to False. + invert_y_axis : bool + Whether to invert the y-axis or not. In many monitors, pixel coordinate + (0,0), which is often referred to as origin, is at the top left of corner. + Defaults to True. show : bool Whether to show the figure or not. @@ -249,7 +256,36 @@ def plot(self, title=None, show=True): 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']}\nmax_error: {self['max_error']}" + ax.text( + 0, + 1.01, + text, + transform=ax.transAxes, + verticalalignment="baseline", + fontsize=8, + ) + + if invert_y_axis: + # Invert the y-axis because origin is at the top left corner for most + # monitors + ax.invert_yaxis() ax.scatter(px, py, color="gray") - ax.scatter(px - dx, py - dy, color="red") + ax.scatter(px - dx, py - dy, color="red", alpha=0.5) + + if show_offsets: + for i in range(len(px)): + x_offset = 0.01 * (px[i] - dx[i]) + text = ax.text( + x=(px[i] - dx[i]) + x_offset, + y=py[i] - dy[i], + s=self["points"]["offset"][i], + fontsize=8, + ha="left", + va="center", + ) + + fig.tight_layout() fig.show() if show else None return fig From 57339b9a7e744b75d1b9e7f42d4b47ee2f1936f8 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 6 Jun 2023 18:56:31 -0400 Subject: [PATCH 18/52] DOC: use no_inherited_members template for Calibration class To prevent the OrderedDict methods from displaying in the API doc for Calibration. I don't think we need to display these. Let me know if you disagree. --- doc/preprocessing.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/preprocessing.rst b/doc/preprocessing.rst index 920d001059f..a93e36de88f 100644 --- a/doc/preprocessing.rst +++ b/doc/preprocessing.rst @@ -152,6 +152,7 @@ Projections: .. autosummary:: :toctree: generated/ + :template: autosummary/class_no_inherited_members.rst Calibration set_channel_types_eyetrack From 03f5b6b52ed6fb8468285f551b53df40138b6a72 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 6 Jun 2023 19:08:42 -0400 Subject: [PATCH 19/52] Revert "DOC: use no_inherited_members template for Calibration class" This reverts commit 57339b9a7e744b75d1b9e7f42d4b47ee2f1936f8. --- doc/preprocessing.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/preprocessing.rst b/doc/preprocessing.rst index a93e36de88f..920d001059f 100644 --- a/doc/preprocessing.rst +++ b/doc/preprocessing.rst @@ -152,7 +152,6 @@ Projections: .. autosummary:: :toctree: generated/ - :template: autosummary/class_no_inherited_members.rst Calibration set_channel_types_eyetrack From 39d9357969b1a073491d48d2325bd42aab58c300 Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Wed, 7 Jun 2023 09:28:47 -0400 Subject: [PATCH 20/52] Update mne/io/eyelink/eyelink.py Co-authored-by: Mathieu Scheltienne --- mne/io/eyelink/eyelink.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index f3da3c6a932..8b51a9b35ef 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -297,7 +297,7 @@ def read_eyelink_calibration( ---------- filename : str Path to the eyelink file (.asc). - screen_size : tuple + screen_size : tuple 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 cm. Defaults to None. From 156c2bd3442a3ca23a05db9b162a38490482a0ce Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Wed, 7 Jun 2023 09:29:03 -0400 Subject: [PATCH 21/52] Update mne/io/eyelink/eyelink.py Co-authored-by: Mathieu Scheltienne --- mne/io/eyelink/eyelink.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index 8b51a9b35ef..f43cf77bf94 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -299,7 +299,7 @@ def read_eyelink_calibration( Path to the eyelink file (.asc). screen_size : tuple 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 + data was collected with. For example ``(.531, .298)`` for a monitor with a display area of 531 x 298 cm. Defaults to None. screen_distance : float The distance (in meters) from the participant's eyes to the screen. From e5feceaa6a59910b5dc5aab2fc9e3064893df506 Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Wed, 7 Jun 2023 09:29:19 -0400 Subject: [PATCH 22/52] Update mne/io/eyelink/eyelink.py Co-authored-by: Mathieu Scheltienne --- mne/io/eyelink/eyelink.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index f43cf77bf94..72f84487c63 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -306,7 +306,7 @@ def read_eyelink_calibration( Defaults to None. screen_resolution : tuple The resolution (in pixels) of the screen that the eyetracking data - was collected with. For example, (1920, 1080) for a 1920x1080 + was collected with. For example, ``(1920, 1080)`` for a 1920x1080 resolution display. Defaults to None. Returns From 87320fb0e2e7a3df63a0efef2193df171a75970c Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Wed, 7 Jun 2023 11:42:35 -0400 Subject: [PATCH 23/52] FIX, DOC: Docstring fixes suggested by Mathieu - made docstring fixes, including some suggested by Mathieu - credited pyeparse and Eric in calibration module since some of the code was adapted from that package --- mne/io/eyelink/eyelink.py | 16 ++--- mne/preprocessing/eyetracking/calibration.py | 62 ++++++++++++-------- 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index 72f84487c63..2ca0ccc6919 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -289,22 +289,22 @@ def _find_overlaps(df, max_time=0.05): @fill_doc def read_eyelink_calibration( - filename, screen_size=None, screen_distance=None, screen_resolution=None + fname, screen_size=None, screen_distance=None, screen_resolution=None ): """Return info on calibrations collected in an eyelink file. Parameters ---------- - filename : str + fname : str Path to the eyelink file (.asc). screen_size : tuple 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 cm. Defaults to None. + 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 : tuple + screen_resolution : tuple 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. @@ -312,12 +312,12 @@ def read_eyelink_calibration( Returns ------- calibrations : list - A list of Calibration instances, one for each eye of every - calibration that was performed during the recording session. + A list of :class:`mne.preprocessing.eyetracking.Calibration` instances, one for + each eye of every calibration that was performed during the recording session. """ - fname = Path(filename) + fname = Path(fname) if not fname.exists(): - raise FileNotFoundError(f"Could not find file {filename}") + raise FileNotFoundError(f"Could not find file {fname}") logger.info("Reading calibration data from {}".format(fname)) with fname.open() as file: lines = file.readlines() diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index 693941da6af..4423e62ab0b 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -1,6 +1,8 @@ """Eyetracking Calibration(s) class constructor.""" # Authors: Scott Huberty +# Eric Larson +# Adapted from: https://github.com/pyeparse/pyeparse # License: BSD-3-Clause from collections import OrderedDict @@ -27,13 +29,13 @@ class Calibration(OrderedDict): onset : float The onset of the calibration in seconds. If the calibration was performed before the recording started, then the onset should be - set to 0 seconds. + set to ``0`` seconds. 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. + 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'. + The eye that was calibrated. For example, ``'left'``, or ``'right'``. avg_error : float The average error in degrees between the calibration points and the actual gaze position. @@ -41,9 +43,9 @@ class Calibration(OrderedDict): The maximum error in degrees that occurred between the calibration points and the actual gaze position. points : list - List of tuples, containing the data for each individual calibration point. - Each tuple should represent data for 1 calibration point. The elements - within each tuple should be (point_x, point_y, offset, diff_x, diff_y), where: + List of tuples, each of shape (5,). Each tuple should contain data for 1 + calibration point. The elements within each tuple should be + ``(point_x, point_y, offset, diff_x, diff_y)``, where: - point_x: the x pixel-coordinate of the calibration point - point_y: the y pixel-coordinate of the calibration point @@ -54,28 +56,30 @@ class Calibration(OrderedDict): - diff_y: the difference in y pixel coordinates between the calibration point and the actual gaze position - screen_size : tuple + See the example below for more details. + + screen_size : tuple 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 + 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 : tuple + screen_resolution : tuple of shape (2,) The resolution (in pixels) of the screen that the eyetracking data - was collected with. For example, (1920, 1080) for a 1920x1080 + was collected with. For example, ``(1920, 1080)`` for a 1920x1080 resolution display. Attributes ---------- onset : float The onset of the calibration in seconds. If the calibration was - performed before the recording started, the onset will be 0 seconds. + performed before the recording started, the onset will be ``0`` seconds. model : str A string, which is the model of the calibration that was applied. For - example 'H3' for a horizontal only 3-point calibration, or 'HV3' for a + 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'. + The eye that was calibrated. For example, ``'left'``, or ``'right'``. avg_error : float The average error in degrees between the calibration points and the actual gaze position. @@ -86,13 +90,19 @@ class Calibration(OrderedDict): a 1D structured numpy array, which contains the data for each calibration point. screen_size : tuple 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. + 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 : tuple The resolution (in pixels) of the screen that the eyetracking data was - collected with. + collected with. For example, ``(1920, 1080)`` for a 1920x1080 resolution + display. + + Examples + -------- + Below is an example of a list of tuples that can be passed to the points parameter: + ``>>> data = [(960., 540., 0.23, 9.9, -4.1), (960., 92., 0.38, -7.8, 16.)]`` """ def __init__( @@ -171,9 +181,9 @@ def set_calibration_array(self, data): Parameters ---------- data : list - List of tuples, containing the data for each individual calibration point. - Each tuple should represent data for 1 calibration point. The elements within - each tuple should be (point_x, point_y, offset, diff_x, diff_y), where: + List of tuples, each of shape (5,). Each tuple should contain data for 1 + calibration point. The elements within each tuple should be + ``(point_x, point_y, offset, diff_x, diff_y)``, where: - point_x: the x pixel-coordinate of the calibration point - point_y: the y pixel-coordinate of the calibration point @@ -194,7 +204,7 @@ def set_calibration_array(self, data): Examples -------- Below is an example of a list of tuples that can be passed to this method: - >>> data = [(960., 540., 0.23, 9.9, -4.1), (960., 92., 0.38, -7.8, 16.)] + ``>>> data = [(960., 540., 0.23, 9.9, -4.1), (960., 92., 0.38, -7.8, 16.)]`` """ field_names = ["point_x", "point_y", "offset", "diff_x", "diff_y"] dtype = [(name, float) for name in field_names] @@ -223,8 +233,8 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): point or not. Defaults to False. invert_y_axis : bool Whether to invert the y-axis or not. In many monitors, pixel coordinate - (0,0), which is often referred to as origin, is at the top left of corner. - Defaults to True. + (0,0), which is often referred to as origin, is at the top left of corner + of the screen. Defaults to True. show : bool Whether to show the figure or not. @@ -257,7 +267,9 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): ax.set_ylabel("y (pixels)") # Display avg_error and max_error in the top left corner - text = f"avg_error: {self['avg_error']}\nmax_error: {self['max_error']}" + text = ( + f"avg_error: {self['avg_error']} deg.\nmax_error: {self['max_error']} deg." + ) ax.text( 0, 1.01, @@ -276,7 +288,7 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): if show_offsets: for i in range(len(px)): - x_offset = 0.01 * (px[i] - dx[i]) + x_offset = 0.01 * (px[i] - dx[i]) # 1% to the right of the point text = ax.text( x=(px[i] - dx[i]) + x_offset, y=py[i] - dy[i], From ca9b156edec36d17732a75862da7fbdcba807d3f Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Wed, 7 Jun 2023 11:56:01 -0400 Subject: [PATCH 24/52] FIX: Remove Cruft - removed blank line I added to fileio.rst - Removed calibration module from numpydoc_show_inherited_class_members because it wasnt doing anything --- doc/conf.py | 1 - doc/file_io.rst | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 6ed5e9d6d2b..4080a485755 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -186,7 +186,6 @@ numpydoc_show_inherited_class_members = { "mne.SourceSpaces": False, "mne.Forward": False, - "mne.preprocessing.eyetracking.Calibration": False, } numpydoc_attributes_as_param_list = True numpydoc_xref_param_type = True diff --git a/doc/file_io.rst b/doc/file_io.rst index 1c6d42e00d4..c7957fb8468 100644 --- a/doc/file_io.rst +++ b/doc/file_io.rst @@ -61,5 +61,4 @@ Base class: :toctree: generated :template: autosummary/class_no_members.rst - BaseEpochs - \ No newline at end of file + BaseEpochs \ No newline at end of file From 5015c9b4e581fef4428d01cc5e203f0cdf131904 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Wed, 7 Jun 2023 14:36:06 -0400 Subject: [PATCH 25/52] FIX, DOC: apply suggestions made by Richard H - use "fname" instead of "filename" in read_eyelink_calibration for consistency with mne codebase - changed "fname : str" to "fname : path-like" in read_eyelink_calibration and read_raw_eyelink docstring - changed screen_size and screen_resolution docstring to say array-like instead of tuple - use _check_fname in read_eyelink_calibration and read_raw_eyelink - use read_text and specify encoding as ASCII insted of readlines in read_eyelink_calibration --- mne/io/eyelink/eyelink.py | 38 ++++++++++---------- mne/preprocessing/eyetracking/calibration.py | 8 ++--- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index 2ca0ccc6919..b83122a3e6e 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -15,7 +15,7 @@ from ..base import BaseRaw from ..meas_info import create_info from ...annotations import Annotations -from ...utils import logger, verbose, fill_doc, _check_pandas_installed +from ...utils import _check_fname, _check_pandas_installed, fill_doc, logger, verbose EYELINK_COLS = { "timestamp": ("time",), @@ -295,16 +295,16 @@ def read_eyelink_calibration( Parameters ---------- - fname : str + fname : path-like Path to the eyelink file (.asc). - screen_size : tuple of shape (2,) + 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 : tuple of shape (2,) + 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. @@ -315,15 +315,13 @@ def read_eyelink_calibration( A list of :class:`mne.preprocessing.eyetracking.Calibration` instances, one for each eye of every calibration that was performed during the recording session. """ - fname = Path(fname) + fname = _check_fname(fname, overwrite="read", must_exist=True, name="fname") + if not fname.exists(): raise FileNotFoundError(f"Could not find file {fname}") logger.info("Reading calibration data from {}".format(fname)) - with fname.open() as file: - lines = file.readlines() - return _parse_calibration( - lines, screen_size, screen_distance, screen_resolution - ) + lines = fname.read_text(encoding="ASCII").splitlines() + return _parse_calibration(lines, screen_size, screen_distance, screen_resolution) @fill_doc @@ -345,7 +343,7 @@ def read_raw_eyelink( Parameters ---------- - fname : str + fname : path-like Path to the eyelink file (.asc). %(preload)s %(verbose)s @@ -378,7 +376,7 @@ def read_raw_eyelink( If True, returns a tuple of (raw, calibrations) where calibrations is a list of Calibration instances, each containing information about a single calibration collected during the recording. - screen_size : tuple + screen_size : array-like of shape (2,) Only set if 'return_calibration' is set to True. The width and height (in meters) of the screen that the eyetracking data was collected with. For example (.531, .298) for a monitor with @@ -387,7 +385,7 @@ def read_raw_eyelink( Only set if 'return_calibration' is set to True. The distance from the participant's eyes to the screen in meters. Defaults to None. - screen_resolution : tuple + screen_resolution : array-like of shape (2,) Only set if 'return_calibration' is set to True. The resolution (in pixels) of the screen that the eyetracking data was collected with. For example, (1920, 1080) for a 1920x1080 @@ -402,12 +400,13 @@ def read_raw_eyelink( -------- mne.io.Raw : Documentation of attribute and methods. """ - extension = Path(fname).suffix + fname = _check_fname(fname, overwrite="read", must_exist=True, name="fname") + extension = fname.suffix if extension not in ".asc": raise ValueError( "This reader can only read eyelink .asc files." - f" Got extension {extension} instead. consult eyelink" - " manual for converting eyelink data format (.edf)" + f" Got extension {extension} instead. consult EyeLink" + " manual for converting EyeLink data format (.edf)" " files to .asc format." ) @@ -423,7 +422,10 @@ def read_raw_eyelink( ) if return_calibration: calibrations = read_eyelink_calibration( - fname, screen_size, screen_distance, screen_resolution + fname=fname, + screen_size=screen_size, + screen_distance=screen_distance, + screen_resolution=screen_resolution, ) return raw_eyelink, calibrations else: @@ -436,7 +438,7 @@ class RawEyelink(BaseRaw): Parameters ---------- - fname : str + fname : path-like Path to the data file (.XXX). create_annotations : bool | list (default True) Whether to create mne.Annotations from occular events diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index 4423e62ab0b..5feeb8d81af 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -58,13 +58,13 @@ class Calibration(OrderedDict): See the example below for more details. - screen_size : tuple of shape (2,) + 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 : tuple of shape (2,) + 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. @@ -88,13 +88,13 @@ class Calibration(OrderedDict): the actual gaze position. points : ndarray a 1D structured numpy array, which contains the data for each calibration point. - screen_size : tuple + screen_size : array-like 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 : tuple + screen_resolution : array-like The resolution (in pixels) of the screen that the eyetracking data was collected with. For example, ``(1920, 1080)`` for a 1920x1080 resolution display. From 17eca8db11a03599cf06f3f7d580060c60c54f1c Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Fri, 9 Jun 2023 10:57:03 -0400 Subject: [PATCH 26/52] FIX: Move mne.io.eyelink.read_eyelink_calibraiton to mne.preprocessing.eyetracking Moved the function to the mne.preprocessing.eyetracking.calibration py file Updated the documentation of the function in doc --- doc/preprocessing.rst | 1 + doc/reading_raw_data.rst | 1 - mne/io/__init__.py | 2 +- mne/io/eyelink/__init__.py | 2 +- mne/io/eyelink/eyelink.py | 40 +------------------ mne/io/eyelink/tests/test_eyelink.py | 3 +- mne/preprocessing/eyetracking/__init__.py | 2 +- mne/preprocessing/eyetracking/calibration.py | 42 ++++++++++++++++++-- 8 files changed, 47 insertions(+), 46 deletions(-) diff --git a/doc/preprocessing.rst b/doc/preprocessing.rst index 920d001059f..7028e7ab307 100644 --- a/doc/preprocessing.rst +++ b/doc/preprocessing.rst @@ -154,6 +154,7 @@ Projections: :toctree: generated/ Calibration + read_eyelink_calibration set_channel_types_eyetrack EEG referencing: diff --git a/doc/reading_raw_data.rst b/doc/reading_raw_data.rst index 88dab12d8be..c9316ffa9b0 100644 --- a/doc/reading_raw_data.rst +++ b/doc/reading_raw_data.rst @@ -21,7 +21,6 @@ Reading raw data read_raw_curry read_raw_edf read_raw_eyelink - read_eyelink_calibration read_raw_bdf read_raw_gdf read_raw_kit diff --git a/mne/io/__init__.py b/mne/io/__init__.py index 69550e4fcaa..e51df7c9183 100644 --- a/mne/io/__init__.py +++ b/mne/io/__init__.py @@ -68,7 +68,7 @@ from .fieldtrip import read_raw_fieldtrip, read_epochs_fieldtrip, read_evoked_fieldtrip from .nihon import read_raw_nihon from ._read_raw import read_raw -from .eyelink import read_raw_eyelink, read_eyelink_calibration +from .eyelink import read_raw_eyelink # for backward compatibility diff --git a/mne/io/eyelink/__init__.py b/mne/io/eyelink/__init__.py index 6136544bb3b..e8f09e1aee5 100644 --- a/mne/io/eyelink/__init__.py +++ b/mne/io/eyelink/__init__.py @@ -5,4 +5,4 @@ # # License: BSD-3-Clause -from .eyelink import read_raw_eyelink, read_eyelink_calibration +from .eyelink import read_raw_eyelink diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index b83122a3e6e..41ec1b60e66 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -10,7 +10,6 @@ from pathlib import Path import numpy as np -from ._utils import _parse_calibration from ..constants import FIFF from ..base import BaseRaw from ..meas_info import create_info @@ -287,43 +286,6 @@ def _find_overlaps(df, max_time=0.05): return ovrlp.drop(columns=tmp_cols).reset_index(drop=True) -@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") - - if not fname.exists(): - raise FileNotFoundError(f"Could not find file {fname}") - logger.info("Reading calibration data from {}".format(fname)) - lines = fname.read_text(encoding="ASCII").splitlines() - return _parse_calibration(lines, screen_size, screen_distance, screen_resolution) - - @fill_doc def read_raw_eyelink( fname, @@ -421,6 +383,8 @@ def read_raw_eyelink( gap_desc=gap_description, ) if return_calibration: + from ...preprocessing.eyetracking import read_eyelink_calibration + calibrations = read_eyelink_calibration( fname=fname, screen_size=screen_size, diff --git a/mne/io/eyelink/tests/test_eyelink.py b/mne/io/eyelink/tests/test_eyelink.py index 2eb252781ee..806f26081d3 100644 --- a/mne/io/eyelink/tests/test_eyelink.py +++ b/mne/io/eyelink/tests/test_eyelink.py @@ -4,9 +4,10 @@ import numpy as np from mne.datasets.testing import data_path, requires_testing_data -from mne.io import read_raw_eyelink, read_eyelink_calibration, BaseRaw +from mne.io import read_raw_eyelink, BaseRaw from mne.io.constants import FIFF from mne.io.pick import _DATA_CH_TYPES_SPLIT +from mne.preprocessing.eyetracking import read_eyelink_calibration from mne.utils import _check_pandas_installed, requires_pandas testing_path = data_path(download=False) diff --git a/mne/preprocessing/eyetracking/__init__.py b/mne/preprocessing/eyetracking/__init__.py index 66e7056012a..c232475b2fc 100644 --- a/mne/preprocessing/eyetracking/__init__.py +++ b/mne/preprocessing/eyetracking/__init__.py @@ -5,4 +5,4 @@ # License: BSD-3-Clause from .eyetracking import set_channel_types_eyetrack -from .calibration import Calibration +from .calibration import Calibration, read_eyelink_calibration diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index 5feeb8d81af..cac19e6e696 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -10,7 +10,7 @@ import numpy as np -from ...utils import fill_doc +from ...utils import _check_fname, fill_doc, logger @fill_doc @@ -21,8 +21,8 @@ class Calibration(OrderedDict): calibration that was conducted during an eye-tracking recording. .. note:: - When possible, this class should be instantiated via an available function, - such as :func:`mne.io.read_eyelink_calibration`. + When possible, this class should be instantiated via a helper function, + such as :func:`mne.preprocessing.eyetracking.read_eyelink_calibration`. Parameters ---------- @@ -301,3 +301,39 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): fig.tight_layout() fig.show() if show else None 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. + """ + from ...io.eyelink._utils import _parse_calibration + + fname = _check_fname(fname, overwrite="read", must_exist=True, name="fname") + logger.info("Reading calibration data from {}".format(fname)) + lines = fname.read_text(encoding="ASCII").splitlines() + return _parse_calibration(lines, screen_size, screen_distance, screen_resolution) From 296794b985a51457c7abf95571711867ca5953ea Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Fri, 9 Jun 2023 10:58:30 -0400 Subject: [PATCH 27/52] DOC: Added PR to changelog --- doc/changes/latest.inc | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changes/latest.inc b/doc/changes/latest.inc index 2befbdc2f9b..42eb4f92eb8 100644 --- a/doc/changes/latest.inc +++ b/doc/changes/latest.inc @@ -25,6 +25,7 @@ Enhancements ~~~~~~~~~~~~ - Add ``cmap`` argument for the :func:`mne.viz.plot_sensors` (:gh:`11720` by :newcontrib:`Gennadiy Belonosov`) - When failing to locate a file, we now print the full path in quotation marks to help spot accidentally added trailing spaces (:gh:`11718` by `Richard Höchenberger`_) +- Added :class:`mne.preprocessing.eyetracking.Calibration` to store eye-tracking calibration info, and :func:`mne.preprocessing.eyetracking.read_eyelink_calibration` to read calibration data from EyeLink systems (:gh:`11719` by `Scott Huberty`_) Bugs ~~~~ From 11ec6b8526da10495176411e0e5655d537b82696 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 12 Jun 2023 14:05:02 -0400 Subject: [PATCH 28/52] FIX, DOC: make calibration['points'] more user friendly and increase tests - moved read_eyelink_calibration and test_plot_calibration test to mne.preprocessing.eyetracking.tests.test_calibration - allow calibration['points'] to be initiated from any array-like object, not just list of tuples - last two elements of calibraiton['points'] array are now gaze_x and gaze_y, not diff_x, diff_y, more straightforward this way. - adjusted docstring to reflect the aforementioned changes - added a few more tests for the calibration class to increase coverage --- mne/io/eyelink/_utils.py | 6 +- mne/io/eyelink/tests/test_eyelink.py | 87 --------- mne/preprocessing/eyetracking/calibration.py | 106 +++++------ .../eyetracking/tests/test_calibration.py | 176 +++++++++++++++++- 4 files changed, 229 insertions(+), 146 deletions(-) diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index ecd354b474f..4de68a113e1 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -37,9 +37,11 @@ def _parse_validation_line(line): tokens = line.split() xy = tokens[-6].strip("[]").split(",") # e.g. '960, 540' xy_diff = tokens[-2].strip("[]").split(",") # e.g. '-1.5, -2.8' - vals = tuple([float(v) for v in [*xy, tokens[-4], *xy_diff]]) + vals = [float(v) for v in [*xy, tokens[-4], *xy_diff]] + vals[3] = vals[0] - vals[3] # cal_x - eye_x + vals[4] = vals[1] - vals[4] # cal_y - eye_y - return vals + return tuple(vals) def _parse_calibration( diff --git a/mne/io/eyelink/tests/test_eyelink.py b/mne/io/eyelink/tests/test_eyelink.py index 806f26081d3..0899ef8ea2a 100644 --- a/mne/io/eyelink/tests/test_eyelink.py +++ b/mne/io/eyelink/tests/test_eyelink.py @@ -1,13 +1,11 @@ import pytest -import matplotlib.pyplot as plt import numpy as np from mne.datasets.testing import data_path, requires_testing_data from mne.io import read_raw_eyelink, BaseRaw from mne.io.constants import FIFF from mne.io.pick import _DATA_CH_TYPES_SPLIT -from mne.preprocessing.eyetracking import read_eyelink_calibration from mne.utils import _check_pandas_installed, requires_pandas testing_path = data_path(download=False) @@ -98,91 +96,6 @@ def test_eyelink(fname, create_annotations, find_overlaps, return_calibration): assert df["description"].iloc[i] == f"{label}_both" -@requires_testing_data -@pytest.mark.parametrize("fname", [(fname)]) -def test_read_calibration(fname): - """Test reading calibration data from an eyelink asc file.""" - calibrations = read_eyelink_calibration(fname) - expected_x_left = np.array( - [ - 960.0, - 960.0, - 960.0, - 115.0, - 1804.0, - 216.0, - 1703.0, - 216.0, - 1703.0, - 537.0, - 1382.0, - 537.0, - 1382.0, - ] - ) - expected_y_right = np.array( - [ - 540.0, - 92.0, - 987.0, - 540.0, - 540.0, - 145.0, - 145.0, - 934.0, - 934.0, - 316.0, - 316.0, - 763.0, - 763.0, - ] - ) - expected_diff_y_left = np.array( - [-4.1, 16.0, -14.2, -14.8, 1.0, -15.4, -1.4, 6.9, -28.1, 7.6, 2.1, -2.0, 8.4] - ) - expected_offset_right = np.array( - [0.36, 0.5, 0.2, 0.1, 0.3, 0.38, 0.13, 0.33, 0.22, 0.18, 0.34, 0.52, 0.21] - ) - - assert len(calibrations) == 2 # calibration[0] is left, calibration[1] is right - assert calibrations[0]["onset"] == 0 - assert calibrations[1]["onset"] == 0 - assert calibrations[0]["model"] == "HV13" - assert calibrations[1]["model"] == "HV13" - assert calibrations[0]["eye"] == "left" - assert calibrations[1]["eye"] == "right" - assert calibrations[0]["avg_error"] == 0.30 - assert calibrations[0]["max_error"] == 0.90 - assert calibrations[1]["avg_error"] == 0.31 - assert calibrations[1]["max_error"] == 0.52 - assert calibrations[0]["points"]["point_x"] == pytest.approx(expected_x_left) - assert calibrations[1]["points"]["point_y"] == pytest.approx(expected_y_right) - assert calibrations[0]["points"]["diff_y"] == pytest.approx(expected_diff_y_left) - assert calibrations[1]["points"]["offset"] == pytest.approx(expected_offset_right) - - -@requires_testing_data -@pytest.mark.parametrize("fname", [(fname)]) -def test_plot_calibration(fname): - """Test plotting calibration data.""" - calibrations = read_eyelink_calibration(fname) - cal_left = calibrations[0] - fig = cal_left.plot(show=False) - ax = fig.axes[0] - - scatter1 = ax.collections[0] - scatter2 = ax.collections[1] - px, py = cal_left.points["point_x"], cal_left.points["point_y"] - dx, dy = cal_left.points["diff_x"], cal_left.points["diff_y"] - - assert ax.title.get_text() == f"Calibration ({cal_left.eye} eye)" - assert len(ax.collections) == 2 # Two scatter plots - - assert np.allclose(scatter1.get_offsets(), np.column_stack((px, py))) - assert np.allclose(scatter2.get_offsets(), np.column_stack((px - dx, py - dy))) - plt.close(fig) - - @requires_testing_data @requires_pandas @pytest.mark.parametrize("fname_href", [(fname_href)]) diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index cac19e6e696..daac8182663 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -9,6 +9,7 @@ from copy import deepcopy import numpy as np +from numpy.lib.recfunctions import unstructured_to_structured from ...utils import _check_fname, fill_doc, logger @@ -42,21 +43,20 @@ class Calibration(OrderedDict): max_error : float The maximum error in degrees that occurred between the calibration points and the actual gaze position. - points : list - List of tuples, each of shape (5,). Each tuple should contain data for 1 - calibration point. The elements within each tuple should be - ``(point_x, point_y, offset, diff_x, diff_y)``, where: - - - point_x: the x pixel-coordinate of the calibration point - - point_y: the y pixel-coordinate of the calibration point - - offset: the error in degrees between the calibration point and the + points : array-like of float, shape (n_calibration_points, 5) + The data for the positions, actual gaze, and offsets for each calibration point. + Each row should contain data for 1 calibration point. The columns should be + of shape (5,) and contain ``(point_x, point_y, offset, gaze_x, gaze_y)``, where: + + - point_x: the x-coordinate of the calibration point + - point_y: the y-coordinate of the calibration point + - offset: the error in degrees between the calibration position and the actual gaze position - - diff_x: the difference in x pixel coordinates between the calibration - point and the actual gaze position - - diff_y: the difference in y pixel coordinates between the calibration - point and the actual gaze position + - gaze_x: the x-coordinate of the actual gaze position + - gaze_y: the y-coordinate of the actual gaze position - See the example below for more details. + If the value for a field is not available, use ``np.nan``. See the example below + for more details. screen_size : array-like of shape (2,) The width and height (in meters) of the screen that the eyetracking @@ -101,8 +101,8 @@ class Calibration(OrderedDict): Examples -------- - Below is an example of a list of tuples that can be passed to the points parameter: - ``>>> data = [(960., 540., 0.23, 9.9, -4.1), (960., 92., 0.38, -7.8, 16.)]`` + Below is an example of data that can be passed to the points parameter: + ``>>> data = [(960., 540., 0.23, 950.1, 544.1), (960., 92., 0.38, 967.8, 76. )]`` """ def __init__( @@ -123,7 +123,7 @@ def __init__( self["eye"] = eye self["avg_error"] = avg_error self["max_error"] = max_error - if points is not None and isinstance(points, list): + if points is not None: self.set_calibration_array(points) else: self["points"] = points @@ -175,24 +175,25 @@ def set_calibration_array(self, data): """ Create a Numpy Array containing data regarding each calibration point. - This method takes a list of tuples and converts it into a structured numpy + This method takes an array-like objects and converts it into a structured numpy array, with field names 'point_x', 'point_y', 'offset', 'diff_x', and 'diff_y'. Parameters ---------- - data : list - List of tuples, each of shape (5,). Each tuple should contain data for 1 - calibration point. The elements within each tuple should be - ``(point_x, point_y, offset, diff_x, diff_y)``, where: - - - point_x: the x pixel-coordinate of the calibration point - - point_y: the y pixel-coordinate of the calibration point - - offset: the error in degrees between the calibration point and the - actual gaze position - - diff_x: the difference in x pixel coordinates between the calibration - point and the actual gaze position - - diff_y: the difference in y pixel coordinates between the calibration - point and the actual gaze position + points : array-like of float, shape (n_calibration_points, 5) + The data for the positions, actual gaze, and offsets for each calibration point. + Each row should contain data for 1 calibration point. The columns should be + of shape (5,) and contain ``(point_x, point_y, offset, gaze_x, gaze_y)``, where: + + - point_x: the x-coordinate of the calibration point + - point_y: the y-coordinate of the calibration point + - offset: the error in degrees between the calibration position and the + actual gaze position + - gaze_x: the x-coordinate of the actual gaze position + - gaze_y: the y-coordinate of the actual gaze position + + If the value for a field is not available, use ``np.nan``. See the example below + for more details. Returns ------- @@ -204,22 +205,28 @@ def set_calibration_array(self, data): Examples -------- Below is an example of a list of tuples that can be passed to this method: - ``>>> data = [(960., 540., 0.23, 9.9, -4.1), (960., 92., 0.38, -7.8, 16.)]`` + ``>>> data = [(960., 540., 0.23, 950.1, 544.1), (960., 92., 0.38, 967.8, 76.)]`` """ - field_names = ["point_x", "point_y", "offset", "diff_x", "diff_y"] + field_names = ("point_x", "point_y", "offset", "gaze_x", "gaze_y") dtype = [(name, float) for name in field_names] - if isinstance(data, list): - if not all([len(elem) == len(field_names) for elem in data]): - raise ValueError( - f"Each tuple in the data list must have have 5 elements: " - f"Got {data}" - ) - structured_array = np.array(data, dtype=dtype) - self["points"] = structured_array - else: + if isinstance(data, (list, tuple)): + data = np.array(data) + + if not isinstance(data, np.ndarray): raise TypeError( - f"Data must be a list. got {data} which is of type {type(data)}" + "data must be array-like of shape (n_points, 5). got {data}" ) + if data.dtype.names == field_names: + # already a structured array + structured_array = data + else: + structured_array = unstructured_to_structured(data, dtype=dtype) + assert structured_array.ndim == 1 + if not len(structured_array[0]) == 5: + raise ValueError( + f"Each column in data must have have 5 elements: got {data}" + ) + self["points"] = structured_array def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): """Visualize calibration. @@ -245,11 +252,6 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): """ import matplotlib.pyplot as plt - if not len(self["points"]): - raise ValueError( - "No calibration data to plot. Use set_calibration_array()" - " to set calibration data." - ) if not isinstance(self["points"], np.ndarray): raise TypeError( "Calibration points must be a numpy array. Use " @@ -257,7 +259,7 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): ) fig, ax = plt.subplots() px, py = self["points"]["point_x"], self["points"]["point_y"] - dx, dy = self["points"]["diff_x"], self["points"]["diff_y"] + gaze_x, gaze_y = self["points"]["gaze_x"], self["points"]["gaze_y"] if title is None: ax.set_title(f"Calibration ({self['eye']} eye)") @@ -284,14 +286,14 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): # monitors ax.invert_yaxis() ax.scatter(px, py, color="gray") - ax.scatter(px - dx, py - dy, color="red", alpha=0.5) + ax.scatter(gaze_x, gaze_y, color="red", alpha=0.5) if show_offsets: for i in range(len(px)): - x_offset = 0.01 * (px[i] - dx[i]) # 1% to the right of the point + x_offset = 0.01 * gaze_x[i] # 1% to the right of the gazepoint text = ax.text( - x=(px[i] - dx[i]) + x_offset, - y=py[i] - dy[i], + x=gaze_x[i] + x_offset, + y=gaze_y[i], s=self["points"]["offset"][i], fontsize=8, ha="left", diff --git a/mne/preprocessing/eyetracking/tests/test_calibration.py b/mne/preprocessing/eyetracking/tests/test_calibration.py index b54aad6a00e..1d9d9623cd3 100644 --- a/mne/preprocessing/eyetracking/tests/test_calibration.py +++ b/mne/preprocessing/eyetracking/tests/test_calibration.py @@ -2,13 +2,36 @@ import numpy as np -from ..calibration import Calibration +from mne.datasets.testing import data_path, requires_testing_data +from ..calibration import Calibration, read_eyelink_calibration +# for test_read_eylink_calibration +testing_path = data_path(download=False) +fname = testing_path / "eyetrack" / "test_eyelink.asc" -test_points = [(960.0, 540.0, 0.23, 9.9, -4.1), (960.0, 92.0, 0.38, -7.8, 16.0)] -field_names = ["point_x", "point_y", "offset", "diff_x", "diff_y"] +# for test_create_calibration +test_points = [ + (115.0, 540.0, 0.42, 101.5, 554.8), + (960.0, 540.0, 0.23, 9.9, -4.1), + (1804.0, 540.0, 0.17, 1795.9, 539.0), +] +field_names = ["point_x", "point_y", "offset", "gaze_x", "gaze_y"] dtypes = [(name, float) for name in field_names] -test_array = np.array(test_points, dtype=dtypes) +test_structured = np.array(test_points, dtype=dtypes) +test_lists = [list(point) for point in test_points] +test_array_2d = np.array(test_lists) + +expected_repr = ( + "Calibration |\n" + " onset: 0 seconds\n" + " model: H3\n" + " eye: right\n" + " average error: 0.5 degrees\n" + " max error: 1.0 degrees\n" + " screen size: (0.531, 0.298) meters\n" + " screen distance: 0.065 meters\n" + " screen resolution: (1920, 1080) pixels\n" +) @pytest.mark.parametrize( @@ -18,6 +41,29 @@ ), [ (0, "H3", "right", 0.5, 1.0, test_points, (0.531, 0.298), 0.065, (1920, 1080)), + ( + 0, + "H3", + "right", + 0.5, + 1.0, + test_structured, + (0.531, 0.298), + 0.065, + (1920, 1080), + ), + (0, "H3", "right", 0.5, 1.0, test_lists, (0.531, 0.298), 0.065, (1920, 1080)), + ( + 0, + "H3", + "right", + 0.5, + 1.0, + test_array_2d, + (0.531, 0.298), + 0.065, + (1920, 1080), + ), (None, None, None, None, None, None, None, None, None), ], ) @@ -51,9 +97,129 @@ def test_create_calibration( assert cal["avg_error"] == avg_error assert cal["max_error"] == max_error if points is not None: - assert np.array_equal(cal["points"], test_array) + assert np.array_equal(cal["points"], test_structured) else: assert cal["points"] is None assert cal["screen_size"] == screen_size assert cal["screen_distance"] == screen_distance assert cal["screen_resolution"] == screen_resolution + # test __getattr__ + assert cal.onset == cal["onset"] + with pytest.raises(AttributeError): + assert cal.fake_key + # test copy method + copied_obj = cal.copy() + # Check if the copied object is an instance of Calibration + assert isinstance(copied_obj, Calibration) + # Check if the an attribute of the copied object is equal to the original object + assert copied_obj["onset"] == cal["onset"] + # Modify the copied object and check if it is independent from the original object + copied_obj["onset"] = 20 + assert copied_obj["onset"] != cal["onset"] + # test __repr__ + if cal["onset"] is not None: + assert repr(cal) == expected_repr # test __repr__ + + +@requires_testing_data +@pytest.mark.parametrize("fname", [(fname)]) +def test_read_calibration(fname): + """Test reading calibration data from an eyelink asc file.""" + calibrations = read_eyelink_calibration(fname) + expected_x_left = np.array( + [ + 960.0, + 960.0, + 960.0, + 115.0, + 1804.0, + 216.0, + 1703.0, + 216.0, + 1703.0, + 537.0, + 1382.0, + 537.0, + 1382.0, + ] + ) + expected_y_right = np.array( + [ + 540.0, + 92.0, + 987.0, + 540.0, + 540.0, + 145.0, + 145.0, + 934.0, + 934.0, + 316.0, + 316.0, + 763.0, + 763.0, + ] + ) + expected_gaze_y_left = np.array( + [ + 544.1, + 76.0, + 1001.2, + 554.8, + 539.0, + 160.4, + 146.4, + 927.1, + 962.1, + 308.4, + 313.9, + 765.0, + 754.6, + ] + ) + expected_offset_right = np.array( + [0.36, 0.5, 0.2, 0.1, 0.3, 0.38, 0.13, 0.33, 0.22, 0.18, 0.34, 0.52, 0.21] + ) + + assert len(calibrations) == 2 # calibration[0] is left, calibration[1] is right + assert calibrations[0]["onset"] == 0 + assert calibrations[1]["onset"] == 0 + assert calibrations[0]["model"] == "HV13" + assert calibrations[1]["model"] == "HV13" + assert calibrations[0]["eye"] == "left" + assert calibrations[1]["eye"] == "right" + assert calibrations[0]["avg_error"] == 0.30 + assert calibrations[0]["max_error"] == 0.90 + assert calibrations[1]["avg_error"] == 0.31 + assert calibrations[1]["max_error"] == 0.52 + assert calibrations[0]["points"]["point_x"] == pytest.approx(expected_x_left) + assert calibrations[1]["points"]["point_y"] == pytest.approx(expected_y_right) + assert calibrations[0]["points"]["gaze_y"] == pytest.approx(expected_gaze_y_left) + assert calibrations[1]["points"]["offset"] == pytest.approx(expected_offset_right) + + +@requires_testing_data +@pytest.mark.parametrize("fname", [(fname)]) +def test_plot_calibration(fname): + """Test plotting calibration data.""" + import matplotlib.pyplot as plt + + # Set the non-interactive backend + plt.switch_backend("agg") + + calibrations = read_eyelink_calibration(fname) + cal_left = calibrations[0] + fig = cal_left.plot(show=True) + ax = fig.axes[0] + + scatter1 = ax.collections[0] + scatter2 = ax.collections[1] + px, py = cal_left.points["point_x"], cal_left.points["point_y"] + gaze_x, gaze_y = cal_left.points["gaze_x"], cal_left.points["gaze_y"] + + assert ax.title.get_text() == f"Calibration ({cal_left.eye} eye)" + assert len(ax.collections) == 2 # Two scatter plots + + assert np.allclose(scatter1.get_offsets(), np.column_stack((px, py))) + assert np.allclose(scatter2.get_offsets(), np.column_stack((gaze_x, gaze_y))) + plt.close(fig) From cda0e097e81a95e63650e193fc58480515342e34 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 12 Jun 2023 14:31:02 -0400 Subject: [PATCH 29/52] FIX: build_doc warnings caused by docstring mistakes in previous commit --- mne/preprocessing/eyetracking/calibration.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index daac8182663..a8e7040d4d1 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -43,7 +43,7 @@ class Calibration(OrderedDict): max_error : float The maximum error in degrees that occurred between the calibration points and the actual gaze position. - points : array-like of float, shape (n_calibration_points, 5) + points : array-like of float, shape ``(n_calibration_points, 5)`` The data for the positions, actual gaze, and offsets for each calibration point. Each row should contain data for 1 calibration point. The columns should be of shape (5,) and contain ``(point_x, point_y, offset, gaze_x, gaze_y)``, where: @@ -176,11 +176,11 @@ def set_calibration_array(self, data): Create a Numpy Array containing data regarding each calibration point. This method takes an array-like objects and converts it into a structured numpy - array, with field names 'point_x', 'point_y', 'offset', 'diff_x', and 'diff_y'. + array, with field names 'point_x', 'point_y', 'offset', 'gaze_x', and 'gaze_y'. Parameters ---------- - points : array-like of float, shape (n_calibration_points, 5) + data : array-like of float, shape ``(n_calibration_points, 5)`` The data for the positions, actual gaze, and offsets for each calibration point. Each row should contain data for 1 calibration point. The columns should be of shape (5,) and contain ``(point_x, point_y, offset, gaze_x, gaze_y)``, where: From 328c50561e14c9c3b017f640c9d6282ee0bb0335 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 12 Jun 2023 14:51:48 -0400 Subject: [PATCH 30/52] FIX: more fixes of docstring errors.. --- mne/preprocessing/eyetracking/calibration.py | 27 ++++++++++---------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index a8e7040d4d1..537f35d1d2e 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -181,19 +181,20 @@ def set_calibration_array(self, data): Parameters ---------- data : array-like of float, shape ``(n_calibration_points, 5)`` - The data for the positions, actual gaze, and offsets for each calibration point. - Each row should contain data for 1 calibration point. The columns should be - of shape (5,) and contain ``(point_x, point_y, offset, gaze_x, gaze_y)``, where: - - - point_x: the x-coordinate of the calibration point - - point_y: the y-coordinate of the calibration point - - offset: the error in degrees between the calibration position and the - actual gaze position - - gaze_x: the x-coordinate of the actual gaze position - - gaze_y: the y-coordinate of the actual gaze position - - If the value for a field is not available, use ``np.nan``. See the example below - for more details. + The data for the positions, actual gaze, and offsets for each calibration + point. Each row should contain data for 1 calibration point. The columns + should be of shape (5,) and contain + ``(point_x, point_y, offset, gaze_x, gaze_y)``, where: + + - point_x: the x-coordinate of the calibration point + - point_y: the y-coordinate of the calibration point + - offset: the error in degrees between the calibration position and the + actual gaze position + - gaze_x: the x-coordinate of the actual gaze position + - gaze_y: the y-coordinate of the actual gaze position + + If the value for a field is not available, use ``np.nan``. See the example + below for more details. Returns ------- From ccbd5ec94f0b66a1f4eadf951f3391a1b0fb227f Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 12 Jun 2023 16:42:55 -0400 Subject: [PATCH 31/52] FIX: error in old-ubuntu CI where dtype is a list This is a strange one that I havent wrapped my head around. From the stacktrace it seems like numpy_array.dtype is a list only in the Ci of old-ubuntu where numpy 1.20 is used, and ubuntu 18 is used. From the code I dont see anywhere that I could have accidently caste the dtype instance to a list, and further, no error like this occurs in any other test. - I added an isinsance(array.dtype, np.dtype) just before the line to see if this fixes the issue - and confirms that this indeed was the problem line. - Unrelated but I also added show_name == True in the test_plot_calibration test to add coverage to those lines of code --- mne/preprocessing/eyetracking/calibration.py | 7 ++++--- mne/preprocessing/eyetracking/tests/test_calibration.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index 537f35d1d2e..43a0d84a405 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -217,9 +217,10 @@ def set_calibration_array(self, data): raise TypeError( "data must be array-like of shape (n_points, 5). got {data}" ) - if data.dtype.names == field_names: - # already a structured array - structured_array = data + if isinstance(data.dtype, np.dtype): + if data.dtype.names == field_names: + # already a structured array + structured_array = data else: structured_array = unstructured_to_structured(data, dtype=dtype) assert structured_array.ndim == 1 diff --git a/mne/preprocessing/eyetracking/tests/test_calibration.py b/mne/preprocessing/eyetracking/tests/test_calibration.py index 1d9d9623cd3..2d2e15b4d38 100644 --- a/mne/preprocessing/eyetracking/tests/test_calibration.py +++ b/mne/preprocessing/eyetracking/tests/test_calibration.py @@ -209,7 +209,7 @@ def test_plot_calibration(fname): calibrations = read_eyelink_calibration(fname) cal_left = calibrations[0] - fig = cal_left.plot(show=True) + fig = cal_left.plot(show=True, show_offsets=True) ax = fig.axes[0] scatter1 = ax.collections[0] From e821ffebd89ad774b574c8db9fb008b3040ec1ef Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 12 Jun 2023 16:47:27 -0400 Subject: [PATCH 32/52] DOC: add example of loading and visualizing Calibration object to tutorial I added this example to the eyetracking tutorial in mne preprocessing --- .../preprocessing/90_eyetracking_data.py | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/tutorials/preprocessing/90_eyetracking_data.py b/tutorials/preprocessing/90_eyetracking_data.py index 3c3a9d84b09..7170b163dfd 100644 --- a/tutorials/preprocessing/90_eyetracking_data.py +++ b/tutorials/preprocessing/90_eyetracking_data.py @@ -36,12 +36,52 @@ from mne import Epochs, find_events from mne.io import read_raw_eyelink from mne.datasets.eyelink import data_path +from mne.preprocessing.eyetracking import read_eyelink_calibration eyelink_fname = data_path() / "mono_multi-block_multi-DINS.asc" raw = read_raw_eyelink(eyelink_fname, create_annotations=["blinks", "messages"]) raw.crop(tmin=0, tmax=146) +# %% +# Load recording calibration +# -------------------------- +# +# We can also load the calibrations from the recording and visualize it. +# Checking the quality of the calibration is a useful first step in assessing +# the quality of the eye tracking data. Note that +# :func:`mne.preprocessing.eyetracking.read_eyelink_calibration` +# will return a list of :class:`mne.preprocessing.eyetracking.Calibration` instances, +# one for each calibration. We can index that list to access a specific calibration. + +cals = read_eyelink_calibration(eyelink_fname) +print(f"number of calibrations: {len(cals)}") +first_cal = cals[0] # let's access the first (and only in this case) calibration +print(first_cal[0]) + +# %% +# Here we can see that a 5-point calibration was performed at the beginning of +# the recording. Note that you can access the calibration information directly +# as you would a dictionary: + +print(f"Eye calibrated: {first_cal['eye']}") +print(f"Calibration average error: {first_cal['avg_error']}") +first_cal["points"] # show the data for the calibration points + +# %% +# The calibration points are stored as a :class:`numpy.ndarray`. You can access +# the data for a specific calibration point by indexing the array, or you can access a +# specific field for all calibration points by indexing the field name. For example: +print(f"data for the first point only: {first_cal['points'][0]}") +print(f"offset for each calibration point: {first_cal['points']['offset']}") + +# %% +# Let's plot the calibration to get a better look. We'll pass +# ``show_offsets=True`` to show the offsets (in visual degrees) between the +# calibration position and the actual gaze position of each calibration point. + +first_cal.plot(show_offsets=True) + # %% # Get stimulus events from DIN channel # ------------------------------------ @@ -70,7 +110,8 @@ # categorized as blinks). Also, notice that we have passed a custom `dict` into # the scalings argument of ``raw.plot``. This is necessary to make the eyegaze # channel traces legible when plotting, since the file contains pixel position -# data (as opposed to eye angles, which are reported in radians). +# data (as opposed to eye angles, which are reported in radians). We also could +# have simply passed ``scalings='auto'``. raw.plot( events=events, @@ -102,7 +143,7 @@ # It is important to note that pupil size data are reported by Eyelink (and # stored internally by MNE) as arbitrary units (AU). While it often can be # preferable to convert pupil size data to millimeters, this requires -# information that is not always present in the file. MNE does not currently +# information that is not present in the file. MNE does not currently # provide methods to convert pupil size data. # See :ref:`tut-importing-eyetracking-data` for more information on pupil size # data. From f1c2c9dd5884053e68edd0b18871745985185ada Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 12 Jun 2023 17:05:14 -0400 Subject: [PATCH 33/52] FIX: more fixes Last commit introduced an error in my if else clause. Tests are pasing on local now. --- mne/preprocessing/eyetracking/calibration.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index 43a0d84a405..b8347846139 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -217,10 +217,8 @@ def set_calibration_array(self, data): raise TypeError( "data must be array-like of shape (n_points, 5). got {data}" ) - if isinstance(data.dtype, np.dtype): - if data.dtype.names == field_names: - # already a structured array - structured_array = data + if isinstance(data.dtype, np.dtype) and data.dtype.names == field_names: + structured_array = data else: structured_array = unstructured_to_structured(data, dtype=dtype) assert structured_array.ndim == 1 From 6f1cffd83f81e28fdf7083b737d4c5d0756b1014 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 12 Jun 2023 18:47:49 -0400 Subject: [PATCH 34/52] FIX: tutorial fix and another ubuntu-old fix attempt - try to make sure the dtype is set before checking dtype.names --- mne/preprocessing/eyetracking/calibration.py | 9 +++++---- tutorials/preprocessing/90_eyetracking_data.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index b8347846139..127bf477c49 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -209,18 +209,19 @@ def set_calibration_array(self, data): ``>>> data = [(960., 540., 0.23, 950.1, 544.1), (960., 92., 0.38, 967.8, 76.)]`` """ field_names = ("point_x", "point_y", "offset", "gaze_x", "gaze_y") - dtype = [(name, float) for name in field_names] + dtypes = [(name, float) for name in field_names] if isinstance(data, (list, tuple)): - data = np.array(data) + data = np.array(data, dtype=np.float64) if not isinstance(data, np.ndarray): raise TypeError( "data must be array-like of shape (n_points, 5). got {data}" ) - if isinstance(data.dtype, np.dtype) and data.dtype.names == field_names: + if data.dtype.names == field_names: + # already a structured array structured_array = data else: - structured_array = unstructured_to_structured(data, dtype=dtype) + structured_array = unstructured_to_structured(data, dtype=dtypes) assert structured_array.ndim == 1 if not len(structured_array[0]) == 5: raise ValueError( diff --git a/tutorials/preprocessing/90_eyetracking_data.py b/tutorials/preprocessing/90_eyetracking_data.py index 7170b163dfd..baeb06a9ecd 100644 --- a/tutorials/preprocessing/90_eyetracking_data.py +++ b/tutorials/preprocessing/90_eyetracking_data.py @@ -72,7 +72,7 @@ # The calibration points are stored as a :class:`numpy.ndarray`. You can access # the data for a specific calibration point by indexing the array, or you can access a # specific field for all calibration points by indexing the field name. For example: -print(f"data for the first point only: {first_cal['points'][0]}") +print(f"data for the first point only: {first_cal['points']}") print(f"offset for each calibration point: {first_cal['points']['offset']}") # %% From 2853dffb29503b9a01f0e4a7eccd4de52cc694a5 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 12 Jun 2023 19:03:39 -0400 Subject: [PATCH 35/52] FIX: Yet another tutorial fix --- tutorials/preprocessing/90_eyetracking_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tutorials/preprocessing/90_eyetracking_data.py b/tutorials/preprocessing/90_eyetracking_data.py index baeb06a9ecd..37fa09085f8 100644 --- a/tutorials/preprocessing/90_eyetracking_data.py +++ b/tutorials/preprocessing/90_eyetracking_data.py @@ -57,7 +57,7 @@ cals = read_eyelink_calibration(eyelink_fname) print(f"number of calibrations: {len(cals)}") first_cal = cals[0] # let's access the first (and only in this case) calibration -print(first_cal[0]) +print(first_cal) # %% # Here we can see that a 5-point calibration was performed at the beginning of From 4938d59678290c204f811ac1bccdba7ae82412e7 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Mon, 12 Jun 2023 19:32:55 -0400 Subject: [PATCH 36/52] FIX, STY: re-run cis and some style revisions to tutorials - I need ubuntu old ci to run and it was cancelled in my last push --- tutorials/preprocessing/90_eyetracking_data.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tutorials/preprocessing/90_eyetracking_data.py b/tutorials/preprocessing/90_eyetracking_data.py index 37fa09085f8..ba6e29f7a9e 100644 --- a/tutorials/preprocessing/90_eyetracking_data.py +++ b/tutorials/preprocessing/90_eyetracking_data.py @@ -44,14 +44,14 @@ raw.crop(tmin=0, tmax=146) # %% -# Load recording calibration -# -------------------------- +# Checking the calibration +# ------------------------ # -# We can also load the calibrations from the recording and visualize it. +# We can also load the calibrations from the recording and visualize them. # Checking the quality of the calibration is a useful first step in assessing # the quality of the eye tracking data. Note that -# :func:`mne.preprocessing.eyetracking.read_eyelink_calibration` -# will return a list of :class:`mne.preprocessing.eyetracking.Calibration` instances, +# :func:`~mne.preprocessing.eyetracking.read_eyelink_calibration` +# will return a list of :class:`~mne.preprocessing.eyetracking.Calibration` instances, # one for each calibration. We can index that list to access a specific calibration. cals = read_eyelink_calibration(eyelink_fname) @@ -66,13 +66,13 @@ print(f"Eye calibrated: {first_cal['eye']}") print(f"Calibration average error: {first_cal['avg_error']}") -first_cal["points"] # show the data for the calibration points +print(f"Calibration data {repr(first_cal['points'])})") # %% # The calibration points are stored as a :class:`numpy.ndarray`. You can access # the data for a specific calibration point by indexing the array, or you can access a # specific field for all calibration points by indexing the field name. For example: -print(f"data for the first point only: {first_cal['points']}") +print(f"data for the first point only: {first_cal['points'][0]}") print(f"offset for each calibration point: {first_cal['points']['offset']}") # %% From 2dee68e19f0397111ddeb83e70cdfc8f0751172c Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 13 Jun 2023 08:06:56 -0400 Subject: [PATCH 37/52] rerun cis From ca9157d85da74a8af083975479dd5acaa188ce38 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 13 Jun 2023 15:40:09 -0400 Subject: [PATCH 38/52] FIX, DOC: fix backward incompatible code, finishing touches on doc - fixed non backward compatible numpy code that failed on ubuntu old - made finishing touches to the docstring and tutorial - all tests pass on local.. --- mne/preprocessing/eyetracking/calibration.py | 42 ++++++++++--------- .../preprocessing/90_eyetracking_data.py | 11 +++-- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index 127bf477c49..f8755c33f97 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -9,7 +9,6 @@ from copy import deepcopy import numpy as np -from numpy.lib.recfunctions import unstructured_to_structured from ...utils import _check_fname, fill_doc, logger @@ -23,7 +22,7 @@ class Calibration(OrderedDict): .. note:: When possible, this class should be instantiated via a helper function, - such as :func:`mne.preprocessing.eyetracking.read_eyelink_calibration`. + such as :func:`~mne.preprocessing.eyetracking.read_eyelink_calibration`. Parameters ---------- @@ -46,7 +45,8 @@ class Calibration(OrderedDict): points : array-like of float, shape ``(n_calibration_points, 5)`` The data for the positions, actual gaze, and offsets for each calibration point. Each row should contain data for 1 calibration point. The columns should be - of shape (5,) and contain ``(point_x, point_y, offset, gaze_x, gaze_y)``, where: + of shape ``(5,)`` and contain ``(point_x, point_y, offset, gaze_x, gaze_y)``, + where: - point_x: the x-coordinate of the calibration point - point_y: the y-coordinate of the calibration point @@ -58,13 +58,13 @@ class Calibration(OrderedDict): If the value for a field is not available, use ``np.nan``. See the example below for more details. - screen_size : array-like of shape (2,) + 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,) + 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. @@ -87,7 +87,8 @@ class Calibration(OrderedDict): The maximum error in degrees that occurred between the calibration points and the actual gaze position. points : ndarray - a 1D structured numpy array, which contains the data for each calibration point. + a 1D structured numpy array of shape ``(n_calibration_points,)``, which contains + the data for each calibration point. screen_size : array-like 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 @@ -176,14 +177,15 @@ def set_calibration_array(self, data): Create a Numpy Array containing data regarding each calibration point. This method takes an array-like objects and converts it into a structured numpy - array, with field names 'point_x', 'point_y', 'offset', 'gaze_x', and 'gaze_y'. + array, with field names ``'point_x'``, ``'point_y'``, ``'offset'``, + ``'gaze_x'``, and ``'gaze_y'``. Parameters ---------- data : array-like of float, shape ``(n_calibration_points, 5)`` The data for the positions, actual gaze, and offsets for each calibration point. Each row should contain data for 1 calibration point. The columns - should be of shape (5,) and contain + should be of shape ``(5,)`` and contain ``(point_x, point_y, offset, gaze_x, gaze_y)``, where: - point_x: the x-coordinate of the calibration point @@ -200,16 +202,18 @@ def set_calibration_array(self, data): ------- self: instance of Calibration The Calibration instance, with the points attribute containing a structured - numpy array, with field names 'point_x', 'point_y', 'offset', 'diff_x', and - 'diff_y'. + numpy array, with field names ``'point_x'``, ``'point_y'``, ``'offset'``, + ``'diff_x'``, and ``'diff_y'``. Examples -------- Below is an example of a list of tuples that can be passed to this method: ``>>> data = [(960., 540., 0.23, 950.1, 544.1), (960., 92., 0.38, 967.8, 76.)]`` """ + from numpy.lib.recfunctions import unstructured_to_structured + field_names = ("point_x", "point_y", "offset", "gaze_x", "gaze_y") - dtypes = [(name, float) for name in field_names] + dtypes = np.dtype([(name, float) for name in field_names]) if isinstance(data, (list, tuple)): data = np.array(data, dtype=np.float64) @@ -235,16 +239,16 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): Parameters ---------- title : str - The title to be displayed. Defaults to None, which uses a generic title. + The title to be displayed. Defaults to ``None``, which uses a generic title. show_offsets : bool Whether to display the offset (in visual degrees) of each calibration - point or not. Defaults to False. + point or not. Defaults to ``False``. invert_y_axis : bool Whether to invert the y-axis or not. In many monitors, pixel coordinate (0,0), which is often referred to as origin, is at the top left of corner - of the screen. Defaults to True. + of the screen. Defaults to ``True``. show : bool - Whether to show the figure or not. + Whether to show the figure or not. Defaults to ``True``. Returns ------- @@ -319,19 +323,19 @@ def read_eyelink_calibration( 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. + 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. + 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. + resolution display. Defaults to ``None``. Returns ------- calibrations : list - A list of :class:`mne.preprocessing.eyetracking.Calibration` instances, one for + A list of :class:`~mne.preprocessing.eyetracking.Calibration` instances, one for each eye of every calibration that was performed during the recording session. """ from ...io.eyelink._utils import _parse_calibration diff --git a/tutorials/preprocessing/90_eyetracking_data.py b/tutorials/preprocessing/90_eyetracking_data.py index ba6e29f7a9e..ac29cade4a4 100644 --- a/tutorials/preprocessing/90_eyetracking_data.py +++ b/tutorials/preprocessing/90_eyetracking_data.py @@ -66,12 +66,15 @@ print(f"Eye calibrated: {first_cal['eye']}") print(f"Calibration average error: {first_cal['avg_error']}") -print(f"Calibration data {repr(first_cal['points'])})") +print(f"Calibration data: {repr(first_cal['points'])})") # %% -# The calibration points are stored as a :class:`numpy.ndarray`. You can access -# the data for a specific calibration point by indexing the array, or you can access a -# specific field for all calibration points by indexing the field name. For example: +# The calibration points are stored as a :class:`numpy.ndarray`. Each element contains +# data for a single calibration point: the x-coordinate and y-coordinate of the point's +# position, the offset, and the x-coordinate and y-coordinate of the actual gaze +# position. You can access the data for a specific calibration point by indexing the +# array, or you can access a specific field for all calibration points by indexing the +# field name. For example: print(f"data for the first point only: {first_cal['points'][0]}") print(f"offset for each calibration point: {first_cal['points']['offset']}") From c97256745e8c8321d5598f7a68424021f4847fa2 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 13 Jun 2023 19:02:40 -0400 Subject: [PATCH 39/52] FIX, DOC: use BAD_ACQ_SKIP and improve docstring - replace bad_rec_gap with BAD_ACQ_SKIP - revised read_raw_eyelink overlap_threshold docstring to be more clear and readable --- mne/io/eyelink/eyelink.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index 41ec1b60e66..7365e52c20e 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -295,7 +295,7 @@ def read_raw_eyelink( apply_offsets=False, find_overlaps=False, overlap_threshold=0.05, - gap_description="bad_rec_gap", + gap_description="BAD_ACQ_SKIP", return_calibration=False, screen_size=None, screen_distance=None, @@ -324,15 +324,19 @@ def read_raw_eyelink( saccades) if their start times and their stop times are both not separated by more than overlap_threshold. overlap_threshold : float (default 0.05) - Time in seconds. Threshold of allowable time-gap between the start and - stop times of the left and right eyes. If gap is larger than threshold, - the :class:`mne.Annotations` will be kept separate (i.e. "blink_L", - "blink_R"). If the gap is smaller than the threshold, the - :class:`mne.Annotations` will be merged (i.e. "blink_both"). - gap_description : str (default 'bad_rec_gap') + Time in seconds. Threshold of allowable time-gap between both the start and + stop times of the left and right eyes. If the gap is larger than the threshold, + the :class:`mne.Annotations` will be kept separate (i.e. ``"blink_L"``, + ``"blink_R"``). If the gap is smaller than the threshold, the + :class:`mne.Annotations` will be merged and labeled as ``"blink_both"``. + Defaults to ``0.05`` seconds (50 ms), meaning that if the blink start times of + the left and right eyes are separated by less than 50 ms, and the blink stop + times of the left and right eyes are separated by less than 50 ms, then the + blink will be merged into a single :class:`mne.Annotations`. + gap_description : str (default 'BAD_ACQ_SKIP') If there are multiple recording blocks in the file, the description of the annotation that will span across the gap period between the - blocks. Uses 'bad_rec_gap' by default so that these time periods will + blocks. Uses 'BAD_ACQ_SKIP' by default so that these time periods will be considered bad by MNE and excluded from operations like epoching. return_calibration : bool (default False) If True, returns a tuple of (raw, calibrations) where calibrations is @@ -424,10 +428,10 @@ class RawEyelink(BaseRaw): the :class:`mne.Annotations` will be kept separate (i.e. "blink_L", "blink_R"). If the gap is smaller than the threshold, the :class:`mne.Annotations` will be merged (i.e. "blink_both"). - gap_desc : str (default 'bad_rec_gap') + gap_desc : str (default 'BAD_ACQ_SKIP') If there are multiple recording blocks in the file, the description of the annotation that will span across the gap period between the - blocks. Uses 'bad_rec_gap' by default so that these time periods will + blocks. Uses 'BAD_ACQ_SKIP' by default so that these time periods will be considered bad by MNE and excluded from operations like epoching. %(preload)s %(verbose)s @@ -472,7 +476,7 @@ def __init__( apply_offsets=False, find_overlaps=False, overlap_threshold=0.05, - gap_desc="bad_rec_gap", + gap_desc="BAD_ACQ_SKIP", ): logger.info("Loading {}".format(fname)) From d71709b370549bf35f9c530d095adc0681ed4130 Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Wed, 14 Jun 2023 15:25:06 -0400 Subject: [PATCH 40/52] Update mne/preprocessing/eyetracking/calibration.py Co-authored-by: Eric Larson --- mne/preprocessing/eyetracking/calibration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index f8755c33f97..5f23e1fdf42 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -14,7 +14,7 @@ @fill_doc -class Calibration(OrderedDict): +class Calibration(dict): """Eye-tracking calibration info. This data structure behaves like a dictionary. It contains information regarding a From 143c583a83a0c28f9d3f7edea27783ebd0f7966a Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Wed, 14 Jun 2023 15:38:22 -0400 Subject: [PATCH 41/52] Apply suggestions from code review suggestions from Eric Larson Co-authored-by: Eric Larson --- mne/preprocessing/eyetracking/calibration.py | 25 ++++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index 5f23e1fdf42..4226340a300 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -57,7 +57,6 @@ class Calibration(dict): If the value for a field is not available, use ``np.nan``. See the example below for more details. - 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 @@ -118,19 +117,20 @@ def __init__( screen_distance=None, screen_resolution=None, ): - super().__init__() - self["onset"] = onset - self["model"] = model - self["eye"] = eye - self["avg_error"] = avg_error - self["max_error"] = max_error + 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, + ) if points is not None: self.set_calibration_array(points) else: self["points"] = points - self["screen_size"] = screen_size - self["screen_distance"] = screen_distance - self["screen_resolution"] = screen_resolution def __repr__(self): """Return a summary of the Calibration object.""" @@ -167,7 +167,7 @@ def copy(self): Returns ------- - info : instance of Calibration + cal : instance of Calibration The copied Calibration. """ return deepcopy(self) @@ -262,7 +262,7 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): "Calibration points must be a numpy array. Use " "set_calibration_array() to set calibration data." ) - fig, ax = plt.subplots() + fig, ax = plt.subplots(constrained_layout=True) px, py = self["points"]["point_x"], self["points"]["point_y"] gaze_x, gaze_y = self["points"]["gaze_x"], self["points"]["gaze_y"] @@ -305,7 +305,6 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): va="center", ) - fig.tight_layout() fig.show() if show else None return fig From 6ede0af45437355c7736e418662a3fda5571533d Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Thu, 15 Jun 2023 13:47:33 -0400 Subject: [PATCH 42/52] FIX: Integrate suggestions by Eric Larson - dont use structured array for calibration data - dont use custom get attr magic method - use plt_show helper func in plot method - deprecate bad_gap_desc parameter of read_raw_eyelink - remove return_calibration parameter from read_raw_eyelink - remove added nitpic_ignore strings from conf and use custom copy method - BUG: wrong operation sign was used when to calculate gaze position of calibraiton point. fixed - TEST: added a test to make sure the aforementioned bug would be caught - STY: changed invert_y_axis kwarg in plot method to origin, can specify topleft, topright, bottomleft, etc - added tests for plot method --- doc/conf.py | 2 - mne/io/eyelink/_utils.py | 11 +- mne/io/eyelink/eyelink.py | 61 ++--- mne/io/eyelink/tests/test_eyelink.py | 24 +- mne/preprocessing/eyetracking/calibration.py | 174 +++++--------- .../eyetracking/tests/test_calibration.py | 222 ++++++++++-------- .../preprocessing/90_eyetracking_data.py | 24 +- 7 files changed, 231 insertions(+), 287 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 4080a485755..d679e786d3b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -724,8 +724,6 @@ def append_attr_meth_examples(app, what, name, obj, options, lines): nitpicky = True nitpick_ignore = [ ("py:class", "None. Remove all items from D."), - ("py:class", "None. Remove all items from od."), - ("py:class", "a shallow copy of od"), ("py:class", "a set-like object providing a view on D's items"), ("py:class", "a set-like object providing a view on D's keys"), ( diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index 4de68a113e1..014017e94a5 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -3,6 +3,7 @@ # License: BSD-3-Clause import re +import numpy as np def _find_recording_start(lines): @@ -38,8 +39,8 @@ def _parse_validation_line(line): xy = tokens[-6].strip("[]").split(",") # e.g. '960, 540' xy_diff = tokens[-2].strip("[]").split(",") # e.g. '-1.5, -2.8' vals = [float(v) for v in [*xy, tokens[-4], *xy_diff]] - vals[3] = vals[0] - vals[3] # cal_x - eye_x - vals[4] = vals[1] - vals[4] # cal_y - eye_y + vals[3] += vals[0] # pos_x + eye_x i.e. 960 + -1.5 + vals[4] += vals[1] # pos_y + eye_y return tuple(vals) @@ -89,7 +90,7 @@ def _parse_calibration( calibration["avg_error"] = avg_error calibration["max_error"] = max_error - n_points = int(regex.search(calibration["model"]).group()) # e.g. 9 + n_points = int(regex.search(calibration["model"]).group()) # e.g. 13 n_points *= 2 if "LR" in line else 1 # one point per eye if "LR" # The next n_point lines contain the validation data points = [] @@ -104,6 +105,8 @@ def _parse_calibration( point_info = _parse_validation_line(subline) points.append(point_info) # Convert the list of validation data into a numpy array - calibration.set_calibration_array(points) + calibration["positions"] = np.array([point[:2] for point in points]) + calibration["offsets"] = np.array([point[2] for point in points]) + calibration["gaze"] = np.array([point[3:] for point in points]) calibrations.append(calibration) return calibrations diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index 7365e52c20e..2e0df542def 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -295,11 +295,7 @@ def read_raw_eyelink( apply_offsets=False, find_overlaps=False, overlap_threshold=0.05, - gap_description="BAD_ACQ_SKIP", - return_calibration=False, - screen_size=None, - screen_distance=None, - screen_resolution=None, + gap_description=None, ): """Reader for an Eyelink .asc file. @@ -338,24 +334,6 @@ def read_raw_eyelink( the annotation that will span across the gap period between the blocks. Uses 'BAD_ACQ_SKIP' by default so that these time periods will be considered bad by MNE and excluded from operations like epoching. - return_calibration : bool (default False) - If True, returns a tuple of (raw, calibrations) where calibrations is - a list of Calibration instances, each containing information about a - single calibration collected during the recording. - screen_size : array-like of shape (2,) - Only set if 'return_calibration' is set to True. - 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 cm. Defaults to None. - screen_distance : float - Only set if 'return_calibration' is set to True. - The distance from the participant's eyes to the screen in meters. - Defaults to None. - screen_resolution : array-like of shape (2,) - Only set if 'return_calibration' is set to True. - 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 ------- @@ -386,18 +364,7 @@ def read_raw_eyelink( overlap_threshold=overlap_threshold, gap_desc=gap_description, ) - if return_calibration: - from ...preprocessing.eyetracking import read_eyelink_calibration - - calibrations = read_eyelink_calibration( - fname=fname, - screen_size=screen_size, - screen_distance=screen_distance, - screen_resolution=screen_resolution, - ) - return raw_eyelink, calibrations - else: - return raw_eyelink + return raw_eyelink @fill_doc @@ -428,11 +395,15 @@ class RawEyelink(BaseRaw): the :class:`mne.Annotations` will be kept separate (i.e. "blink_L", "blink_R"). If the gap is smaller than the threshold, the :class:`mne.Annotations` will be merged (i.e. "blink_both"). - gap_desc : str (default 'BAD_ACQ_SKIP') + gap_desc : str If there are multiple recording blocks in the file, the description of the annotation that will span across the gap period between the - blocks. Uses 'BAD_ACQ_SKIP' by default so that these time periods will - be considered bad by MNE and excluded from operations like epoching. + blocks. Default is ``None``, which uses 'BAD_ACQ_SKIP' by default so that these + timeperiods will be considered bad by MNE and excluded from operations like + epoching. Note that this parameter is deprecated and will be removed in 1.6. + Use ``mne.annotations.rename`` instead. + + %(preload)s %(verbose)s @@ -459,7 +430,8 @@ class RawEyelink(BaseRaw): Whether whether a single eye was tracked ('monocular'), or both ('binocular'). _gap_desc : str - The description to be used for annotations returned by _make_gap_annots + The description to be used for annotations returned by _make_gap_annots. + Deprecated and will be removed in 1.6. Use ``mne.annotations.rename`` See Also -------- @@ -476,7 +448,7 @@ def __init__( apply_offsets=False, find_overlaps=False, overlap_threshold=0.05, - gap_desc="BAD_ACQ_SKIP", + gap_desc=None, ): logger.info("Loading {}".format(fname)) @@ -487,6 +459,15 @@ def __init__( self._tracking_mode = None # assigned in self._infer_col_names self._meas_date = None self._rec_info = None + if gap_desc is None: + gap_desc = "BAD_ACQ_SKIP" + else: + logger.warn( + "gap_description is deprecated in 1.5 and will be removed in 1.6, " + "use raw.annotations.rename to use a description other than " + "'BAD_ACQ_SKIP'", + FutureWarning, + ) self._gap_desc = gap_desc self.dataframes = {} diff --git a/mne/io/eyelink/tests/test_eyelink.py b/mne/io/eyelink/tests/test_eyelink.py index 0899ef8ea2a..c16970a26dc 100644 --- a/mne/io/eyelink/tests/test_eyelink.py +++ b/mne/io/eyelink/tests/test_eyelink.py @@ -3,7 +3,7 @@ import numpy as np from mne.datasets.testing import data_path, requires_testing_data -from mne.io import read_raw_eyelink, BaseRaw +from mne.io import read_raw_eyelink from mne.io.constants import FIFF from mne.io.pick import _DATA_CH_TYPES_SPLIT from mne.utils import _check_pandas_installed, requires_pandas @@ -26,29 +26,21 @@ def test_eyetrack_not_data_ch(): @requires_testing_data @requires_pandas @pytest.mark.parametrize( - "fname, create_annotations, find_overlaps, return_calibration", + "fname, create_annotations, find_overlaps", [ - (fname, False, False, False), - (fname, False, False, True), - (fname, True, False, False), - (fname, True, True, False), - (fname, ["fixations", "saccades", "blinks"], True, False), + (fname, False, False), + (fname, False, False), + (fname, True, False), + (fname, True, True), + (fname, ["fixations", "saccades", "blinks"], True), ], ) -def test_eyelink(fname, create_annotations, find_overlaps, return_calibration): +def test_eyelink(fname, create_annotations, find_overlaps): """Test reading eyelink asc files.""" - if return_calibration: - raw, calibrations = read_raw_eyelink( - fname, return_calibration=return_calibration - ) - assert len(calibrations) == 2 - assert isinstance(raw, BaseRaw) - return raw = read_raw_eyelink( fname, create_annotations=create_annotations, find_overlaps=find_overlaps, - return_calibration=return_calibration, ) # First, tests that shouldn't change based on function arguments diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index 4226340a300..d23ea36550b 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -5,12 +5,12 @@ # Adapted from: https://github.com/pyeparse/pyeparse # License: BSD-3-Clause -from collections import OrderedDict from copy import deepcopy import numpy as np from ...utils import _check_fname, fill_doc, logger +from ...viz.utils import plt_show @fill_doc @@ -21,7 +21,7 @@ class Calibration(dict): calibration that was conducted during an eye-tracking recording. .. note:: - When possible, this class should be instantiated via a helper function, + When possible, a Calibration instance should be created with a helper function, such as :func:`~mne.preprocessing.eyetracking.read_eyelink_calibration`. Parameters @@ -37,26 +37,18 @@ class Calibration(dict): eye : str The eye that was calibrated. For example, ``'left'``, or ``'right'``. avg_error : float - The average error in degrees between the calibration points and the + 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 - points and the actual gaze position. - points : array-like of float, shape ``(n_calibration_points, 5)`` - The data for the positions, actual gaze, and offsets for each calibration point. - Each row should contain data for 1 calibration point. The columns should be - of shape ``(5,)`` and contain ``(point_x, point_y, offset, gaze_x, gaze_y)``, - where: - - - point_x: the x-coordinate of the calibration point - - point_y: the y-coordinate of the calibration point - - offset: the error in degrees between the calibration position and the - actual gaze position - - gaze_x: the x-coordinate of the actual gaze position - - gaze_y: the y-coordinate of the actual gaze position - - If the value for a field is not available, use ``np.nan``. See the example below - for more details. + 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 @@ -85,9 +77,13 @@ class Calibration(dict): max_error : float The maximum error in degrees that occurred between the calibration points and the actual gaze position. - points : ndarray - a 1D structured numpy array of shape ``(n_calibration_points,)``, which contains - the data for each calibration point. + positions : ndarray of float, shape ``(n_calibration_points, 2)`` + The x and y coordinates of the calibration points. + offsets : ndarray of float, shape ``(n_calibration_points,)`` + The error in degrees between the calibration position and the actual + gaze position for each calibration point. + gaze : ndarray 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 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 @@ -98,11 +94,6 @@ class Calibration(dict): The resolution (in pixels) of the screen that the eyetracking data was collected with. For example, ``(1920, 1080)`` for a 1920x1080 resolution display. - - Examples - -------- - Below is an example of data that can be passed to the points parameter: - ``>>> data = [(960., 540., 0.23, 950.1, 544.1), (960., 92., 0.38, 967.8, 76. )]`` """ def __init__( @@ -112,7 +103,9 @@ def __init__( eye=None, avg_error=None, max_error=None, - points=None, + positions=None, + offsets=None, + gaze=None, screen_size=None, screen_distance=None, screen_resolution=None, @@ -127,10 +120,9 @@ def __init__( screen_distance=screen_distance, screen_resolution=screen_resolution, ) - if points is not None: - self.set_calibration_array(points) - else: - self["points"] = points + self["positions"] = positions + self["offsets"] = offsets + self["gaze"] = gaze def __repr__(self): """Return a summary of the Calibration object.""" @@ -154,14 +146,6 @@ def __repr__(self): f" screen resolution: {screen_resolution} pixels\n" ) - def __getattr__(self, name): - """Allow dot indexing of dict keys.""" - if name in self: - return self[name] - raise AttributeError( - f"'{self.__class__.__name__}' object has no attribute '{name}'" - ) - def copy(self): """Copy the instance. @@ -172,68 +156,17 @@ def copy(self): """ return deepcopy(self) - def set_calibration_array(self, data): - """ - Create a Numpy Array containing data regarding each calibration point. - - This method takes an array-like objects and converts it into a structured numpy - array, with field names ``'point_x'``, ``'point_y'``, ``'offset'``, - ``'gaze_x'``, and ``'gaze_y'``. - - Parameters - ---------- - data : array-like of float, shape ``(n_calibration_points, 5)`` - The data for the positions, actual gaze, and offsets for each calibration - point. Each row should contain data for 1 calibration point. The columns - should be of shape ``(5,)`` and contain - ``(point_x, point_y, offset, gaze_x, gaze_y)``, where: - - - point_x: the x-coordinate of the calibration point - - point_y: the y-coordinate of the calibration point - - offset: the error in degrees between the calibration position and the - actual gaze position - - gaze_x: the x-coordinate of the actual gaze position - - gaze_y: the y-coordinate of the actual gaze position + def __setitem__(self, key, value): + """Make sure that some keys are caste as numpy arrays. - If the value for a field is not available, use ``np.nan``. See the example - below for more details. - - Returns - ------- - self: instance of Calibration - The Calibration instance, with the points attribute containing a structured - numpy array, with field names ``'point_x'``, ``'point_y'``, ``'offset'``, - ``'diff_x'``, and ``'diff_y'``. - - Examples - -------- - Below is an example of a list of tuples that can be passed to this method: - ``>>> data = [(960., 540., 0.23, 950.1, 544.1), (960., 92., 0.38, 967.8, 76.)]`` + Because methods like plot expect numpy arrays. """ - from numpy.lib.recfunctions import unstructured_to_structured - - field_names = ("point_x", "point_y", "offset", "gaze_x", "gaze_y") - dtypes = np.dtype([(name, float) for name in field_names]) - if isinstance(data, (list, tuple)): - data = np.array(data, dtype=np.float64) - - if not isinstance(data, np.ndarray): - raise TypeError( - "data must be array-like of shape (n_points, 5). got {data}" - ) - if data.dtype.names == field_names: - # already a structured array - structured_array = data - else: - structured_array = unstructured_to_structured(data, dtype=dtypes) - assert structured_array.ndim == 1 - if not len(structured_array[0]) == 5: - raise ValueError( - f"Each column in data must have have 5 elements: got {data}" - ) - self["points"] = structured_array + if key in ("positions", "offsets", "gaze") and isinstance(value, (tuple, list)): + logger.info("Converting %s to numpy array", key) + value = np.array(value) + super().__setitem__(key, value) - def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): + def plot(self, title=None, show_offsets=False, origin="top-left", show=True): """Visualize calibration. Parameters @@ -243,10 +176,11 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): show_offsets : bool Whether to display the offset (in visual degrees) of each calibration point or not. Defaults to ``False``. - invert_y_axis : bool - Whether to invert the y-axis or not. In many monitors, pixel coordinate - (0,0), which is often referred to as origin, is at the top left of corner - of the screen. Defaults to ``True``. + origin : str + What should be considered the origin of the screen. Can be ``'top-left'``, + ``'top-right'``, ``'bottom-left'``, or ``'bottom-right'``. Defaults to + ``'top-left'`` because for most monitors, pixel coordinate ``(0,0)``, often + referred to as origin, is at the top left of corner of the screen. show : bool Whether to show the figure or not. Defaults to ``True``. @@ -257,14 +191,13 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): """ import matplotlib.pyplot as plt - if not isinstance(self["points"], np.ndarray): - raise TypeError( - "Calibration points must be a numpy array. Use " - "set_calibration_array() to set calibration data." - ) + 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 + fig, ax = plt.subplots(constrained_layout=True) - px, py = self["points"]["point_x"], self["points"]["point_y"] - gaze_x, gaze_y = self["points"]["gaze_x"], self["points"]["gaze_y"] + px, py = self["positions"].T + gaze_x, gaze_y = self["gaze"].T if title is None: ax.set_title(f"Calibration ({self['eye']} eye)") @@ -286,10 +219,21 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): fontsize=8, ) - if invert_y_axis: + msg = ( + "origin must be 'top-left', 'top-right', 'bottom-left', or 'bottom-right." + f" got {origin}" + ) + assert origin in ("top-left", "top-right", "bottom-left", "bottom-right"), msg + if origin == "top-left": # Invert the y-axis because origin is at the top left corner for most # monitors ax.invert_yaxis() + elif origin == "top-right": + ax.invert_yaxis() + ax.invert_xaxis() + elif origin == "bottom-right": + ax.invert_xaxis() + # if origin is 'bottom-left' no need to do anything ax.scatter(px, py, color="gray") ax.scatter(gaze_x, gaze_y, color="red", alpha=0.5) @@ -299,13 +243,13 @@ def plot(self, title=None, show_offsets=False, invert_y_axis=True, show=True): text = ax.text( x=gaze_x[i] + x_offset, y=gaze_y[i], - s=self["points"]["offset"][i], + s=self["offsets"][i], fontsize=8, ha="left", va="center", ) - fig.show() if show else None + plt_show(show) return fig @@ -319,14 +263,14 @@ def read_eyelink_calibration( ---------- fname : path-like Path to the eyelink file (.asc). - screen_size : array-like of shape (2,) + 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,) + 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``. diff --git a/mne/preprocessing/eyetracking/tests/test_calibration.py b/mne/preprocessing/eyetracking/tests/test_calibration.py index 2d2e15b4d38..1f49bce37ca 100644 --- a/mne/preprocessing/eyetracking/tests/test_calibration.py +++ b/mne/preprocessing/eyetracking/tests/test_calibration.py @@ -10,18 +10,11 @@ fname = testing_path / "eyetrack" / "test_eyelink.asc" # for test_create_calibration -test_points = [ - (115.0, 540.0, 0.42, 101.5, 554.8), - (960.0, 540.0, 0.23, 9.9, -4.1), - (1804.0, 540.0, 0.17, 1795.9, 539.0), -] -field_names = ["point_x", "point_y", "offset", "gaze_x", "gaze_y"] -dtypes = [(name, float) for name in field_names] -test_structured = np.array(test_points, dtype=dtypes) -test_lists = [list(point) for point in test_points] -test_array_2d = np.array(test_lists) - -expected_repr = ( +POSITIONS = [[115.0, 540.0], [960.0, 540.0], [1804.0, 540.0]] +OFFSETS = [0.42, 0.23, 0.17] +GAZES = [[101.5, 554.8], [9.9, -4.1], [1795.9, 539.0]] + +EXPECTED_REPR = ( "Calibration |\n" " onset: 0 seconds\n" " model: H3\n" @@ -36,35 +29,24 @@ @pytest.mark.parametrize( ( - "onset, model, eye, avg_error, max_error, points, screen_size, screen_distance," - " screen_resolution" + "onset, model, eye, avg_error, max_error, positions, offsets, gaze," + " screen_size, screen_distance, screen_resolution" ), [ - (0, "H3", "right", 0.5, 1.0, test_points, (0.531, 0.298), 0.065, (1920, 1080)), ( 0, "H3", "right", 0.5, 1.0, - test_structured, + POSITIONS, + OFFSETS, + GAZES, (0.531, 0.298), 0.065, (1920, 1080), ), - (0, "H3", "right", 0.5, 1.0, test_lists, (0.531, 0.298), 0.065, (1920, 1080)), - ( - 0, - "H3", - "right", - 0.5, - 1.0, - test_array_2d, - (0.531, 0.298), - 0.065, - (1920, 1080), - ), - (None, None, None, None, None, None, None, None, None), + (None, None, None, None, None, None, None, None, None, None, None), ], ) def test_create_calibration( @@ -73,7 +55,9 @@ def test_create_calibration( eye, avg_error, max_error, - points, + positions, + offsets, + gaze, screen_size, screen_distance, screen_resolution, @@ -85,7 +69,9 @@ def test_create_calibration( eye=eye, avg_error=avg_error, max_error=max_error, - points=points, + positions=positions, + offsets=offsets, + gaze=gaze, screen_size=screen_size, screen_distance=screen_distance, screen_resolution=screen_resolution, @@ -96,17 +82,24 @@ def test_create_calibration( assert cal["eye"] == eye assert cal["avg_error"] == avg_error assert cal["max_error"] == max_error - if points is not None: - assert np.array_equal(cal["points"], test_structured) + if positions is not None: + assert isinstance(cal["positions"], np.ndarray) + assert np.array_equal(cal["positions"], np.array(POSITIONS)) else: - assert cal["points"] is None + assert cal["positions"] is None + if offsets is None: + # test setting offsets with __set_item__ + cal["offsets"] = OFFSETS + assert isinstance(cal["offsets"], np.ndarray) + assert np.array_equal(cal["offsets"], np.array(OFFSETS)) + if gaze is None: + # test setting gaze with __set_item__ + cal["gaze"] = GAZES + assert isinstance(cal["gaze"], np.ndarray) + assert np.array_equal(cal["gaze"], np.array(GAZES)) assert cal["screen_size"] == screen_size assert cal["screen_distance"] == screen_distance assert cal["screen_resolution"] == screen_resolution - # test __getattr__ - assert cal.onset == cal["onset"] - with pytest.raises(AttributeError): - assert cal.fake_key # test copy method copied_obj = cal.copy() # Check if the copied object is an instance of Calibration @@ -118,7 +111,7 @@ def test_create_calibration( assert copied_obj["onset"] != cal["onset"] # test __repr__ if cal["onset"] is not None: - assert repr(cal) == expected_repr # test __repr__ + assert repr(cal) == EXPECTED_REPR # test __repr__ @requires_testing_data @@ -126,60 +119,87 @@ def test_create_calibration( def test_read_calibration(fname): """Test reading calibration data from an eyelink asc file.""" calibrations = read_eyelink_calibration(fname) - expected_x_left = np.array( - [ - 960.0, - 960.0, - 960.0, - 115.0, - 1804.0, - 216.0, - 1703.0, - 216.0, - 1703.0, - 537.0, - 1382.0, - 537.0, - 1382.0, - ] + # These numbers were pulled from the file and confirmed. + POSITIONS_L = ( + [960, 540], + [960, 92], + [960, 987], + [115, 540], + [1804, 540], + [216, 145], + [1703, 145], + [216, 934], + [1703, 934], + [537, 316], + [1382, 316], + [537, 763], + [1382, 763], ) - expected_y_right = np.array( - [ - 540.0, - 92.0, - 987.0, - 540.0, - 540.0, - 145.0, - 145.0, - 934.0, - 934.0, - 316.0, - 316.0, - 763.0, - 763.0, - ] + + DIFF_L = ( + [9.9, -4.1], + [-7.8, 16.0], + [-1.9, -14.2], + [13.5, -14.8], + [8.1, 1.0], + [-7.0, -15.4], + [-10.1, -1.4], + [-0.3, 6.9], + [-32.3, -28.1], + [8.2, 7.6], + [9.6, 2.1], + [-10.6, -2.0], + [-11.8, 8.4], ) - expected_gaze_y_left = np.array( - [ - 544.1, - 76.0, - 1001.2, - 554.8, - 539.0, - 160.4, - 146.4, - 927.1, - 962.1, - 308.4, - 313.9, - 765.0, - 754.6, - ] + GAZE_L = np.array(POSITIONS_L) + np.array(DIFF_L) + + POSITIONS_R = ( + [960, 540], + [960, 92], + [960, 987], + [115, 540], + [1804, 540], + [216, 145], + [1703, 145], + [216, 934], + [1703, 934], + [537, 316], + [1382, 316], + [537, 763], + [1382, 763], ) - expected_offset_right = np.array( - [0.36, 0.5, 0.2, 0.1, 0.3, 0.38, 0.13, 0.33, 0.22, 0.18, 0.34, 0.52, 0.21] + DIFF_R = ( + [-5.2, -16.1], + [23.7, 1.3], + [2.0, -9.3], + [4.4, 1.5], + [-6.5, -12.7], + [16.6, -7.5], + [5.7, -1.8], + [15.4, -3.5], + [-2.0, -10.2], + [0.1, 8.3], + [1.9, -15.8], + [-24.8, -2.3], + [3.2, -9.2], ) + GAZE_R = np.array(POSITIONS_R) + np.array(DIFF_R) + + OFFSETS_R = [ + 0.36, + 0.50, + 0.20, + 0.10, + 0.30, + 0.38, + 0.13, + 0.33, + 0.22, + 0.18, + 0.34, + 0.52, + 0.21, + ] assert len(calibrations) == 2 # calibration[0] is left, calibration[1] is right assert calibrations[0]["onset"] == 0 @@ -192,15 +212,19 @@ def test_read_calibration(fname): assert calibrations[0]["max_error"] == 0.90 assert calibrations[1]["avg_error"] == 0.31 assert calibrations[1]["max_error"] == 0.52 - assert calibrations[0]["points"]["point_x"] == pytest.approx(expected_x_left) - assert calibrations[1]["points"]["point_y"] == pytest.approx(expected_y_right) - assert calibrations[0]["points"]["gaze_y"] == pytest.approx(expected_gaze_y_left) - assert calibrations[1]["points"]["offset"] == pytest.approx(expected_offset_right) + assert np.array_equal(POSITIONS_L, calibrations[0]["positions"]) + assert np.array_equal(POSITIONS_R, calibrations[1]["positions"]) + assert np.array_equal(GAZE_L, calibrations[0]["gaze"]) + assert np.array_equal(GAZE_R, calibrations[1]["gaze"]) + assert np.array_equal(OFFSETS_R, calibrations[1]["offsets"]) @requires_testing_data -@pytest.mark.parametrize("fname", [(fname)]) -def test_plot_calibration(fname): +@pytest.mark.parametrize( + "fname, origin", + [(fname, "top-left"), (fname, "top-right"), (fname, "bottom-right")], +) +def test_plot_calibration(fname, origin): """Test plotting calibration data.""" import matplotlib.pyplot as plt @@ -209,15 +233,15 @@ def test_plot_calibration(fname): calibrations = read_eyelink_calibration(fname) cal_left = calibrations[0] - fig = cal_left.plot(show=True, show_offsets=True) + fig = cal_left.plot(show=True, show_offsets=True, origin=origin) ax = fig.axes[0] scatter1 = ax.collections[0] scatter2 = ax.collections[1] - px, py = cal_left.points["point_x"], cal_left.points["point_y"] - gaze_x, gaze_y = cal_left.points["gaze_x"], cal_left.points["gaze_y"] + px, py = cal_left["positions"].T + gaze_x, gaze_y = cal_left["gaze"].T - assert ax.title.get_text() == f"Calibration ({cal_left.eye} eye)" + assert ax.title.get_text() == f"Calibration ({cal_left['eye']} eye)" assert len(ax.collections) == 2 # Two scatter plots assert np.allclose(scatter1.get_offsets(), np.column_stack((px, py))) diff --git a/tutorials/preprocessing/90_eyetracking_data.py b/tutorials/preprocessing/90_eyetracking_data.py index ac29cade4a4..917a924c4de 100644 --- a/tutorials/preprocessing/90_eyetracking_data.py +++ b/tutorials/preprocessing/90_eyetracking_data.py @@ -61,22 +61,24 @@ # %% # Here we can see that a 5-point calibration was performed at the beginning of -# the recording. Note that you can access the calibration information directly -# as you would a dictionary: +# the recording. Note that you can access the calibration information using +# dictionary style indexing: print(f"Eye calibrated: {first_cal['eye']}") +print(f"Calibration model: {first_cal['model']}") print(f"Calibration average error: {first_cal['avg_error']}") -print(f"Calibration data: {repr(first_cal['points'])})") # %% -# The calibration points are stored as a :class:`numpy.ndarray`. Each element contains -# data for a single calibration point: the x-coordinate and y-coordinate of the point's -# position, the offset, and the x-coordinate and y-coordinate of the actual gaze -# position. You can access the data for a specific calibration point by indexing the -# array, or you can access a specific field for all calibration points by indexing the -# field name. For example: -print(f"data for the first point only: {first_cal['points'][0]}") -print(f"offset for each calibration point: {first_cal['points']['offset']}") +# The data for individual calibration points are stored as :class:`numpy.ndarray` +# arrays, in the ``'positions'``, ``'gaze'``, and ``'offsets'`` keys. ``'positions'`` +# contains the x and y coordinates of each calibration point. ``'gaze'`` contains the +# x and y coordinates of the actual gaze position for each calibration point. +# ``'offsets'`` contains the offset (in visual degrees) between the calibration position +# and the actual gaze position for each calibration point. Below is an example of +# how to access these data: +print(f"offset of the first calibration point: {first_cal['offsets'][0]}") +print(f"offset for each calibration point: {first_cal['offsets']}") +print(f"x-coordinate for each calibration point: {first_cal['positions'].T[0]}") # %% # Let's plot the calibration to get a better look. We'll pass From b2564549173b0bedc232795ad628d52f9f684869 Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Thu, 15 Jun 2023 14:03:21 -0400 Subject: [PATCH 43/52] DOC: add bug fix to change log --- doc/changes/latest.inc | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/changes/latest.inc b/doc/changes/latest.inc index 42eb4f92eb8..afa8660d81d 100644 --- a/doc/changes/latest.inc +++ b/doc/changes/latest.inc @@ -34,6 +34,7 @@ Bugs - Fix bug with PySide6 compatibility (:gh:`11721` by `Eric Larson`_) - Fix hanging interpreter with matplotlib figures using ``mne/viz/_mpl_figure.py`` in spyder console and jupyter notebooks`(:gh:`11696` by `Mathieu Scheltienne`_) - Fix bug with overlapping text for :meth:`mne.Evoked.plot` (:gh:`11698` by `Alex Rockhill`_) +- Deprecated ``gap_description`` kwarg of :func:`mne.io.read_raw_eyelink` and changed default value to ``'BAD_ACQ_SKIP'`` (:gh:`11719` by `Scott Huberty`_) API changes ~~~~~~~~~~~ From c0f8bac03486bd63e006defd2e387db1d29cb3f9 Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Thu, 15 Jun 2023 16:44:09 -0400 Subject: [PATCH 44/52] Update doc/changes/latest.inc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Richard Höchenberger --- doc/changes/latest.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changes/latest.inc b/doc/changes/latest.inc index e80f313c463..c4212eba330 100644 --- a/doc/changes/latest.inc +++ b/doc/changes/latest.inc @@ -36,7 +36,7 @@ Bugs - Fix bug with PySide6 compatibility (:gh:`11721` by `Eric Larson`_) - Fix hanging interpreter with matplotlib figures using ``mne/viz/_mpl_figure.py`` in spyder console and jupyter notebooks`(:gh:`11696` by `Mathieu Scheltienne`_) - Fix bug with overlapping text for :meth:`mne.Evoked.plot` (:gh:`11698` by `Alex Rockhill`_) -- Deprecated ``gap_description`` kwarg of :func:`mne.io.read_raw_eyelink` and changed default value to ``'BAD_ACQ_SKIP'`` (:gh:`11719` by `Scott Huberty`_) +- Deprecated ``gap_description`` keyword argument of :func:`mne.io.read_raw_eyelink` and changed default value to ``'BAD_ACQ_SKIP'`` (:gh:`11719` by `Scott Huberty`_) API changes ~~~~~~~~~~~ From 48fdd2ed469ab30025028ec902f3a726438cd3bd Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Fri, 16 Jun 2023 08:35:01 -0400 Subject: [PATCH 45/52] DOC: Add API change to change log - described that we are deprecating the gap_description kwarg of read_raw_eyelink --- doc/changes/latest.inc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/changes/latest.inc b/doc/changes/latest.inc index c4212eba330..a03f5ec83a9 100644 --- a/doc/changes/latest.inc +++ b/doc/changes/latest.inc @@ -36,8 +36,9 @@ Bugs - Fix bug with PySide6 compatibility (:gh:`11721` by `Eric Larson`_) - Fix hanging interpreter with matplotlib figures using ``mne/viz/_mpl_figure.py`` in spyder console and jupyter notebooks`(:gh:`11696` by `Mathieu Scheltienne`_) - Fix bug with overlapping text for :meth:`mne.Evoked.plot` (:gh:`11698` by `Alex Rockhill`_) -- Deprecated ``gap_description`` keyword argument of :func:`mne.io.read_raw_eyelink` and changed default value to ``'BAD_ACQ_SKIP'`` (:gh:`11719` by `Scott Huberty`_) +- For :func:`mne.io.read_raw_eyelink`, the default value of the ``gap_description`` parameter is now ``'BAD_ACQ_SKIP'``, following MNE convention (:gh:`11719` by `Scott Huberty`_) API changes ~~~~~~~~~~~ - The ``baseline`` argument can now be array-like (e.g. ``list``, ``tuple``, ``np.ndarray``, ...) instead of only a ``tuple`` (:gh:`11713` by `Clemens Brunner`_) +- Deprecated ``gap_description`` keyword argument of :func:`mne.io.read_raw_eyelink`, which will be removed in mne version 1.6, in favor of using :func:`mne.Annotations.rename` (:gh:`11719` by `Scott Huberty`_) \ No newline at end of file From 5b437fad73afb83d6c6465a586e478d789c07fec Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Fri, 16 Jun 2023 14:03:56 -0400 Subject: [PATCH 46/52] Update doc/changes/latest.inc Co-authored-by: Eric Larson --- doc/changes/latest.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/changes/latest.inc b/doc/changes/latest.inc index a03f5ec83a9..e551df0fa05 100644 --- a/doc/changes/latest.inc +++ b/doc/changes/latest.inc @@ -41,4 +41,4 @@ Bugs API changes ~~~~~~~~~~~ - The ``baseline`` argument can now be array-like (e.g. ``list``, ``tuple``, ``np.ndarray``, ...) instead of only a ``tuple`` (:gh:`11713` by `Clemens Brunner`_) -- Deprecated ``gap_description`` keyword argument of :func:`mne.io.read_raw_eyelink`, which will be removed in mne version 1.6, in favor of using :func:`mne.Annotations.rename` (:gh:`11719` by `Scott Huberty`_) \ No newline at end of file +- Deprecated ``gap_description`` keyword argument of :func:`mne.io.read_raw_eyelink`, which will be removed in mne version 1.6, in favor of using :meth:`mne.Annotations.rename` (:gh:`11719` by `Scott Huberty`_) \ No newline at end of file From 86d013807f568ca735ebda4714196ef133d67f2e Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Fri, 16 Jun 2023 14:14:17 -0400 Subject: [PATCH 47/52] Apply suggestions from code review 2 more of Eric's suggestions. 1 remaining that has not been added just yet. Co-authored-by: Eric Larson --- mne/io/eyelink/eyelink.py | 3 ++- mne/preprocessing/eyetracking/calibration.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index 2e0df542def..626ad875b85 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -330,7 +330,8 @@ def read_raw_eyelink( times of the left and right eyes are separated by less than 50 ms, then the blink will be merged into a single :class:`mne.Annotations`. gap_description : str (default 'BAD_ACQ_SKIP') - If there are multiple recording blocks in the file, the description of + This parameter is deprecated and will be removed in 1.6. + Use :meth:`mne.Annotations.rename` instead. the annotation that will span across the gap period between the blocks. Uses 'BAD_ACQ_SKIP' by default so that these time periods will be considered bad by MNE and excluded from operations like epoching. diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index d23ea36550b..7be38bcc995 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -166,7 +166,7 @@ def __setitem__(self, key, value): value = np.array(value) super().__setitem__(key, value) - def plot(self, title=None, show_offsets=False, origin="top-left", show=True): + def plot(self, title=None, show_offsets=True, origin="top-left", show=True): """Visualize calibration. Parameters From 1184215bddb48fe8ea96dfc05c9ef678d361eddf Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Sat, 17 Jun 2023 09:48:55 -0400 Subject: [PATCH 48/52] FIX: add _check_option import --- mne/preprocessing/eyetracking/calibration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index 7be38bcc995..255436bdba8 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -9,7 +9,7 @@ import numpy as np -from ...utils import _check_fname, fill_doc, logger +from ...utils import _check_fname, _check_option, fill_doc, logger from ...viz.utils import plt_show From 5781a599a8197de460f7539276037eb5ef448fe5 Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Sat, 17 Jun 2023 09:49:59 -0400 Subject: [PATCH 49/52] Apply suggestions from code review more suggestions from Eric Co-authored-by: Eric Larson --- mne/preprocessing/eyetracking/calibration.py | 60 +++++--------------- 1 file changed, 13 insertions(+), 47 deletions(-) diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index 255436bdba8..f27c2af8c63 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -59,53 +59,19 @@ class Calibration(dict): The resolution (in pixels) of the screen that the eyetracking data was collected with. For example, ``(1920, 1080)`` for a 1920x1080 resolution display. - - Attributes - ---------- - onset : float - The onset of the calibration in seconds. If the calibration was - performed before the recording started, the onset will be ``0`` seconds. - model : str - A string, which is the model of the 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 points and the actual gaze - position. - max_error : float - The maximum error in degrees that occurred between the calibration points and - the actual gaze position. - positions : ndarray of float, shape ``(n_calibration_points, 2)`` - The x and y coordinates of the calibration points. - offsets : ndarray of float, shape ``(n_calibration_points,)`` - The error in degrees between the calibration position and the actual - gaze position for each calibration point. - gaze : ndarray 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 - 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 - 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=None, - model=None, - eye=None, - avg_error=None, - max_error=None, - positions=None, - offsets=None, - gaze=None, + *, + onset, + model, + eye, + avg_error, + max_error, + positions, + offsets, + gaze, screen_size=None, screen_distance=None, screen_resolution=None, @@ -119,10 +85,10 @@ def __init__( screen_size=screen_size, screen_distance=screen_distance, screen_resolution=screen_resolution, + positions=positions, + offsets=offsets, + gaze=gaze, ) - self["positions"] = positions - self["offsets"] = offsets - self["gaze"] = gaze def __repr__(self): """Return a summary of the Calibration object.""" @@ -223,7 +189,7 @@ def plot(self, title=None, show_offsets=True, origin="top-left", show=True): "origin must be 'top-left', 'top-right', 'bottom-left', or 'bottom-right." f" got {origin}" ) - assert origin in ("top-left", "top-right", "bottom-left", "bottom-right"), msg + _check_option('origin', origin, ("top-left", "top-right", "bottom-left", "bottom-right")) if origin == "top-left": # Invert the y-axis because origin is at the top left corner for most # monitors From c7d7dd9126b232715431a148d95e8b896a43d14f Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Sat, 17 Jun 2023 11:00:06 -0400 Subject: [PATCH 50/52] FIX: add Eric's suggestions - don't use custom __set_item__ dunder method - make most Calibration params positional - and now just use f string formatting in __repr__ - add notes section to read_raw_eyelink about bad_acq_skip - remove private attribute docstring from read_raw_eyelink - updated tests - updated doc because show_offsets defaults to true --- mne/io/eyelink/_utils.py | 41 ++++++++++--------- mne/io/eyelink/eyelink.py | 34 ++++++--------- mne/preprocessing/eyetracking/calibration.py | 40 ++++++------------ .../eyetracking/tests/test_calibration.py | 22 ++++------ .../preprocessing/90_eyetracking_data.py | 7 ++-- 5 files changed, 58 insertions(+), 86 deletions(-) diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index 014017e94a5..533d232068a 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -70,27 +70,15 @@ def _parse_calibration( if ( "!CAL VALIDATION " in line and "ABORTED" not in line ): # Start of a calibration - calibration = Calibration( - screen_size=screen_size, - screen_distance=screen_distance, - screen_resolution=screen_resolution, - ) tokens = line.split() - this_eye = tokens[6].lower() - assert this_eye in ["left", "right"], this_eye - calibration["model"] = tokens[4] # e.g. 'HV13' - assert calibration["model"].startswith("H") - calibration["eye"] = this_eye + model = tokens[4] # e.g. 'HV13' + this_eye = tokens[6].lower() # e.g. 'left' timestamp = float(tokens[1]) onset = (timestamp - rec_start) / 1000.0 # in seconds - calibration["onset"] = 0 if onset < 0 else onset - avg_error = float(line.split("avg.")[0].split()[-1]) # e.g. 0.3 max_error = float(line.split("max")[0].split()[-1]) # e.g. 0.9 - calibration["avg_error"] = avg_error - calibration["max_error"] = max_error - n_points = int(regex.search(calibration["model"]).group()) # e.g. 13 + n_points = int(regex.search(model).group()) # e.g. 13 n_points *= 2 if "LR" in line else 1 # one point per eye if "LR" # The next n_point lines contain the validation data points = [] @@ -98,15 +86,28 @@ def _parse_calibration( subline = lines[line_number + validation_index + 1] if "!CAL VALIDATION" in subline: continue # for bino mode, skip the second eye's validation summary - subline_eye = subline.split("at")[0].split()[-1].lower() - assert subline_eye in ["left", "right"], subline_eye + subline_eye = subline.split("at")[0].split()[-1].lower() # e.g. 'left' if subline_eye != this_eye: continue # skip the validation lines for the other eye point_info = _parse_validation_line(subline) points.append(point_info) # Convert the list of validation data into a numpy array - calibration["positions"] = np.array([point[:2] for point in points]) - calibration["offsets"] = np.array([point[2] for point in points]) - calibration["gaze"] = np.array([point[3:] for point in points]) + positions = np.array([point[:2] for point in points]) + offsets = np.array([point[2] for point in points]) + gaze = np.array([point[3:] for point in points]) + # create the Calibration instance + calibration = Calibration( + onset=0 if onset < 0 else onset, # 0 if calibrated before recording + model=model, + eye=this_eye, + avg_error=avg_error, + max_error=max_error, + positions=positions, + offsets=offsets, + gaze=gaze, + screen_size=screen_size, + screen_distance=screen_distance, + screen_resolution=screen_resolution, + ) calibrations.append(calibration) return calibrations diff --git a/mne/io/eyelink/eyelink.py b/mne/io/eyelink/eyelink.py index 626ad875b85..5321ddc136d 100644 --- a/mne/io/eyelink/eyelink.py +++ b/mne/io/eyelink/eyelink.py @@ -333,7 +333,7 @@ def read_raw_eyelink( This parameter is deprecated and will be removed in 1.6. Use :meth:`mne.Annotations.rename` instead. the annotation that will span across the gap period between the - blocks. Uses 'BAD_ACQ_SKIP' by default so that these time periods will + blocks. Uses ``'BAD_ACQ_SKIP'`` by default so that these time periods will be considered bad by MNE and excluded from operations like epoching. Returns @@ -344,6 +344,14 @@ def read_raw_eyelink( See Also -------- mne.io.Raw : Documentation of attribute and methods. + + Notes + ----- + It is common for SR Research Eyelink eye trackers to only record data during trials. + To avoid frequent data discontinuities and to ensure that the data is continuous + so that it can be aligned with EEG and MEG data (if applicable), this reader will + preserve the times between recording trials and annotate them with + ``'BAD_ACQ_SKIP'``. """ fname = _check_fname(fname, overwrite="read", must_exist=True, name="fname") extension = fname.suffix @@ -415,24 +423,6 @@ class RawEyelink(BaseRaw): dataframes : dict Dictionary of pandas DataFrames. One for eyetracking samples, and one for each type of eyelink event (blinks, messages, etc) - _sample_lines : list - List of lists, each list is one sample containing eyetracking - X/Y and pupil channel data (+ other channels, if they exist) - _event_lines : dict - Each key contains a list of lists, for an event-type that occurred - during the recording period. Events can vary, from occular events - (blinks, saccades, fixations), to messages from the stimulus - presentation software, or info from a response controller. - _system_lines : list - List of tab delimited strings. Each string is a system message, - that in most cases aren't needed. System messages occur for - Eyelinks DataViewer application. - _tracking_mode : str - Whether whether a single eye was tracked ('monocular'), or both - ('binocular'). - _gap_desc : str - The description to be used for annotations returned by _make_gap_annots. - Deprecated and will be removed in 1.6. Use ``mne.annotations.rename`` See Also -------- @@ -454,9 +444,9 @@ def __init__( logger.info("Loading {}".format(fname)) self.fname = Path(fname) - self._sample_lines = None - self._event_lines = None - self._system_lines = None + self._sample_lines = None # sample lines from file + self._event_lines = None # event messages from file + self._system_lines = None # unparsed lines of system messages from file self._tracking_mode = None # assigned in self._infer_col_names self._meas_date = None self._rec_info = None diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index f27c2af8c63..71c2bc416f0 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -92,24 +92,16 @@ def __init__( def __repr__(self): """Return a summary of the Calibration object.""" - onset = self.get("onset", "N/A") - model = self.get("model", "N/A") - eye = self.get("eye", "N/A") - avg_error = self.get("avg_error", "N/A") - max_error = self.get("max_error", "N/A") - screen_size = self.get("screen_size", "N/A") - screen_distance = self.get("screen_distance", "N/A") - screen_resolution = self.get("screen_resolution", "N/A") return ( f"Calibration |\n" - f" onset: {onset} seconds\n" - f" model: {model}\n" - f" eye: {eye}\n" - f" average error: {avg_error} degrees\n" - f" max error: {max_error} degrees\n" - f" screen size: {screen_size} meters\n" - f" screen distance: {screen_distance} meters\n" - f" screen resolution: {screen_resolution} pixels\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): @@ -122,16 +114,6 @@ def copy(self): """ return deepcopy(self) - def __setitem__(self, key, value): - """Make sure that some keys are caste as numpy arrays. - - Because methods like plot expect numpy arrays. - """ - if key in ("positions", "offsets", "gaze") and isinstance(value, (tuple, list)): - logger.info("Converting %s to numpy array", key) - value = np.array(value) - super().__setitem__(key, value) - def plot(self, title=None, show_offsets=True, origin="top-left", show=True): """Visualize calibration. @@ -141,7 +123,7 @@ def plot(self, title=None, show_offsets=True, origin="top-left", show=True): The title to be displayed. Defaults to ``None``, which uses a generic title. show_offsets : bool Whether to display the offset (in visual degrees) of each calibration - point or not. Defaults to ``False``. + point or not. Defaults to ``True``. origin : str What should be considered the origin of the screen. Can be ``'top-left'``, ``'top-right'``, ``'bottom-left'``, or ``'bottom-right'``. Defaults to @@ -189,7 +171,9 @@ def plot(self, title=None, show_offsets=True, origin="top-left", show=True): "origin must be 'top-left', 'top-right', 'bottom-left', or 'bottom-right." f" got {origin}" ) - _check_option('origin', origin, ("top-left", "top-right", "bottom-left", "bottom-right")) + _check_option( + "origin", origin, ("top-left", "top-right", "bottom-left", "bottom-right") + ) if origin == "top-left": # Invert the y-axis because origin is at the top left corner for most # monitors diff --git a/mne/preprocessing/eyetracking/tests/test_calibration.py b/mne/preprocessing/eyetracking/tests/test_calibration.py index 1f49bce37ca..83826f3db04 100644 --- a/mne/preprocessing/eyetracking/tests/test_calibration.py +++ b/mne/preprocessing/eyetracking/tests/test_calibration.py @@ -10,9 +10,9 @@ fname = testing_path / "eyetrack" / "test_eyelink.asc" # for test_create_calibration -POSITIONS = [[115.0, 540.0], [960.0, 540.0], [1804.0, 540.0]] -OFFSETS = [0.42, 0.23, 0.17] -GAZES = [[101.5, 554.8], [9.9, -4.1], [1795.9, 539.0]] +POSITIONS = np.array([[115.0, 540.0], [960.0, 540.0], [1804.0, 540.0]]) +OFFSETS = np.array([0.42, 0.23, 0.17]) +GAZES = np.array([[101.5, 554.8], [9.9, -4.1], [1795.9, 539.0]]) EXPECTED_REPR = ( "Calibration |\n" @@ -87,16 +87,12 @@ def test_create_calibration( assert np.array_equal(cal["positions"], np.array(POSITIONS)) else: assert cal["positions"] is None - if offsets is None: - # test setting offsets with __set_item__ - cal["offsets"] = OFFSETS - assert isinstance(cal["offsets"], np.ndarray) - assert np.array_equal(cal["offsets"], np.array(OFFSETS)) - if gaze is None: - # test setting gaze with __set_item__ - cal["gaze"] = GAZES - assert isinstance(cal["gaze"], np.ndarray) - assert np.array_equal(cal["gaze"], np.array(GAZES)) + if offsets is not None: + assert isinstance(cal["offsets"], np.ndarray) + assert np.array_equal(cal["offsets"], np.array(OFFSETS)) + if gaze is not None: + assert isinstance(cal["gaze"], np.ndarray) + assert np.array_equal(cal["gaze"], np.array(GAZES)) assert cal["screen_size"] == screen_size assert cal["screen_distance"] == screen_distance assert cal["screen_resolution"] == screen_resolution diff --git a/tutorials/preprocessing/90_eyetracking_data.py b/tutorials/preprocessing/90_eyetracking_data.py index 917a924c4de..07b6846f768 100644 --- a/tutorials/preprocessing/90_eyetracking_data.py +++ b/tutorials/preprocessing/90_eyetracking_data.py @@ -81,9 +81,10 @@ print(f"x-coordinate for each calibration point: {first_cal['positions'].T[0]}") # %% -# Let's plot the calibration to get a better look. We'll pass -# ``show_offsets=True`` to show the offsets (in visual degrees) between the -# calibration position and the actual gaze position of each calibration point. +# Let's plot the calibration to get a better look. Below we see the location that each +# calibration point was displayed (gray dots), the positions of the actual gaze (red), +# and the offsets (in visual degrees) between the calibration position and the actual +# gaze position of each calibration point. first_cal.plot(show_offsets=True) From 0e5133c3384eadd97924535bfa042b5cae50d40d Mon Sep 17 00:00:00 2001 From: Scott Huberty <52462026+scott-huberty@users.noreply.github.com> Date: Tue, 20 Jun 2023 13:07:42 -0400 Subject: [PATCH 51/52] Update mne/io/eyelink/_utils.py Don't force onset to be 0, allow negative time if before recording start. Co-authored-by: Eric Larson --- mne/io/eyelink/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index 533d232068a..c410d76c2f7 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -97,7 +97,7 @@ def _parse_calibration( gaze = np.array([point[3:] for point in points]) # create the Calibration instance calibration = Calibration( - onset=0 if onset < 0 else onset, # 0 if calibrated before recording + onset=max(0., onset), # 0 if calibrated before recording model=model, eye=this_eye, avg_error=avg_error, From 3d5d5d7de7875a91bedd4ba0b338d40e8cfab21b Mon Sep 17 00:00:00 2001 From: Scott Huberty Date: Tue, 20 Jun 2023 16:29:29 -0400 Subject: [PATCH 52/52] FIX, remove origin kwarg from plot method, add axes kwarg. doc upate and flake fixes --- mne/io/eyelink/_utils.py | 2 +- mne/preprocessing/eyetracking/calibration.py | 44 +++++++------------ .../eyetracking/tests/test_calibration.py | 10 +++-- 3 files changed, 24 insertions(+), 32 deletions(-) diff --git a/mne/io/eyelink/_utils.py b/mne/io/eyelink/_utils.py index c410d76c2f7..3e6cf76e2fe 100644 --- a/mne/io/eyelink/_utils.py +++ b/mne/io/eyelink/_utils.py @@ -97,7 +97,7 @@ def _parse_calibration( gaze = np.array([point[3:] for point in points]) # create the Calibration instance calibration = Calibration( - onset=max(0., onset), # 0 if calibrated before recording + onset=max(0.0, onset), # 0 if calibrated before recording model=model, eye=this_eye, avg_error=avg_error, diff --git a/mne/preprocessing/eyetracking/calibration.py b/mne/preprocessing/eyetracking/calibration.py index 71c2bc416f0..d6002eaa1f8 100644 --- a/mne/preprocessing/eyetracking/calibration.py +++ b/mne/preprocessing/eyetracking/calibration.py @@ -9,7 +9,7 @@ import numpy as np -from ...utils import _check_fname, _check_option, fill_doc, logger +from ...utils import _check_fname, _validate_type, fill_doc, logger from ...viz.utils import plt_show @@ -28,8 +28,8 @@ class Calibration(dict): ---------- onset : float The onset of the calibration in seconds. If the calibration was - performed before the recording started, then the onset should be - set to ``0`` seconds. + 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'`` @@ -114,7 +114,7 @@ def copy(self): """ return deepcopy(self) - def plot(self, title=None, show_offsets=True, origin="top-left", show=True): + def plot(self, title=None, show_offsets=True, axes=None, show=True): """Visualize calibration. Parameters @@ -124,11 +124,9 @@ def plot(self, title=None, show_offsets=True, origin="top-left", show=True): show_offsets : bool Whether to display the offset (in visual degrees) of each calibration point or not. Defaults to ``True``. - origin : str - What should be considered the origin of the screen. Can be ``'top-left'``, - ``'top-right'``, ``'bottom-left'``, or ``'bottom-right'``. Defaults to - ``'top-left'`` because for most monitors, pixel coordinate ``(0,0)``, often - referred to as origin, is at the top left of corner of the screen. + 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``. @@ -143,7 +141,14 @@ def plot(self, title=None, show_offsets=True, origin="top-left", show=True): assert isinstance(self["positions"], np.ndarray), msg assert isinstance(self["gaze"], np.ndarray), msg - fig, ax = plt.subplots(constrained_layout=True) + 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(constrained_layout=True) px, py = self["positions"].T gaze_x, gaze_y = self["gaze"].T @@ -167,23 +172,8 @@ def plot(self, title=None, show_offsets=True, origin="top-left", show=True): fontsize=8, ) - msg = ( - "origin must be 'top-left', 'top-right', 'bottom-left', or 'bottom-right." - f" got {origin}" - ) - _check_option( - "origin", origin, ("top-left", "top-right", "bottom-left", "bottom-right") - ) - if origin == "top-left": - # Invert the y-axis because origin is at the top left corner for most - # monitors - ax.invert_yaxis() - elif origin == "top-right": - ax.invert_yaxis() - ax.invert_xaxis() - elif origin == "bottom-right": - ax.invert_xaxis() - # if origin is 'bottom-left' no need to do anything + # 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) diff --git a/mne/preprocessing/eyetracking/tests/test_calibration.py b/mne/preprocessing/eyetracking/tests/test_calibration.py index 83826f3db04..21a0d8b35ea 100644 --- a/mne/preprocessing/eyetracking/tests/test_calibration.py +++ b/mne/preprocessing/eyetracking/tests/test_calibration.py @@ -217,19 +217,21 @@ def test_read_calibration(fname): @requires_testing_data @pytest.mark.parametrize( - "fname, origin", - [(fname, "top-left"), (fname, "top-right"), (fname, "bottom-right")], + "fname, axes", + [(fname, None), (fname, True)], ) -def test_plot_calibration(fname, origin): +def test_plot_calibration(fname, axes): """Test plotting calibration data.""" import matplotlib.pyplot as plt # Set the non-interactive backend plt.switch_backend("agg") + if axes: + axes = plt.subplot() calibrations = read_eyelink_calibration(fname) cal_left = calibrations[0] - fig = cal_left.plot(show=True, show_offsets=True, origin=origin) + fig = cal_left.plot(show=True, show_offsets=True, axes=axes) ax = fig.axes[0] scatter1 = ax.collections[0]