Skip to content

Commit

Permalink
[BUG] Correct annotation onset for exportation to EDF and EEGLAB (#12656
Browse files Browse the repository at this point in the history
)
  • Loading branch information
qian-chu authored Jan 21, 2025
1 parent 4f53a37 commit 3c6a054
Show file tree
Hide file tree
Showing 6 changed files with 112 additions and 14 deletions.
1 change: 1 addition & 0 deletions doc/changes/devel/12656.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix bug where :func:`mne.export.export_raw` does not correct for recording start time (`raw.first_time`) when exporting Raw instances to EDF or EEGLAB formats, by `Qian Chu`_.
7 changes: 7 additions & 0 deletions mne/export/_brainvision.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ def _export_mne_raw(*, raw, fname, events=None, overwrite=False):

def _mne_annots2pybv_events(raw):
"""Convert mne Annotations to pybv events."""
# check that raw.annotations.orig_time is the same as raw.info["meas_date"]
# so that onsets are relative to the first sample
# (after further correction for first_time)
if raw.annotations and raw.info["meas_date"] != raw.annotations.orig_time:
raise ValueError(
"Annotations must have the same orig_time as raw.info['meas_date']"
)
events = []
for annot in raw.annotations:
# handle onset and duration: seconds to sample, relative to
Expand Down
5 changes: 4 additions & 1 deletion mne/export/_edf.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import numpy as np

from ..annotations import _sync_onset
from ..utils import _check_edfio_installed, warn

_check_edfio_installed()
Expand Down Expand Up @@ -204,7 +205,9 @@ def _export_raw(fname, raw, physical_range, add_ch_type):

for desc, onset, duration, ch_names in zip(
raw.annotations.description,
raw.annotations.onset,
# subtract raw.first_time because EDF marks events starting from the first
# available data point and ignores raw.first_time
_sync_onset(raw, raw.annotations.onset, inverse=False),
raw.annotations.duration,
raw.annotations.ch_names,
):
Expand Down
16 changes: 11 additions & 5 deletions mne/export/_eeglab.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import numpy as np

from ..annotations import _sync_onset
from ..utils import _check_eeglabio_installed

_check_eeglabio_installed()
Expand All @@ -24,11 +25,16 @@ def _export_raw(fname, raw):
ch_names = [ch for ch in raw.ch_names if ch not in drop_chs]
cart_coords = _get_als_coords_from_chs(raw.info["chs"], drop_chs)

annotations = [
raw.annotations.description,
raw.annotations.onset,
raw.annotations.duration,
]
if raw.annotations:
annotations = [
raw.annotations.description,
# subtract raw.first_time because EEGLAB marks events starting from
# the first available data point and ignores raw.first_time
_sync_onset(raw, raw.annotations.onset, inverse=False),
raw.annotations.duration,
]
else:
annotations = None
eeglabio.raw.export_set(
fname,
data=raw.get_data(picks=ch_names),
Expand Down
8 changes: 7 additions & 1 deletion mne/export/_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,15 @@ def export_raw(
"""Export Raw to external formats.
%(export_fmt_support_raw)s
%(export_warning)s
.. warning::
When exporting ``Raw`` with annotations, ``raw.info["meas_date"]`` must be the
same as ``raw.annotations.orig_time``. This guarantees that the annotations are
in the same reference frame as the samples.
When `Raw.first_time` is not zero (e.g., after cropping), the onsets are
automatically corrected so that onsets are always relative to the first sample.
Parameters
----------
%(fname_export_params)s
Expand Down
89 changes: 82 additions & 7 deletions mne/export/tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,49 @@ def test_export_raw_eeglab(tmp_path):
raw.export(temp_fname, overwrite=True)


@pytest.mark.parametrize("tmin", (0, 1, 5, 10))
def test_export_raw_eeglab_annotations(tmp_path, tmin):
"""Test annotations in the exported EEGLAB file.
All annotations should be preserved and onset corrected.
"""
pytest.importorskip("eeglabio")
raw = read_raw_fif(fname_raw, preload=True)
raw.apply_proj()
annotations = Annotations(
onset=[0.01, 0.05, 0.90, 1.05],
duration=[0, 1, 0, 0],
description=["test1", "test2", "test3", "test4"],
ch_names=[["MEG 0113"], ["MEG 0113", "MEG 0132"], [], ["MEG 0143"]],
)
raw.set_annotations(annotations)
raw.crop(tmin)

# export
temp_fname = tmp_path / "test.set"
raw.export(temp_fname)

# read in the file
with pytest.warns(RuntimeWarning, match="is above the 99th percentile"):
raw_read = read_raw_eeglab(temp_fname, preload=True, montage_units="m")
assert raw_read.first_time == 0 # exportation resets first_time
valid_annot = (
raw.annotations.onset >= tmin
) # only annotations in the cropped range gets exported

# compare annotations before and after export
assert_array_almost_equal(
raw.annotations.onset[valid_annot] - raw.first_time,
raw_read.annotations.onset,
)
assert_array_equal(
raw.annotations.duration[valid_annot], raw_read.annotations.duration
)
assert_array_equal(
raw.annotations.description[valid_annot], raw_read.annotations.description
)


def _create_raw_for_edf_tests(stim_channel_index=None):
rng = np.random.RandomState(12345)
ch_types = [
Expand Down Expand Up @@ -154,6 +197,7 @@ def test_double_export_edf(tmp_path):
"""Test exporting an EDF file multiple times."""
raw = _create_raw_for_edf_tests(stim_channel_index=2)
raw.info.set_meas_date("2023-09-04 14:53:09.000")
raw.set_annotations(Annotations(onset=[1], duration=[0], description=["test"]))

# include subject info and measurement date
raw.info["subject_info"] = dict(
Expand Down Expand Up @@ -258,8 +302,12 @@ def test_edf_padding(tmp_path, pad_width):


@edfio_mark()
def test_export_edf_annotations(tmp_path):
"""Test that exporting EDF preserves annotations."""
@pytest.mark.parametrize("tmin", (0, 0.005, 0.03, 1))
def test_export_edf_annotations(tmp_path, tmin):
"""Test annotations in the exported EDF file.
All annotations should be preserved and onset corrected.
"""
raw = _create_raw_for_edf_tests()
annotations = Annotations(
onset=[0.01, 0.05, 0.90, 1.05],
Expand All @@ -268,17 +316,44 @@ def test_export_edf_annotations(tmp_path):
ch_names=[["0"], ["0", "1"], [], ["1"]],
)
raw.set_annotations(annotations)
raw.crop(tmin)
assert raw.first_time == tmin

if raw.n_times % raw.info["sfreq"] == 0:
expectation = nullcontext()
else:
expectation = pytest.warns(
RuntimeWarning, match="EDF format requires equal-length data blocks"
)

# export
temp_fname = tmp_path / "test.edf"
raw.export(temp_fname)
with expectation:
raw.export(temp_fname)

# read in the file
raw_read = read_raw_edf(temp_fname, preload=True)
assert_array_equal(raw.annotations.onset, raw_read.annotations.onset)
assert_array_equal(raw.annotations.duration, raw_read.annotations.duration)
assert_array_equal(raw.annotations.description, raw_read.annotations.description)
assert_array_equal(raw.annotations.ch_names, raw_read.annotations.ch_names)
assert raw_read.first_time == 0 # exportation resets first_time
bad_annot = raw_read.annotations.description == "BAD_ACQ_SKIP"
if bad_annot.any():
raw_read.annotations.delete(bad_annot)
valid_annot = (
raw.annotations.onset >= tmin
) # only annotations in the cropped range gets exported

# compare annotations before and after export
assert_array_almost_equal(
raw.annotations.onset[valid_annot] - raw.first_time, raw_read.annotations.onset
)
assert_array_equal(
raw.annotations.duration[valid_annot], raw_read.annotations.duration
)
assert_array_equal(
raw.annotations.description[valid_annot], raw_read.annotations.description
)
assert_array_equal(
raw.annotations.ch_names[valid_annot], raw_read.annotations.ch_names
)


@edfio_mark()
Expand Down

0 comments on commit 3c6a054

Please sign in to comment.