Skip to content

Commit 614c75d

Browse files
authored
Merge pull request #1035 from JuliaSprenger/add/edf
Add EDF IO
2 parents 7cf65eb + 6f08db3 commit 614c75d

File tree

8 files changed

+446
-1
lines changed

8 files changed

+446
-1
lines changed

neo/io/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@
291291
from neo.io.brainwaref32io import BrainwareF32IO
292292
from neo.io.brainwaresrcio import BrainwareSrcIO
293293
from neo.io.cedio import CedIO
294+
from neo.io.edfio import EDFIO
294295
from neo.io.elanio import ElanIO
295296
from neo.io.elphyio import ElphyIO
296297
from neo.io.exampleio import ExampleIO
@@ -341,6 +342,7 @@
341342
BrainwareF32IO,
342343
BrainwareSrcIO,
343344
CedIO,
345+
EDFIO,
344346
ElanIO,
345347
# ElphyIO,
346348
ExampleIO,

neo/io/edfio.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
IO for reading edf and edf+ files using pyedflib
3+
4+
PyEDFLib
5+
https://pyedflib.readthedocs.io
6+
https://github.com/holgern/pyedflib
7+
8+
EDF Format Specifications: https://www.edfplus.info/
9+
10+
Author: Julia Sprenger
11+
"""
12+
13+
from neo.io.basefromrawio import BaseFromRaw
14+
from neo.rawio.edfrawio import EDFRawIO
15+
16+
17+
class EDFIO(EDFRawIO, BaseFromRaw):
18+
"""
19+
IO for reading edf and edf+ files.
20+
"""
21+
name = 'EDF IO'
22+
description = "IO for reading EDF and EDF+ files"
23+
24+
_prefered_signal_group_mode = 'group-by-same-units'
25+
26+
def __init__(self, filename=''):
27+
EDFRawIO.__init__(self, filename=filename)
28+
BaseFromRaw.__init__(self, filename)

neo/rawio/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
* :attr:`BlackrockRawIO`
2020
* :attr:`BrainVisionRawIO`
2121
* :attr:`CedRawIO`
22+
* :attr: `EdfRawIO`
2223
* :attr:`ElanRawIO`
2324
* :attr:`IntanRawIO`
2425
* :attr:`MaxwellRawIO`
@@ -70,6 +71,10 @@
7071
7172
.. autoattribute:: extensions
7273
74+
.. autoclass:: neo.rawio.EdfRawIO
75+
76+
.. autoattribute:: extensions
77+
7378
.. autoclass:: neo.rawio.ElanRawIO
7479
7580
.. autoattribute:: extensions
@@ -164,6 +169,7 @@
164169
from neo.rawio.blackrockrawio import BlackrockRawIO
165170
from neo.rawio.brainvisionrawio import BrainVisionRawIO
166171
from neo.rawio.cedrawio import CedRawIO
172+
from neo.rawio.edfrawio import EDFRawIO
167173
from neo.rawio.elanrawio import ElanRawIO
168174
from neo.rawio.examplerawio import ExampleRawIO
169175
from neo.rawio.intanrawio import IntanRawIO
@@ -195,6 +201,7 @@
195201
BlackrockRawIO,
196202
BrainVisionRawIO,
197203
CedRawIO,
204+
EDFRawIO,
198205
ElanRawIO,
199206
IntanRawIO,
200207
MicromedRawIO,

neo/rawio/edfrawio.py

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
"""
2+
RawIO for reading EDF and EDF+ files using pyedflib
3+
4+
PyEDFLib
5+
https://pyedflib.readthedocs.io
6+
https://github.com/holgern/pyedflib
7+
8+
EDF Format Specifications: https://www.edfplus.info/
9+
10+
Author: Julia Sprenger
11+
"""
12+
13+
from .baserawio import (BaseRawIO, _signal_channel_dtype, _signal_stream_dtype,
14+
_spike_channel_dtype, _event_channel_dtype)
15+
16+
import numpy as np
17+
18+
try:
19+
from pyedflib import EdfReader
20+
21+
HAS_PYEDF = True
22+
except ImportError:
23+
HAS_PYEDF = False
24+
25+
26+
class EDFRawIO(BaseRawIO):
27+
"""
28+
Class for reading European Data Format files (EDF and EDF+).
29+
Currently only continuous EDF+ files (EDF+C) and original EDF files (EDF) are supported
30+
31+
Usage:
32+
>>> import neo.rawio
33+
>>> r = neo.rawio.EdfRawIO(filename='file.edf')
34+
>>> r.parse_header()
35+
>>> print(r)
36+
>>> raw_chunk = r.get_analogsignal_chunk(block_index=0, seg_index=0,
37+
i_start=0, i_stop=1024, stream_index=0, channel_indexes=range(10))
38+
>>> float_chunk = reader.rescale_signal_raw_to_float(raw_chunk, dtype='float64',
39+
channel_indexes=[0, 3, 6])
40+
"""
41+
42+
extensions = ['edf']
43+
rawmode = 'one-file'
44+
45+
def __init__(self, filename=''):
46+
if not HAS_PYEDF:
47+
raise ValueError('Requires pyedflib')
48+
BaseRawIO.__init__(self)
49+
50+
# note that this filename is used in self._source_name
51+
self.filename = filename
52+
53+
self.signal_headers = []
54+
self.edf_header = {}
55+
56+
def _source_name(self):
57+
return self.filename
58+
59+
def _parse_header(self):
60+
61+
# read basic header
62+
with open(self.filename, 'rb') as f:
63+
f.seek(192)
64+
file_version_header = f.read(44).decode('ascii')
65+
# only accepting basic EDF files (no 'EDF+' in header)
66+
# or continuous EDF+ files ('EDF+C' in header)
67+
if ('EDF+' in file_version_header) and ('EDF+C' not in file_version_header):
68+
raise ValueError('Only continuous EDF+ files are currently supported.')
69+
70+
self.edf_reader = EdfReader(self.filename)
71+
# load headers, signal information and
72+
self.edf_header = self.edf_reader.getHeader()
73+
self.signal_headers = self.edf_reader.getSignalHeaders()
74+
75+
# add annotations to header
76+
annotations = self.edf_reader.readAnnotations()
77+
self.signal_annotations = [[s, d, a] for s, d, a in zip(*annotations)]
78+
79+
# 1 stream = 1 sampling rate
80+
stream_characteristics = []
81+
self.stream_idx_to_chidx = {}
82+
83+
signal_channels = []
84+
for ch_idx, sig_dict in enumerate(self.signal_headers):
85+
ch_name = sig_dict['label']
86+
chan_id = ch_idx
87+
sr = sig_dict['sample_rate'] # Hz
88+
dtype = 'int16' # assume general int16 based on edf documentation
89+
units = sig_dict['dimension']
90+
physical_range = sig_dict['physical_max'] - sig_dict['physical_min']
91+
# number of digital steps resolved (+1 to account for '0')
92+
digital_range = sig_dict['digital_max'] - sig_dict['digital_min'] + 1
93+
gain = physical_range / digital_range
94+
offset = -1 * sig_dict['digital_min'] * gain + sig_dict['physical_min']
95+
96+
# identify corresponding stream based on sampling rate
97+
if (sr,) not in stream_characteristics:
98+
stream_characteristics += [(sr,)]
99+
100+
stream_id = stream_characteristics.index((sr,))
101+
self.stream_idx_to_chidx.setdefault(stream_id, []).append(ch_idx)
102+
103+
signal_channels.append((ch_name, chan_id, sr, dtype, units, gain, offset, stream_id))
104+
105+
# convert channel index lists to arrays for indexing
106+
self.stream_idx_to_chidx = {k: np.array(v) for k, v in self.stream_idx_to_chidx.items()}
107+
108+
signal_channels = np.array(signal_channels, dtype=_signal_channel_dtype)
109+
110+
signal_streams = [(f'stream ({sr} Hz)', i) for i, sr in enumerate(stream_characteristics)]
111+
signal_streams = np.array(signal_streams, dtype=_signal_stream_dtype)
112+
113+
# no unit/epoch information contained in edf
114+
spike_channels = []
115+
spike_channels = np.array(spike_channels, dtype=_spike_channel_dtype)
116+
117+
event_channels = []
118+
event_channels.append(('Event', 'event_channel', 'event'))
119+
event_channels.append(('Epoch', 'epoch_channel', 'epoch'))
120+
event_channels = np.array(event_channels, dtype=_event_channel_dtype)
121+
122+
self.header = {}
123+
self.header['nb_block'] = 1
124+
self.header['nb_segment'] = [1] # we only accept continuous edf files
125+
self.header['signal_streams'] = signal_streams
126+
self.header['signal_channels'] = signal_channels
127+
self.header['spike_channels'] = spike_channels
128+
self.header['event_channels'] = event_channels
129+
130+
self._generate_minimal_annotations()
131+
132+
# Add custom annotations for neo objects
133+
bl_ann = self.raw_annotations['blocks'][0]
134+
bl_ann['name'] = 'EDF Data Block'
135+
bl_ann.update(self.edf_header)
136+
seg_ann = bl_ann['segments'][0]
137+
seg_ann['name'] = 'Seg #0 Block #0'
138+
139+
# extract keys for array_annotations common to all signals and not already used
140+
ignore_annotations = ['label', 'dimension', 'sample_rate', 'physical_min', 'physical_max',
141+
'digital_min', 'digital_max']
142+
array_keys = []
143+
for k in self.signal_headers[0]:
144+
if k not in ignore_annotations and all([k in h for h in self.signal_headers]):
145+
array_keys.append(k)
146+
147+
for array_key in array_keys:
148+
array_anno = {array_key: [h[array_key] for h in self.signal_headers]}
149+
seg_ann['signals'].append({'__array_annotations__': array_anno})
150+
151+
def _get_stream_channels(self, stream_index):
152+
return self.header['signal_channels'][self.stream_idx_to_chidx[stream_index]]
153+
154+
def _segment_t_start(self, block_index, seg_index):
155+
# no time offset provided by EDF format
156+
return 0. # in seconds
157+
158+
def _segment_t_stop(self, block_index, seg_index):
159+
t_stop = self.edf_reader.datarecord_duration * self.edf_reader.datarecords_in_file
160+
# this must return an float scale in second
161+
return t_stop
162+
163+
def _get_signal_size(self, block_index, seg_index, stream_index):
164+
chidx = self.stream_idx_to_chidx[stream_index][0]
165+
# use sample count of first signal in stream
166+
return self.edf_reader.getNSamples()[chidx]
167+
168+
def _get_signal_t_start(self, block_index, seg_index, stream_index):
169+
return 0. # EDF does not provide temporal offset information
170+
171+
def _get_analogsignal_chunk(self, block_index, seg_index, i_start, i_stop,
172+
stream_index, channel_indexes):
173+
# only dealing with single block and segment edf files
174+
assert (block_index, seg_index) == (0, 0)
175+
176+
stream_channel_idxs = self.stream_idx_to_chidx[stream_index]
177+
178+
# keep all channels of the stream if none are selected
179+
if channel_indexes is None:
180+
channel_indexes = slice(None)
181+
182+
if i_start is None:
183+
i_start = 0
184+
if i_stop is None:
185+
i_stop = self.get_signal_size(block_index=block_index, seg_index=seg_index,
186+
stream_index=stream_index)
187+
n = i_stop - i_start
188+
189+
# raw_signals = self.edf_reader. am[channel_indexes, i_start:i_stop]
190+
selected_channel_idxs = stream_channel_idxs[channel_indexes]
191+
192+
# load data into numpy array buffer
193+
data = []
194+
for i, channel_idx in enumerate(selected_channel_idxs):
195+
# use int32 for compatibility with pyedflib
196+
buffer = np.empty(n, dtype=np.int32)
197+
self.edf_reader.read_digital_signal(channel_idx, i_start, n, buffer)
198+
data.append(buffer)
199+
200+
# downgrade to int16 as this is what is used in the edf file format
201+
# use fortran (column major) order to be more efficient after transposal
202+
data = np.asarray(data, dtype=np.int16, order='F')
203+
204+
# use dimensions (time, channel)
205+
data = data.T
206+
207+
return data
208+
209+
def _spike_count(self, block_index, seg_index, spike_channel_index):
210+
return None
211+
212+
def _get_spike_timestamps(self, block_index, seg_index, spike_channel_index, t_start, t_stop):
213+
return None
214+
215+
def _rescale_spike_timestamp(self, spike_timestamps, dtype):
216+
return None
217+
218+
def _get_spike_raw_waveforms(self, block_index, seg_index, spike_channel_index, t_start,
219+
t_stop):
220+
return None
221+
222+
def _event_count(self, block_index, seg_index, event_channel_index):
223+
return len(self.edf_reader.readAnnotations()[0])
224+
225+
def _get_event_timestamps(self, block_index, seg_index, event_channel_index, t_start, t_stop):
226+
# these time should be already in seconds
227+
timestamps, durations, labels = self.edf_reader.readAnnotations()
228+
if t_start is None:
229+
t_start = self.segment_t_start(block_index, seg_index)
230+
if t_stop is None:
231+
t_stop = self.segment_t_stop(block_index, seg_index)
232+
233+
# only consider events and epochs that overlap with t_start t_stop range
234+
time_mask = ((t_start < timestamps) & (timestamps < t_stop)) | \
235+
((t_start < (timestamps + durations)) & ((timestamps + durations) < t_stop))
236+
237+
# separate event from epoch times
238+
event_mask = durations[time_mask] == 0
239+
if self.header['event_channels']['type'][event_channel_index] == b'epoch':
240+
event_mask = ~event_mask
241+
durations = durations[time_mask][event_mask]
242+
elif self.header['event_channels']['type'][event_channel_index] == b'event':
243+
durations = None
244+
245+
times = timestamps[time_mask][event_mask]
246+
labels = np.asarray(labels[time_mask][event_mask], dtype='U')
247+
248+
return times, durations, labels
249+
250+
def _rescale_event_timestamp(self, event_timestamps, dtype, event_channel_index):
251+
return np.asarray(event_timestamps, dtype=dtype)
252+
253+
def _rescale_epoch_duration(self, raw_duration, dtype, event_channel_index):
254+
return np.asarray(raw_duration, dtype=dtype)
255+
256+
def __enter__(self):
257+
return self
258+
259+
def __del__(self):
260+
self._close_reader()
261+
262+
def __exit__(self, exc_type, exc_val, ex_tb):
263+
self._close_reader()
264+
265+
def close(self):
266+
"""
267+
Closes the file handler
268+
"""
269+
self._close_reader()
270+
271+
def _close_reader(self):
272+
if hasattr(self, 'edf_reader'):
273+
self.edf_reader.close()

0 commit comments

Comments
 (0)