1919from ..._report import _open_report
2020from ..._run import _prep_out_files , _update_for_splits , failsafe_run , save_logs
2121
22+ def _check_HEOG_ET_vars (cfg ):
23+ # helper function for sorting out heog and et channels
24+ bipolar = False
25+ if isinstance (cfg .sync_heog_ch , tuple ):
26+ heog_ch = "bi_HEOG"
27+ bipolar = True
28+ else :
29+ heog_ch = cfg .sync_heog_ch
30+
31+ return heog_ch , bipolar
32+
33+ def _mark_calibration_as_bad (raw ):
34+ # marks recalibration beginnings and ends as one bad segment
35+ cur_idx = None
36+ cur_start_time = 0.
37+ for annot in raw .annotations :
38+ calib_match = re .match (".* Recalibration (start|end) \\ | (\\ d*)" , annot ["description" ])
39+ if not calib_match : continue
40+
41+ calib_status , calib_idx = calib_match .group (1 ), calib_match .group (2 )
42+ if calib_idx == cur_idx and calib_status == "end" :
43+ duration = annot ["onset" ] - cur_start_time
44+ raw .annotations .append (cur_start_time , duration , f"BAD_Recalibrate { calib_idx } " )
45+ cur_idx , cur_start_time = None , 0.
46+ elif calib_status == "start" and cur_idx is None :
47+ cur_idx = calib_idx
48+ cur_start_time = annot ["onset" ]
49+ elif calib_status == "start" and cur_idx is not None :
50+ raise ValueError (f"Annotation { annot ["description" ]} could not be assigned membership" )
51+
52+ return raw
53+
2254
2355def get_input_fnames_sync_eyelink (
2456 * ,
@@ -104,6 +136,7 @@ def sync_eyelink(
104136) -> dict :
105137 """Run Sync for Eyelink."""
106138 import matplotlib .pyplot as plt
139+ from scipy .signal import correlate
107140
108141 raw_fnames = [in_files .pop (f"raw_run-{ run } " ) for run in cfg .runs ]
109142 et_fnames = [in_files .pop (f"et_run-{ run } " ) for run in cfg .runs ]
@@ -114,8 +147,6 @@ def sync_eyelink(
114147 bids_basename = raw_fnames [0 ].copy ().update (processing = None , split = None , run = None )
115148 out_files ["eyelink" ] = bids_basename .copy ().update (processing = "eyelink" , suffix = "raw" )
116149 del bids_basename
117-
118-
119150
120151 for idx , (run , raw_fname ,et_fname ,et_edf_fname ) in enumerate (zip (cfg .runs , raw_fnames ,et_fnames ,et_edf_fnames )):
121152 msg = f"Syncing eyelink data (fake for now) { raw_fname .basename } "
@@ -137,7 +168,6 @@ def sync_eyelink(
137168
138169 et_sync_times = [annotation ["onset" ] for annotation in raw_et .annotations if re .search (cfg .sync_eventtype_regex_et ,annotation ["description" ])]
139170 sync_times = [annotation ["onset" ] for annotation in raw .annotations if re .search (cfg .sync_eventtype_regex , annotation ["description" ])]
140-
141171 assert len (et_sync_times ) == len (sync_times ),f"Detected eyetracking and EEG sync events were not of equal size ({ len (et_sync_times )} vs { len (sync_times )} ). Adjust your regular expressions via 'sync_eventtype_regex_et' and 'sync_eventtype_regex' accordingly"
142172 #logger.info(**gen_log_kwargs(message=f"{et_sync_times}"))
143173 #logger.info(**gen_log_kwargs(message=f"{sync_times}"))
@@ -154,18 +184,24 @@ def sync_eyelink(
154184
155185 #mne.preprocessing.eyetracking.interpolate_blinks(raw_et, buffer=(0.05, 0.05), interpolate_gaze=True)
156186
157-
158187 # Align the data
159188 mne .preprocessing .realign_raw (raw , raw_et , sync_times , et_sync_times )
160189
161-
162190 # Add ET data to EEG
163191 raw .add_channels ([raw_et ], force_update_info = True )
164192 raw ._raw_extras .append (raw_et ._raw_extras )
165193
166194 # Also add ET annotations to EEG
167- raw .set_annotations (mne .annotations ._combine_annotations (raw .annotations ,raw_et .annotations ,0 ,raw .first_samp ,raw_et .first_samp ,raw .info ["sfreq" ]))
168-
195+ # first mark et sync event descriptions so we can differentiate them later
196+ for idx , desc in enumerate (raw_et .annotations .description ):
197+ if re .search (cfg .sync_eventtype_regex_et , desc ):
198+ raw_et .annotations .description [idx ] = "ET_" + desc
199+ raw .set_annotations (mne .annotations ._combine_annotations (raw .annotations ,
200+ raw_et .annotations ,
201+ 0 ,
202+ raw .first_samp ,
203+ raw_et .first_samp ,
204+ raw .info ["sfreq" ]))
169205
170206 msg = f"Saving synced data to disk."
171207 logger .info (** gen_log_kwargs (message = msg ))
@@ -178,25 +214,96 @@ def sync_eyelink(
178214 # no idea what the split stuff is...
179215 _update_for_splits (out_files , "eyelink" ) # TODO: Find out if we need to add this or not
180216
181-
182217
183218 # Add to report
219+ fig , axes = plt .subplots (2 , 2 , figsize = (19.2 , 19.2 ))
220+ msg = f"Adding figure to report."
221+ logger .info (** gen_log_kwargs (message = msg ))
184222 tags = ("sync" , "eyelink" )
185223 title = "Synchronize Eyelink"
224+ caption = (
225+ f"The `realign_raw` function from MNE was used to align an Eyelink `asc` file to the M/EEG file."
226+ f"The Eyelink-data was added as annotations and appended as new channels."
227+ )
228+ if cfg .sync_heog_ch is None or cfg .sync_et_ch is None :
229+ # we need both an HEOG channel and ET channel specified to do cross-correlation
230+ msg = f"HEOG and/or ET channel not specified; cannot produce cross-correlation for report."
231+ logger .info (** gen_log_kwargs (message = msg ))
232+ caption += "\n HEOG and/or eye tracking channels were not specified and no cross-correlation was performed."
233+ axes [0 ,0 ].text (0.5 , 0.5 , 'HEOG/ET cross-correlation unavailable' , fontsize = 34 ,
234+ horizontalalignment = 'center' , verticalalignment = 'center' , transform = axes [0 ,0 ].transAxes )
235+ axes [0 ,0 ].axis ("off" )
236+ else :
237+ # return _prep_out_files(exec_params=exec_params, out_files=out_files)
238+ # calculate cross correlation of HEOG with ET
239+ heog_ch , bipolar = _check_HEOG_ET_vars (cfg )
240+ if bipolar :
241+ # create bipolar HEOG
242+ raw = mne .set_bipolar_reference (raw , * cfg .sync_heog_ch , ch_name = heog_ch , drop_refs = False )
243+
244+ raw .filter (l_freq = cfg .sync_heog_highpass , h_freq = cfg .sync_heog_lowpass , picks = heog_ch ) # get rid of drift and high freq noise
245+ _mark_calibration_as_bad (raw )
246+ # extract HEOG and ET as arrays
247+ eye_arrays = raw .get_data (picks = [heog_ch , cfg .sync_et_ch ], reject_by_annotation = "omit" )
248+ # cross correlate them
249+ corr = correlate (eye_arrays [0 ,], eye_arrays [1 ,], mode = "same" ) / eye_arrays .shape [1 ]
250+ # plot cross correlation
251+ # figure out how much we plot
252+ midpoint = len (corr ) // 2
253+ plot_samps = (- cfg .sync_plot_samps , cfg .sync_plot_samps ) if isinstance (cfg .sync_plot_samps , int ) else cfg .sync_plot_samps
254+ if isinstance (plot_samps , tuple ):
255+ x_range = np .arange (plot_samps [0 ], plot_samps [1 ])
256+ y_range = np .arange (midpoint + plot_samps [0 ], midpoint + plot_samps [1 ])
257+ else : # None
258+ y_range = np .arange (len (corr ))
259+ x_range = y_range - midpoint
260+ # plot
261+ axes [0 ,0 ].plot (x_range , corr [y_range ], color = "black" )
262+ axes [0 ,0 ].axvline (linestyle = "--" )
263+ axes [0 ,0 ].set_title ("Cross correlation HEOG and ET" )
264+ axes [0 ,0 ].set_xlabel ("Samples" )
265+ axes [0 ,0 ].set_ylabel ("X correlation" )
266+ # calculate delay
267+ delay_idx = abs (corr ).argmax () - midpoint
268+ delay_time = delay_idx * (raw .times [1 ] - raw .times [0 ])
269+ caption += f"\n There was an estimated synchronisation delay of { delay_idx } samples ({ delay_time :.3f} seconds.)"
270+
271+ # regression between synced events
272+ # we assume here that these annotations are sequential pairs of the same event in raw and et. otherwise this will break
273+ raw_onsets = [annot ["onset" ] for annot in raw .annotations if re .match (cfg .sync_eventtype_regex , annot ["description" ])]
274+ et_onsets = [annot ["onset" ] for annot in raw .annotations if re .match ("ET_" + cfg .sync_eventtype_regex , annot ["description" ])]
275+ if len (raw_onsets ) != len (et_onsets ):
276+ raise ValueError (f"Lengths of raw { len (raw_onsets )} and ET { len (et_onsets )} onsets do not match." )
277+ # regress and plot
278+ coef = np .polyfit (raw_onsets , et_onsets , 1 )
279+ preds = np .poly1d (coef )(raw_onsets )
280+ resids = et_onsets - preds
281+ axes [0 ,1 ].plot (raw_onsets , et_onsets , "o" , alpha = 0.3 , color = "black" )
282+ axes [0 ,1 ].plot (raw_onsets , preds , "--k" )
283+ axes [0 ,1 ].set_title ("Regression" )
284+ axes [0 ,1 ].set_xlabel ("Raw onsets (seconds)" )
285+ axes [0 ,1 ].set_ylabel ("ET onsets (seconds)" )
286+ # residuals
287+ axes [1 ,0 ].plot (np .arange (len (resids )), resids , "o" , alpha = 0.3 , color = "black" )
288+ axes [1 ,0 ].axhline (linestyle = "--" )
289+ axes [1 ,0 ].set_title ("Residuals" )
290+ axes [1 ,0 ].set_ylabel ("Residual (seconds)" )
291+ axes [1 ,0 ].set_xlabel ("Samples" )
292+ # histogram of distances between events in time
293+ axes [1 ,1 ].hist (np .array (raw_onsets ) - np .array (et_onsets ), bins = 11 , range = (- 5 ,5 ), color = "black" )
294+ axes [1 ,1 ].set_title ("Raw - ET event onset distances histogram" )
295+ axes [1 ,1 ].set_xlabel ("milliseconds" )
296+ # this doesn't seem to help, though it should...
297+ fig .tight_layout ()
298+
186299 with _open_report (
187300 cfg = cfg ,
188301 exec_params = exec_params ,
189302 subject = subject ,
190303 session = session ,
191304 task = cfg .task ,
192305 ) as report :
193-
194-
195- caption = (
196- f"The `realign_raw` function from MNE was used to align an Eyelink `asc` file to the M/EEG file."
197- f"The Eyelink-data was added as annotations and appended as new channels."
198- )
199- fig = raw_et .plot (scalings = dict (eyegaze = 1e3 ))
306+ caption = caption
200307 report .add_figure (
201308 fig = fig ,
202309 title = "Eyelink data" ,
@@ -211,9 +318,6 @@ def sync_eyelink(
211318
212319
213320
214-
215-
216-
217321def get_config (
218322 * ,
219323 config : SimpleNamespace ,
@@ -227,6 +331,11 @@ def get_config(
227331 remove_blink_saccades = config .remove_blink_saccades ,
228332 sync_eventtype_regex = config .sync_eventtype_regex ,
229333 sync_eventtype_regex_et = config .sync_eventtype_regex_et ,
334+ sync_heog_ch = config .sync_heog_ch ,
335+ sync_et_ch = config .sync_et_ch ,
336+ sync_heog_highpass = config .sync_heog_highpass ,
337+ sync_heog_lowpass = config .sync_heog_lowpass ,
338+ sync_plot_samps = config .sync_plot_samps ,
230339 processing = "filt" if config .regress_artifact is None else "regress" ,
231340 _raw_split_size = config ._raw_split_size ,
232341
@@ -255,7 +364,4 @@ def main(*, config: SimpleNamespace) -> None:
255364 for subject in get_subjects (config )
256365 for session in get_sessions (config )
257366 )
258- save_logs (config = config , logs = logs )
259-
260-
261-
367+ save_logs (config = config , logs = logs )
0 commit comments