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 11 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
1 change: 1 addition & 0 deletions doc/file_io.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ Base class:
:template: autosummary/class_no_members.rst

BaseEpochs

1 change: 1 addition & 0 deletions doc/preprocessing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ Projections:
.. autosummary::
:toctree: generated/

Calibration
set_channel_types_eyetrack

EEG referencing:
Expand Down
1 change: 1 addition & 0 deletions doc/reading_raw_data.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion mne/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions mne/io/eyelink/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Module for loading Eye-Tracker data."""

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

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

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.

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 = tuple([float(v) for v in [*xy, tokens[-4], *xy_diff]])

return 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.
"""
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. 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:
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.set_calibration_array(points)
calibrations.append(calibration)
return calibrations
73 changes: 72 additions & 1 deletion 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 @@ -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
Expand Down Expand Up @@ -284,6 +287,45 @@ 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 : 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():
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,
Expand All @@ -294,6 +336,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.

Expand Down Expand Up @@ -328,6 +374,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
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
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
-------
Expand All @@ -347,7 +411,7 @@ def read_raw_eyelink(
" files to .asc format."
)

return RawEyelink(
raw_eyelink = RawEyelink(
fname,
preload=preload,
verbose=verbose,
Expand All @@ -357,6 +421,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
Expand Down
Loading