Skip to content

Commit b65a218

Browse files
authored
Merge branch 'main' into perf/reader-block-sizes
2 parents 475ada5 + 03e4240 commit b65a218

12 files changed

Lines changed: 119 additions & 118 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a ``block`` parameter to :func:`mne.viz.plot_source_estimates`, :meth:`mne.SourceEstimate.plot` and :meth:`mne.VolSourceEstimate.plot_3d` to halt execution until the figure is closed, by `Cedric Conday`_.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Speed up channel selection and projector construction by replacing linear channel-name lookups with dictionaries, by `Bruno Aristimunha`_.

mne/_fiff/pick.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -319,13 +319,17 @@ def pick_channels(ch_names, include, exclude=(), ordered=True, *, verbose=None):
319319
include = list(ch_names)
320320
if not isinstance(exclude, list):
321321
exclude = list(exclude)
322+
# ch_names is unique (checked above), so a lookup table is safe here; the
323+
# list scans this replaces made the loop quadratic in the channel count
324+
name_to_idx = {name: ii for ii, name in enumerate(ch_names)}
325+
exclude_set = set(exclude)
322326
sel, missing = list(), list()
323327
for name in include:
324-
if name in ch_names:
325-
if name not in exclude:
326-
sel.append(ch_names.index(name))
327-
else:
328+
idx = name_to_idx.get(name)
329+
if idx is None:
328330
missing.append(name)
331+
elif name not in exclude_set:
332+
sel.append(idx)
329333
if len(missing) and ordered:
330334
raise ValueError(
331335
f"Missing channels from ch_names required by include:\n{missing}"
@@ -1408,13 +1412,20 @@ def _picks_str_to_idx(
14081412
# second: match all to channel names
14091413
#
14101414

1415+
# setdefault keeps the first occurrence, so this matches list.index()
1416+
# exactly even for duplicate names (which are rejected further down, so
1417+
# the difference is not reachable today -- it just keeps the swap honest)
1418+
name_to_idx = {}
1419+
for ii, name in enumerate(info["ch_names"]):
1420+
name_to_idx.setdefault(name, ii)
14111421
bad_names = []
14121422
picks_name = list()
14131423
for pick in picks:
1414-
try:
1415-
picks_name.append(info["ch_names"].index(pick))
1416-
except ValueError:
1424+
idx = name_to_idx.get(pick)
1425+
if idx is None:
14171426
bad_names.append(pick)
1427+
else:
1428+
picks_name.append(idx)
14181429

14191430
#
14201431
# third: match all to types

mne/_fiff/proj.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -889,11 +889,15 @@ def _make_projector(projs, ch_names, bads=(), include_active=True, inplace=False
889889
# the projection vectors omitting bad channels
890890
sel = []
891891
vecsel = []
892-
p_set = set(p["data"]["col_names"]) # faster membership access
892+
# map name -> position once; .index() here made this loop quadratic
893+
# in the channel count (~16x slower at 306 channels, ~47x at 1000)
894+
p_idx = {name: i for i, name in enumerate(p["data"]["col_names"])}
893895
for c, name in enumerate(ch_names):
894-
if name not in bads and name in p_set:
895-
sel.append(c)
896-
vecsel.append(p["data"]["col_names"].index(name))
896+
if name not in bads:
897+
vi = p_idx.get(name)
898+
if vi is not None:
899+
sel.append(c)
900+
vecsel.append(vi)
897901

898902
# If there is something to pick, pickit
899903
nrow = p["data"]["nrow"]

mne/_fiff/tests/test_pick.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,3 +762,28 @@ def test_get_channel_types_equiv(meg, eeg, ordered):
762762
types = np.array(raw.get_channel_types(picks=picks))
763763
types_iter = np.array([channel_type(raw.info, idx) for idx in picks])
764764
assert_array_equal(types, types_iter)
765+
766+
767+
def test_pick_channels_matches_by_name():
768+
"""Test picking maps names to positions regardless of the order given."""
769+
ch_names = ["a", "b", "c", "d"]
770+
# include is out of order, repeats a name, and names one that is excluded
771+
sel = pick_channels(ch_names, ["d", "b", "b", "c"], exclude=["c"], ordered=False)
772+
assert_array_equal(sel, [1, 3])
773+
# with ordered=True the caller's order is kept, duplicates and all
774+
sel = pick_channels(ch_names, ["d", "b", "b"], ordered=True)
775+
assert_array_equal(sel, [3, 1, 1])
776+
# a name that is not present is an error, not a silent skip
777+
with pytest.raises(ValueError, match="Missing channels"):
778+
pick_channels(ch_names, ["a", "nope"], ordered=True)
779+
780+
781+
def test_picks_to_idx_duplicate_names():
782+
"""Test a repeated channel name resolves to its first position."""
783+
with pytest.warns(RuntimeWarning, match="not unique"):
784+
info = create_info(["a", "b", "a"], 100.0, "eeg")
785+
# "b" is unambiguous; picking it must not be shifted by the duplicate "a"
786+
assert_array_equal(_picks_to_idx(info, ["b"]), [1])
787+
# an ambiguous name is rejected rather than silently resolved
788+
with pytest.raises(ValueError, match="could not be interpreted"):
789+
_picks_to_idx(info, ["a"])

mne/gui/_coreg.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
_plot_mri_fiducials,
6666
_plot_sensors_3d,
6767
)
68-
from ..viz.backends._utils import _qt_app_exec, _qt_safe_window
68+
from ..viz.backends._utils import _qt_block, _qt_safe_window
6969
from ..viz.utils import safe_event
7070

7171

@@ -380,8 +380,8 @@ def _get_default(var, val):
380380
self._trans_modified = False
381381
self._mri_fids_modified = False
382382
self._mri_scale_modified = False
383-
if block and self._renderer._kind != "notebook":
384-
_qt_app_exec(self._renderer.figure.store["app"])
383+
if block and self._renderer._kind == "qt":
384+
_qt_block(self._renderer.plotter.app_window)
385385

386386
def _set_subjects_dir(self, subjects_dir):
387387
if subjects_dir is None or not subjects_dir:

mne/gui/_dipolefit.py

Lines changed: 29 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,15 @@
2020
from ..cov import _ensure_cov, compute_whitener, make_ad_hoc_cov
2121
from ..dipole import Dipole, fit_dipole
2222
from ..evoked import Evoked
23-
from ..forward import convert_forward_solution, make_field_map
23+
from ..forward import make_field_map
2424
from ..forward._make_forward import _ForwardModeler
2525
from ..minimum_norm import apply_inverse, make_inverse_operator
2626
from ..source_estimate import (
2727
SourceEstimate,
2828
_BaseSurfaceSourceEstimate,
2929
read_source_estimate,
3030
)
31-
from ..source_space import setup_volume_source_space
31+
from ..source_space._source_space import _complete_vol_src, _make_discrete_source_space
3232
from ..surface import _normal_orth
3333
from ..transforms import _get_trans, _get_transforms_to_coord_frame, apply_trans
3434
from ..utils import (
@@ -842,10 +842,6 @@ def _on_dipole_toggle(active, dip_num):
842842
def _on_dipole_set_name(name, dip_num):
843843
return self._on_dipole_set_name(name, dip_num)
844844

845-
@_auto_weakref
846-
def _on_dipole_toggle_fix_orientation(fix, dip_num):
847-
return self._on_dipole_toggle_fix_orientation(fix, dip_num)
848-
849845
@_auto_weakref
850846
def _on_dipole_delete(dip_num):
851847
return self._on_dipole_delete(dip_num)
@@ -881,12 +877,9 @@ def _on_dipole_hover(dip_num, hover):
881877
arrow_mesh=arrow_mesh,
882878
color=dip_color,
883879
dip=dip,
884-
fix_ori=True,
885-
fix_position=True,
886880
helmet_coords=helmet_coords,
887881
helmet_pos=helmet_pos,
888882
num=dip_num,
889-
# fit_time=self._current_time,
890883
)
891884
self._dipoles[dip_num] = dipole_dict
892885

@@ -924,16 +917,6 @@ def _on_dipole_hover(dip_num, hover):
924917
enter=partial(_on_dipole_hover, dip_num=dip_num, hover=True),
925918
leave=partial(_on_dipole_hover, dip_num=dip_num, hover=False),
926919
)
927-
widgets.append(
928-
r._dock_add_check_box(
929-
name="Fix ori",
930-
value=True,
931-
callback=partial(
932-
_on_dipole_toggle_fix_orientation, dip_num=dip_num
933-
),
934-
layout=hlayout,
935-
)
936-
)
937920
widgets.append(
938921
r._dock_add_button(
939922
name="",
@@ -1011,58 +994,32 @@ def _fit_timecourses(self):
1011994
# TODO: When two active dipoles have (nearly) identical positions, they
1012995
# collapse to a single point in the discrete source space below, which
1013996
# errors out. Ideal behavior unclear: merge them, or error informatively?
1014-
this_src = setup_volume_source_space(
1015-
"sample",
1016-
pos=dict(
1017-
rr=apply_trans(
1018-
self._head_mri_t,
1019-
np.vstack([d["dip"].pos[0] for d in active_dips]),
1020-
),
1021-
nn=apply_trans(
1022-
self._head_mri_t,
1023-
np.vstack([d["dip"].ori[0] for d in active_dips]),
1024-
),
1025-
),
997+
this_src = _complete_vol_src(
998+
[
999+
_make_discrete_source_space(
1000+
pos=dict(
1001+
rr=np.vstack([d["dip"].pos[0] for d in active_dips]),
1002+
nn=np.vstack([d["dip"].ori[0] for d in active_dips]),
1003+
),
1004+
coord_frame="head",
1005+
)
1006+
]
10261007
)
10271008
this_fwd = self.fwd.compute(this_src)
1028-
this_fwd = convert_forward_solution(this_fwd, surf_ori=False)
10291009

10301010
if self._multi_dipole_method == "Multi dipole (MNE)":
10311011
inv = make_inverse_operator(
1032-
self._evoked.info,
1033-
# fwd,
1034-
this_fwd,
1035-
self._cov,
1036-
fixed=False,
1037-
loose=1.0,
1012+
info=self._evoked.info,
1013+
forward=this_fwd,
1014+
noise_cov=self._cov,
1015+
loose=0,
10381016
depth=0,
10391017
rank=self._rank,
10401018
)
1041-
stc = apply_inverse(
1042-
self._evoked,
1043-
inv,
1044-
method="MNE",
1045-
lambda2=1e-6,
1046-
pick_ori="vector",
1047-
)
1048-
1049-
timecourses = stc.magnitude().data
1050-
orientations = (stc.data / timecourses[:, np.newaxis, :]).transpose(
1051-
0, 2, 1
1052-
)
1053-
fixed_timecourses = stc.project(
1054-
np.array([dip["dip"].ori[0] for dip in active_dips])
1055-
)[0].data
1056-
1019+
stc = apply_inverse(self._evoked, inv, method="MNE", lambda2=1e-6)
10571020
for i, dip in enumerate(active_dips):
1058-
if dip["fix_ori"]:
1059-
dip["timecourse"] = fixed_timecourses[i]
1060-
dip["orientation"] = dip["dip"].ori.repeat(
1061-
len(stc.times), axis=0
1062-
)
1063-
else:
1064-
dip["timecourse"] = timecourses[i]
1065-
dip["orientation"] = orientations[i]
1021+
dip["timecourse"] = stc.data[i]
1022+
dip["orientation"] = dip["dip"].ori.repeat(len(stc.times), axis=0)
10661023
else:
10671024
assert self._multi_dipole_method == "Single dipole" # only other option
10681025
for dip in active_dips:
@@ -1071,20 +1028,16 @@ def _fit_timecourses(self):
10711028
self._cov,
10721029
self._bem,
10731030
pos=dip["dip"].pos[0], # position is always fixed
1074-
ori=dip["dip"].ori[0] if dip["fix_ori"] else None,
1031+
ori=dip["dip"].ori[0],
10751032
trans=self._head_mri_t,
10761033
rank=self._rank,
10771034
n_jobs=self._n_jobs,
10781035
verbose=True,
10791036
)
1080-
if dip["fix_ori"]:
1081-
dip["timecourse"] = dip_with_timecourse.data[0]
1082-
dip["orientation"] = dip["dip"].ori.repeat(
1083-
len(dip_with_timecourse.times), axis=0
1084-
)
1085-
else:
1086-
dip["timecourse"] = dip_with_timecourse.amplitude
1087-
dip["orientation"] = dip_with_timecourse.ori
1037+
dip["timecourse"] = dip_with_timecourse.data[0]
1038+
dip["orientation"] = dip["dip"].ori.repeat(
1039+
len(dip_with_timecourse.times), axis=0
1040+
)
10881041

10891042
# Update matplotlib canvas at the bottom of the window. Timecourses are
10901043
# stored in SI units (Am), but shown in nAm, hence the 1e9 scaling at the
@@ -1261,7 +1214,11 @@ def _update_arrows(self):
12611214
# TODO: Need to expose a public method for setting the multi-dipole method
12621215
def _on_select_method(self, method):
12631216
"""Select the method to use for multi-dipole timecourse fitting."""
1264-
_check_option("method", method, ("Multi dipole (MNE)", "Single dipole"))
1217+
_check_option(
1218+
"method",
1219+
method,
1220+
("Multi dipole (MNE)", "Single dipole"),
1221+
)
12651222
if method == self._multi_dipole_method:
12661223
return
12671224
self._multi_dipole_method = method
@@ -1297,11 +1254,6 @@ def _on_dipole_set_name(self, name, dip_num):
12971254
self._dipoles[dip_num]["dip"].name = name
12981255
self._renderer._mplcanvas.update_plot()
12991256

1300-
def _on_dipole_toggle_fix_orientation(self, fix, dip_num):
1301-
"""Fix dipole orientation when fitting timecourse."""
1302-
self._dipoles[dip_num]["fix_ori"] = bool(fix)
1303-
self._fit_timecourses()
1304-
13051257
def _on_dipole_delete(self, dip_num):
13061258
"""Delete previously fitted dipole."""
13071259
dipole = self._dipoles[dip_num]

mne/gui/tests/test_dipolefit.py

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ def test_dipolefit_gui_dipole_controls(
303303
g._set_camera_preset("Sideways")
304304

305305
# Test toggling dipoles off and on. This is done through the GUI widgets, which are
306-
# ordered: [active, name, fix orientation, delete].
306+
# ordered: [active, name, delete].
307307
dip = mne.read_dipole(fname_dip)[[12, 15]] # 80ms and 90ms
308308
g.add_dipole(dip, name=["rh", "lh"])
309309
dip1, dip2 = g._dipoles.values()
@@ -354,17 +354,6 @@ def test_dipolefit_gui_dipole_controls(
354354
new_timecourses = np.vstack((dip1["timecourse"], dip2["timecourse"]))
355355
assert np.allclose(old_timecourses, new_timecourses, atol=0)
356356

357-
# Toggle fixed orientation off and on.
358-
assert dip1["fix_ori"] and dip2["fix_ori"]
359-
dip1["widgets"][2].set_value(False)
360-
assert not dip1["fix_ori"]
361-
new_timecourses = np.vstack((dip1["timecourse"], dip2["timecourse"]))
362-
assert not np.allclose(old_timecourses, new_timecourses, atol=1e-9)
363-
dip1["widgets"][2].set_value(True)
364-
assert dip1["fix_ori"]
365-
new_timecourses = np.vstack((dip1["timecourse"], dip2["timecourse"]))
366-
assert np.allclose(old_timecourses, new_timecourses, atol=0)
367-
368357
# Change the names of the dipoles.
369358
dip1["widgets"][1].set_value("dipole1")
370359
g._on_dipole_set_name("dipole2", dip2["num"])
@@ -373,7 +362,7 @@ def test_dipolefit_gui_dipole_controls(
373362

374363
# Remove a dipole (through the "delete" button).
375364
line, dot = dip1["line_artist"], dip1["dot_artist"]
376-
dip1["widgets"][3].set_value(None)
365+
dip1["widgets"][2].set_value(None)
377366
assert line not in g._renderer._mplcanvas.axes.lines
378367
assert dot not in g._renderer._mplcanvas.axes.lines
379368
assert len(g.dipoles) == 1
@@ -385,17 +374,6 @@ def test_dipolefit_gui_dipole_controls(
385374
assert list(g._dipoles.keys())[1] == 2
386375
assert list(g._dipoles.values())[1]["num"] == 2 # new dipole number
387376

388-
# Fitting the timecourse of a single dipole, with a free orientation.
389-
g._on_dipole_toggle(False, 2) # only leave a single dipole active
390-
g._on_select_method("Single dipole")
391-
g._renderer._process_events() # run the deferred refit
392-
assert dip2["fix_ori"]
393-
assert_allclose(dip2["orientation"], dip2["dip"].ori.repeat(len(evoked.times), 0))
394-
g._on_dipole_toggle_fix_orientation(False, dip2["num"])
395-
assert not dip2["fix_ori"]
396-
assert dip2["orientation"].shape == (len(evoked.times), 3)
397-
assert not np.allclose(dip2["orientation"][0], dip2["orientation"][-1], atol=1e-9)
398-
399377
g.close()
400378

401379

mne/source_estimate.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ def plot(
778778
view_layout="vertical",
779779
add_data_kwargs=None,
780780
brain_kwargs=None,
781+
block=False,
781782
verbose=None,
782783
):
783784
from .viz import plot_source_estimates
@@ -813,6 +814,7 @@ def plot(
813814
view_layout=view_layout,
814815
add_data_kwargs=add_data_kwargs,
815816
brain_kwargs=brain_kwargs,
817+
block=block,
816818
verbose=verbose,
817819
)
818820
return brain
@@ -2399,6 +2401,7 @@ def plot_3d(
23992401
view_layout="vertical",
24002402
add_data_kwargs=None,
24012403
brain_kwargs=None,
2404+
block=False,
24022405
verbose=None,
24032406
):
24042407
return super().plot(
@@ -2431,6 +2434,7 @@ def plot_3d(
24312434
view_layout=view_layout,
24322435
add_data_kwargs=add_data_kwargs,
24332436
brain_kwargs=brain_kwargs,
2437+
block=block,
24342438
verbose=verbose,
24352439
)
24362440

0 commit comments

Comments
 (0)