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 48 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 :func:`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
112 changes: 112 additions & 0 deletions mne/io/eyelink/_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""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
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
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 *= 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()
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)
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])
calibrations.append(calibration)
return calibrations
62 changes: 42 additions & 20 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,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.

Returns
Expand All @@ -338,16 +344,17 @@ def read_raw_eyelink(
--------
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

"""
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 +364,7 @@ def read_raw_eyelink(
overlap_threshold=overlap_threshold,
gap_desc=gap_description,
)
return raw_eyelink


@fill_doc
Expand All @@ -365,7 +373,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 +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_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 @@ -418,7 +430,8 @@ class RawEyelink(BaseRaw):
Whether whether a single eye was tracked ('monocular'), or both
('binocular').
_gap_desc : str
Copy link
Member

Choose a reason for hiding this comment

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

No _-prefixed attributes should be documented in the class doc. Feel free to move these to code comments if you want, though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, I'll go through and remove these from the read_raw_eyelink docstring.

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
--------
Expand All @@ -435,7 +448,7 @@ 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))

Expand All @@ -446,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 = {}

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