Skip to content

Commit 82f6042

Browse files
committed
working xdf import with markers
1 parent 4af65ef commit 82f6042

3 files changed

Lines changed: 105 additions & 57 deletions

File tree

hypyp/xdf/xdf_import.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
from typing import List
1+
from typing import List, Dict
22
import warnings
33

44
import pyxdf
5+
import numpy as np
56

6-
from .xdf_stream import XDFStream
7+
from .xdf_stream import XDFStream, Markers
78

89
class XDFImport():
910
"""
@@ -28,7 +29,7 @@ def __init__(self,
2829
select_type: str = None,
2930
select_matches: list = None,
3031
mne_type_map: dict = None,
31-
scale: float | str = None,
32+
scale: float | str | None = None,
3233
verbose: bool = False,
3334
convert_to_mne: bool = True,
3435
):
@@ -73,22 +74,21 @@ def raws(self):
7374
return [stream.raw for stream in self.selected_data_streams]
7475

7576
@property
76-
def annotations_dict(self):
77+
def markers_dict(self) -> Dict[str, Markers]:
7778
ret = dict()
7879
for stream in self.selected_markers_streams:
79-
ret[stream.name] = stream.annotations
80+
ret[stream.name] = stream.markers
8081
return ret
8182

8283
@property
83-
def annotations(self):
84-
return [stream.annotations for stream in self.selected_markers_streams]
85-
86-
@property
87-
def annotations_flat(self):
88-
ret = []
89-
for x in self.annotations:
90-
ret += x
91-
return ret
84+
def markers(self) -> Markers:
85+
markers = None
86+
for stream in self.selected_markers_streams:
87+
if markers is None:
88+
markers = stream.markers
89+
else:
90+
markers += stream.markers
91+
return markers
9292

9393
@property
9494
def selected_streams(self):
@@ -200,6 +200,11 @@ def convert_streams_to_mne(self):
200200
warnings.warn("Multiple streams have the same name. Adding original stream_id as suffixes to the generated raws")
201201

202202
stream.convert_to_mne(self.scale, append_stream_id=has_duplicate_names)
203+
204+
# add annotations from markers streams to data streams
205+
for data_stream in self.selected_data_streams:
206+
if self.markers is not None:
207+
data_stream.raw.set_annotations(self.markers.as_mne_annotations_for_stream(data_stream.stream))
203208

204209
if self.verbose:
205210
print("Convertion done.")

hypyp/xdf/xdf_stream.py

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,49 @@
11
from typing import List
22
import os
3+
import warnings
4+
from dataclasses import dataclass
35

46
import mne
57
import numpy as np
68
from pyxdf.pyxdf import StreamData
79

10+
@dataclass
11+
class Markers():
12+
timestamps: np.array
13+
durations: np.array
14+
descriptions: List[str]
15+
16+
def as_mne_annotations(self, reference_time: float = 0.0):
17+
onset_times = np.array(self.timestamps) - reference_time
18+
empty = mne.Annotations(onset=[], duration=[], description=[])
19+
if len(onset_times) == 0:
20+
return empty
21+
22+
i = 0
23+
while onset_times[i] < 0:
24+
i += 1
25+
if i == len(onset_times):
26+
return empty
27+
28+
return mne.Annotations(onset=onset_times[i:], duration=self.durations[i:], description=self.descriptions[i:])
29+
30+
def as_mne_annotations_for_stream(self, stream: StreamData):
31+
reference_time = stream['time_stamps'][0]
32+
return self.as_mne_annotations(reference_time)
33+
34+
def __add__(self, other):
35+
if not isinstance(other, Markers):
36+
return NotImplemented
37+
timestamps = np.concatenate([self.timestamps, self.timestamps])
38+
durations = np.concatenate([self.durations, self.durations])
39+
descriptions = self.descriptions + other.descriptions
40+
return Markers(timestamps, durations, descriptions)
41+
842
class XDFStream():
943
stream: StreamData
1044
metadata_desc: dict
1145
raw: mne.io.RawArray | None
12-
annotations: mne.Annotations | None
13-
time_offset: float
46+
markers: mne.Annotations | None
1447

1548
@staticmethod
1649
def stream_type_to_mne_ch_type(stream_type: str, type_map: dict = None):
@@ -64,12 +97,12 @@ def __init__(self, stream, mne_type_map: dict = None):
6497
self.mne_type_map = mne_type_map
6598
self.metadata_desc = {}
6699
self.raw = None
67-
self.annotations = None
100+
68101
if self.stream["info"]["desc"] and self.stream["info"]["desc"][0]:
69102
self.metadata_desc = self.stream["info"]["desc"][0]
70103

71-
self.time_offset = float(self.stream['info']['created_at'][0])
72-
104+
if self.is_markers_compatible:
105+
self.init_markers()
73106

74107
@property
75108
def name(self):
@@ -87,6 +120,10 @@ def id(self):
87120
def srate(self):
88121
return float(self.stream["info"]["nominal_srate"][0])
89122

123+
@property
124+
def is_markers_compatible(self):
125+
return self.srate == 0.0
126+
90127
@property
91128
def is_mne_compatible(self):
92129
return self.is_mne_raw_compatible or self.is_mne_annotations_compatible
@@ -99,7 +136,7 @@ def is_mne_raw_compatible(self):
99136

100137
@property
101138
def is_mne_annotations_compatible(self):
102-
return self.srate == 0.0
139+
return self.is_markers_compatible
103140

104141
@property
105142
def time_series(self):
@@ -143,27 +180,16 @@ def ch_names(self):
143180
def ch_types(self):
144181
return XDFStream.get_mne_ch_types(self.type, self.ch_names, self.mne_type_map)
145182

146-
def create_mne_annotations(self):
147-
if self.type == 'Markers':
148-
timestamps = self.stream['time_stamps'] # Time stamps of the markers
149-
descriptions = self.stream['time_series'] # Marker descriptions (the event labels)
150-
151-
# Ensure timestamps are in seconds (if they aren't already)
152-
timestamps = np.array(timestamps) - self.time_offset
153-
onset_times = timestamps
154-
duration = np.zeros_like(onset_times)
183+
def init_markers(self):
184+
assert self.is_markers_compatible
155185

156-
# Create the description for each marker (can be customized, here we use the marker text)
157-
description = [str(desc[0]) for desc in descriptions] # Flatten description if necessary
186+
timestamps = np.array(self.stream['time_stamps'])
187+
descriptions = np.array(self.stream['time_series'])
188+
durations = np.zeros_like(timestamps)
158189

159-
# Create the annotations object
160-
self.annotations = mne.Annotations(onset=onset_times, duration=duration, description=description)
161-
# TODO debug time stamps
162-
# print("AAAAAAAAAAAAAAAAAAAAAAAAAaaa")
163-
# print(onset_times)
190+
descriptions = [str(desc[0]) for desc in descriptions] # Flatten description
164191

165-
else:
166-
self.markers = None
192+
self.markers = Markers(timestamps, durations, descriptions)
167193

168194
def get_unique_name(self, append_stream_id: bool = False):
169195
unique_stream_name = self.name
@@ -172,15 +198,17 @@ def get_unique_name(self, append_stream_id: bool = False):
172198
return unique_stream_name
173199

174200

175-
def convert_to_mne(self, scale: float | str | None, append_stream_id: bool = False, verbose: bool = False):
201+
def convert_to_mne(
202+
self,
203+
scale: float | str | None,
204+
append_stream_id: bool = False,
205+
verbose: bool = False
206+
):
176207
self.unique_name = self.get_unique_name(append_stream_id=append_stream_id)
177208
if self.is_mne_raw_compatible:
178209
mne_info = self.create_mne_info(verbose=verbose)
179210
self.raw = self.create_mne_raw(mne_info, scale, verbose=verbose)
180211

181-
if self.is_mne_annotations_compatible:
182-
self.create_mne_annotations()
183-
184212
def create_mne_info(self, verbose: bool = False):
185213
"""
186214
Create a mne.info object from the XDF's EEG stream metadata.

tests/test_xdf.py

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -189,21 +189,41 @@ def test_subject_unique_name():
189189

190190
def test_markers():
191191
xdf = XDFImport(file_path_with_markers)
192-
assert xdf.selected_markers_streams[0].time_offset >= 0
193-
keys = list(xdf.annotations_dict)
192+
keys = list(xdf.markers_dict)
194193
assert len(keys) == 1
195194
assert keys[0] == XDFImport(file_path_with_markers, select_type='Markers').selected_stream_names[0]
196-
assert len(list(xdf.annotations_dict.values())[0]) > 0
197-
n_annotations_0 = len(list(xdf.annotations_dict.values())[0])
198-
assert len(xdf.annotations[0]) == n_annotations_0
199-
assert len(xdf.annotations_flat) == n_annotations_0
195+
markers = list(xdf.markers_dict.values())[0]
196+
annotations = markers.as_mne_annotations()
197+
assert len(annotations) > 0
200198

201-
xdf.raws[0].set_annotations(xdf.annotations[0])
199+
def test_merge_markers():
200+
xdf = XDFImport(file_path_with_markers)
201+
annotations = xdf.markers.as_mne_annotations()
202+
markers_dup = xdf.markers + xdf.markers
203+
assert len(markers_dup.as_mne_annotations()) == 2 * len(annotations)
204+
205+
def test_markers_timing():
206+
xdf = XDFImport(file_path_with_markers)
207+
markers = xdf.markers
208+
offset = markers.timestamps[0]
209+
annotations = markers.as_mne_annotations(offset)
210+
assert annotations[0]['onset'] == 0.0
211+
212+
annotations_for_stream = markers.as_mne_annotations_for_stream(xdf.selected_data_streams[0].stream)
213+
assert annotations_for_stream[0]['onset'] > 0
214+
assert annotations_for_stream[0]['onset'] < 10
215+
xdf.raws[0].set_annotations(annotations_for_stream)
202216

203-
def test_time_alignment():
217+
def test_markers_negative():
204218
xdf = XDFImport(file_path_with_markers)
205-
assert xdf.selected_markers_streams[0].time_offset >= 0
206-
assert 'TODO: Need to check that the time of annotations is correct when adding them to the RawArray' == True
219+
annotations_all = xdf.markers.as_mne_annotations()
220+
first_time = xdf.markers.timestamps[0]
221+
# let's say a data stream started AFTER the first marker, we want to exclude the first markers
222+
annotations_partial = xdf.markers.as_mne_annotations(first_time + 2)
223+
assert len(annotations_partial) < len(annotations_all)
224+
225+
# have a big time offset to make sure we receive no annotations
226+
assert len(xdf.markers.as_mne_annotations(first_time + 2000)) == 0
207227

208228
def test_fif_files():
209229
xdf = XDFImport(file_path, select_type='EEG')
@@ -218,8 +238,3 @@ def test_montage():
218238
xdf = XDFImport(file_path, select_type='EEG')
219239
xdf.rename_channels(['Fp1', 'Fp2', 'F3', 'F4', 'C3', 'C4', 'P3', 'P4', 'O1', 'O2', 'F7', 'F8', 'T7', 'T8', 'P7', 'P8', 'Fz', 'Cz', 'Pz', 'POz', 'FC1', 'FC2', 'CP1', 'CP2', 'FC5', 'FC6', 'CP5', 'CP6', 'FT9', 'FT10', 'TP9', 'TP10'])
220240
xdf.set_montage('standard_1020')
221-
222-
223-
224-
##
225-
### test have markers in raw

0 commit comments

Comments
 (0)