Skip to content

Commit cadaa00

Browse files
authored
Merge pull request #294 from CAMBI-tech/multimodal
Multimodal (gaze) initial integration!
2 parents c6bce92 + 17051a2 commit cadaa00

40 files changed

+1666
-178
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Our last release candidate before the official 2.0 release!
77
- Multimodal Acquisition and Querying
88
- Support for multiple devices in online querying #286
99
- Support for trigger handling relative to a given device #293
10+
- Model
11+
- Offline analysis of multimodal fusion. First version of gaze model is completed #294 Gaze reshaper is updated #294 Visualization for the gaze classification is added #294
1012
- Stimuli
1113
- Updates to ensure stimuli are presented at the same frequency #287
1214
- Dynamic Selection Window

MANIFEST.in

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
include bcipy/parameters/*.json
2+
include bcipy/parameters/experiment/*.json
3+
include bcipy/parameters/field/*.json
4+
include bcipy/language/lms/*
5+
include bcipy/language/sets/*
6+
include bcipy/static/images/*/*
7+
include bcipy/static/sounds/*/*

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ This a list of the major modules and their functionality. Each module will conta
102102

103103
- `acquisition`: acquires data, gives back desired time series, saves to file at end of session.
104104
- `display`: handles display of stimuli on screen and passes back stimuli timing.
105-
- `signal`: eeg signal models, filters, processing, evaluators and viewers.
105+
- `signal`: eeg signal models, gaze signal models, filters, processing, evaluators and viewers.
106106
- `gui`: end-user interface into registered bci tasks and parameter editing. See BCInterface.py.
107107
- `helpers`: helpful functions needed for interactions between modules, basic I/O, and data visualization.
108108
- `language`: gives probabilities of next symbols during typing.

bcipy/config.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
EXPERIMENT_DATA_FILENAME = 'experiment_data.json'
1515
BCIPY_ROOT = Path(__file__).resolve().parent
1616
ROOT = BCIPY_ROOT.parent
17-
DEFAULT_EXPERIMENT_PATH = f'{ROOT}/.bcipy/experiment'
18-
DEFAULT_FIELD_PATH = f'{ROOT}/.bcipy/field'
17+
DEFAULT_EXPERIMENT_PATH = f'{BCIPY_ROOT}/parameters/experiment'
18+
DEFAULT_FIELD_PATH = f'{BCIPY_ROOT}/parameters/field'
1919

2020
DEFAULT_PARAMETER_FILENAME = 'parameters.json'
2121
DEFAULT_PARAMETERS_PATH = f'{BCIPY_ROOT}/parameters/{DEFAULT_PARAMETER_FILENAME}'
@@ -28,12 +28,13 @@
2828
STATIC_IMAGES_PATH = f'{STATIC_PATH}/images'
2929
STATIC_AUDIO_PATH = f'{STATIC_PATH}/sounds'
3030
BCIPY_LOGO_PATH = f'{STATIC_IMAGES_PATH}/gui/cambi.png'
31-
PREFERENCES_PATH = f'{ROOT}/.bcipy/bcipy_cache'
31+
PREFERENCES_PATH = f'{ROOT}/bcipy_cache'
3232
LM_PATH = f'{BCIPY_ROOT}/language/lms'
3333
SIGNAL_MODEL_FILE_SUFFIX = '.pkl'
3434

3535
# core data configuration
3636
RAW_DATA_FILENAME = 'raw_data'
37+
EYE_TRACKER_FILENAME_PREFIX = 'eyetracker_data'
3738
TRIGGER_FILENAME = 'triggers.txt'
3839
SESSION_DATA_FILENAME = 'session.json'
3940
SESSION_SUMMARY_FILENAME = 'session.xlsx'

bcipy/display/components/__init__.py

Whitespace-only changes.

bcipy/helpers/acquisition.py

+19-7
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,19 @@
22
import logging
33
import subprocess
44
import time
5-
from typing import Dict, List, Tuple, Optional
5+
from typing import Dict, List, Optional, Tuple
66

77
import numpy as np
88

9+
from bcipy.acquisition import (ClientManager, LslAcquisitionClient,
10+
LslDataServer, await_start,
11+
discover_device_spec)
912
from bcipy.acquisition.devices import (DeviceSpec, preconfigured_device,
1013
with_content_type)
11-
from bcipy.acquisition import (LslAcquisitionClient, await_start,
12-
LslDataServer, discover_device_spec,
13-
ClientManager)
14+
from bcipy.config import BCIPY_ROOT
15+
from bcipy.config import DEFAULT_DEVICE_SPEC_FILENAME as spec_name
16+
from bcipy.config import RAW_DATA_FILENAME
1417
from bcipy.helpers.save import save_device_specs
15-
from bcipy.config import BCIPY_ROOT, RAW_DATA_FILENAME, DEFAULT_DEVICE_SPEC_FILENAME as spec_name
1618

1719
log = logging.getLogger(__name__)
1820

@@ -62,7 +64,7 @@ def init_eeg_acquisition(
6264
await_start(dataserver)
6365

6466
device_spec = init_device(content_type, device_name)
65-
raw_data_name = f'{RAW_DATA_FILENAME}.csv' if content_type == 'EEG' else None
67+
raw_data_name = raw_data_filename(device_spec)
6668

6769
client = init_lsl_client(parameters, device_spec, save_folder,
6870
raw_data_name)
@@ -80,6 +82,16 @@ def init_eeg_acquisition(
8082
return (manager, servers)
8183

8284

85+
def raw_data_filename(device_spec: DeviceSpec) -> str:
86+
"""Returns the name of the raw data file for the given device."""
87+
if device_spec.content_type == 'EEG':
88+
return f'{RAW_DATA_FILENAME}.csv'
89+
90+
content_type = '_'.join(device_spec.content_type.split()).lower()
91+
name = '_'.join(device_spec.name.split()).lower()
92+
return f"{content_type}_data_{name}.csv"
93+
94+
8395
def init_device(content_type: str,
8496
device_name: Optional[str] = None) -> DeviceSpec:
8597
"""Initialize a DeviceSpec for the given content type.
@@ -206,7 +218,7 @@ def analysis_channels(channels: List[str], device_spec: DeviceSpec) -> list:
206218
----------
207219
- channels(list(str)): list of channel names from the raw_data
208220
(excluding the timestamp)
209-
- device_spec(str): device from which the data was collected
221+
- device_spec: device from which the data was collected
210222
211223
Returns
212224
--------

bcipy/helpers/convert.py

+7-9
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,20 @@
33
import os
44
import tarfile
55
from pathlib import Path
6-
from typing import Dict, List, Tuple, Optional
6+
from typing import Dict, List, Optional, Tuple
77

8+
import mne
89
import numpy as np
9-
10-
from pyedflib import FILETYPE_EDFPLUS, EdfWriter, FILETYPE_BDFPLUS
10+
from mne.io import RawArray
11+
from pyedflib import FILETYPE_BDFPLUS, FILETYPE_EDFPLUS, EdfWriter
1112
from tqdm import tqdm
1213

13-
from bcipy.config import RAW_DATA_FILENAME, TRIGGER_FILENAME, DEFAULT_PARAMETER_FILENAME
14+
from bcipy.config import (DEFAULT_PARAMETER_FILENAME, RAW_DATA_FILENAME,
15+
TRIGGER_FILENAME)
1416
from bcipy.helpers.load import load_json_parameters, load_raw_data
1517
from bcipy.helpers.raw_data import RawData
16-
from bcipy.signal.process import Composition, get_default_transform
1718
from bcipy.helpers.triggers import trigger_decoder, trigger_durations
18-
19-
import mne
20-
from mne.io import RawArray
21-
19+
from bcipy.signal.process import Composition, get_default_transform
2220

2321
logger = logging.getLogger(__name__)
2422

bcipy/helpers/demo/demo_load_raw_data.py

-30
This file was deleted.

bcipy/helpers/demo/demo_visualization.py

+8-11
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,16 @@
1313
- - `python bcipy/helpers/demo/demo_visualization.py --save"`
1414
this will save the visualizations generated to the provided or selected path
1515
"""
16-
from bcipy.config import (TRIGGER_FILENAME, DEFAULT_PARAMETER_FILENAME,
17-
RAW_DATA_FILENAME, DEFAULT_DEVICE_SPEC_FILENAME)
16+
import bcipy.acquisition.devices as devices
17+
from bcipy.config import (DEFAULT_DEVICE_SPEC_FILENAME,
18+
DEFAULT_PARAMETER_FILENAME, RAW_DATA_FILENAME,
19+
TRIGGER_FILENAME)
1820
from bcipy.helpers.acquisition import analysis_channels
19-
from bcipy.signal.process import get_default_transform
20-
from bcipy.helpers.load import (
21-
load_experimental_data,
22-
load_json_parameters,
23-
load_raw_data,
24-
)
25-
from bcipy.helpers.visualization import visualize_erp
21+
from bcipy.helpers.load import (load_experimental_data, load_json_parameters,
22+
load_raw_data)
2623
from bcipy.helpers.triggers import TriggerType, trigger_decoder
27-
import bcipy.acquisition.devices as devices
28-
24+
from bcipy.helpers.visualization import visualize_erp
25+
from bcipy.signal.process import get_default_transform
2926

3027
if __name__ == '__main__':
3128
import argparse

bcipy/helpers/raw_data.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ def append(self, row: List):
181181
self._rows.append(row)
182182
self._dataframe = None
183183

184+
def __str__(self) -> str:
185+
return f"RawData({self.daq_type})"
186+
187+
def __repr__(self) -> str:
188+
return f"RawData({self.daq_type})"
189+
184190

185191
def maybe_float(val):
186192
"""Attempt to convert the given value to float. If conversion fails return
@@ -323,7 +329,7 @@ def load(filename: str) -> RawData:
323329

324330

325331
def read_metadata(file_obj: TextIO) -> Tuple[str, float]:
326-
"""Reads the metadata from an open raw data file and retuns the result as
332+
"""Reads the metadata from an open raw data file and returns the result as
327333
a tuple. Increments the reader.
328334
329335
Parameters

bcipy/helpers/stimuli.py

+106-1
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def __call__(self,
129129
Returns:
130130
reshaped_data (np.ndarray): inquiry data of shape (Channels, Inquiries, Samples)
131131
labels (np.ndarray): integer label for each inquiry. With `trials_per_inquiry=K`,
132-
a label of [0, K-1] indicates the position of `target_label`, or label of K indicates
132+
a label of [0, K-1] indicates the position of `target_label`, or label of [0 ... 0] indicates
133133
`target_label` was not present.
134134
reshaped_trigger_timing (List[List[int]]): For each inquiry, a list of the sample index where each trial
135135
begins, accounting for the prestim buffer that may have been added to the front of each inquiry.
@@ -229,6 +229,111 @@ def extract_trials(
229229
return np.stack(new_trials, 1) # C x T x S
230230

231231

232+
class GazeReshaper:
233+
def __call__(self,
234+
inq_start_times: List[float],
235+
target_symbols: List[str],
236+
gaze_data: np.ndarray,
237+
sample_rate: int,
238+
symbol_set: List[str],
239+
channel_map: List[int] = None,
240+
) -> dict:
241+
"""Extract inquiry data and labels. Different from the EEG inquiry, the gaze inquiry window starts with
242+
the first flicker and ends with the last flicker in the inquiry. Each inquiry has a length of ~3 seconds.
243+
The labels are provided in the target_symbols list. It returns a Dict, where keys are the target symbols and
244+
the values are inquiries (appended in order of appearance) where the corresponding target symbol is prompted.
245+
Optional outputs:
246+
reshape_data is the list of data reshaped into (Inquiries, Channels, Samples), where inquirires are appended
247+
in chronological order. labels returns the list of target symbols in each inquiry.
248+
249+
Args:
250+
inq_start_times (List[float]): Timestamp of each event in seconds
251+
target_symbols (List[str]): Prompted symbol in each inquiry
252+
gaze_data (np.ndarray): shape (channels, samples) eye tracking data
253+
sample_rate (int): sample rate of data provided in eeg_data
254+
channel_map (List[int], optional): Describes which channels to include or discard.
255+
Defaults to None; all channels will be used.
256+
257+
Returns:
258+
data_by_targets (dict): Dictionary where keys are the symbol set and values are the appended inquiries
259+
for each symbol. dict[Key] = (np.ndarray) of shape (Channels, Samples)
260+
261+
reshaped_data (List[float]) [optional]: inquiry data of shape (Inquiries, Channels, Samples)
262+
labels (List[str]) [optional] : Target symbol in each inquiry.
263+
"""
264+
if channel_map:
265+
# Remove the channels that we are not interested in
266+
channels_to_remove = [idx for idx, value in enumerate(channel_map) if value == 0]
267+
gaze_data = np.delete(gaze_data, channels_to_remove, axis=0)
268+
269+
# Find the value closest to (& greater than) inq_start_times
270+
gaze_data_timing = gaze_data[-1, :].tolist()
271+
272+
start_times = []
273+
for times in inq_start_times:
274+
temp = list(filter(lambda x: x > times, gaze_data_timing))
275+
if len(temp) > 0:
276+
start_times.append(temp[0])
277+
278+
triggers = []
279+
for val in start_times:
280+
triggers.append(gaze_data_timing.index(val))
281+
282+
# Label for every inquiry
283+
labels = target_symbols
284+
285+
# Create a dictionary with symbols as keys and data as values
286+
# 'A': [], 'B': [] ...
287+
data_by_targets = {}
288+
for symbol in symbol_set:
289+
data_by_targets[symbol] = []
290+
291+
window_length = 3 # seconds, total length of flickering after prompt for each inquiry
292+
293+
reshaped_data = []
294+
# Merge the inquiries if they have the same target letter:
295+
for i, inquiry_index in enumerate(triggers):
296+
start = inquiry_index
297+
stop = int(inquiry_index + (sample_rate * window_length)) # (60 samples * 3 seconds)
298+
# Check if the data exists for the inquiry:
299+
if stop > len(gaze_data[0, :]):
300+
continue
301+
302+
reshaped_data.append(gaze_data[:, start:stop])
303+
# (Optional) extracted data (Inquiries x Channels x Samples)
304+
305+
# Populate the dict by appending the inquiry to the correct key:
306+
data_by_targets[labels[i]].append(gaze_data[:, start:stop])
307+
308+
# After populating, flatten the arrays in the dictionary to (Channels x Samples):
309+
for symbol in symbol_set:
310+
if len(data_by_targets[symbol]) > 0:
311+
data_by_targets[symbol] = np.transpose(np.array(data_by_targets[symbol]), (1, 0, 2))
312+
data_by_targets[symbol] = np.reshape(data_by_targets[symbol], (len(data_by_targets[symbol]), -1))
313+
314+
# Note that this is a workaround to the issue of having different number of targetness in
315+
# each symbol. If a target symbol is prompted more than once, the data is appended to the dict as a list.
316+
# Which is why we need to convert it to a (np.ndarray) and flatten the dimensions.
317+
# This is not ideal, but it works for now.
318+
319+
# return np.stack(reshaped_data, 0), labels
320+
return data_by_targets
321+
322+
@staticmethod
323+
def centralize_all_data(data, symbol_pos):
324+
""" Using the symbol locations in matrix, centralize all data (in Tobii units).
325+
This data will only be used in certain model types.
326+
Args:
327+
data (np.ndarray): Data in shape of num_channels x num_samples
328+
symbol_pos (np.ndarray(float)): Array of the current symbol posiiton in Tobii units
329+
Returns:
330+
data (np.ndarray): Centralized data in shape of num_channels x num_samples
331+
"""
332+
for i in range(len(data)):
333+
data[i] = data[i] - symbol_pos
334+
return data
335+
336+
232337
class TrialReshaper(Reshaper):
233338
def __call__(self,
234339
trial_targetness_label: list,

bcipy/helpers/system_utils.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,9 @@ def configure_logger(
211211
'[%(threadName)-9s][%(asctime)s][%(name)s][%(levelname)s]: %(message)s'))
212212
root_logger.addHandler(handler)
213213

214-
print(f'Printing all BciPy logs to: {logfile}')
214+
# print to console the absolute path of the log file to aid in debugging
215+
path_to_logs = os.path.abspath(logfile)
216+
print(f'Printing all BciPy logs to: {path_to_logs}')
215217

216218
if version:
217219
logging.info(f'Start of Session for BciPy Version: ({version})')

bcipy/helpers/tests/test_acquisition.py

+23-2
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
from pathlib import Path
55
from unittest.mock import Mock, patch
66

7+
from bcipy.acquisition.devices import DeviceSpec
78
from bcipy.config import DEFAULT_PARAMETERS_PATH
8-
from bcipy.helpers.acquisition import (init_device, init_eeg_acquisition,
9+
from bcipy.helpers.acquisition import (RAW_DATA_FILENAME, init_device,
10+
init_eeg_acquisition,
911
max_inquiry_duration, parse_stream_type,
10-
server_spec, stream_types)
12+
raw_data_filename, server_spec,
13+
stream_types)
1114
from bcipy.helpers.load import load_json_parameters
1215
from bcipy.helpers.save import init_save_data_structure
1316

@@ -166,6 +169,24 @@ def test_server_spec_without_named_device(self, with_content_type_mock):
166169
with_content_type_mock.assert_called_with('EEG')
167170
self.assertEqual(device_spec, device1)
168171

172+
def test_raw_data_filename_eeg(self):
173+
"""Test generation of filename for EEG devices"""
174+
device = DeviceSpec(name='DSI-24',
175+
channels=[],
176+
sample_rate=300.0,
177+
content_type='EEG')
178+
self.assertEqual(raw_data_filename(device), f'{RAW_DATA_FILENAME}.csv')
179+
180+
def test_raw_data_filename_eye_tracker(self):
181+
"""Test generation of filename for EyeTracker devices"""
182+
183+
device = DeviceSpec(name='Tobii-P0',
184+
channels=[],
185+
sample_rate=60,
186+
content_type='EYETRACKER')
187+
self.assertEqual(raw_data_filename(device),
188+
'eyetracker_data_tobii-p0.csv')
189+
169190

170191
if __name__ == '__main__':
171192
unittest.main()

0 commit comments

Comments
 (0)