Skip to content

Commit d955ab7

Browse files
committed
Extract and synchronise ET events
1 parent 2366e2b commit d955ab7

File tree

1 file changed

+73
-13
lines changed

1 file changed

+73
-13
lines changed

mne_bids_pipeline/steps/preprocessing/_05b_sync_eyelink.py

Lines changed: 73 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import re
55
import numpy as np
66
from mne_bids import BIDSPath
7+
import pandas as pd
8+
from numpy.polynomial.polynomial import Polynomial
79

810
from ..._config_utils import (
911
_bids_kwargs,
@@ -167,12 +169,21 @@ def sync_eyelink(
167169

168170
raw_fnames = [in_files.pop(f"raw_run-{run}") for run in cfg.runs]
169171
et_fnames = [in_files.pop(f"et_run-{run}") for run in cfg.runs]
170-
171172
logger.info(**gen_log_kwargs(message=f"Found the following eye-tracking files: {et_fnames}"))
172173
out_files = dict()
173-
bids_basename = raw_fnames[0].copy().update(processing=None, split=None, run=None)
174-
out_files["eyelink"] = bids_basename.copy().update(processing="eyelink", suffix="raw")
174+
bids_basename = raw_fnames[0].copy().update(processing=None, split=None, run=None) #TODO: Do we need to remove the run here?
175+
out_files["eyelink_eeg"] = bids_basename.copy().update(processing="eyelink", suffix="raw")
175176
del bids_basename
177+
178+
logger.info(**gen_log_kwargs(message=f"Create `beh` folder for eye-tracking events."))
179+
out_dir_beh = cfg.deriv_root / f"sub-{subject}"
180+
if session is not None:
181+
out_dir_beh /= f"ses-{session}"
182+
183+
out_dir_beh /= "beh"
184+
out_dir_beh.mkdir(exist_ok=True, parents=True) # TODO: Check whether the parameter settings make sense or if there is a danger that something could be accidentally overwritten
185+
186+
out_files["eyelink_et_events"] = et_fnames[0].copy().update(root=cfg.deriv_root, suffix="et_events", extension=".tsv")
176187

177188
for idx, (run, raw_fname,et_fname) in enumerate(zip(cfg.runs, raw_fnames,et_fnames)):
178189
msg = f"Syncing Eyelink ({et_fname.basename}) and EEG data ({raw_fname.basename})."
@@ -188,7 +199,7 @@ def sync_eyelink(
188199
subprocess.run(["edf2asc", et_fname]) # TODO: Still needs to be tested
189200
et_fname.update(extension='.asc')
190201

191-
raw_et = mne.io.read_raw_eyelink(et_fname, find_overlaps=True)
202+
raw_et = mne.io.read_raw_eyelink(et_fname, find_overlaps=False) # TODO: Make find_overlaps optional
192203

193204
# If the user did not specify a regular expression for the eye-tracking sync events, it is assumed that it's
194205
# identical to the regex for the EEG sync events
@@ -208,6 +219,7 @@ def sync_eyelink(
208219
# Set all nan values in the eye-tracking data to 0 (to make resampling possible)
209220
# TODO: Decide whether this is a good approach or whether interpolation (e.g. of blinks) is useful
210221
# TODO: Decide about setting the values (e.g. for blinks) back to nan after synchronising the signals
222+
# TODO: Tip: With `mne.preprocessing.annotate_nan` you could get the timings comparatively easy, and then after `realign_raw` put nans on top.
211223
np.nan_to_num(raw_et._data, copy=False, nan=0.0)
212224
logger.info(**gen_log_kwargs(message=f"The eye-tracking data contained nan values. They were replaced with zeros."))
213225

@@ -218,14 +230,12 @@ def sync_eyelink(
218230

219231
# Add ET data to EEG
220232
raw.add_channels([raw_et], force_update_info=True)
221-
raw._raw_extras.append(raw_et._raw_extras)
222233

223234
# Also add ET annotations to EEG
224235
# first mark et sync event descriptions so we can differentiate them later
225-
for idx, desc in enumerate(raw_et.annotations.description):
226-
if re.search(cfg.sync_eventtype_regex_et, desc):
227-
raw_et.annotations.description[idx] = "ET_" + desc
228-
raw.set_annotations(mne.annotations._combine_annotations(raw.annotations,
236+
# TODO: For now all ET events will be marked with ET and added to the EEG annotations, maybe later filter for certain events only
237+
raw_et.annotations.description = map(lambda desc: "ET_" + desc, raw_et.annotations.description)
238+
raw.set_annotations(mne.annotations._combine_annotations(raw.annotations,
229239
raw_et.annotations,
230240
0,
231241
raw.first_samp,
@@ -235,14 +245,64 @@ def sync_eyelink(
235245
msg = f"Saving synced data to disk."
236246
logger.info(**gen_log_kwargs(message=msg))
237247
raw.save(
238-
out_files["eyelink"],
248+
out_files["eyelink_eeg"],
239249
overwrite=True,
240250
split_naming="bids", # TODO: Find out if we need to add this or not
241251
split_size=cfg._raw_split_size, # ???
242252
)
243253
# no idea what the split stuff is...
244-
_update_for_splits(out_files, "eyelink") # TODO: Find out if we need to add this or not
245-
254+
_update_for_splits(out_files, "eyelink_eeg") # TODO: Find out if we need to add this or not
255+
256+
# Extract and concatenate eye-tracking event data frames
257+
et_dfs = raw_et._raw_extras[0]["dfs"]
258+
df_list = [] # List to collect extracted data frames before concatenation
259+
260+
# Extract fixations, saccades and blinks data frames
261+
for df_name, trial_type in zip(["fixations", "saccades", "blinks"], ["fixation", "saccade", "blink"]):
262+
df = et_dfs[df_name]
263+
df["trial_type"] = trial_type
264+
df_list.append(df)
265+
266+
et_combined_df = pd.concat(df_list, ignore_index=True)
267+
et_combined_df.rename(columns={"time":"onset"}, inplace=True)
268+
et_combined_df.sort_values(by="onset", inplace=True, ignore_index=True)
269+
et_combined_df = et_combined_df[ # Adapt column order
270+
[
271+
"onset", # needs to be first (BIDS convention)
272+
"duration",
273+
"end_time",
274+
"trial_type",
275+
"eye",
276+
"fix_avg_x",
277+
"fix_avg_y",
278+
"fix_avg_pupil_size",
279+
"sacc_start_x",
280+
"sacc_start_y",
281+
"sacc_end_x",
282+
"sacc_end_y",
283+
"sacc_visual_angle",
284+
"peak_velocity"
285+
]
286+
]
287+
288+
# Synchronize eye-tracking events with EEG data
289+
290+
# Recalculate regression coefficients (because the realign_raw function does not output them)
291+
# Code snippet from `mne.preprocessing.realign_raw` function:
292+
# https://github.com/mne-tools/mne-python/blob/b44c46ae7f9b6ffc5318b5d64f12906c1f2d875c/mne/preprocessing/realign.py#L69-L71
293+
poly = Polynomial.fit(x=et_sync_times, y=sync_times, deg=1)
294+
converted = poly.convert(domain=(-1, 1))
295+
[zero_ord, first_ord] = converted.coef
296+
# print(zero_ord, first_ord)
297+
298+
# Synchronize time stamps of ET events
299+
et_combined_df["onset"] = (et_combined_df["onset"] * first_ord + zero_ord)
300+
et_combined_df["end_time"] = (et_combined_df["end_time"] * first_ord + zero_ord)
301+
# TODO: Do we also need to "synchronize"/adapt the duration column?
302+
303+
msg = f"Saving synced eye-tracking events to disk."
304+
logger.info(**gen_log_kwargs(message=msg))
305+
et_combined_df.to_csv(out_files["eyelink_et_events"], sep="\t", index=False)
246306

247307
# Add to report
248308
fig, axes = plt.subplots(2, 2, figsize=(19.2, 19.2))
@@ -301,7 +361,7 @@ def sync_eyelink(
301361

302362
# regression between synced events
303363
# we assume here that these annotations are sequential pairs of the same event in raw and et. otherwise this will break
304-
raw_onsets = [annot["onset"] for annot in raw.annotations if re.match(cfg.sync_eventtype_regex, annot["description"])]
364+
raw_onsets = [annot["onset"] for annot in raw.annotations if re.match("^(?!.*ET_)"+cfg.sync_eventtype_regex, annot["description"])]
305365
et_onsets = [annot["onset"] for annot in raw.annotations if re.match("ET_"+cfg.sync_eventtype_regex_et, annot["description"])]
306366

307367
if len(raw_onsets) != len(et_onsets):

0 commit comments

Comments
 (0)