Skip to content
Merged
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
1 change: 1 addition & 0 deletions doc/changes/dev/14149.newfeature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a trace-list sidebar to the :class:`mne.viz.Brain` GUI, replacing legend by `Payam Sadeghi-Shabestari`_.
145 changes: 127 additions & 18 deletions mne/viz/_brain/_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,8 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True):
self.rms = None
self._picked_patches = {key: list() for key in all_keys}
self._picked_points = dict()
self._peak_vertices = {}
self._trace_meta = {}
self._mouse_no_mvt = -1
self._show_hover_info = False
self._hover_caption = None
Expand Down Expand Up @@ -614,6 +616,10 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True):
self.separate_canvas = False
del show_traces

# Start with the first-added overlay active (the colormap dock's
# default) so that the scalar bar, picking, and traces are all
# configured against the same overlay
self._active_data_key = next(iter(self._all_data))
self._configure_time_label()
self._configure_scalar_bar()
self._configure_shortcuts()
Expand Down Expand Up @@ -668,8 +674,9 @@ def _clean(self):
self.plotter._Iren = _FakeIren()
if getattr(self.plotter, "picker", None) is not None:
self.plotter.picker = None
if getattr(self._renderer, "_picker", None) is not None:
self._renderer._picker = None
for picker in ("_picker", "_hover_picker"):
if getattr(self._renderer, picker, None) is not None:
setattr(self._renderer, picker, None)
# XXX end PyVista
for key in (
"plotter",
Expand Down Expand Up @@ -1044,6 +1051,7 @@ def _configure_dock_trace_widget(self, name):
layout = self._renderer._dock_add_group_box(name, collapse=True)

# setup candidate annots
@safe_event
@_auto_weakref
def _set_annot(annot):
self.clear_glyphs()
Expand All @@ -1060,6 +1068,7 @@ def _set_annot(annot):
self._renderer._update()

# setup label extraction parameters
@safe_event
@_auto_weakref
def _set_label_mode(mode):
if self.traces_mode != "label":
Expand All @@ -1085,7 +1094,10 @@ def _set_label_mode(mode):
cands = cands + ["None"]
self.annot = cands[0]
stc = self._data["stc"]
modes = _get_allowed_label_modes(stc)
# None (no extraction) is allowed by _get_allowed_label_modes but is
# not a valid choice here; with src=None it would otherwise end up
# last and become the default, breaking label extraction
modes = [m for m in _get_allowed_label_modes(stc) if m is not None]
if self._data["src"] is None:
modes = [
m for m in modes if m not in self.default_label_extract_modes["src"]
Expand Down Expand Up @@ -1116,9 +1128,19 @@ def _configure_dock(self):
self._configure_dock_colormap_widget(name="Color Limits")
self._configure_dock_orientation_widget(name="Orientation")
self._configure_dock_surface_widget(name="Surface")
self._configure_dock_trace_widget(name="Trace")
self._configure_dock_trace_widget(name="Atlas")
self._configure_dock_trace_list_widget(name="Trace List")
self._renderer._dock_finalize()

def _configure_dock_trace_list_widget(self, name):
if not self.show_traces or self.mpl_canvas is None:
return
add_trace_list = getattr(self._renderer, "_dock_add_trace_list", None)
if add_trace_list is None:
return
self.mpl_canvas._trace_list = add_trace_list(name, collapse=False)
self.mpl_canvas.sync_traces()

def _configure_mplcanvas(self):
# Get the fractional components for the brain and mpl
self.mpl_canvas = self._renderer._window_get_mplcanvas(
Expand Down Expand Up @@ -1148,6 +1170,7 @@ def _configure_vertex_time_course(self):

# Plot one RMS curve per overlay so the viewer shows all overlays.
self.rms = []
self._peak_vertices = {}
multi = len(self._all_data) > 1
for overlay_key, overlay_data in self._all_data.items():
y_parts = []
Expand All @@ -1170,12 +1193,11 @@ def _configure_vertex_time_course(self):
(line,) = self.mpl_canvas.axes.plot(
overlay_data["time"],
rms,
lw=3,
lw=3.5,
label=label,
zorder=3,
color=next(self.color_cycle),
alpha=0.5,
ls=":",
)
self.rms.append(line)

Expand Down Expand Up @@ -1204,9 +1226,11 @@ def _configure_vertex_time_course(self):
ind = np.unravel_index(
np.argmax(np.abs(use_data), axis=None), use_data.shape
)
vertex_id = vertices[ind[0]]
self._peak_vertices[hemi] = vertex_id
publish(
self,
VertexSelect(hemi=hemi, vertex_id=vertices[ind[0]], source_id=ind[0]),
VertexSelect(hemi=hemi, vertex_id=vertex_id, source_id=ind[0]),
)

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

x, y = iren.GetEventPosition()
picked_renderer = iren.FindPokedRenderer(x, y)
vtk_picker = self._renderer._picker
vtk_picker = self._renderer._hover_picker
vtk_picker.Pick(x, y, 0, picked_renderer)
cell_id = vtk_picker.GetCellId()
mapper = vtk_picker.GetMapper()
Expand Down Expand Up @@ -1579,11 +1603,19 @@ def _add_label_glyph(self, hemi, mesh, vertex_id):

def _remove_label_glyph(self, hemi, label_id):
label = self._annotation_labels[hemi][label_id]
label._line.remove()
# do the bookkeeping first so that a failure partway cannot leave a
# picked label whose line is already detached, which would make every
# subsequent removal (and clear_glyphs at annotation changes) fail too
self._picked_patches[hemi].remove(label_id)
line, label._line = label._line, None
if line is not None:
try:
line.remove()
except ValueError: # already detached from the axes
pass
self.color_cycle.restore(label._color)
self.mpl_canvas.update_plot()
self.layered_meshes[hemi].remove_overlay(label.name)
self._picked_patches[hemi].remove(label_id)

def _add_vertex_glyph(self, hemi, mesh, vertex_id, update=True):
_ensure_int(vertex_id)
Expand Down Expand Up @@ -1659,6 +1691,7 @@ def _remove_vertex_glyph(self, *, hemi, vertex_id, render=True):
return
color, line = spheres[0]["color"], spheres[0]["line"]
line.remove()
self._trace_meta.pop(line, None)
self.mpl_canvas.update_plot()

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

def _set_trace_visible(self, line, visible):
"""Toggle a trace's 3D glyph visibility to match its plot visibility."""
for spheres in self._picked_points.values():
if spheres[0]["line"] is line:
for sphere in spheres:
sphere["actor"].SetVisibility(visible)
self._renderer._update()
return

def _set_trace_highlight(self, line):
"""Dim the 3D glyphs of every picked trace except the highlighted one."""
if not self._picked_points:
return
for spheres in self._picked_points.values():
opacity = 1.0 if line in (None, spheres[0]["line"]) else 0.3
for sphere in spheres:
sphere["actor"].GetProperty().SetOpacity(opacity)
self._renderer._update()

def _trace_display_label(self, line):
"""Return a short, dock-friendly trace-list label.

The vertex auto-picked at peak activation for each hemisphere gets a
"Peak (LH) 1000"-style name; other picked vertices get a compact
"LH 1000"-style name instead of the full MNI-coordinate string (still
available as the row's tooltip). RMS curves are returned unchanged.
"""
meta = self._trace_meta.get(line)
if meta is None:
return line.get_label()
hemi, vertex_id, _ = meta
hemi_names = {"lh": "LH", "rh": "RH", "vol": "Vol"}
if self._peak_vertices.get(hemi) == vertex_id:
return f"Peak ({hemi_names[hemi]}) {vertex_id}"
return f"{hemi_names[hemi]} {vertex_id}"

def clear_glyphs(self):
"""Clear the picking glyphs."""
if not self.time_viewer:
Expand All @@ -1686,6 +1755,7 @@ def clear_glyphs(self):
if self.rms is not None:
for line in self.rms:
line.remove()
self.color_cycle.restore(line.get_color())
self.rms = None
self._renderer._update()

Expand Down Expand Up @@ -1732,10 +1802,12 @@ def plot_time_course(self, hemi, vertex_id, color, update=True):
except Exception:
mni = None
if mni is not None:
mni = " MNI: " + ", ".join(f"{m:5.1f}" for m in mni)
mni_str = ", ".join(f"{m:5.1f}" for m in mni)
mni_suffix = " MNI: " + mni_str
else:
mni = ""
label = f"{hemi_str}:{str(vertex_id).ljust(6)}{mni}"
mni_str = None
mni_suffix = ""
label = f"{hemi_str}:{str(vertex_id).ljust(6)}{mni_suffix}"
act_data, smooth = self.act_data_smooth[hemi]
if smooth is not None:
act_data = (smooth[[vertex_id]] @ act_data)[0]
Expand All @@ -1745,11 +1817,14 @@ def plot_time_course(self, hemi, vertex_id, color, update=True):
time,
act_data,
label=label,
lw=1.0,
lw=1.8,
color=color,
zorder=4,
update=update,
update=False,
)
self._trace_meta[line] = (hemi, vertex_id, mni_str)
if update:
self.mpl_canvas.update_plot()
return line

@fill_doc
Expand All @@ -1770,7 +1845,9 @@ def plot_time_line(self, update=True):
x=current_time,
label="time",
color=self._fg_color,
lw=1,
lw=1.5,
ls="--",
alpha=0.7,
update=update,
)
self.time_line.set_xdata([current_time])
Expand Down Expand Up @@ -2133,6 +2210,36 @@ def add_data(
self._all_data[key][hemi]["glyph_actor"] = None
self._all_data[key][hemi]["array"] = array
self._all_data[key][hemi]["vertices"] = vertices
if (
stc is None
and hemi in ("lh", "rh")
and vertices is not None
and len(array) == len(vertices)
):
# Synthesize an stc from the raw arrays so that label-mode traces
# (which use stc.extract_label_time_course) also work when data
# is passed directly rather than plotted from an stc
from ...source_estimate import SourceEstimate, VectorSourceEstimate

stc_verts, stc_data = list(), list()
for stc_hemi in ("lh", "rh"):
hemi_data = self._all_data[key].get(stc_hemi)
if not isinstance(hemi_data, dict) or "array" not in hemi_data:
stc_verts.append(np.array([], int))
continue
stc_array = hemi_data["array"]
if stc_array.ndim == 1:
stc_array = stc_array[:, np.newaxis]
stc_verts.append(hemi_data["vertices"])
stc_data.append(stc_array)
if time is not None and len(time) > 1:
tmin, tstep = time[0], time[1] - time[0]
else:
tmin, tstep = 0.0, 1.0
klass = VectorSourceEstimate if stc_data[0].ndim == 3 else SourceEstimate
self._all_data[key]["stc"] = klass(
np.concatenate(stc_data), stc_verts, tmin, tstep, subject=self._subject
)
self._all_data[key]["alpha"] = alpha
self._all_data[key]["colormap"] = colormap
self._all_data[key]["center"] = center
Expand Down Expand Up @@ -2502,7 +2609,9 @@ def add_label(
tc = stc.extract_label_time_course(
label, src=src, mode=self.label_extract_mode
)
tc = tc[0] if tc.ndim == 2 else tc[0, 0, :]
tc = tc[0]
if tc.ndim == 2: # vector data: show the norm across orientations
tc = np.linalg.norm(tc, axis=0)
color = next(self.color_cycle)
line = self.mpl_canvas.plot(
self._data["time"], tc, label=label_name, color=color
Expand Down Expand Up @@ -3468,7 +3577,7 @@ def _on_annotation_hover(self, iren, event): # event == "MouseMoveEvent"

x, y = iren.GetEventPosition()
picked_renderer = iren.FindPokedRenderer(x, y)
vtk_picker = self._renderer._picker
vtk_picker = self._renderer._hover_picker
vtk_picker.Pick(x, y, 0, picked_renderer)
cell_id = vtk_picker.GetCellId()
# This returns a vtkPolyData we don't seem to have access to:
Expand Down
Loading
Loading