Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/dev/13940.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions doc/changes/names.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions mne/preprocessing/eyetracking/_pupillometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"]):
Expand All @@ -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}"
Expand Down
43 changes: 42 additions & 1 deletion mne/preprocessing/eyetracking/tests/test_pupillometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)