Skip to content

Commit

Permalink
Merge branch 'main' into doc-folder-reorg
Browse files Browse the repository at this point in the history
  • Loading branch information
drammock authored Oct 2, 2023
2 parents f1ad928 + fd08b52 commit 9415da7
Show file tree
Hide file tree
Showing 13 changed files with 299 additions and 103 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: '3.10'
Expand Down
4 changes: 3 additions & 1 deletion doc/changes/devel.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ Bugs
- Fix bug with axis clip box boundaries in :func:`mne.viz.plot_evoked_topo` and related functions (:gh:`11999` by `Eric Larson`_)
- Fix bug with ``subject_info`` when loading data from and exporting to EDF file (:gh:`11952` by `Paul Roujansky`_)
- Fix bug with delayed checking of :class:`info["bads"] <mne.Info>` (:gh:`12038` by `Eric Larson`_)
- Fix handling of channel information in annotations when loading data from and exporting to EDF file (:gh:`11960` :gh:`12017` by `Paul Roujansky`_)
- Fix handling of channel information in annotations when loading data from and exporting to EDF file (:gh:`11960` :gh:`12017` :gh:`12044` by `Paul Roujansky`_)
- Add missing ``overwrite`` and ``verbose`` parameters to :meth:`Transform.save() <mne.transforms.Transform.save>` (:gh:`12004` by `Marijn van Vliet`_)
- 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`_)
- 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`_)
- Fix :func:`~mne.viz.plot_volume_source_estimates` with :class:`~mne.VolSourceEstimate` which include a list of vertices (:gh:`12025` by `Mathieu Scheltienne`_)
- Correctly handle passing ``"eyegaze"`` or ``"pupil"`` to :meth:`mne.io.Raw.pick` (:gh:`12019` by `Scott Huberty`_)

API changes
Expand Down
33 changes: 16 additions & 17 deletions mne/io/edf/edf.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ def __init__(
)
annotations = _read_annotations_edf(
tal_data[0],
ch_names=info["ch_names"],
encoding=encoding,
)
self.set_annotations(annotations, on_missing="warn")
Expand Down Expand Up @@ -1892,25 +1893,21 @@ def read_raw_gdf(


@fill_doc
def _read_annotations_edf(annotations, encoding="utf8"):
def _read_annotations_edf(annotations, ch_names=None, encoding="utf8"):
"""Annotation File Reader.
Parameters
----------
annotations : ndarray (n_chans, n_samples) | str
Channel data in EDF+ TAL format or path to annotation file.
ch_names : list of string
List of channels' names.
%(encoding_edf)s
Returns
-------
onset : array of float, shape (n_annotations,)
The starting time of annotations in seconds after ``orig_time``.
duration : array of float, shape (n_annotations,)
Durations of the annotations in seconds.
description : array of str, shape (n_annotations,)
Array of strings containing description for each annotation. If a
string, all the annotations are given the same description. To reject
epochs, use description starting with keyword 'bad'. See example above.
annot : instance of Annotations
The annotations.
"""
pat = "([+-]\\d+\\.?\\d*)(\x15(\\d+\\.?\\d*))?(\x14.*?)\x14\x00"
if isinstance(annotations, str):
Expand Down Expand Up @@ -1949,7 +1946,11 @@ def _read_annotations_edf(annotations, encoding="utf8"):
duration = float(ev[2]) if ev[2] else 0
for description in ev[3].split("\x14")[1:]:
if description:
if "@@" in description:
if (
"@@" in description
and ch_names is not None
and description.split("@@")[1] in ch_names
):
description, ch_name = description.split("@@")
key = f"{onset}_{duration}_{description}"
else:
Expand Down Expand Up @@ -1979,22 +1980,20 @@ def _read_annotations_edf(annotations, encoding="utf8"):
offset = -onset

if events:
onset, duration, description, ch_names = zip(*events.values())
onset, duration, description, annot_ch_names = zip(*events.values())
else:
onset, duration, description, ch_names = list(), list(), list(), list()
onset, duration, description, annot_ch_names = list(), list(), list(), list()

assert len(onset) == len(duration) == len(description) == len(ch_names)
assert len(onset) == len(duration) == len(description) == len(annot_ch_names)

annotations = Annotations(
return Annotations(
onset=onset,
duration=duration,
description=description,
orig_time=None,
ch_names=ch_names,
ch_names=annot_ch_names,
)

return annotations


def _get_annotations_gdf(edf_info, sfreq):
onset, duration, desc = list(), list(), list()
Expand Down
77 changes: 76 additions & 1 deletion mne/io/edf/tests/test_edf.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import pytest

from mne import pick_types, Annotations
from mne.annotations import events_from_annotations, read_annotations
from mne.annotations import _ndarray_ch_names, events_from_annotations, read_annotations
from mne.datasets import testing
from mne.io import read_raw_edf, read_raw_bdf, read_raw_fif, edf, read_raw_gdf
from mne.io.tests.test_raw import _test_raw_reader
Expand Down Expand Up @@ -504,6 +504,81 @@ def test_read_utf8_annotations():
assert raw.annotations[1]["description"] == "仰卧"


def test_read_annotations_edf(tmp_path):
"""Test reading annotations from EDF file."""
annot = (
b"+1.1\x14Event A@@CH1\x14\x00\x00"
b"+1.2\x14Event A\x14\x00\x00"
b"+1.3\x14Event B@@CH1\x14\x00\x00"
b"+1.3\x14Event B@@CH2\x14\x00\x00"
b"+1.4\x14Event A@@CH3\x14\x00\x00"
b"+1.5\x14Event B\x14\x00\x00"
)
annot_file = tmp_path / "annotations.edf"
with open(annot_file, "wb") as f:
f.write(annot)

# Test reading annotations from channel data
with open(annot_file, "rb") as f:
tal_channel = _read_ch(
f,
subtype="EDF",
dtype="<i2",
samp=-1,
dtype_byte=None,
)

# Read annotations without input channel names: annotations are left untouched and
# assigned as global
annotations = _read_annotations_edf(tal_channel, ch_names=None, encoding="latin1")
assert_allclose(annotations.onset, [1.1, 1.2, 1.3, 1.3, 1.4, 1.5])
assert not any(annotations.duration) # all durations are 0
assert_array_equal(
annotations.description,
[
"Event A@@CH1",
"Event A",
"Event B@@CH1",
"Event B@@CH2",
"Event A@@CH3",
"Event B",
],
)
assert_array_equal(
annotations.ch_names, _ndarray_ch_names([(), (), (), (), (), ()])
)

# Read annotations with complete input channel names: each annotation is parsed and
# associated to a channel
annotations = _read_annotations_edf(
tal_channel, ch_names=["CH1", "CH2", "CH3"], encoding="latin1"
)
assert_allclose(annotations.onset, [1.1, 1.2, 1.3, 1.4, 1.5])
assert not any(annotations.duration) # all durations are 0
assert_array_equal(
annotations.description, ["Event A", "Event A", "Event B", "Event A", "Event B"]
)
assert_array_equal(
annotations.ch_names,
_ndarray_ch_names([("CH1",), (), ("CH1", "CH2"), ("CH3",), ()]),
)

# Read annotations with incomplete input channel names: "CH3" is missing from input
# channels, turning the related annotation into a global one
annotations = _read_annotations_edf(
tal_channel, ch_names=["CH1", "CH2"], encoding="latin1"
)
assert_allclose(annotations.onset, [1.1, 1.2, 1.3, 1.4, 1.5])
assert not any(annotations.duration) # all durations are 0
assert_array_equal(
annotations.description,
["Event A", "Event A", "Event B", "Event A@@CH3", "Event B"],
)
assert_array_equal(
annotations.ch_names, _ndarray_ch_names([("CH1",), (), ("CH1", "CH2"), (), ()])
)


def test_read_latin1_annotations(tmp_path):
"""Test if annotations encoded as Latin-1 can be read.
Expand Down
33 changes: 20 additions & 13 deletions mne/io/eyelink/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
}


def _parse_eyelink_ascii(fname, find_overlaps=True, overlap_threshold=0.05):
def _parse_eyelink_ascii(
fname, find_overlaps=True, overlap_threshold=0.05, apply_offsets=False
):
# ======================== Parse ASCII File =========================
raw_extras = dict()
raw_extras.update(_parse_recording_blocks(fname))
Expand All @@ -49,7 +51,7 @@ def _parse_eyelink_ascii(fname, find_overlaps=True, overlap_threshold=0.05):
_validate_data(raw_extras)

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


def _create_dataframes(raw_extras):
def _create_dataframes(raw_extras, apply_offsets):
"""Create pandas.DataFrame for Eyelink samples and events.
Creates a pandas DataFrame for sample_lines and for each
Expand Down Expand Up @@ -280,17 +282,22 @@ def _create_dataframes(raw_extras):
# make dataframe for experiment messages
if raw_extras["event_lines"]["MSG"]:
msgs = []
for tokens in raw_extras["event_lines"]["MSG"]:
timestamp = tokens[0]
# if offset token exists, it will be the 1st index and is numeric
if tokens[1].lstrip("-").replace(".", "", 1).isnumeric():
offset = float(tokens[1])
msg = " ".join(str(x) for x in tokens[2:])
else:
# there is no offset token
for token in raw_extras["event_lines"]["MSG"]:
if apply_offsets and len(token) == 2:
ts, msg = token
offset = np.nan
msg = " ".join(str(x) for x in tokens[1:])
msgs.append([timestamp, offset, msg])
elif apply_offsets:
ts = token[0]
try:
offset = float(token[1])
msg = " ".join(str(x) for x in token[2:])
except ValueError:
offset = np.nan
msg = " ".join(str(x) for x in token[1:])
else:
ts, offset = token[0], np.nan
msg = " ".join(str(x) for x in token[1:])
msgs.append([ts, offset, msg])
df_dict["messages"] = pd.DataFrame(msgs)

# make dataframe for recording block start, end times
Expand Down
61 changes: 12 additions & 49 deletions mne/io/eyelink/eyelink.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,35 +28,15 @@ def read_raw_eyelink(
overlap_threshold=0.05,
verbose=None,
):
"""Reader for an Eyelink .asc file.
"""Reader for an Eyelink ``.asc`` file.
Parameters
----------
fname : path-like
Path to the eyelink file (.asc).
create_annotations : bool | list (default True)
Whether to create mne.Annotations from occular events
(blinks, fixations, saccades) and experiment messages. If a list, must
contain one or more of ['fixations', 'saccades',' blinks', messages'].
If True, creates mne.Annotations for both occular events and experiment
messages.
apply_offsets : bool (default False)
Adjusts the onset time of the mne.Annotations created from Eyelink
experiment messages, if offset values exist in the ASCII file.
find_overlaps : bool (default False)
Combine left and right eye :class:`mne.Annotations` (blinks, fixations,
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 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`.
%(eyelink_fname)s
%(eyelink_create_annotations)s
%(eyelink_apply_offsets)s
%(eyelink_find_overlaps)s
%(eyelink_overlap_threshold)s
%(verbose)s
Returns
Expand Down Expand Up @@ -95,28 +75,11 @@ class RawEyelink(BaseRaw):
Parameters
----------
fname : path-like
Path to the data file (.XXX).
create_annotations : bool | list (default True)
Whether to create mne.Annotations from occular events
(blinks, fixations, saccades) and experiment messages. If a list, must
contain one or more of ['fixations', 'saccades',' blinks', messages'].
If True, creates mne.Annotations for both occular events and experiment
messages.
apply_offsets : bool (default False)
Adjusts the onset time of the mne.Annotations created from Eyelink
experiment messages, if offset values exist in the ASCII file.
find_overlaps : boolean (default False)
Combine left and right eye :class:`mne.Annotations` (blinks, fixations,
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").
%(eyelink_fname)s
%(eyelink_create_annotations)s
%(eyelink_apply_offsets)s
%(eyelink_find_overlaps)s
%(eyelink_overlap_threshold)s
%(verbose)s
See Also
Expand All @@ -141,7 +104,7 @@ def __init__(

# ======================== Parse ASCII file ==========================
eye_ch_data, info, raw_extras = _parse_eyelink_ascii(
fname, find_overlaps, overlap_threshold
fname, find_overlaps, overlap_threshold, apply_offsets
)
# ======================== Create Raw Object =========================
super(RawEyelink, self).__init__(
Expand Down
30 changes: 29 additions & 1 deletion mne/io/eyelink/tests/test_eyelink.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest

import numpy as np
from numpy.testing import assert_allclose

from mne.datasets.testing import data_path, requires_testing_data
from mne.io import read_raw_eyelink
Expand Down Expand Up @@ -254,7 +255,7 @@ def test_multi_block_misc_channels(fname, tmp_path):
_simulate_eye_tracking_data(fname, out_file)

with pytest.warns(RuntimeWarning, match="Raw eyegaze coordinates"):
raw = read_raw_eyelink(out_file)
raw = read_raw_eyelink(out_file, apply_offsets=True)

chs_in_file = [
"xpos_right",
Expand Down Expand Up @@ -286,3 +287,30 @@ def test_multi_block_misc_channels(fname, tmp_path):
def test_basics(this_fname):
"""Test basics of reading."""
_test_raw_reader(read_raw_eyelink, fname=this_fname, test_preloading=False)


def test_annotations_without_offset(tmp_path):
"""Test read of annotations without offset."""
out_file = tmp_path / "tmp_eyelink.asc"

# create fake dataset
with open(fname_href, "r") as file:
lines = file.readlines()
ts = lines[-3].split("\t")[0]
line = f"MSG\t{ts} test string\n"
lines = lines[:-3] + [line] + lines[-3:]

with open(out_file, "w") as file:
file.writelines(lines)

raw = read_raw_eyelink(out_file, apply_offsets=False)
assert raw.annotations[-1]["description"] == "test string"
onset1 = raw.annotations[-1]["onset"]
assert raw.annotations[1]["description"] == "-2 SYNCTIME"
onset2 = raw.annotations[1]["onset"]

raw = read_raw_eyelink(out_file, apply_offsets=True)
assert raw.annotations[-1]["description"] == "test string"
assert raw.annotations[1]["description"] == "SYNCTIME"
assert_allclose(raw.annotations[-1]["onset"], onset1)
assert_allclose(raw.annotations[1]["onset"], onset2 - 2 / raw.info["sfreq"])
Loading

0 comments on commit 9415da7

Please sign in to comment.