Skip to content

Commit 21b7cf5

Browse files
payamsashlarsoner
andauthored
Brain GUI modernization (Phase 6) (mne-tools#14222)
Co-authored-by: Eric Larson <larson.eric.d@gmail.com>
1 parent ecdc498 commit 21b7cf5

4 files changed

Lines changed: 184 additions & 18 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The auto-picked peak vertex and its activity trace in the :class:`mne.viz.Brain` GUI now follow the active overlay, by `Payam Sadeghi-Shabestari`_.

mne/viz/_brain/_brain.py

Lines changed: 74 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,7 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True):
581581
self._picked_patches = {key: list() for key in all_keys}
582582
self._picked_points = dict()
583583
self._peak_vertices = {}
584+
self._auto_peak_points = set()
584585
self._trace_meta = {}
585586
self._mouse_no_mvt = -1
586587
self._show_hover_info = False
@@ -920,6 +921,13 @@ def _configure_dock_colormap_widget(self, name):
920921
def select_data_key(value):
921922
self._active_data_key = value
922923
self._refresh_colormap_widgets()
924+
self._update_act_data_smooth()
925+
if self.show_traces:
926+
self._update_peak_vertices()
927+
if self.mpl_canvas is not None:
928+
self.mpl_canvas.axes.relim()
929+
self.mpl_canvas.axes.autoscale_view()
930+
self.mpl_canvas.update_plot()
923931

924932
self.widgets["data_key"] = self._renderer._dock_add_combo_box(
925933
name="Overlay",
@@ -1205,6 +1213,17 @@ def _configure_vertex_time_course(self):
12051213
self.plot_time_line(update=False)
12061214

12071215
# then the picked points
1216+
self._update_peak_vertices()
1217+
1218+
def _update_peak_vertices(self):
1219+
"""(Re)compute the peak vertex per hemi for the active overlay."""
1220+
if self.traces_mode != "vertex":
1221+
# in label mode a VertexSelect would toggle the label containing
1222+
# the peak (and label extraction may not even be possible, e.g.,
1223+
# data added without an src)
1224+
return
1225+
old_peak_vertices = self._peak_vertices
1226+
self._peak_vertices = {}
12081227
for idx, hemi in enumerate(["lh", "rh", "vol"]):
12091228
act_data = self.act_data_smooth.get(hemi, [None])[0]
12101229
if act_data is None:
@@ -1228,16 +1247,46 @@ def _configure_vertex_time_course(self):
12281247
)
12291248
vertex_id = vertices[ind[0]]
12301249
self._peak_vertices[hemi] = vertex_id
1250+
1251+
old_vertex_id = old_peak_vertices.get(hemi)
1252+
if old_vertex_id == vertex_id:
1253+
# same peak vertex but possibly different data: refresh the
1254+
# auto-picked trace in place (manually picked traces keep
1255+
# showing the overlay they were picked from)
1256+
spheres = self._picked_points.get((hemi, vertex_id))
1257+
if (hemi, vertex_id) in self._auto_peak_points and spheres is not None:
1258+
spheres[0]["line"].set_ydata(
1259+
self._vertex_trace_data(hemi, vertex_id)
1260+
)
1261+
continue
1262+
was_auto_picked = (hemi, old_vertex_id) in self._auto_peak_points
1263+
if old_vertex_id is not None and was_auto_picked:
1264+
self._remove_vertex_glyph(hemi=hemi, vertex_id=old_vertex_id)
1265+
# a vertex the user already picked stays a manual pick (and must
1266+
# not be auto-removed on the next overlay switch)
1267+
if (hemi, vertex_id) not in self._picked_points:
1268+
self._auto_peak_points.add((hemi, vertex_id))
12311269
publish(
12321270
self,
12331271
VertexSelect(hemi=hemi, vertex_id=vertex_id, source_id=ind[0]),
12341272
)
1273+
if self.mpl_canvas is not None:
1274+
self.mpl_canvas.sync_traces()
12351275

1236-
def _configure_picking(self):
1276+
def _vertex_trace_data(self, hemi, vertex_id):
1277+
"""Get the active overlay's time course at a mesh vertex."""
1278+
act_data, smooth = self.act_data_smooth[hemi]
1279+
if smooth is not None:
1280+
act_data = (smooth[[vertex_id]] @ act_data)[0]
1281+
else: # full-resolution data
1282+
act_data = act_data[vertex_id].copy()
1283+
return act_data
1284+
1285+
def _update_act_data_smooth(self):
12371286
# get data for each hemi
12381287
from scipy.sparse import csr_array
12391288

1240-
for idx, hemi in enumerate(["vol", "lh", "rh"]):
1289+
for hemi in ["vol", "lh", "rh"]:
12411290
hemi_data = self._data.get(hemi)
12421291
if hemi_data is not None:
12431292
act_data = hemi_data["array"]
@@ -1252,6 +1301,9 @@ def _configure_picking(self):
12521301
)
12531302
self.act_data_smooth[hemi] = (act_data, smooth_mat)
12541303

1304+
def _configure_picking(self):
1305+
self._update_act_data_smooth()
1306+
12551307
self._renderer._update_picking_callback(
12561308
self._on_mouse_move,
12571309
self._on_button_press,
@@ -1656,6 +1708,7 @@ def _add_vertex_glyph(self, hemi, mesh, vertex_id, update=True):
16561708
rindex = lst.index(self._picked_renderer)
16571709
row, col = self._renderer._index_to_loc(rindex)
16581710

1711+
is_peak = self._peak_vertices.get(hemi) == vertex_id
16591712
spheres = list()
16601713
for _ in self._iter_views(hemi):
16611714
# Using _sphere() instead of renderer.sphere() for 2 reasons:
@@ -1667,8 +1720,14 @@ def _add_vertex_glyph(self, hemi, mesh, vertex_id, update=True):
16671720
actor, mesh = self._renderer._sphere(
16681721
center=np.array(center),
16691722
color=color,
1670-
radius=4.0,
1723+
radius=4.5 if is_peak else 3.0,
1724+
resolution=24 if is_peak else 8,
16711725
)
1726+
if is_peak:
1727+
prop = actor.GetProperty()
1728+
prop.SetSpecular(0.6)
1729+
prop.SetSpecularPower(40)
1730+
prop.SetSpecularColor(1, 1, 1)
16721731
spheres.append(dict(mesh=mesh, actor=actor))
16731732

16741733
# add metadata for picking
@@ -1686,6 +1745,7 @@ def _remove_vertex_glyph(self, *, hemi, vertex_id, render=True):
16861745
# to all linked brains, so by the time a given brain's own loop (e.g.
16871746
# in clear_glyphs) reaches this (hemi, vertex_id) it may already be
16881747
# gone; just no-op in that case.
1748+
self._auto_peak_points.discard((hemi, vertex_id))
16891749
spheres = self._picked_points.pop((hemi, vertex_id), None)
16901750
if spheres is None:
16911751
return
@@ -1808,11 +1868,7 @@ def plot_time_course(self, hemi, vertex_id, color, update=True):
18081868
mni_str = None
18091869
mni_suffix = ""
18101870
label = f"{hemi_str}:{str(vertex_id).ljust(6)}{mni_suffix}"
1811-
act_data, smooth = self.act_data_smooth[hemi]
1812-
if smooth is not None:
1813-
act_data = (smooth[[vertex_id]] @ act_data)[0]
1814-
else:
1815-
act_data = act_data[vertex_id].copy()
1871+
act_data = self._vertex_trace_data(hemi, vertex_id)
18161872
line = self.mpl_canvas.plot(
18171873
time,
18181874
act_data,
@@ -2253,14 +2309,6 @@ def add_data(
22532309
self.set_time_interpolation(self.time_interpolation)
22542310
self._update_colormap_range()
22552311

2256-
if "data_key" in self.widgets:
2257-
keys = list(self._all_data.keys())
2258-
self.widgets["data_key"].set_items(keys)
2259-
self.widgets["data_key"].set_value(key)
2260-
if len(keys) > 1:
2261-
self.widgets["data_key"].show()
2262-
self._refresh_colormap_widgets()
2263-
22642312
# 1) add the surfaces first
22652313
actor = None
22662314
for _ in self._iter_views(hemi):
@@ -2277,6 +2325,16 @@ def add_data(
22772325
# _current_time
22782326
self.set_data_smoothing(self._all_data[key]["smoothing_steps"])
22792327

2328+
# setting the data_key widget fires select_data_key, which needs this
2329+
# overlay's smooth_mat (and, for volumes, its grid) to already exist
2330+
if "data_key" in self.widgets:
2331+
keys = list(self._all_data.keys())
2332+
self.widgets["data_key"].set_items(keys)
2333+
self.widgets["data_key"].set_value(key)
2334+
if len(keys) > 1:
2335+
self.widgets["data_key"].show()
2336+
self._refresh_colormap_widgets()
2337+
22802338
# 3) add the other actors
22812339
if colorbar is True:
22822340
# bottom left by default

mne/viz/_brain/tests/test_brain.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,6 +1205,38 @@ def test_brain_overlay_selector(renderer_interactive_pyvistaqt, brain_gc):
12051205
brain.close()
12061206

12071207

1208+
@testing.requires_testing_data
1209+
def test_brain_overlay_switch_label_mode(renderer_interactive_pyvistaqt, brain_gc):
1210+
"""Overlay switching in label-traces mode must not auto-pick labels.
1211+
1212+
Regression: _update_peak_vertices published VertexSelect events in label
1213+
mode, toggling the label containing each hemi's peak on every switch (and
1214+
crashing outright for overlays added without an src).
1215+
"""
1216+
brain = _create_testing_brain(hemi="lh", show_traces="label", initial_time=0)
1217+
assert brain.traces_mode == "label"
1218+
vertices = brain._all_data["data"]["lh"]["vertices"]
1219+
time = brain._all_data["data"]["time"]
1220+
array2 = np.zeros((len(vertices), len(time)))
1221+
array2[0] = 1.0
1222+
brain.add_data(
1223+
array2,
1224+
fmin=0.0,
1225+
fmid=0.5,
1226+
fmax=1.0,
1227+
vertices=vertices,
1228+
time=time,
1229+
hemi="lh",
1230+
colormap="hot",
1231+
key="data2",
1232+
smoothing_steps=0,
1233+
remove_existing=False,
1234+
)
1235+
assert brain._active_data_key == "data2"
1236+
assert sum(len(v) for v in brain._picked_patches.values()) == 0
1237+
brain.close()
1238+
1239+
12081240
@testing.requires_testing_data
12091241
@pytest.mark.parametrize(
12101242
"hemi, src",
@@ -1543,6 +1575,78 @@ def row_text(row):
15431575
new_peak_line = next(iter(brain._picked_points.values()))[0]["line"]
15441576
assert new_peak_line.get_color() == peak_color
15451577

1578+
# the auto-picked "Peak" trace must follow the active overlay; use
1579+
# decimated data with the same smoothing as the current widget value so
1580+
# that select_data_key cannot rely on a smooth_mat computed as a side
1581+
# effect of the smoothing spin box changing (regression: add_data updated
1582+
# the data_key widget before set_data_smoothing, crashing on decimated
1583+
# overlays)
1584+
peak1 = brain._peak_vertices["lh"]
1585+
vertices2 = brain._all_data["data"]["lh"]["vertices"]
1586+
peak2 = int(vertices2[-1] if peak1 != vertices2[-1] else vertices2[-2])
1587+
manual_vertex = next(v for v in (0, 1, 2) if v not in (peak1, peak2))
1588+
ui_events.publish(brain, ui_events.VertexSelect(hemi="lh", vertex_id=manual_vertex))
1589+
assert ("lh", manual_vertex) in brain._picked_points
1590+
time = brain._all_data["data"]["time"]
1591+
array2 = np.zeros((len(vertices2), len(time)))
1592+
array2[np.searchsorted(vertices2, peak2)] = 1.0
1593+
brain.add_data(
1594+
array2,
1595+
fmin=0.0,
1596+
fmid=0.5,
1597+
fmax=1.0,
1598+
vertices=vertices2,
1599+
time=time,
1600+
hemi="lh",
1601+
colormap="hot",
1602+
key="data2",
1603+
smoothing_steps=0, # match the smoothing widget's current value
1604+
remove_existing=False, # keep "data" around so we can switch back
1605+
)
1606+
assert brain._active_data_key == "data2"
1607+
assert brain._peak_vertices["lh"] == peak2
1608+
assert ("lh", peak2) in brain._picked_points
1609+
assert ("lh", peak1) not in brain._picked_points
1610+
assert ("lh", manual_vertex) in brain._picked_points # manual pick untouched
1611+
1612+
# switching back to the original overlay restores its peak
1613+
brain.widgets["data_key"].set_value("data")
1614+
assert brain._peak_vertices["lh"] == peak1
1615+
assert ("lh", peak1) in brain._picked_points
1616+
assert ("lh", peak2) not in brain._picked_points
1617+
assert ("lh", manual_vertex) in brain._picked_points # still untouched
1618+
row_lines2 = [rows.itemAt(i).widget()._line for i in range(rows.count())]
1619+
peak_row2 = next(
1620+
rows.itemAt(i).widget()
1621+
for i, ln in enumerate(row_lines2)
1622+
if brain._trace_meta.get(ln, (None,))[0] == "lh"
1623+
and brain._trace_meta[ln][1] == peak1
1624+
)
1625+
assert row_text(peak_row2) == f"Peak (LH) {peak1}"
1626+
1627+
# an overlay peaking at the *same* vertex must still refresh the trace
1628+
# data (regression: the unchanged-peak branch skipped the re-plot)
1629+
peak_line1 = brain._picked_points[("lh", peak1)][0]["line"]
1630+
y1 = peak_line1.get_ydata().copy()
1631+
brain.add_data(
1632+
2.0 * brain._all_data["data"]["lh"]["array"],
1633+
fmin=0.0,
1634+
fmid=0.5,
1635+
fmax=1.0,
1636+
vertices=vertices2,
1637+
time=time,
1638+
hemi="lh",
1639+
colormap="hot",
1640+
key="data3",
1641+
smoothing_steps=0,
1642+
initial_time=0, # match "data" so the peak vertex is the same
1643+
remove_existing=False,
1644+
)
1645+
assert brain._peak_vertices["lh"] == peak1
1646+
peak_line3 = brain._picked_points[("lh", peak1)][0]["line"]
1647+
assert peak_line3 is peak_line1 # refreshed in place, not re-added
1648+
assert_allclose(peak_line3.get_ydata(), 2.0 * y1)
1649+
15461650

15471651
def _send_mouse_move(widget, point, buttons=None):
15481652
"""Deliver a synthetic Qt mouse move (QTest.mouseMove warps the real cursor)."""

mne/viz/backends/_pyvista.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,9 +1070,12 @@ def _set_volume_range(self, volume, ctable, alpha, scalar_bar, rng, fmt=None):
10701070
def _update_volume_rgba(self, grid, ctable, rng):
10711071
_update_volume_rgba(grid, ctable, rng)
10721072

1073-
def _sphere(self, center, color, radius):
1073+
def _sphere(self, center, color, radius, *, resolution=8):
10741074
mesh = pyvista.Sphere(
1075-
radius=radius, center=center, theta_resolution=8, phi_resolution=8
1075+
radius=radius,
1076+
center=center,
1077+
theta_resolution=resolution,
1078+
phi_resolution=resolution,
10761079
)
10771080
actor = _add_mesh(self.plotter, mesh=mesh, color=color)
10781081
return actor, mesh

0 commit comments

Comments
 (0)