Skip to content
Draft
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
21 changes: 14 additions & 7 deletions mne/cuda.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 34 additions & 14 deletions mne/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
23 changes: 19 additions & 4 deletions mne/viz/tests/test_raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,26 +292,35 @@ 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)
# ± 1000 fT, 400 fT/cm, 20 µV
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:
# 1 green vline, 3 data, 3 scalebars
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()]
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion tools/install_pre_requirements.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
Loading