Skip to content

Commit f03ed48

Browse files
payamsashlarsoner
andauthored
Brain GUI modernization (Phase 5) (#14149)
Co-authored-by: Eric Larson <larson.eric.d@gmail.com>
1 parent c249d79 commit f03ed48

9 files changed

Lines changed: 549 additions & 41 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a trace-list sidebar to the :class:`mne.viz.Brain` GUI, replacing legend by `Payam Sadeghi-Shabestari`_.

mne/viz/_brain/_brain.py

Lines changed: 127 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,8 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True):
580580
self.rms = None
581581
self._picked_patches = {key: list() for key in all_keys}
582582
self._picked_points = dict()
583+
self._peak_vertices = {}
584+
self._trace_meta = {}
583585
self._mouse_no_mvt = -1
584586
self._show_hover_info = False
585587
self._hover_caption = None
@@ -614,6 +616,10 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True):
614616
self.separate_canvas = False
615617
del show_traces
616618

619+
# Start with the first-added overlay active (the colormap dock's
620+
# default) so that the scalar bar, picking, and traces are all
621+
# configured against the same overlay
622+
self._active_data_key = next(iter(self._all_data))
617623
self._configure_time_label()
618624
self._configure_scalar_bar()
619625
self._configure_shortcuts()
@@ -668,8 +674,9 @@ def _clean(self):
668674
self.plotter._Iren = _FakeIren()
669675
if getattr(self.plotter, "picker", None) is not None:
670676
self.plotter.picker = None
671-
if getattr(self._renderer, "_picker", None) is not None:
672-
self._renderer._picker = None
677+
for picker in ("_picker", "_hover_picker"):
678+
if getattr(self._renderer, picker, None) is not None:
679+
setattr(self._renderer, picker, None)
673680
# XXX end PyVista
674681
for key in (
675682
"plotter",
@@ -1044,6 +1051,7 @@ def _configure_dock_trace_widget(self, name):
10441051
layout = self._renderer._dock_add_group_box(name, collapse=True)
10451052

10461053
# setup candidate annots
1054+
@safe_event
10471055
@_auto_weakref
10481056
def _set_annot(annot):
10491057
self.clear_glyphs()
@@ -1060,6 +1068,7 @@ def _set_annot(annot):
10601068
self._renderer._update()
10611069

10621070
# setup label extraction parameters
1071+
@safe_event
10631072
@_auto_weakref
10641073
def _set_label_mode(mode):
10651074
if self.traces_mode != "label":
@@ -1085,7 +1094,10 @@ def _set_label_mode(mode):
10851094
cands = cands + ["None"]
10861095
self.annot = cands[0]
10871096
stc = self._data["stc"]
1088-
modes = _get_allowed_label_modes(stc)
1097+
# None (no extraction) is allowed by _get_allowed_label_modes but is
1098+
# not a valid choice here; with src=None it would otherwise end up
1099+
# last and become the default, breaking label extraction
1100+
modes = [m for m in _get_allowed_label_modes(stc) if m is not None]
10891101
if self._data["src"] is None:
10901102
modes = [
10911103
m for m in modes if m not in self.default_label_extract_modes["src"]
@@ -1116,9 +1128,19 @@ def _configure_dock(self):
11161128
self._configure_dock_colormap_widget(name="Color Limits")
11171129
self._configure_dock_orientation_widget(name="Orientation")
11181130
self._configure_dock_surface_widget(name="Surface")
1119-
self._configure_dock_trace_widget(name="Trace")
1131+
self._configure_dock_trace_widget(name="Atlas")
1132+
self._configure_dock_trace_list_widget(name="Trace List")
11201133
self._renderer._dock_finalize()
11211134

1135+
def _configure_dock_trace_list_widget(self, name):
1136+
if not self.show_traces or self.mpl_canvas is None:
1137+
return
1138+
add_trace_list = getattr(self._renderer, "_dock_add_trace_list", None)
1139+
if add_trace_list is None:
1140+
return
1141+
self.mpl_canvas._trace_list = add_trace_list(name, collapse=False)
1142+
self.mpl_canvas.sync_traces()
1143+
11221144
def _configure_mplcanvas(self):
11231145
# Get the fractional components for the brain and mpl
11241146
self.mpl_canvas = self._renderer._window_get_mplcanvas(
@@ -1148,6 +1170,7 @@ def _configure_vertex_time_course(self):
11481170

11491171
# Plot one RMS curve per overlay so the viewer shows all overlays.
11501172
self.rms = []
1173+
self._peak_vertices = {}
11511174
multi = len(self._all_data) > 1
11521175
for overlay_key, overlay_data in self._all_data.items():
11531176
y_parts = []
@@ -1170,12 +1193,11 @@ def _configure_vertex_time_course(self):
11701193
(line,) = self.mpl_canvas.axes.plot(
11711194
overlay_data["time"],
11721195
rms,
1173-
lw=3,
1196+
lw=3.5,
11741197
label=label,
11751198
zorder=3,
11761199
color=next(self.color_cycle),
11771200
alpha=0.5,
1178-
ls=":",
11791201
)
11801202
self.rms.append(line)
11811203

@@ -1204,9 +1226,11 @@ def _configure_vertex_time_course(self):
12041226
ind = np.unravel_index(
12051227
np.argmax(np.abs(use_data), axis=None), use_data.shape
12061228
)
1229+
vertex_id = vertices[ind[0]]
1230+
self._peak_vertices[hemi] = vertex_id
12071231
publish(
12081232
self,
1209-
VertexSelect(hemi=hemi, vertex_id=vertices[ind[0]], source_id=ind[0]),
1233+
VertexSelect(hemi=hemi, vertex_id=vertex_id, source_id=ind[0]),
12101234
)
12111235

12121236
def _configure_picking(self):
@@ -1260,7 +1284,7 @@ def _on_surface_hover(self, iren, event): # event == "MouseMoveEvent"
12601284

12611285
x, y = iren.GetEventPosition()
12621286
picked_renderer = iren.FindPokedRenderer(x, y)
1263-
vtk_picker = self._renderer._picker
1287+
vtk_picker = self._renderer._hover_picker
12641288
vtk_picker.Pick(x, y, 0, picked_renderer)
12651289
cell_id = vtk_picker.GetCellId()
12661290
mapper = vtk_picker.GetMapper()
@@ -1579,11 +1603,19 @@ def _add_label_glyph(self, hemi, mesh, vertex_id):
15791603

15801604
def _remove_label_glyph(self, hemi, label_id):
15811605
label = self._annotation_labels[hemi][label_id]
1582-
label._line.remove()
1606+
# do the bookkeeping first so that a failure partway cannot leave a
1607+
# picked label whose line is already detached, which would make every
1608+
# subsequent removal (and clear_glyphs at annotation changes) fail too
1609+
self._picked_patches[hemi].remove(label_id)
1610+
line, label._line = label._line, None
1611+
if line is not None:
1612+
try:
1613+
line.remove()
1614+
except ValueError: # already detached from the axes
1615+
pass
15831616
self.color_cycle.restore(label._color)
15841617
self.mpl_canvas.update_plot()
15851618
self.layered_meshes[hemi].remove_overlay(label.name)
1586-
self._picked_patches[hemi].remove(label_id)
15871619

15881620
def _add_vertex_glyph(self, hemi, mesh, vertex_id, update=True):
15891621
_ensure_int(vertex_id)
@@ -1659,6 +1691,7 @@ def _remove_vertex_glyph(self, *, hemi, vertex_id, render=True):
16591691
return
16601692
color, line = spheres[0]["color"], spheres[0]["line"]
16611693
line.remove()
1694+
self._trace_meta.pop(line, None)
16621695
self.mpl_canvas.update_plot()
16631696

16641697
with warnings.catch_warnings(record=True):
@@ -1672,6 +1705,42 @@ def _remove_vertex_glyph(self, *, hemi, vertex_id, render=True):
16721705
if render:
16731706
self._renderer._update()
16741707

1708+
def _set_trace_visible(self, line, visible):
1709+
"""Toggle a trace's 3D glyph visibility to match its plot visibility."""
1710+
for spheres in self._picked_points.values():
1711+
if spheres[0]["line"] is line:
1712+
for sphere in spheres:
1713+
sphere["actor"].SetVisibility(visible)
1714+
self._renderer._update()
1715+
return
1716+
1717+
def _set_trace_highlight(self, line):
1718+
"""Dim the 3D glyphs of every picked trace except the highlighted one."""
1719+
if not self._picked_points:
1720+
return
1721+
for spheres in self._picked_points.values():
1722+
opacity = 1.0 if line in (None, spheres[0]["line"]) else 0.3
1723+
for sphere in spheres:
1724+
sphere["actor"].GetProperty().SetOpacity(opacity)
1725+
self._renderer._update()
1726+
1727+
def _trace_display_label(self, line):
1728+
"""Return a short, dock-friendly trace-list label.
1729+
1730+
The vertex auto-picked at peak activation for each hemisphere gets a
1731+
"Peak (LH) 1000"-style name; other picked vertices get a compact
1732+
"LH 1000"-style name instead of the full MNI-coordinate string (still
1733+
available as the row's tooltip). RMS curves are returned unchanged.
1734+
"""
1735+
meta = self._trace_meta.get(line)
1736+
if meta is None:
1737+
return line.get_label()
1738+
hemi, vertex_id, _ = meta
1739+
hemi_names = {"lh": "LH", "rh": "RH", "vol": "Vol"}
1740+
if self._peak_vertices.get(hemi) == vertex_id:
1741+
return f"Peak ({hemi_names[hemi]}) {vertex_id}"
1742+
return f"{hemi_names[hemi]} {vertex_id}"
1743+
16751744
def clear_glyphs(self):
16761745
"""Clear the picking glyphs."""
16771746
if not self.time_viewer:
@@ -1686,6 +1755,7 @@ def clear_glyphs(self):
16861755
if self.rms is not None:
16871756
for line in self.rms:
16881757
line.remove()
1758+
self.color_cycle.restore(line.get_color())
16891759
self.rms = None
16901760
self._renderer._update()
16911761

@@ -1732,10 +1802,12 @@ def plot_time_course(self, hemi, vertex_id, color, update=True):
17321802
except Exception:
17331803
mni = None
17341804
if mni is not None:
1735-
mni = " MNI: " + ", ".join(f"{m:5.1f}" for m in mni)
1805+
mni_str = ", ".join(f"{m:5.1f}" for m in mni)
1806+
mni_suffix = " MNI: " + mni_str
17361807
else:
1737-
mni = ""
1738-
label = f"{hemi_str}:{str(vertex_id).ljust(6)}{mni}"
1808+
mni_str = None
1809+
mni_suffix = ""
1810+
label = f"{hemi_str}:{str(vertex_id).ljust(6)}{mni_suffix}"
17391811
act_data, smooth = self.act_data_smooth[hemi]
17401812
if smooth is not None:
17411813
act_data = (smooth[[vertex_id]] @ act_data)[0]
@@ -1745,11 +1817,14 @@ def plot_time_course(self, hemi, vertex_id, color, update=True):
17451817
time,
17461818
act_data,
17471819
label=label,
1748-
lw=1.0,
1820+
lw=1.8,
17491821
color=color,
17501822
zorder=4,
1751-
update=update,
1823+
update=False,
17521824
)
1825+
self._trace_meta[line] = (hemi, vertex_id, mni_str)
1826+
if update:
1827+
self.mpl_canvas.update_plot()
17531828
return line
17541829

17551830
@fill_doc
@@ -1770,7 +1845,9 @@ def plot_time_line(self, update=True):
17701845
x=current_time,
17711846
label="time",
17721847
color=self._fg_color,
1773-
lw=1,
1848+
lw=1.5,
1849+
ls="--",
1850+
alpha=0.7,
17741851
update=update,
17751852
)
17761853
self.time_line.set_xdata([current_time])
@@ -2133,6 +2210,36 @@ def add_data(
21332210
self._all_data[key][hemi]["glyph_actor"] = None
21342211
self._all_data[key][hemi]["array"] = array
21352212
self._all_data[key][hemi]["vertices"] = vertices
2213+
if (
2214+
stc is None
2215+
and hemi in ("lh", "rh")
2216+
and vertices is not None
2217+
and len(array) == len(vertices)
2218+
):
2219+
# Synthesize an stc from the raw arrays so that label-mode traces
2220+
# (which use stc.extract_label_time_course) also work when data
2221+
# is passed directly rather than plotted from an stc
2222+
from ...source_estimate import SourceEstimate, VectorSourceEstimate
2223+
2224+
stc_verts, stc_data = list(), list()
2225+
for stc_hemi in ("lh", "rh"):
2226+
hemi_data = self._all_data[key].get(stc_hemi)
2227+
if not isinstance(hemi_data, dict) or "array" not in hemi_data:
2228+
stc_verts.append(np.array([], int))
2229+
continue
2230+
stc_array = hemi_data["array"]
2231+
if stc_array.ndim == 1:
2232+
stc_array = stc_array[:, np.newaxis]
2233+
stc_verts.append(hemi_data["vertices"])
2234+
stc_data.append(stc_array)
2235+
if time is not None and len(time) > 1:
2236+
tmin, tstep = time[0], time[1] - time[0]
2237+
else:
2238+
tmin, tstep = 0.0, 1.0
2239+
klass = VectorSourceEstimate if stc_data[0].ndim == 3 else SourceEstimate
2240+
self._all_data[key]["stc"] = klass(
2241+
np.concatenate(stc_data), stc_verts, tmin, tstep, subject=self._subject
2242+
)
21362243
self._all_data[key]["alpha"] = alpha
21372244
self._all_data[key]["colormap"] = colormap
21382245
self._all_data[key]["center"] = center
@@ -2502,7 +2609,9 @@ def add_label(
25022609
tc = stc.extract_label_time_course(
25032610
label, src=src, mode=self.label_extract_mode
25042611
)
2505-
tc = tc[0] if tc.ndim == 2 else tc[0, 0, :]
2612+
tc = tc[0]
2613+
if tc.ndim == 2: # vector data: show the norm across orientations
2614+
tc = np.linalg.norm(tc, axis=0)
25062615
color = next(self.color_cycle)
25072616
line = self.mpl_canvas.plot(
25082617
self._data["time"], tc, label=label_name, color=color
@@ -3468,7 +3577,7 @@ def _on_annotation_hover(self, iren, event): # event == "MouseMoveEvent"
34683577

34693578
x, y = iren.GetEventPosition()
34703579
picked_renderer = iren.FindPokedRenderer(x, y)
3471-
vtk_picker = self._renderer._picker
3580+
vtk_picker = self._renderer._hover_picker
34723581
vtk_picker.Pick(x, y, 0, picked_renderer)
34733582
cell_id = vtk_picker.GetCellId()
34743583
# This returns a vtkPolyData we don't seem to have access to:

0 commit comments

Comments
 (0)