Skip to content

Commit

Permalink
ENH: Create a Calibrations class for eyetracking data (#11719)
Browse files Browse the repository at this point in the history
Co-authored-by: Eric Larson <[email protected]>
Co-authored-by: Mathieu Scheltienne <[email protected]>
Co-authored-by: Richard Höchenberger <[email protected]>
  • Loading branch information
4 people authored Jun 20, 2023
1 parent b0f8ce6 commit 35d3797
Show file tree
Hide file tree
Showing 13 changed files with 706 additions and 45 deletions.
3 changes: 3 additions & 0 deletions doc/changes/latest.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~
Expand All @@ -35,7 +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`_)
- 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 :meth:`mne.Annotations.rename` (:gh:`11719` by `Scott Huberty`_)
2 changes: 2 additions & 0 deletions doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@
r"\.fromkeys",
r"\.items",
r"\.keys",
r"\.move_to_end",
r"\.pop",
r"\.popitem",
r"\.setdefault",
Expand Down Expand Up @@ -735,6 +736,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.*"),
Expand Down
2 changes: 1 addition & 1 deletion doc/file_io.rst
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ Base class:
:toctree: generated
:template: autosummary/class_no_members.rst

BaseEpochs
BaseEpochs
2 changes: 2 additions & 0 deletions doc/preprocessing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ Projections:
.. autosummary::
:toctree: generated/

Calibration
read_eyelink_calibration
set_channel_types_eyetrack

EEG referencing:
Expand Down
3 changes: 2 additions & 1 deletion mne/io/eyelink/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Module for loading Eye-Tracker data."""

# Author: Dominik Welke <[email protected]>
# Authors: Dominik Welke <[email protected]>
# Scott Huberty <[email protected]>
#
# License: BSD-3-Clause

Expand Down
113 changes: 113 additions & 0 deletions mne/io/eyelink/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Helper functions for reading eyelink ASCII files."""
# Authors: Scott Huberty <[email protected]>
# License: BSD-3-Clause

import re
import numpy as np


def _find_recording_start(lines):
"""Return the first START line in an SR Research 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 list of tuples containing the validation data.
"""
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]]
vals[3] += vals[0] # pos_x + eye_x i.e. 960 + -1.5
vals[4] += vals[1] # pos_y + eye_y

return tuple(vals)


def _parse_calibration(
lines, screen_size=None, screen_distance=None, screen_resolution=None
):
"""Parse the lines in the given list and returns a list of Calibration instances.
Parameters
----------
lines: A list of strings, which are The lines in an eyelink ASCII file.
Returns
-------
A list containing one or more Calibration instances,
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])

for line_number, line in enumerate(lines):
if (
"!CAL VALIDATION " in line and "ABORTED" not in line
): # Start of a calibration
tokens = line.split()
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
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

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 = []
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() # 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
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=max(0.0, 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
93 changes: 53 additions & 40 deletions mne/io/eyelink/eyelink.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""SR Research Eyelink Load Function."""

# Authors: Dominik Welke <[email protected]>
# Scott Huberty <[email protected]>
# Christian O'Reilly <[email protected]>
Expand All @@ -12,7 +14,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",),
Expand Down Expand Up @@ -293,13 +295,13 @@ def read_raw_eyelink(
apply_offsets=False,
find_overlaps=False,
overlap_threshold=0.05,
gap_description="bad_rec_gap",
gap_description=None,
):
"""Reader for an Eyelink .asc file.
Parameters
----------
fname : str
fname : path-like
Path to the eyelink file (.asc).
%(preload)s
%(verbose)s
Expand All @@ -318,15 +320,20 @@ 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')
If there are multiple recording blocks in the file, the description of
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')
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_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.
Returns
Expand All @@ -337,17 +344,26 @@ 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'``.
"""
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."
)

return RawEyelink(
raw_eyelink = RawEyelink(
fname,
preload=preload,
verbose=verbose,
Expand All @@ -357,6 +373,7 @@ def read_raw_eyelink(
overlap_threshold=overlap_threshold,
gap_desc=gap_description,
)
return raw_eyelink


@fill_doc
Expand All @@ -365,7 +382,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
Expand All @@ -387,11 +404,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_rec_gap')
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_rec_gap' 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
Expand All @@ -402,23 +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
See Also
--------
Expand All @@ -435,17 +439,26 @@ def __init__(
apply_offsets=False,
find_overlaps=False,
overlap_threshold=0.05,
gap_desc="bad_rec_gap",
gap_desc=None,
):
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
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 = {}

Expand Down
5 changes: 4 additions & 1 deletion mne/io/eyelink/tests/test_eyelink.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def test_eyetrack_not_data_ch():
@pytest.mark.parametrize(
"fname, create_annotations, find_overlaps",
[
(fname, False, False),
(fname, False, False),
(fname, True, False),
(fname, True, True),
Expand All @@ -37,7 +38,9 @@ def test_eyetrack_not_data_ch():
def test_eyelink(fname, create_annotations, find_overlaps):
"""Test reading eyelink asc files."""
raw = read_raw_eyelink(
fname, create_annotations=create_annotations, find_overlaps=find_overlaps
fname,
create_annotations=create_annotations,
find_overlaps=find_overlaps,
)

# First, tests that shouldn't change based on function arguments
Expand Down
1 change: 1 addition & 0 deletions mne/preprocessing/eyetracking/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
# License: BSD-3-Clause

from .eyetracking import set_channel_types_eyetrack
from .calibration import Calibration, read_eyelink_calibration
Loading

0 comments on commit 35d3797

Please sign in to comment.