44import re
55import numpy as np
66from mne_bids import BIDSPath
7+ import pandas as pd
8+ from numpy .polynomial .polynomial import Polynomial
79
810from ..._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