-
-
Save skjerns/bc660ef59dca0dbd53f00ed38c42f6be to your computer and use it in GitHub Desktop.
# -*- coding: utf-8 -*- | |
""" | |
Created on Wed Dec 5 12:56:31 2018 | |
@author: skjerns | |
Gist to save a mne.io.Raw object to an EDF file using pyEDFlib | |
(https://github.com/holgern/pyedflib) | |
Disclaimer: | |
- Saving your data this way will result in slight | |
loss of precision (magnitude +-1e-09). | |
- It is assumed that the data is presented in Volt (V), | |
it will be internally converted to microvolt | |
- BDF or EDF+ is selected based on the filename extension | |
- Annotations preserved | |
Update: Since 2021, MNE also supports exporting EDF via edfio: | |
https://mne.tools/stable/generated/mne.export.export_raw.html | |
""" | |
import pyedflib # pip install pyedflib | |
from pyedflib import highlevel # new high-level interface | |
from pyedflib import FILETYPE_BDF, FILETYPE_BDFPLUS, FILETYPE_EDF, FILETYPE_EDFPLUS | |
from datetime import datetime, timezone, timedelta | |
import mne | |
import os | |
def _stamp_to_dt(utc_stamp): | |
"""Convert timestamp to datetime object in Windows-friendly way.""" | |
if 'datetime' in str(type(utc_stamp)): return utc_stamp | |
# The min on windows is 86400 | |
stamp = [int(s) for s in utc_stamp] | |
if len(stamp) == 1: # In case there is no microseconds information | |
stamp.append(0) | |
return (datetime.fromtimestamp(0, tz=timezone.utc) + | |
timedelta(0, stamp[0], stamp[1])) # day, sec, μs | |
def write_mne_edf(mne_raw, fname, picks=None, tmin=0, tmax=None, | |
overwrite=False): | |
""" | |
Saves the raw content of an MNE.io.Raw and its subclasses to | |
a file using the EDF+/BDF filetype | |
pyEDFlib is used to save the raw contents of the RawArray to disk | |
Parameters | |
update 2021: edf export is now also supported in MNE: | |
https://mne.tools/stable/generated/mne.export.export_raw.html | |
---------- | |
mne_raw : mne.io.Raw | |
An object with super class mne.io.Raw that contains the data | |
to save | |
fname : string | |
File name of the new dataset. This has to be a new filename | |
unless data have been preloaded. Filenames should end with .edf | |
picks : array-like of int | None | |
Indices of channels to include. If None all channels are kept. | |
tmin : float | None | |
Time in seconds of first sample to save. If None first sample | |
is used. | |
tmax : float | None | |
Time in seconds of last sample to save. If None last sample | |
is used. | |
overwrite : bool | |
If True, the destination file (if it exists) will be overwritten. | |
If False (default), an error will be raised if the file exists. | |
""" | |
print('did you know EDF export is now supported in MNE via edfio? have a look at https://mne.tools/stable/generated/mne.export.export_raw.html') | |
if not issubclass(type(mne_raw), mne.io.BaseRaw): | |
raise TypeError('Must be mne.io.Raw type') | |
if not overwrite and os.path.exists(fname): | |
raise OSError('File already exists. No overwrite.') | |
# static settings | |
has_annotations = True if len(mne_raw.annotations)>0 else False | |
if os.path.splitext(fname)[-1] == '.edf': | |
file_type = FILETYPE_EDFPLUS if has_annotations else FILETYPE_EDF | |
dmin, dmax = -32768, 32767 | |
else: | |
file_type = FILETYPE_BDFPLUS if has_annotations else FILETYPE_BDF | |
dmin, dmax = -8388608, 8388607 | |
print('saving to {}, filetype {}'.format(fname, file_type)) | |
sfreq = mne_raw.info['sfreq'] | |
date = _stamp_to_dt(mne_raw.info['meas_date']) | |
if tmin: | |
date += timedelta(seconds=tmin) | |
# no conversion necessary, as pyedflib can handle datetime. | |
#date = date.strftime('%d %b %Y %H:%M:%S') | |
first_sample = int(sfreq*tmin) | |
last_sample = int(sfreq*tmax) if tmax is not None else None | |
# convert data | |
channels = mne_raw.get_data(picks, | |
start = first_sample, | |
stop = last_sample) | |
# convert to microvolts to scale up precision | |
channels *= 1e6 | |
# set conversion parameters | |
n_channels = len(channels) | |
# create channel from this | |
try: | |
f = pyedflib.EdfWriter(fname, | |
n_channels=n_channels, | |
file_type=file_type) | |
channel_info = [] | |
ch_idx = range(n_channels) if picks is None else picks | |
keys = list(mne_raw._orig_units.keys()) | |
for i in ch_idx: | |
try: | |
ch_dict = {'label': mne_raw.ch_names[i], | |
'dimension': mne_raw._orig_units[keys[i]], | |
'sample_rate': mne_raw._raw_extras[0]['n_samps'][i], | |
'physical_min': mne_raw._raw_extras[0]['physical_min'][i], | |
'physical_max': mne_raw._raw_extras[0]['physical_max'][i], | |
'digital_min': mne_raw._raw_extras[0]['digital_min'][i], | |
'digital_max': mne_raw._raw_extras[0]['digital_max'][i], | |
'transducer': '', | |
'prefilter': ''} | |
except: | |
ch_dict = {'label': mne_raw.ch_names[i], | |
'dimension': mne_raw._orig_units[keys[i]], | |
'sample_rate': sfreq, | |
'physical_min': channels.min(), | |
'physical_max': channels.max(), | |
'digital_min': dmin, | |
'digital_max': dmax, | |
'transducer': '', | |
'prefilter': ''} | |
channel_info.append(ch_dict) | |
f.setPatientCode(mne_raw._raw_extras[0]['subject_info'].get('id', '0')) | |
f.setPatientName(mne_raw._raw_extras[0]['subject_info'].get('name', 'noname')) | |
f.setTechnician('mne-gist-save-edf-skjerns') | |
f.setSignalHeaders(channel_info) | |
f.setStartdatetime(date) | |
f.writeSamples(channels) | |
for annotation in mne_raw.annotations: | |
onset = annotation['onset'] | |
duration = annotation['duration'] | |
description = annotation['description'] | |
f.writeAnnotation(onset, duration, description) | |
except Exception as e: | |
raise e | |
finally: | |
f.close() | |
return True |
Is EEGLab in general unable to load BDF+? Then this might be an issue to post at https://github.com/sccn/eeglab ?
Else I can implement your change, even if it feels a bit hacky.
Can you try to call f.set_number_of_annotation_signals(0)
right after line 95? might fix the issue more gracefully.
(don't have a matlab licence anymore, post-student-life etc ;-) )
Is EEGLab in general unable to load BDF+? Then this might be an issue to post at https://github.com/sccn/eeglab ?
Else I can implement your change, even if it feels a bit hacky.
Can you try to call
f.set_number_of_annotation_signals(0)
right after line 95? might fix the issue more gracefully.(don't have a matlab licence anymore, post-student-life etc ;-) )
Basically yes. There are a couple different methods of handling BDF imports. One (BioSig) merely gives a warning, the other (FileIO) gives an error. Here are a couple of things that reference the issue indirectly:
https://sccn.ucsd.edu/bugzilla/show_bug.cgi?id=1865
https://sccn.ucsd.edu/bugzilla/show_bug.cgi?id=1726
I downloaded a test BDF+ file from here (https://www.teuniz.net/edf_bdf_testfiles/) and got the same error (I've tried to attach a picture to this comment showing that, but GitHub doesn't like it).
How about something like this?
if mne_raw._raw_extras[0]['nchan'] == (len(mne_raw.get_data()) + 1):
annot = True
elif mne_raw._raw_extras[0]['nchan'] == len(mne_raw.get_data()):
annot = False
else:
# ??? throw Exception?
annot = False
if os.path.splitext(fname)[-1] == '.edf':
if annot:
file_type = pyedflib.FILETYPE_EDFPLUS
else:
file_type = pyedflib.FILETYPE_EDF
dmin, dmax = -32768, 32767
else:
if annot:
file_type = pyedflib.FILETYPE_BDFPLUS
else:
file_type = pyedflib.FILETYPE_BDF
dmin, dmax = -8388608, 8388607
This allows one to set the file to all four types. It's the only way I can see to detect the + version. It's possible for an object to have an annotation channel, but 0 annotations, so one cannot depend upon the raw.annotations object.
Okay. I'll look into this.
Actually saving the file as XDF instead of XDF+ should be no problem as all the additional features of XDF+ are not used anyway in this implementation.
Edit: @GABowers can you check if it works now? Somehow my test files only produce garbage
I can confirm it works. Running:
if __name__ == '__main__':
bdf = 'I:/???/BDFtestfiles/Newtest17-2048.bdf'
save = bdf[:-4] + '_save.bdf'
mne_1 = mne.io.read_raw_bdf(input_fname=bdf, preload=True)
write_mne_edf(mne_1, save, overwrite=True)
mne_2 = mne.io.read_raw_bdf(input_fname=save, preload=True)
channels_1 = len(mne_1.get_data())
print('visible: {} - total: {} ({})'.format(channels_1, mne_1._raw_extras[0]['nchan'], bdf))
channels_2 = len(mne_2.get_data())
print('visible: {} - total: {} ({})'.format(channels_2, mne_2._raw_extras[0]['nchan'], save))
...with the latest version of the gist above outputs the following:
visible: 17 - total: 17 (I:/???/BDFtestfiles/Newtest17-2048.bdf)
visible: 17 - total: 17 (I:/???/BDFtestfiles/Newtest17-2048_save.bdf)
And I can import it into Matlab just fine. Just for the record, that file can be downloaded here.
It should be noted that it is possible for a BDF+ file to not have any annotations, but still have an annotation channel. But one could easily argue that such a file should be BDF, not BDF+.
Good afternoon there.
I was wondering whether you managed to keep the annotations in saving raw data to a edf.file
Thanks.
M.
@MM75CO yes it does that.
Hi,
Great work!! I really miss this option in the mne package :D
I am trying to use the gist to convert a csv file to edf. I have contructed my own mne raw object from the data in the csv file, but I get an error when trying to save the Raw object to edf. I get the following error and the output file is empty (size 0KB):
'NoneType' object has no attribute 'keys'
The error seems to originate from line 103 and mne_raw._orig_units?
I also get this error when converting the timestamp. But I think solved this by omitting it. (But maybe that is messing something else up?)
'NoneType' object is not iterable
@LanaHub You might want to see what the mne field is for the example mne data and just copy from there.
@skjerns do you think there is any interesting in making this a PR to mne? There is a lot of i/o structure in mne already but it's all based around loading data, I think it would be a bit of a new direction to save data in non-fif formats. MNE-BIDS already uses pybv because fif is not one of the BIDS required formats for EEG.
@LanaHub if you just want to convert a csv
to edf
(and do not need mne
functionality), just use pyedflib
directly! the highlevel interface should make that quite easy. Load the CSV with numpy
or pandas
and save it with pyedflib
.
https://github.com/holgern/pyedflib#highlevel-interface
@alexrockhill I'd gladly do it, but the devs of mne do not want any third-party dependencies (which using pyedflib would introduce), that's why they told me to create a gist instead. That would mean that I'd have to create the edf creation from scratch in raw python. See also mne-tools/mne-python#5755
Thank you for getting back so soon!
I would like to use the mne functionallity, but I also want to save the data as edf to be compliant with BIDS. Btw. I get the same error when trying to convert a gdf file to edf..
I will have another look at the example mne data and try to figure out a way to make it work.
@skjerns ahh, I see. Maybe worth thinking about whether this could be in mne-bids since there is already a pybv dependency. That does seem like when the data needs to be in edf, i.e. when it's in BIDS. @jasmainak what do you think?
Also, the gist is nice, thanks for doing that but it is a rather important part that can go very wrong if the file is exported incorrectly and it would be great to have some round trip tests that would be added if it were to be a PR.
I'm a bit hesitant to take on the responsibility of an edf writer. There are tons of corner cases in the edf format. Why not contribute to existing efforts such as pyedf: https://github.com/bids-standard/pyedf
That doesn't have any tests either haha
It seems like there is a lot of use of the gist and like people would probably like it to be officially supported by mne
and @skjerns said he would write the initial PR but I understand the hesitance and I get the message. I thought it was worth floating the idea.
Hi,
this gist is really great idea! Just the functionality, which I have been searched in mne and did not find. A great supplement. Big thanks to you!
I tried to save the preprocessed mne raw object into a new edf, and got the following error
...
line 27, in _stamp_to_dt
stamp = [int(s) for s in utc_stamp]
TypeError: 'datetime.datetime' object is not iterable
raw.info['meas_date'] in my script gives me:
datetime.datetime(2020, 3, 12, 16, 34, 55, tzinfo=datetime.timezone.utc)
Have you encoutered this problem?
Yes, I had the same problem. I solved it by changing line 76 to date = mne_raw.info['meas_date']
. However then I got a new problem and I actually gave up :P Let me know if you succed!
I've adapted the script.
@datalw can you check if it works now?
@skjerns Thanks a lot for the swift response! I tried the adapted version and got the following error:
----> 3 if 'datetime' in type(utc_stamp): return utc_stamp
4 # The min on windows is 86400
5 stamp = [int(s) for s in utc_stamp]
TypeError: argument of type 'type' is not iterable
So I changed if 'datetime' in type(utc_stamp):
to if 'datetime' in str(type(utc_stamp))
and it works :D Would you mind to adapt it again?
Thanks a lot for the great work!
@LunaHub it works for me so far, you could try it again : )
oops, yes didnt test it, thank's, I corrected it
I have used the gist for a while, it is really awesome, especially for saving the preprocessed data!
I am here again for a follow-up question. So far the files without annotations have been saved successfully. However for a file with annotations, the raw.times in the saved file was incorrect. I have looked into the code and found out the problem might be due to this line
'sample_rate': mne_raw._raw_extras[0]['n_samps'][i]
mne_raw.info['sfreq']
gives 256, but mne_raw._raw_extras[0]['n_samps'][i]
gives 2048 instead.
Since I have never dealt with _raw_extras, I tried to find some information about _raw_extras in mne documention, but failed.
Does anyone know how to solve this problem, or how I can proceed? Or is this a bug in _raw_extras?
I am not familiar with raw._raw_extras
you might be able to reach a wider audience that wrote that part of the code on Gitter unless someone on this thread knows https://gitter.im/mne-tools/mne-python
@datalw did you resample the data? was one of original sample frequencies 2048?
to be honest, I just hacked this snippet together and changed it often over time, I'm also not very familiar with the mne
internals. If you find a solution, let me know! I'm still in favor of integrating edf writing compatibility inside mne
, but currently they don't want to introduce optional dependencies. maybe that changes as a python-native implementation of edflib
has been published last month :)
So this still isn't incorporated into mne-python, any chance it ever will? Been a few years.
Feel free to ask this directly at MNE: https://github.com/mne-tools/mne-python/issues or the gitter https://gitter.im/mne-tools/mne-python
The problem was the additional dependency on pyedflib
and that pyedflib
uses Cython to compile some C libraries, which they did not want to include (afaik, and which I somehow understand).
However, we could ask if it would be possible to include an optional dependency on pyedflib. Additionally, EDDlib has just released a Python-only-version which would solve some problems :) but its quite slow (it's Python, nevertheless)
@alexrockhill @skjerns Thanks a lot for the reply and sorry for my delayed answer. I plan to come back to this issue in the next couple of weeks and if I find a solution I will let you @skjerns know ; )
Hello, thanks a lot for the great work! I am having one problem when using this script. When I try to open the resulted edf file this message came up "Error, number of datarecords is 0, expected >0. You can fix this problem with the header editor, check the manual for details. File is not a valid EDF or BDF file.". I am not quite sure why is this the case and I wonder if anyone is having the same problem or has any solution.
Thanks a lot!
That's a bit hard to diagnose what's going on without sharing a minimally reproducible example that someone else can run on their machine.
It sounds like maybe your mne.io.Raw
object didn't have any data in it or any data of type eeg
, grad
, mag
or seeg
. See https://mne.tools/stable/generated/mne.io.Raw.html#mne.io.Raw.set_channel_types.
That's a bit hard to diagnose what's going on without sharing a minimally reproducible example that someone else can run on their machine.
It sounds like maybe your
mne.io.Raw
object didn't have any data in it or any data of typeeeg
,grad
,mag
orseeg
. See https://mne.tools/stable/generated/mne.io.Raw.html#mne.io.Raw.set_channel_types.
@alexrockhill Hello, thanks a lot for the reply. This is the piece of code I used when creating the mne.io.Raw
object.
sample_data_raw_file = os.path.join("filename.edf")
raw = mne.io.read_raw_edf(sample_data_raw_file)
My original document is already in .edf, I put the file through some processing in mne-python and I would want to save the end-product as .edf using your script. Thanks a lot for your help!
From my experience reading in edf files, often the data types are not correctly set by mne by default. I would try setting all your channel types to eeg
I assume but whatever type they are and saving again. I'd be interested to know if that works.
Also, I helped a bit but the thanks definitely goes to @skjerns for this gist.
Maybe this will help
import mne
raw_fname = 'filename.edf'
raw = mne.io.read_raw_edf(raw_fname)
raw = raw.set_channel_types({ch: 'eeg' for ch in raw.ch_names})
@alexrockhill
Thanks for the reply. I have tried setting the channel types to eeg
using raw = raw.set_channel_types({ch: 'eeg' for ch in raw.ch_names})
, but the resulting file still can't be opened.
Found another issue, I'm afraid.
Relevant code:
If one runs this with the above gist script, one gets the following output:
This is because pyedflib is saving an additional annotation channel. If one changes line 69 of the gist to simply be "pyedflib.FILETYPE_BDF" one gets 17 both times. The annotation channel is not created.
The problem here is that other packages, like EEGLab in Matlab, cannot properly handle the annotation channel. Using the latest version of EEGLab and attempting to load the annotated file with BioSig throws an error ("EDFANNOT2EVT.M is currently not supported!").
Additionally, it doesn't look like annotations are even being saved here at all. You have an
EdfWriter.writeAnnotation
method, but it isn't being called here. In such a case, I think the best way to handle this would be to just change lines 66 and 69 to the non-plus versions (FILETYPE_EDF
andFILETYPE_BDF
).If you wanted to include EDF+ and BDF+ in the future, you could check if
mne_raw._raw_extras[0]['nchan']
is different from the total number of shown channels.