diff --git a/doc/changes/dev/13940.bugfix.rst b/doc/changes/dev/13940.bugfix.rst new file mode 100644 index 00000000000..a8be41790f0 --- /dev/null +++ b/doc/changes/dev/13940.bugfix.rst @@ -0,0 +1 @@ +Fix :func:`mne.preprocessing.eyetracking.interpolate_blinks` so that all annotation descriptions passed via ``match`` are interpolated over, rather than only ``BAD_blink`` ones, by :newcontrib:`gaoflow`. diff --git a/doc/changes/names.inc b/doc/changes/names.inc index a8a0726e9d7..ed6d4e8b5f2 100644 --- a/doc/changes/names.inc +++ b/doc/changes/names.inc @@ -111,6 +111,7 @@ .. _Florin Pop: https://github.com/florin-pop .. _Frederik Weber: https://github.com/Frederik-D-Weber .. _Fu-Te Wong: https://github.com/zuxfoucault +.. _gaoflow: https://github.com/gaoflow .. _Gennadiy Belonosov: https://github.com/Genuster .. _Geoff Brookshire: https://github.com/gbrookshire .. _George O'Neill: https://georgeoneill.github.io diff --git a/mne/preprocessing/eyetracking/_pupillometry.py b/mne/preprocessing/eyetracking/_pupillometry.py index cfc630b6441..4b62bfe7f8a 100644 --- a/mne/preprocessing/eyetracking/_pupillometry.py +++ b/mne/preprocessing/eyetracking/_pupillometry.py @@ -63,7 +63,9 @@ def interpolate_blinks(raw, buffer=0.05, match="BAD_blink", interpolate_gaze=Fal if not blink_annots: warn(f"No annotations matching {match} found. Aborting.") return raw - _interpolate_blinks(raw, buffer, blink_annots, interpolate_gaze=interpolate_gaze) + _interpolate_blinks( + raw, buffer, blink_annots, interpolate_gaze=interpolate_gaze, match=match + ) # remove bad from the annotation description for desc in match: @@ -73,10 +75,17 @@ def interpolate_blinks(raw, buffer=0.05, match="BAD_blink", interpolate_gaze=Fal return raw -def _interpolate_blinks(raw, buffer, blink_annots, interpolate_gaze): +def _interpolate_blinks(raw, buffer, blink_annots, interpolate_gaze, match): """Interpolate eyetracking signals during blinks in-place.""" logger.info("Interpolating missing data during blinks...") pre_buffer, post_buffer = buffer + # Derive the start/stop time (in seconds) of every matched annotation. Passing + # ``match`` (rather than re-querying a hardcoded ``"BAD_blink"``) ensures all + # descriptions in ``match`` are interpolated over and that the start/stop times + # stay aligned with ``blink_annots`` in the loop below. + starts, ends = _annotations_starts_stops(raw, match) + starts = starts / raw.info["sfreq"] + ends = ends / raw.info["sfreq"] # iterate over each eyetrack channel and interpolate the blinks interpolated_chs = [] for ci, ch_info in enumerate(raw.info["chs"]): @@ -88,9 +97,6 @@ def _interpolate_blinks(raw, buffer, blink_annots, interpolate_gaze): continue # Create an empty boolean mask mask = np.zeros_like(raw.times, dtype=bool) - starts, ends = _annotations_starts_stops(raw, "BAD_blink") - starts = np.divide(starts, raw.info["sfreq"]) - ends = np.divide(ends, raw.info["sfreq"]) for annot, start, end in zip(blink_annots, starts, ends): if "ch_names" not in annot or not annot["ch_names"]: msg = f"Blink annotation missing values for 'ch_names' key: {annot}" diff --git a/mne/preprocessing/eyetracking/tests/test_pupillometry.py b/mne/preprocessing/eyetracking/tests/test_pupillometry.py index 19077e44d81..1b9c3424ba5 100644 --- a/mne/preprocessing/eyetracking/tests/test_pupillometry.py +++ b/mne/preprocessing/eyetracking/tests/test_pupillometry.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from mne import create_info +from mne import Annotations, create_info from mne.annotations import _annotations_starts_stops from mne.datasets.testing import data_path, requires_testing_data from mne.io import RawArray, read_raw_eyelink @@ -71,3 +71,44 @@ def test_interpolate_blinks(buffer, match, cause_error, interpolate_gaze, crop): if interpolate_gaze: assert not np.isnan(data[0, blink_ind]).any() # left eye assert not np.isnan(data[1, blink_ind]).any() # right eye + + +def _pupil_raw_with_gaps(): + """Synthetic pupil Raw with two missing segments at known sample ranges.""" + info = create_info(["pupil_left", "pupil_right"], 100.0, ["pupil", "pupil"]) + data = np.ones((2, 1000)) * 5.0 + data[:, 100:150] = 0.0 # a blink + data[:, 600:650] = 0.0 # a non-blink gap (e.g. NaN/dropout) + raw = RawArray(data, info) + raw.set_annotations( + Annotations( + onset=[1.0, 6.0], + duration=[0.5, 0.5], + description=["BAD_blink", "BAD_NAN"], + ch_names=[("pupil_left", "pupil_right"), ("pupil_left", "pupil_right")], + ) + ) + return raw + + +def test_interpolate_blinks_respects_match(): + """All descriptions in ``match`` are interpolated, not just ``BAD_blink``. + + Regression test for #13880: ``interpolate_blinks`` exposes a ``match`` + parameter, but the segment start/stop times were derived from a hardcoded + ``"BAD_blink"`` query, so any other matched annotation (e.g. ``BAD_NAN`` + from :func:`mne.preprocessing.annotate_nan`) was silently ignored. + """ + # default match="BAD_blink": only the blink gap is filled, the other remains + raw = _pupil_raw_with_gaps() + interpolate_blinks(raw, buffer=(0.0, 0.0)) + data = raw.get_data() + assert not np.any(data[:, 100:150] == 0.0) # blink interpolated + assert np.all(data[:, 600:650] == 0.0) # unmatched gap untouched + + # match both descriptions: both gaps are interpolated + raw = _pupil_raw_with_gaps() + interpolate_blinks(raw, buffer=(0.0, 0.0), match=["BAD_blink", "BAD_NAN"]) + data = raw.get_data() + assert not np.any(data[:, 100:150] == 0.0) + assert not np.any(data[:, 600:650] == 0.0)