Skip to content

Commit 4af65ef

Browse files
committed
xdf import testing and import markers
1 parent 34488df commit 4af65ef

File tree

6 files changed

+452
-146
lines changed

6 files changed

+452
-146
lines changed

hypyp/xdf/xdf_import.py

Lines changed: 76 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ class XDFImport():
1010
Read an XDF file and enable to export stream in a convenient format (e.g., an EEG stream into an mne.Raw instance).
1111
1212
Arguments:
13-
file_path: Path to LSL data (i.e., XDF file). Can be absolute or relative.
13+
file_path: Path to XDF file (LSL data recorded with LabRecorder). Can be absolute or relative.
1414
stream_type: Define which type of stream the user is looking to convert.
1515
stream_matches: List of the stream index(es) in the XDF the user wishes to convert (can be `str` which the class will try to match to the name of an existing stream or an `int` which will be interpreted as such). Do not set to convert all of the request type
16+
mne_type_map: Dict to map stream types to mne channel types
1617
scale: Scaling factor or 'auto' for automatic scaling, None for no scaling.
17-
save_FIF_path: Boolean indicating whether to save the converted data to FIF format.
1818
"""
1919

2020
file_path: str
@@ -25,18 +25,16 @@ class XDFImport():
2525

2626
def __init__(self,
2727
file_path: str,
28-
stream_type: str = 'EEG',
29-
stream_matches: list = None,
28+
select_type: str = None,
29+
select_matches: list = None,
3030
mne_type_map: dict = None,
3131
scale: float | str = None,
32-
save_FIF_path: bool = None,
3332
verbose: bool = False,
3433
convert_to_mne: bool = True,
3534
):
3635

3736
self.file_path = file_path
3837
self.scale = scale
39-
self.save_FIF_path = save_FIF_path
4038

4139
self.selected_stream_indices = []
4240
self.map_id_to_idx = {}
@@ -48,44 +46,74 @@ def __init__(self,
4846

4947
self.available_streams = [XDFStream(stream, mne_type_map=mne_type_map) for stream in streams]
5048

51-
52-
if verbose:
53-
self.print_available_streams()
54-
5549
self.map_streams()
5650

5751
# Prepare the "selected_streams" list
58-
if stream_matches is None:
59-
self.select_streams_by_type(stream_type)
52+
if select_matches is not None:
53+
self.select_streams_by_matches(select_matches)
6054
else:
61-
self.select_streams_by_matches(stream_matches)
55+
self.select_streams_by_type(select_type)
56+
57+
if verbose:
58+
print(self)
6259

6360
# Create MNE objects
6461
if convert_to_mne:
6562
self.convert_streams_to_mne()
6663

64+
@property
65+
def raws_dict(self):
66+
ret = dict()
67+
for stream in self.selected_data_streams:
68+
ret[stream.name] = stream.raw
69+
return ret
70+
71+
@property
72+
def raws(self):
73+
return [stream.raw for stream in self.selected_data_streams]
74+
75+
@property
76+
def annotations_dict(self):
77+
ret = dict()
78+
for stream in self.selected_markers_streams:
79+
ret[stream.name] = stream.annotations
80+
return ret
81+
82+
@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
92+
6793
@property
6894
def selected_streams(self):
6995
return [self.available_streams[idx] for idx in self.selected_stream_indices]
7096

97+
@property
98+
def selected_data_streams(self):
99+
return [self.available_streams[idx] for idx in self.selected_stream_indices if self.available_streams[idx].is_mne_raw_compatible]
100+
101+
@property
102+
def selected_markers_streams(self):
103+
return [self.available_streams[idx] for idx in self.selected_stream_indices if self.available_streams[idx].is_mne_annotations_compatible]
104+
71105
@property
72106
def selected_stream_names(self):
73107
return [stream.name for stream in self.selected_streams]
74108

75109
@property
76-
def raw_all(self):
77-
ret = dict()
78-
for stream in self.selected_streams:
79-
ret[stream.name] = stream.raw
80-
return ret
110+
def selected_data_stream_names(self):
111+
return [stream.name for stream in self.selected_data_streams]
112+
113+
@property
114+
def selected_markers_stream_names(self):
115+
return [stream.name for stream in self.selected_markers_streams]
81116

82-
def print_available_streams(self):
83-
print(f"List of available streams in XDF file {self.file_path}:")
84-
for stream in self.available_streams:
85-
print(f" Stream id {stream.id} of type '{stream.type}' with name '{stream.name}'")
86-
print(f" Channel names: {','.join(stream.ch_names)}")
87-
print(f" Channel types: {','.join(stream.ch_types)}")
88-
89117
def get_streams_for_type(self, stream_type: str):
90118
return [stream for stream in self.available_streams if stream.type == stream_type]
91119

@@ -113,8 +141,13 @@ def select_streams_by_type(self, stream_type: str) -> list:
113141

114142
if self.verbose:
115143
print(f"Looking for streams of type '{stream_type}'")
116-
117-
self.selected_stream_indices = self.get_stream_indices_for_type(stream_type)
144+
145+
if stream_type is not None:
146+
self.selected_stream_indices = self.get_stream_indices_for_type(stream_type)
147+
else:
148+
# Use all the mne raw compatible
149+
# TODO this if is complicated
150+
self.selected_stream_indices = [idx for idx, stream in enumerate(self.available_streams) if stream.is_mne_compatible]
118151

119152
# Assert that we have found at least one real stream of selected type
120153
if len(self.selected_stream_indices) == 0:
@@ -156,7 +189,7 @@ def convert_streams_to_mne(self):
156189
"""
157190

158191
# Find if all the stream have unique names (true if any stream name is duplicated)
159-
names = [stream.name for stream in self.selected_streams]
192+
names = self.selected_stream_names
160193

161194
for stream in self.selected_streams:
162195
if self.verbose:
@@ -168,18 +201,14 @@ def convert_streams_to_mne(self):
168201

169202
stream.convert_to_mne(self.scale, append_stream_id=has_duplicate_names)
170203

171-
# Save file is asked too
172-
if self.save_FIF_path is not None:
173-
stream.save_fif_file(self.save_FIF_path)
174-
175204
if self.verbose:
176205
print("Convertion done.")
177206

178-
def save_fif_files(self, dir_path):
179-
return [stream.save_fif_file(dir_path) for stream in self.selected_streams]
207+
def save_to_fif_files(self, dir_path):
208+
return [stream.save_to_fif_file(dir_path) for stream in self.selected_data_streams]
180209

181210
def rename_channels(self, new_names):
182-
return [stream.rename_channels(new_names) for stream in self.selected_streams]
211+
return [stream.rename_channels(new_names) for stream in self.selected_data_streams]
183212

184213
def set_montage(self, montage):
185214
"""
@@ -190,12 +219,22 @@ def set_montage(self, montage):
190219
montage: A path to a local Dig montage or a mne standard montage.
191220
"""
192221
if self.verbose or True:
193-
print(f"Setting '{montage}' as the montage for streams: {','.join(self.selected_stream_names)}")
222+
print(f"Setting '{montage}' as the montage for streams: {','.join(self.selected_data_stream_names)}")
194223

195224

196-
for stream in self.selected_streams:
225+
for stream in self.selected_data_streams:
197226
try:
198227
stream.set_montage(montage)
199228
except ValueError as e:
200229
warnings.warn(f"Invalid montage given to mne.set_montage(): {montage}")
201230
raise e
231+
232+
def __str__(self):
233+
available_streams_str = "\n ".join([str(stream) for stream in self.available_streams])
234+
selected_streams_str = ",".join([stream.name for stream in self.selected_streams])
235+
return f"""XDFImport with {len(self.available_streams)} available streams and {len(self.selected_streams)} selected streams (loaded from {self.file_path})
236+
Available streams:
237+
{available_streams_str}
238+
Selected streams: [{selected_streams_str}]
239+
"""
240+

hypyp/xdf/xdf_stream.py

Lines changed: 80 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from typing import List
12
import os
23

34
import mne
@@ -6,12 +7,13 @@
67

78
class XDFStream():
89
stream: StreamData
9-
metadata: dict
1010
metadata_desc: dict
11-
raw: mne.io.RawArray
11+
raw: mne.io.RawArray | None
12+
annotations: mne.Annotations | None
13+
time_offset: float
1214

1315
@staticmethod
14-
def stream_type_to_mne_type(stream_type: str, type_map: dict = None):
16+
def stream_type_to_mne_ch_type(stream_type: str, type_map: dict = None):
1517
if type_map is not None and stream_type in type_map.keys():
1618
return type_map[stream_type]
1719

@@ -39,12 +41,12 @@ def stream_type_to_mne_type(stream_type: str, type_map: dict = None):
3941
@staticmethod
4042
def get_mne_ch_types(stream_type: str, ch_names: list, type_map: dict = None):
4143
ch_types = []
42-
base_type = XDFStream.stream_type_to_mne_type(stream_type, type_map=type_map)
44+
type_from_stream = XDFStream.stream_type_to_mne_ch_type(stream_type, type_map=type_map)
4345
for ch_name in ch_names:
44-
ch_type = base_type
46+
ch_type = type_from_stream
4547
if stream_type.startswith('Acc'):
4648
ch_type = 'misc'
47-
if ch_name.startswith('Marker'):
49+
if ch_name.startswith('Markers'):
4850
ch_type = 'stim'
4951
if ch_name.startswith('Acc'):
5052
ch_type = 'misc'
@@ -61,9 +63,14 @@ def __init__(self, stream, mne_type_map: dict = None):
6163
self.stream = stream
6264
self.mne_type_map = mne_type_map
6365
self.metadata_desc = {}
66+
self.raw = None
67+
self.annotations = None
6468
if self.stream["info"]["desc"] and self.stream["info"]["desc"][0]:
6569
self.metadata_desc = self.stream["info"]["desc"][0]
66-
70+
71+
self.time_offset = float(self.stream['info']['created_at'][0])
72+
73+
6774
@property
6875
def name(self):
6976
return self.stream["info"]["name"][0]
@@ -80,10 +87,28 @@ def id(self):
8087
def srate(self):
8188
return float(self.stream["info"]["nominal_srate"][0])
8289

90+
@property
91+
def is_mne_compatible(self):
92+
return self.is_mne_raw_compatible or self.is_mne_annotations_compatible
93+
94+
@property
95+
def is_mne_raw_compatible(self):
96+
if self.type.lower() == 'quality':
97+
return False
98+
return self.srate > 0
99+
100+
@property
101+
def is_mne_annotations_compatible(self):
102+
return self.srate == 0.0
103+
83104
@property
84105
def time_series(self):
85106
# Here we check wether the data is in the correct shape () and transpose it if necessary
86107
# ! We assume that no EEG recoding would have more channels than sample point !
108+
# TODO debug time stamps
109+
# print("BBBBBBBBBBBBBBBBBBB")
110+
# print(self.time_offset) # Time stamps of the markers
111+
# print(self.stream['time_stamps']) # Time stamps of the markers
87112
if self.stream["time_series"].shape[0] > self.stream["time_series"].shape[1]:
88113
return self.stream["time_series"].T
89114

@@ -118,12 +143,45 @@ def ch_names(self):
118143
def ch_types(self):
119144
return XDFStream.get_mne_ch_types(self.type, self.ch_names, self.mne_type_map)
120145

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)
155+
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
158+
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)
164+
165+
else:
166+
self.markers = None
167+
168+
def get_unique_name(self, append_stream_id: bool = False):
169+
unique_stream_name = self.name
170+
if append_stream_id:
171+
unique_stream_name += f'-{self.id}'
172+
return unique_stream_name
173+
174+
121175
def convert_to_mne(self, scale: float | str | None, append_stream_id: bool = False, verbose: bool = False):
122-
mne_info = self.create_mne_info(append_stream_id=append_stream_id, verbose=verbose)
123-
self.raw = self.create_mne_raw(mne_info, scale, verbose=verbose)
124-
self.unique_stream_name = mne_info['subject_info']['his_id']
176+
self.unique_name = self.get_unique_name(append_stream_id=append_stream_id)
177+
if self.is_mne_raw_compatible:
178+
mne_info = self.create_mne_info(verbose=verbose)
179+
self.raw = self.create_mne_raw(mne_info, scale, verbose=verbose)
180+
181+
if self.is_mne_annotations_compatible:
182+
self.create_mne_annotations()
125183

126-
def create_mne_info(self, append_stream_id: bool = False, verbose: bool = False):
184+
def create_mne_info(self, verbose: bool = False):
127185
"""
128186
Create a mne.info object from the XDF's EEG stream metadata.
129187
@@ -139,14 +197,9 @@ def create_mne_info(self, append_stream_id: bool = False, verbose: bool = False)
139197

140198
mne_info = mne.create_info(ch_names, ch_types=ch_types, sfreq=self.srate, verbose=verbose)
141199

142-
unique_stream_name = self.name
143-
if append_stream_id:
144-
unique_stream_name += f'-{self.id}'
145-
146200
mne_info["subject_info"] = {
147-
"id": unique_stream_name,
148201
"id": self.id, # integer identifier of the subject
149-
"his_id": self.name, # string identifier of the subject
202+
"his_id": self.unique_name, # string identifier of the subject
150203
}
151204

152205
return mne_info
@@ -222,10 +275,17 @@ def rename_channels(self, new_names):
222275

223276
self.raw.rename_channels(mapping)
224277

225-
def save_fif_file(self, dir_path: str):
278+
def save_to_fif_file(self, dir_path: str):
226279
os.makedirs(dir_path, exist_ok=True)
227-
save_file_path = os.path.join(dir_path, f"{self.unique_stream_name}_raw.fif")
280+
save_file_path = os.path.join(dir_path, f"{self.unique_name}_raw.fif")
228281
self.raw.save(save_file_path, overwrite=True)
229282
return save_file_path
230283

231-
284+
285+
def __str__(self):
286+
ch_names = ','.join(self.ch_names)
287+
ch_types = ','.join(self.ch_types)
288+
return f"""Stream id {self.id} of type '{self.type}' with name '{self.name}'
289+
Sampling Rate: {self.srate}
290+
Channel names: [{ch_names}]
291+
Channel types: [{ch_types}]"""

0 commit comments

Comments
 (0)