From 310fe76bd3f11c3afe77d1e09d7b939ea7254284 Mon Sep 17 00:00:00 2001 From: gaoflow Date: Tue, 2 Jun 2026 01:04:40 +0200 Subject: [PATCH 1/2] Fix interpolate_blinks ignoring non-BAD_blink match descriptions (#13880) `interpolate_blinks` accepts a `match` parameter (str or list of str) and correctly filters `blink_annots` by it, but `_interpolate_blinks` derived the segment start/stop times from a hardcoded `_annotations_starts_stops(raw, "BAD_blink")` query. As a result, annotations whose description was in `match` but did not start with `BAD_blink` (e.g. `BAD_NAN` from `annotate_nan`) were silently dropped, and the `zip(blink_annots, starts, ends)` could misalign. Compute the start/stop times directly from the `blink_annots` that were passed in, using the same onset-sync and sample rounding as before, so the default `BAD_blink` behaviour is unchanged while every matched annotation is now interpolated over. --- doc/changes/names.inc | 1 + .../eyetracking/_pupillometry.py | 14 ++++-- .../eyetracking/tests/test_pupillometry.py | 43 ++++++++++++++++++- 3 files changed, 53 insertions(+), 5 deletions(-) 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..81b5d3740e3 100644 --- a/mne/preprocessing/eyetracking/_pupillometry.py +++ b/mne/preprocessing/eyetracking/_pupillometry.py @@ -5,7 +5,7 @@ import numpy as np from ..._fiff.constants import FIFF -from ...annotations import _annotations_starts_stops +from ...annotations import _sync_onset from ...io import BaseRaw from ...utils import _check_preload, _validate_type, logger, warn @@ -77,6 +77,15 @@ def _interpolate_blinks(raw, buffer, blink_annots, interpolate_gaze): """Interpolate eyetracking signals during blinks in-place.""" logger.info("Interpolating missing data during blinks...") pre_buffer, post_buffer = buffer + # Compute the start/stop time (in seconds, relative to the raw start) of each + # matched annotation directly from ``blink_annots``. Deriving these from the + # passed annotations (rather than re-querying a hardcoded ``"BAD_blink"``) + # ensures every annotation in ``match`` is interpolated over and that the + # start/stop times stay aligned with ``blink_annots`` in the loop below. + onsets = _sync_onset(raw, np.array([annot["onset"] for annot in blink_annots])) + durations = np.array([annot["duration"] for annot in blink_annots]) + starts = raw.time_as_index(onsets, use_rounding=True) / raw.info["sfreq"] + ends = raw.time_as_index(onsets + durations, use_rounding=True) / 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) From d0df66140ffd5efe0f0b7d6d3e70d02812159f69 Mon Sep 17 00:00:00 2001 From: gaoflow Date: Tue, 2 Jun 2026 01:05:06 +0200 Subject: [PATCH 2/2] Add changelog fragment for #13940 --- doc/changes/dev/13940.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/13940.bugfix.rst 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`.