-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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
[BUG] Correct annotation onset for exportation to EDF and EEGLAB #12656
base: main
Are you sure you want to change the base?
Conversation
for more information, see https://pre-commit.ci
mne/export/tests/test_export.py
Outdated
@pytest.mark.parametrize("tmin", (0, 1, 5, 10)) | ||
def test_export_raw_eeglab_annotations(tmp_path, tmin): | ||
"""Test that exporting EEGLAB preserves annotations and corects for raw.first_time.""" | ||
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 | ||
|
||
valid_annot = raw.annotations.onset >= tmin | ||
assert_array_almost_equal( | ||
raw.annotations.onset[valid_annot] - raw.first_time, | ||
raw_read.annotations.onset - raw_read.first_time, | ||
) | ||
assert_array_equal( | ||
raw.annotations.duration[valid_annot], raw_read.annotations.duration | ||
) | ||
assert_array_equal( | ||
raw.annotations.description[valid_annot], raw_read.annotations.description | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually should this annotation test for EEGLAB been written before, the bug should be noticable because the test Raw has actually been cropped. The saved onset would not correspond to the original onset.
mne/export/tests/test_export.py
Outdated
if tmin % 1 == 0: | ||
expectation = nullcontext() | ||
else: | ||
expectation = pytest.warns( | ||
RuntimeWarning, match="EDF format requires equal-length data blocks" | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is this check doing? If you are checking for tmin
to be an integer, you could also use tmin.is_integer()
, but is this what is required to have "equal-length data blocks"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because the constructed raw signal is 2 sec long and edfio can segment it into 2 data records of 1 sec. If a non-integer amount of time is cropped, then the signal is no longer a multiple of 1 sec and edfio will append zeroes and issue a RuntimeWarning. Maybe this should have been a test on its own but I'm adding it here since pytest wouldn't pass me otherwise.
As for the %1 == 0
condition, I was thinking to make space for more flexible use should I know how edfio determines data record length. For example if one can specify a data record length of .5 or 2 s, then the statement can be replaced with %data_length == 0
. But I agree it looks uncessary in its current form.
Hmmm, really? Annotations are not affected by cropping? Why would that be convenient? |
At least when Lines 1560 to 1570 in e2c8010
So that when annotations have their own time reference, cropping the data wouldn't affect them. Actually this is a good reminder that we might need to account for different |
To be honest, I didn't even know that annotations work like that. I always thought that |
I'm not too sure how that will work out. Do you mean that cropping should always reset
|
Maybe it's just me, but I gave up trying to understand how this works. The ASCII diagram is probably meant to be helpful, but for me it is the complete opposite, I have no idea how these different concepts (meas_date, orig_time, first_samp, and whatnot) actually work, sorry. |
I agree, I've tried several times over the past couple of years to decipher what it's trying to tell me and at one point just gave up. It's just been trial and error for me regarding all things annotations ever since 😅 |
It's definitely OK! As I'm re-looking at this PR after some time I'm also struggling to wrap my head around this system. FYI this diagram was copied from https://mne.tools/dev/generated/mne.Annotations.html. One potential conflict I found is, the diagram says when Lines 1562 to 1563 in 4954672
instead of
If someone who's familiar with the design can clarify that would be great. But I do confirm that the EDF and EEGLAB export will malfunction without correcting for first_time so eventually we would want this fix. |
Coming back to this issue after some time, I re-confirmed the existence of the problem with a minimalist code: import numpy as np
from mne import create_info, Annotations
from mne.io import RawArray, read_raw_brainvision, read_raw_edf, read_raw_eeglab
from mne.viz import set_browser_backend
set_browser_backend('qt')
# Create a raw object of SR 1000 Hz, all zero, except for 1s of 1e-6 from 2-3s
data = np.zeros((1, 5000))
data[0, 2000:3000] = 1
scalings = dict(eeg=1)
info = create_info(['CH1'], 1000, ['eeg'])
raw_orig = RawArray(data, info)
annot = Annotations(onset=[2], duration=[1], description=['stim'])
raw_orig.set_annotations(annot)
# Crop raw to 1-5s
raw_orig.crop(1)
fig_orig = raw_orig.plot(scalings=scalings)
fig_orig.grab().save('orig.png')
# Export to BrainVision and re-read
raw_orig.export('test.vhdr')
raw_brainvision = read_raw_brainvision('test.vhdr')
fig_brainvision = raw_brainvision.plot(scalings=scalings)
fig_brainvision.grab().save('brainvision.png')
# Export to EDF and re-read
raw_orig.export('test.edf')
raw_edf = read_raw_edf('test.edf')
fig_edf = raw_edf.plot(scalings=scalings)
fig_edf.grab().save('edf.png')
# Export to EEGLAB and re-read
raw_orig.export('test.set')
raw_eeglab = read_raw_eeglab('test.set')
fig_eeglab = raw_eeglab.plot(scalings=scalings)
fig_eeglab.grab().save('eeglab.png') Outputs using the current
|
Very nice, thanks for this example, this makes the issue really easy to see! Could you take a look at the failing tests? |
for more information, see https://pre-commit.ci
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), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also re-wrote the tests and they are passed without an issue now :D From my side it's in principle good to go.
One final note though is I'm using _sync_onset
, which in addition to performing annot_start = onset - raw._first_time
also assert raw.info["meas_date"] == raw.annotations.orig_time
. Therefore, this would enforce users to export only Raw
that has identical meas_date
and orig_time
.
When cropping the start of a recording,
raw.first_time
is updated whileannotations.onset
is conveniently untouched. However, when exporting to another format where times are reset (starting from zero),annotations.onset
should be corrected so that they represent relative time from the first sample.This correction has been performed when
fmt=‘brainvision’
:mne-python/mne/export/_brainvision.py
Lines 78 to 85 in e2c8010
But is curiously missing when
fmt=‘edf’
orfmt=‘eeglab’
:mne-python/mne/export/_edf.py
Lines 200 to 213 in e2c8010
mne-python/mne/export/_eeglab.py
Lines 28 to 32 in e2c8010
This PR aims to fix this by performing the similar correction (
annotations.onset - raw.first_time