Skip to content

Commit b917495

Browse files
Mathieu Scheltiennesnwnde
authored andcommitted
[MRG] Don't look for an offset in an eyelink message if the message contains only 2 elements (mne-tools#12003)
1 parent c3ee827 commit b917495

File tree

7 files changed

+115
-66
lines changed

7 files changed

+115
-66
lines changed

doc/changes/devel.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Bugs
5858
- Fix bug with delayed checking of :class:`info["bads"] <mne.Info>` (:gh:`12038` by `Eric Larson`_)
5959
- Fix handling of channel information in annotations when loading data from and exporting to EDF file (:gh:`11960` :gh:`12017` by `Paul Roujansky`_)
6060
- Add missing ``overwrite`` and ``verbose`` parameters to :meth:`Transform.save() <mne.transforms.Transform.save>` (:gh:`12004` by `Marijn van Vliet`_)
61+
- Fix parsing of eye-link :class:`~mne.Annotations` when ``apply_offsets=False`` is provided to :func:`~mne.io.read_raw_eyelink` (:gh:`12003` by `Mathieu Scheltienne`_)
6162
- Correctly prune channel-specific :class:`~mne.Annotations` when creating :class:`~mne.Epochs` without the channel(s) included in the channel specific annotations (:gh:`12010` by `Mathieu Scheltienne`_)
6263
- Correctly handle passing ``"eyegaze"`` or ``"pupil"`` to :meth:`mne.io.Raw.pick` (:gh:`12019` by `Scott Huberty`_)
6364

mne/io/eyelink/_utils.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@
4040
}
4141

4242

43-
def _parse_eyelink_ascii(fname, find_overlaps=True, overlap_threshold=0.05):
43+
def _parse_eyelink_ascii(
44+
fname, find_overlaps=True, overlap_threshold=0.05, apply_offsets=False
45+
):
4446
# ======================== Parse ASCII File =========================
4547
raw_extras = dict()
4648
raw_extras.update(_parse_recording_blocks(fname))
@@ -49,7 +51,7 @@ def _parse_eyelink_ascii(fname, find_overlaps=True, overlap_threshold=0.05):
4951
_validate_data(raw_extras)
5052

5153
# ======================== Create DataFrames ========================
52-
raw_extras["dfs"] = _create_dataframes(raw_extras)
54+
raw_extras["dfs"] = _create_dataframes(raw_extras, apply_offsets)
5355
del raw_extras["sample_lines"] # free up memory
5456
# add column names to dataframes and set the dtype of each column
5557
col_names, ch_names = _infer_col_names(raw_extras)
@@ -252,7 +254,7 @@ def _get_sfreq_from_ascii(rec_info):
252254
return float(rec_info[rec_info.index("RATE") + 1])
253255

254256

255-
def _create_dataframes(raw_extras):
257+
def _create_dataframes(raw_extras, apply_offsets):
256258
"""Create pandas.DataFrame for Eyelink samples and events.
257259
258260
Creates a pandas DataFrame for sample_lines and for each
@@ -280,17 +282,22 @@ def _create_dataframes(raw_extras):
280282
# make dataframe for experiment messages
281283
if raw_extras["event_lines"]["MSG"]:
282284
msgs = []
283-
for tokens in raw_extras["event_lines"]["MSG"]:
284-
timestamp = tokens[0]
285-
# if offset token exists, it will be the 1st index and is numeric
286-
if tokens[1].lstrip("-").replace(".", "", 1).isnumeric():
287-
offset = float(tokens[1])
288-
msg = " ".join(str(x) for x in tokens[2:])
289-
else:
290-
# there is no offset token
285+
for token in raw_extras["event_lines"]["MSG"]:
286+
if apply_offsets and len(token) == 2:
287+
ts, msg = token
291288
offset = np.nan
292-
msg = " ".join(str(x) for x in tokens[1:])
293-
msgs.append([timestamp, offset, msg])
289+
elif apply_offsets:
290+
ts = token[0]
291+
try:
292+
offset = float(token[1])
293+
msg = " ".join(str(x) for x in token[2:])
294+
except ValueError:
295+
offset = np.nan
296+
msg = " ".join(str(x) for x in token[1:])
297+
else:
298+
ts, offset = token[0], np.nan
299+
msg = " ".join(str(x) for x in token[1:])
300+
msgs.append([ts, offset, msg])
294301
df_dict["messages"] = pd.DataFrame(msgs)
295302

296303
# make dataframe for recording block start, end times

mne/io/eyelink/eyelink.py

Lines changed: 12 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -28,35 +28,15 @@ def read_raw_eyelink(
2828
overlap_threshold=0.05,
2929
verbose=None,
3030
):
31-
"""Reader for an Eyelink .asc file.
31+
"""Reader for an Eyelink ``.asc`` file.
3232
3333
Parameters
3434
----------
35-
fname : path-like
36-
Path to the eyelink file (.asc).
37-
create_annotations : bool | list (default True)
38-
Whether to create mne.Annotations from occular events
39-
(blinks, fixations, saccades) and experiment messages. If a list, must
40-
contain one or more of ['fixations', 'saccades',' blinks', messages'].
41-
If True, creates mne.Annotations for both occular events and experiment
42-
messages.
43-
apply_offsets : bool (default False)
44-
Adjusts the onset time of the mne.Annotations created from Eyelink
45-
experiment messages, if offset values exist in the ASCII file.
46-
find_overlaps : bool (default False)
47-
Combine left and right eye :class:`mne.Annotations` (blinks, fixations,
48-
saccades) if their start times and their stop times are both not
49-
separated by more than overlap_threshold.
50-
overlap_threshold : float (default 0.05)
51-
Time in seconds. Threshold of allowable time-gap between both the start and
52-
stop times of the left and right eyes. If the gap is larger than the threshold,
53-
the :class:`mne.Annotations` will be kept separate (i.e. ``"blink_L"``,
54-
``"blink_R"``). If the gap is smaller than the threshold, the
55-
:class:`mne.Annotations` will be merged and labeled as ``"blink_both"``.
56-
Defaults to ``0.05`` seconds (50 ms), meaning that if the blink start times of
57-
the left and right eyes are separated by less than 50 ms, and the blink stop
58-
times of the left and right eyes are separated by less than 50 ms, then the
59-
blink will be merged into a single :class:`mne.Annotations`.
35+
%(eyelink_fname)s
36+
%(eyelink_create_annotations)s
37+
%(eyelink_apply_offsets)s
38+
%(eyelink_find_overlaps)s
39+
%(eyelink_overlap_threshold)s
6040
%(verbose)s
6141
6242
Returns
@@ -95,28 +75,11 @@ class RawEyelink(BaseRaw):
9575
9676
Parameters
9777
----------
98-
fname : path-like
99-
Path to the data file (.XXX).
100-
create_annotations : bool | list (default True)
101-
Whether to create mne.Annotations from occular events
102-
(blinks, fixations, saccades) and experiment messages. If a list, must
103-
contain one or more of ['fixations', 'saccades',' blinks', messages'].
104-
If True, creates mne.Annotations for both occular events and experiment
105-
messages.
106-
apply_offsets : bool (default False)
107-
Adjusts the onset time of the mne.Annotations created from Eyelink
108-
experiment messages, if offset values exist in the ASCII file.
109-
find_overlaps : boolean (default False)
110-
Combine left and right eye :class:`mne.Annotations` (blinks, fixations,
111-
saccades) if their start times and their stop times are both not
112-
separated by more than overlap_threshold.
113-
overlap_threshold : float (default 0.05)
114-
Time in seconds. Threshold of allowable time-gap between the start and
115-
stop times of the left and right eyes. If gap is larger than threshold,
116-
the :class:`mne.Annotations` will be kept separate (i.e. "blink_L",
117-
"blink_R"). If the gap is smaller than the threshold, the
118-
:class:`mne.Annotations` will be merged (i.e. "blink_both").
119-
78+
%(eyelink_fname)s
79+
%(eyelink_create_annotations)s
80+
%(eyelink_apply_offsets)s
81+
%(eyelink_find_overlaps)s
82+
%(eyelink_overlap_threshold)s
12083
%(verbose)s
12184
12285
See Also
@@ -141,7 +104,7 @@ def __init__(
141104

142105
# ======================== Parse ASCII file ==========================
143106
eye_ch_data, info, raw_extras = _parse_eyelink_ascii(
144-
fname, find_overlaps, overlap_threshold
107+
fname, find_overlaps, overlap_threshold, apply_offsets
145108
)
146109
# ======================== Create Raw Object =========================
147110
super(RawEyelink, self).__init__(

mne/io/eyelink/tests/test_eyelink.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44

55
import numpy as np
6+
from numpy.testing import assert_allclose
67

78
from mne.datasets.testing import data_path, requires_testing_data
89
from mne.io import read_raw_eyelink
@@ -254,7 +255,7 @@ def test_multi_block_misc_channels(fname, tmp_path):
254255
_simulate_eye_tracking_data(fname, out_file)
255256

256257
with pytest.warns(RuntimeWarning, match="Raw eyegaze coordinates"):
257-
raw = read_raw_eyelink(out_file)
258+
raw = read_raw_eyelink(out_file, apply_offsets=True)
258259

259260
chs_in_file = [
260261
"xpos_right",
@@ -286,3 +287,30 @@ def test_multi_block_misc_channels(fname, tmp_path):
286287
def test_basics(this_fname):
287288
"""Test basics of reading."""
288289
_test_raw_reader(read_raw_eyelink, fname=this_fname, test_preloading=False)
290+
291+
292+
def test_annotations_without_offset(tmp_path):
293+
"""Test read of annotations without offset."""
294+
out_file = tmp_path / "tmp_eyelink.asc"
295+
296+
# create fake dataset
297+
with open(fname_href, "r") as file:
298+
lines = file.readlines()
299+
ts = lines[-3].split("\t")[0]
300+
line = f"MSG\t{ts} test string\n"
301+
lines = lines[:-3] + [line] + lines[-3:]
302+
303+
with open(out_file, "w") as file:
304+
file.writelines(lines)
305+
306+
raw = read_raw_eyelink(out_file, apply_offsets=False)
307+
assert raw.annotations[-1]["description"] == "test string"
308+
onset1 = raw.annotations[-1]["onset"]
309+
assert raw.annotations[1]["description"] == "-2 SYNCTIME"
310+
onset2 = raw.annotations[1]["onset"]
311+
312+
raw = read_raw_eyelink(out_file, apply_offsets=True)
313+
assert raw.annotations[-1]["description"] == "test string"
314+
assert raw.annotations[1]["description"] == "SYNCTIME"
315+
assert_allclose(raw.annotations[-1]["onset"], onset1)
316+
assert_allclose(raw.annotations[1]["onset"], onset2 - 2 / raw.info["sfreq"])

mne/preprocessing/realign.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def realign_raw(raw, other, t_raw, t_other, verbose=None):
2929
t_raw : array-like, shape (n_events,)
3030
The times of shared events in ``raw`` relative to ``raw.times[0]`` (0).
3131
Typically these could be events on some TTL channel like
32-
``find_events(raw)[:, 0] - raw.first_event``.
32+
``find_events(raw)[:, 0] - raw.first_samp``.
3333
t_other : array-like, shape (n_events,)
3434
The times of shared events in ``other`` relative to ``other.times[0]``.
3535
%(verbose)s

mne/utils/docs.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1518,6 +1518,56 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
15181518
the head circle.
15191519
"""
15201520

1521+
docdict[
1522+
"eyelink_apply_offsets"
1523+
] = """
1524+
apply_offsets : bool (default False)
1525+
Adjusts the onset time of the :class:`~mne.Annotations` created from Eyelink
1526+
experiment messages, if offset values exist in the ASCII file. If False, any
1527+
offset-like values will be prepended to the annotation description.
1528+
"""
1529+
1530+
docdict[
1531+
"eyelink_create_annotations"
1532+
] = """
1533+
create_annotations : bool | list (default True)
1534+
Whether to create :class:`~mne.Annotations` from occular events
1535+
(blinks, fixations, saccades) and experiment messages. If a list, must
1536+
contain one or more of ``['fixations', 'saccades',' blinks', messages']``.
1537+
If True, creates :class:`~mne.Annotations` for both occular events and
1538+
experiment messages.
1539+
"""
1540+
1541+
docdict[
1542+
"eyelink_find_overlaps"
1543+
] = """
1544+
find_overlaps : bool (default False)
1545+
Combine left and right eye :class:`mne.Annotations` (blinks, fixations,
1546+
saccades) if their start times and their stop times are both not
1547+
separated by more than overlap_threshold.
1548+
"""
1549+
1550+
docdict[
1551+
"eyelink_fname"
1552+
] = """
1553+
fname : path-like
1554+
Path to the eyelink file (``.asc``)."""
1555+
1556+
docdict[
1557+
"eyelink_overlap_threshold"
1558+
] = """
1559+
overlap_threshold : float (default 0.05)
1560+
Time in seconds. Threshold of allowable time-gap between both the start and
1561+
stop times of the left and right eyes. If the gap is larger than the threshold,
1562+
the :class:`mne.Annotations` will be kept separate (i.e. ``"blink_L"``,
1563+
``"blink_R"``). If the gap is smaller than the threshold, the
1564+
:class:`mne.Annotations` will be merged and labeled as ``"blink_both"``.
1565+
Defaults to ``0.05`` seconds (50 ms), meaning that if the blink start times of
1566+
the left and right eyes are separated by less than 50 ms, and the blink stop
1567+
times of the left and right eyes are separated by less than 50 ms, then the
1568+
blink will be merged into a single :class:`mne.Annotations`.
1569+
"""
1570+
15211571
# %%
15221572
# F
15231573

tutorials/preprocessing/90_eyetracking_data.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,8 @@
156156
event_dict = dict(Flash=2)
157157

158158
# %%
159-
# Align the eye-tracking data with EEG the data
160-
# ---------------------------------------------
159+
# Align the eye-tracking data with EEG data
160+
# -----------------------------------------
161161
#
162162
# In this dataset, eye-tracking and EEG data were recorded simultaneously, but on
163163
# different systems, so we'll need to align the data before we can analyze them

0 commit comments

Comments
 (0)