Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Create a Calibrations class for eyetracking data #11719

Merged
merged 56 commits into from
Jun 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
5fb94fd
ENH: Create a Calibrations class for eyetracking and reader function …
scott-huberty Jun 3, 2023
444f6d8
FIX, DOC: please review. add read_eyelink_calibration to mne namespace
scott-huberty Jun 5, 2023
48a946c
ENH, STY: Refactor read_eyelink_calibration and update test
scott-huberty Jun 5, 2023
000bc5c
ENH, STY: Remove Calibrations class in favor of simple list. Please R…
scott-huberty Jun 5, 2023
1863b24
ENH: Add plot method and a test
scott-huberty Jun 5, 2023
e1115c1
FIX: Incorporate Britta's suggested revisions.
scott-huberty Jun 5, 2023
ea80885
ENH, STY: refactor code to create Calibration.points array
scott-huberty Jun 5, 2023
7fac97e
FIX: nest matplotlib import under plot method
scott-huberty Jun 5, 2023
71ccbac
FIX, DOC: Fixes to docstring that were raised by test_docstring_param…
scott-huberty Jun 5, 2023
2bfff19
FIX, DOC: More docstring error fixes
scott-huberty Jun 5, 2023
ec13a2b
ENH, DOC: Nest Calibration class under mne.preprocessing.eyetracking
scott-huberty Jun 6, 2023
1d09b00
Update mne/io/eyelink/_utils.py
scott-huberty Jun 6, 2023
dd25b35
Update mne/io/eyelink/_utils.py
scott-huberty Jun 6, 2023
ca01ed8
FIX: black error
scott-huberty Jun 6, 2023
f484759
FIX, DOC: More docstring error fixes, hopefully
scott-huberty Jun 6, 2023
90fbdd2
FIX, DOC: more sphinx build fixes
scott-huberty Jun 6, 2023
604e981
ENH: additional parameters to calibration.plot method
scott-huberty Jun 6, 2023
57339b9
DOC: use no_inherited_members template for Calibration class
scott-huberty Jun 6, 2023
03f5b6b
Revert "DOC: use no_inherited_members template for Calibration class"
scott-huberty Jun 6, 2023
39d9357
Update mne/io/eyelink/eyelink.py
scott-huberty Jun 7, 2023
156c2bd
Update mne/io/eyelink/eyelink.py
scott-huberty Jun 7, 2023
e5fecea
Update mne/io/eyelink/eyelink.py
scott-huberty Jun 7, 2023
ebcd020
Merge branch 'main' into et_calibration
larsoner Jun 7, 2023
87320fb
FIX, DOC: Docstring fixes suggested by Mathieu
scott-huberty Jun 7, 2023
781bc6f
Merge branch 'et_calibration' of github.com:scott-huberty/mne-python …
scott-huberty Jun 7, 2023
ca9b156
FIX: Remove Cruft
scott-huberty Jun 7, 2023
5015c9b
FIX, DOC: apply suggestions made by Richard H
scott-huberty Jun 7, 2023
17eca8d
FIX: Move mne.io.eyelink.read_eyelink_calibraiton to mne.preprocessin…
scott-huberty Jun 9, 2023
296794b
DOC: Added PR to changelog
scott-huberty Jun 9, 2023
11ec6b8
FIX, DOC: make calibration['points'] more user friendly and increase …
scott-huberty Jun 12, 2023
cda0e09
FIX: build_doc warnings caused by docstring mistakes in previous commit
scott-huberty Jun 12, 2023
328c505
FIX: more fixes of docstring errors..
scott-huberty Jun 12, 2023
ccbd5ec
FIX: error in old-ubuntu CI where dtype is a list
scott-huberty Jun 12, 2023
e821ffe
DOC: add example of loading and visualizing Calibration object to tut…
scott-huberty Jun 12, 2023
f1c2c9d
FIX: more fixes
scott-huberty Jun 12, 2023
6f1cffd
FIX: tutorial fix and another ubuntu-old fix attempt
scott-huberty Jun 12, 2023
2853dff
FIX: Yet another tutorial fix
scott-huberty Jun 12, 2023
4938d59
FIX, STY: re-run cis and some style revisions to tutorials
scott-huberty Jun 12, 2023
2dee68e
rerun cis
scott-huberty Jun 13, 2023
ca9157d
FIX, DOC: fix backward incompatible code, finishing touches on doc
scott-huberty Jun 13, 2023
c972567
FIX, DOC: use BAD_ACQ_SKIP and improve docstring
scott-huberty Jun 13, 2023
d71709b
Update mne/preprocessing/eyetracking/calibration.py
scott-huberty Jun 14, 2023
143c583
Apply suggestions from code review
scott-huberty Jun 14, 2023
6ede0af
FIX: Integrate suggestions by Eric Larson
scott-huberty Jun 15, 2023
b256454
DOC: add bug fix to change log
scott-huberty Jun 15, 2023
cd3b34a
Merge branch 'main' into et_calibration
scott-huberty Jun 15, 2023
c0f8bac
Update doc/changes/latest.inc
scott-huberty Jun 15, 2023
48fdd2e
DOC: Add API change to change log
scott-huberty Jun 16, 2023
5b437fa
Update doc/changes/latest.inc
scott-huberty Jun 16, 2023
86d0138
Apply suggestions from code review
scott-huberty Jun 16, 2023
1184215
FIX: add _check_option import
scott-huberty Jun 17, 2023
5781a59
Apply suggestions from code review
scott-huberty Jun 17, 2023
c7d7dd9
FIX: add Eric's suggestions
scott-huberty Jun 17, 2023
0e5133c
Update mne/io/eyelink/_utils.py
scott-huberty Jun 20, 2023
3d5d5d7
FIX, remove origin kwarg from plot method, add axes kwarg. doc upate …
scott-huberty Jun 20, 2023
073fba9
Merge branch 'main' into et_calibration
scott-huberty Jun 20, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add Notes section mentioning that gaps between recording blocks will be annotated with BAD_ACQ_SKIP


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