Skip to content

Commit e4e01a7

Browse files
committed
eyelink sync updates, first commit
1 parent 5469d3a commit e4e01a7

File tree

2 files changed

+203
-23
lines changed

2 files changed

+203
-23
lines changed

mne_bids_pipeline/_config.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1136,11 +1136,85 @@
11361136
```
11371137
"""
11381138

1139+
# ## Sync with eyelink
1140+
#
1141+
# These parameters determine how eyelink syncing is performed and reported
1142+
11391143
sync_eyelink: bool = False
1144+
"""
1145+
Carry out eyelink syncing if True
1146+
"""
11401147

11411148
remove_blink_saccades: bool = True
1142-
sync_eventtype_regex: str = ".*"
1149+
"""
1150+
Apparently not implemented?
1151+
"""
1152+
1153+
sync_eventtype_regex: str = ""
1154+
"""
1155+
Regular expression which will be used to select events in the EEG file for synchronisation
1156+
1157+
???+ example "Example"
1158+
```python
1159+
sync_eventtype_regex = ".*_grab_this" # select all events with some text, immediately followed by "_grab_this"
1160+
```
1161+
"""
1162+
11431163
sync_eventtype_regex_et: str = ""
1164+
"""
1165+
Regular expression which will be used to select events in the eye-tracking file for synchronisation
1166+
1167+
???+ example "Example"
1168+
```python
1169+
sync_eventtype_regex_et = ".*_grab_this" # select all events with some text, immediately followed by "_grab_this"
1170+
```
1171+
"""
1172+
sync_heog_ch: tuple[str, str] | str | None = None
1173+
"""
1174+
The HEOG channel(s) to use for assessing synchronisation by means of cross correlation
1175+
1176+
???+ example "Example"
1177+
```python
1178+
sync_heog_ch = ["HEOGL", "HEOGR"] # subtract the second channel from the first to make a bipolar HEOG channel
1179+
sync_heog_ch = "HEOGL" # use the HEOGL channel
1180+
sync_heog_ch = None # skip HEOG cross correlation
1181+
```
1182+
"""
1183+
1184+
sync_et_ch: str | None = None
1185+
"""
1186+
The ET channel to use for assessing synchronisation by means of cross correlation
1187+
1188+
???+ example "Example"
1189+
```python
1190+
1191+
sync_heog_ch = "xpos_left" # use the xpos_left channel
1192+
sync_heog_ch = None # skip HEOG cross correlation
1193+
```
1194+
"""
1195+
1196+
sync_heog_highpass: float = 0.1
1197+
"""
1198+
High-pass filter to apply to the HEOG channel. Filtered signal will not be saved.
1199+
"""
1200+
1201+
sync_heog_lowpass: float = 10
1202+
"""
1203+
Low-pass filter to apply to the HEOG channel. Filtered signal will not be saved.
1204+
"""
1205+
1206+
sync_plot_samps: int | tuple[int, int] | None = None
1207+
"""
1208+
When displaying HEOG-ET cross-correlation, constrict plotting to sync_plot_samps[0] to sync_plot_samps[1] around the midpoint
1209+
1210+
???+ example "Example"
1211+
```python
1212+
1213+
sync_plot_samps = 500 # plot -500 to 500 around the midpoint
1214+
sync_plot_samps = (-300, 500) # plot -300 to 500 around the midpoint
1215+
sync_plot_samps = None # plot the entire cross correlation
1216+
```
1217+
"""
11441218

11451219
# ### SSP, ICA, and artifact regression
11461220

mne_bids_pipeline/steps/preprocessing/_05b_sync_eyelink.py

Lines changed: 128 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,38 @@
1919
from ..._report import _open_report
2020
from ..._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

2355
def 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 += "\nHEOG 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"\nThere 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-
217321
def 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

Comments
 (0)