From 6bfda21743a63d929b0f636ebe5c85fb2122b79c Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 23 Jul 2026 12:04:09 -0400 Subject: [PATCH] Fix mne-qt-browser scaling --- mne/cuda.py | 21 +++++++++----- mne/filter.py | 48 ++++++++++++++++++++++--------- mne/viz/tests/test_raw.py | 23 ++++++++++++--- tools/install_pre_requirements.sh | 2 +- 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/mne/cuda.py b/mne/cuda.py index ce46207563d..2be835c016a 100644 --- a/mne/cuda.py +++ b/mne/cuda.py @@ -366,28 +366,35 @@ def _fft_resample(x, new_len, npads, to_removes, cuda_dict=None, pad="reflect_li # this has to go in mne.cuda instead of mne.filter to avoid import errors def _smart_pad(x, n_pad, pad="reflect_limited"): - """Pad vector x.""" + """Pad the last axis of x.""" n_pad = np.asarray(n_pad) assert n_pad.shape == (2,) if (n_pad == 0).all(): return x elif (n_pad < 0).any(): raise RuntimeError("n_pad must be non-negative") + n_times = x.shape[-1] if pad == "reflect_limited": - l_z_pad = np.zeros(max(n_pad[0] - len(x) + 1, 0), dtype=x.dtype) - r_z_pad = np.zeros(max(n_pad[1] - len(x) + 1, 0), dtype=x.dtype) + l_z_pad = np.zeros( + x.shape[:-1] + (max(n_pad[0] - n_times + 1, 0),), dtype=x.dtype + ) + r_z_pad = np.zeros( + x.shape[:-1] + (max(n_pad[1] - n_times + 1, 0),), dtype=x.dtype + ) out = np.concatenate( [ l_z_pad, - 2 * x[0] - x[n_pad[0] : 0 : -1], + 2 * x[..., :1] - x[..., n_pad[0] : 0 : -1], x, - 2 * x[-1] - x[-2 : -n_pad[1] - 2 : -1], + 2 * x[..., -1:] - x[..., -2 : -n_pad[1] - 2 : -1], r_z_pad, - ] + ], + axis=-1, ) else: kwargs = dict() if pad == "reflect": kwargs["reflect_type"] = "odd" - out = np.pad(x, (tuple(n_pad),), pad, **kwargs) + pad_width = [(0, 0)] * (x.ndim - 1) + [tuple(n_pad)] + out = np.pad(x, pad_width, pad, **kwargs) return out diff --git a/mne/filter.py b/mne/filter.py index 977bc51bf95..75edcce6086 100644 --- a/mne/filter.py +++ b/mne/filter.py @@ -537,6 +537,21 @@ def _check_coefficients(system): ) +def _picks_chunks(picks, n_times, max_size=2**22): + """Group picks into chunks of channels to filter together. + + Filtering several channels per call amortizes the per-call Python overhead + (see ``_iir_pad_apply_unpad``), which dominates for short signals and which + blocks other threads via the GIL. The chunks are kept below ``max_size`` + samples so that the temporary copies do not scale with the channel count, + and expressed as slices where possible so that indexing stays view-only. + """ + n_per_chunk = max(1, max_size // n_times) # a single channel can be bigger + chunks = [picks[ii : ii + n_per_chunk] for ii in range(0, len(picks), n_per_chunk)] + # picks is never empty (_prep_for_filtering), so neither is any chunk + return [slice(c[0], c[-1] + 1) if c[-1] - c[0] == len(c) - 1 else c for c in chunks] + + def _iir_filter(x, iir_params, picks, n_jobs, copy, phase="zero"): """Call filtfilt or lfilter.""" # set up array for filtering, reshape to 2D, operate on last axis @@ -569,14 +584,15 @@ def _iir_filter(x, iir_params, picks, n_jobs, copy, phase="zero"): else: fun = partial(signal.lfilter, b=iir_params["b"], a=iir_params["a"], axis=-1) _check_coefficients((iir_params["b"], iir_params["a"])) + chunks = _picks_chunks(picks, x.shape[-1]) parallel, p_fun, n_jobs = parallel_func(fun, n_jobs) if n_jobs == 1: - for p in picks: - x[p] = fun(x=x[p]) + for chunk in chunks: + x[chunk] = fun(x=x[chunk]) else: - data_new = parallel(p_fun(x=x[p]) for p in picks) - for pp, p in enumerate(picks): - x[p] = data_new[pp] + data_new = parallel(p_fun(x=x[chunk]) for chunk in chunks) + for chunk, this_data in zip(chunks, data_new): + x[chunk] = this_data x = _reshape_view(x, orig_shape) return x @@ -2945,12 +2961,16 @@ def _filt_update_info(info, update_info, l_freq, h_freq): def _iir_pad_apply_unpad(x, *, func, padlen, padtype, **kwargs): - x_out = np.reshape(x, (-1, x.shape[-1])).copy() - for this_x in x_out: - x_ext = this_x - if padlen: - x_ext = _smart_pad(x_ext, (padlen, padlen), padtype) - x_ext = func(x=x_ext, axis=-1, padlen=0, **kwargs) - this_x[:] = x_ext[padlen : len(x_ext) - padlen] - x_out = _reshape_view(x_out, x.shape) - return x_out + # All rows are padded and filtered in a single call rather than one at a + # time: SciPy loops over them in C, whereas looping here costs a GIL + # round-trip per row, which makes filtering in a worker thread orders of + # magnitude slower whenever another thread is busy (e.g. a live GUI) + x_out = np.reshape(x, (-1, x.shape[-1])) + x_ext = x_out + if padlen: + x_ext = _smart_pad(x_ext, (padlen, padlen), padtype) + x_ext = func(x=x_ext, axis=-1, padlen=0, **kwargs) + x_out = x_ext[..., padlen : x_ext.shape[-1] - padlen] + if x_out.shape != x.shape: # unpadding leaves a view that cannot be reshaped + x_out = np.ascontiguousarray(x_out) + return _reshape_view(x_out, x.shape) diff --git a/mne/viz/tests/test_raw.py b/mne/viz/tests/test_raw.py index 153caaee19b..5d1b636deae 100644 --- a/mne/viz/tests/test_raw.py +++ b/mne/viz/tests/test_raw.py @@ -292,9 +292,15 @@ def _child_fig_helper(fig, key, attr, browse_backend): ) -def test_scale_bar(browser_backend): +@pytest.mark.parametrize("butterfly", (False, True)) +def test_scale_bar(browser_backend, butterfly): """Test scale bar for raw.""" ismpl = browser_backend.name == "matplotlib" + if butterfly and not ismpl: + import mne_qt_browser._pg_figure + + if not getattr(mne_qt_browser._pg_figure, "_SCALEBARS_FIXED", False): + pytest.skip("butterfly scalebars fixed in a later mne-qt-browser") sfreq = 1000.0 t = np.arange(10000) / sfreq data = np.sin(2 * np.pi * 10.0 * t) @@ -302,9 +308,11 @@ def test_scale_bar(browser_backend): data = data * np.array([[1000e-15, 400e-13, 20e-6]]).T info = create_info(3, sfreq, ("mag", "grad", "eeg")) raw = RawArray(data, info) - fig = raw.plot() + fig = raw.plot(butterfly=butterfly) texts = fig._get_scale_bar_texts() assert len(texts) == 3 # ch_type scale-bars + # butterfly mode draws traces at half amplitude, but the scalebars shrink to + # match, so the reported values are identical either way wants = ("800.0 fT/cm", "2000.0 fT", "40.0 µV") assert texts == wants if ismpl: @@ -312,6 +320,7 @@ def test_scale_bar(browser_backend): assert len(fig.mne.ax_main.lines) == 7 else: assert len(fig.mne.scalebars) == 3 + # each trace spans exactly its scalebar (peak/trough == bar top/bottom) for data, bar in zip(fig.mne.traces, fig.mne.scalebars.values()): y = data.get_ydata() y_lims = [y.min(), y.max()] @@ -1420,10 +1429,17 @@ def test_plotting_scalebars(browser_backend, qtbot): ismpl = browser_backend.name == "matplotlib" raw = mne.io.read_raw_fif(raw_fname).crop(0, 1).load_data() fig = raw.plot(butterfly=True) + # in butterfly mode the traces are drawn at half amplitude, so each scalebar + # spans half of the y-unit that its channel type occupies + delta = 0.25 + if not ismpl: + import mne_qt_browser._pg_figure + + if not getattr(mne_qt_browser._pg_figure, "_SCALEBARS_FIXED", False): + delta = 0.5 # traces were drawn at full amplitude before then if ismpl: ch_types = [text.get_text() for text in fig.mne.ax_main.get_yticklabels()] assert ch_types == ["mag", "grad", "eeg", "eog", "stim"] - delta = 0.25 offset = 0 else: qtbot.wait_exposed(fig) @@ -1434,7 +1450,6 @@ def test_plotting_scalebars(browser_backend, qtbot): qtbot.wait(100) # pragma: no cover # the grad/mag difference here is intentional in _pg_figure.py assert ch_types == ["grad", "mag", "eeg", "eog", "stim"] - delta = 0.5 # TODO: Probably should also be 0.25? offset = 1 assert ch_types.pop(-1) == "stim" for ci, ch_type in enumerate(ch_types, offset): diff --git a/tools/install_pre_requirements.sh b/tools/install_pre_requirements.sh index 8c56b85e2e7..8b22c0ed6b1 100755 --- a/tools/install_pre_requirements.sh +++ b/tools/install_pre_requirements.sh @@ -61,7 +61,7 @@ python -m pip install $STD_ARGS \ "git+https://github.com/the-siesta-group/edfio" \ "https://gitlab.com/obob/pymatreader/-/archive/master/pymatreader-master.zip" \ git+https://github.com/pyqtgraph/pyqtgraph \ - "mne-qt-browser @ https://github.com/mne-tools/mne-qt-browser/archive/refs/heads/main.zip" \ + "mne-qt-browser @ https://github.com/larsoner/mne-qt-browser/archive/refs/heads/fix-276.zip" \ "mne-bids @ https://github.com/mne-tools/mne-bids/archive/refs/heads/main.zip" \ "nibabel @ https://github.com/nipy/nibabel/archive/refs/heads/master.zip" \ git+https://github.com/joblib/joblib \