Skip to content
Open
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`_.
71 changes: 64 additions & 7 deletions mne/viz/_brain/_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,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 @@ -1119,9 +1121,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=True)
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 @@ -1151,6 +1163,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 @@ -1173,12 +1186,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 @@ -1207,9 +1219,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 @@ -1653,6 +1667,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 @@ -1666,6 +1681,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)"-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]})"
return f"{hemi_names[hemi]} {vertex_id}"

def clear_glyphs(self):
"""Clear the picking glyphs."""
if not self.time_viewer:
Expand All @@ -1680,6 +1731,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 @@ -1739,11 +1791,14 @@ def plot_time_course(self, hemi, vertex_id, color, update=True):
time,
act_data,
label=label,
lw=1.0,
lw=2.4,
color=color,
zorder=4,
update=update,
update=False,
)
self._trace_meta[line] = (hemi, vertex_id)
if update:
self.mpl_canvas.update_plot()
return line

@fill_doc
Expand All @@ -1764,7 +1819,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
94 changes: 94 additions & 0 deletions mne/viz/_brain/tests/test_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,100 @@ def test_brain_traces_vertex(
assert_allclose(img.shape[0], screenshot_all.shape[0], atol=1)


@testing.requires_testing_data
def test_brain_native_trace_list(renderer_interactive_pyvistaqt, brain_gc):
"""Test the native Qt trace-list sidebar that replaces the mpl legend."""
from qtpy.QtWidgets import QLabel

brain = _create_testing_brain(hemi="lh", show_traces=True, initial_time=0)
canvas = brain.mpl_canvas
assert canvas._legend_in_figure is False
trace_list = canvas._trace_list
assert trace_list is not None

def row_text(row):
return row.findChild(QLabel, "trace_label").text()

rows = trace_list._rows_layout
row_lines = [rows.itemAt(i).widget()._line for i in range(rows.count())]
assert row_lines == [
line for line in canvas.axes.get_lines() if line is not brain.time_line
]

# the auto-picked peak-activation vertex gets a friendly display label,
# distinct from the underlying matplotlib line label
peak_line = next(
ln for ln in row_lines if brain._trace_meta.get(ln, (None,))[0] == "lh"
)
peak_row = rows.itemAt(row_lines.index(peak_line)).widget()
assert row_text(peak_row) == "Peak (LH)"
assert row_text(peak_row) != peak_line.get_label()

# picking a new vertex should grow the sidebar to match, and the new
# row's displayed label must be correct immediately -- this guards
# against a real bug where the label lookup ran before the line was
# tagged with its hemi/vertex_id, showing the raw label for one redraw
picked = set(brain.get_picked_points()["lh"])
n_verts = len(brain.geo["lh"].coords)
vertex_id = next(v for v in range(n_verts) if v not in picked)
ui_events.publish(brain, ui_events.VertexSelect(hemi="lh", vertex_id=vertex_id))
assert rows.count() == len(row_lines) + 1
row = rows.itemAt(rows.count() - 1).widget()
line = row._line
assert str(vertex_id) in line.get_label()
assert row_text(row) == f"LH {vertex_id}"

# toggling a row hides the trace and its 3D glyph together, without
# rebuilding the row list (sync() must skip unchanged trace sets --
# the whole point of the native list was to stop rebuilding on every
# update, which is what caused the original matplotlib-legend lag)
assert line.get_visible()
row._on_toggle()
assert not line.get_visible()
assert rows.itemAt(rows.count() - 1).widget() is row # not rebuilt
sphere = next(s[0] for s in brain._picked_points.values() if s[0]["line"] is line)
assert not sphere["actor"].GetVisibility()
row._on_toggle()
assert line.get_visible()
assert sphere["actor"].GetVisibility()
assert rows.itemAt(rows.count() - 1).widget() is row # still not rebuilt

# hovering a row dims the other traces without disturbing the RMS
# curve's own (deliberately non-default) alpha
rms_line = next(
ln for ln in canvas.axes.get_lines() if ln.get_label().startswith("RMS")
)
assert rms_line.get_alpha() == 0.5
canvas.set_trace_highlight(line)
assert line.get_alpha() == 1.0
assert rms_line.get_alpha() == 0.25
canvas.set_trace_highlight(None)
assert rms_line.get_alpha() == 0.5 # restored, not clobbered to 1.0

# hovering a *hidden* trace must not dim its still-visible siblings
row._on_toggle() # hide it again
assert not line.get_visible()
canvas.set_trace_highlight(line)
assert rms_line.get_alpha() == 0.5 # untouched, not dimmed to 0.25
row._on_toggle()

# switching to Atlas/label mode and back to "None" must not shift trace
# colors -- regression: clear_glyphs() used to drop RMS lines without
# returning their color to brain.color_cycle, leaking a color (and
# shifting every subsequent one) on each round trip. Only RMS/peak are
# compared: the manually-added second pick above is legitimately not
# restored by a mode switch, only the auto-picked peak vertex is.
rms_colors = [ln.get_color() for ln in brain.rms]
peak_color = peak_line.get_color()
brain.widgets["annotation"].set_value("aparc")
brain.widgets["annotation"].set_value("None")
assert [ln.get_color() for ln in brain.rms] == rms_colors
new_peak_line = next(iter(brain._picked_points.values()))[0]["line"]
assert new_peak_line.get_color() == peak_color

brain.close()


def test_brain_traces_colormap(renderer_interactive_pyvistaqt, brain_gc):
"""Test colormap selection."""
brain = _create_testing_brain(
Expand Down
96 changes: 81 additions & 15 deletions mne/viz/backends/_abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1435,16 +1435,38 @@ def update_plot(self):

def set_color(self, bg_color, fg_color):
"""Set the widget colors."""
from matplotlib.ticker import AutoMinorLocator

self.axes.set_facecolor(bg_color)
self.fig.patch.set_facecolor(bg_color)

self.axes.spines["top"].set_visible(False)
self.axes.spines["right"].set_visible(False)
for side in ("bottom", "left"):
spine = self.axes.spines[side]
spine.set_color(fg_color)
spine.set_linewidth(2.0)

self.axes.xaxis.label.set_color(fg_color)
self.axes.yaxis.label.set_color(fg_color)
self.axes.spines["top"].set_color(fg_color)
self.axes.spines["bottom"].set_color(fg_color)
self.axes.spines["left"].set_color(fg_color)
self.axes.spines["right"].set_color(fg_color)
self.axes.tick_params(axis="x", colors=fg_color)
self.axes.tick_params(axis="y", colors=fg_color)
self.fig.patch.set_facecolor(bg_color)
self.axes.xaxis.label.set_fontsize(14)
self.axes.yaxis.label.set_fontsize(14)

self.axes.tick_params(
axis="both",
colors=fg_color,
labelsize=13,
length=6,
width=1.5,
direction="out",
)

self.axes.xaxis.set_minor_locator(AutoMinorLocator())
self.axes.yaxis.set_minor_locator(AutoMinorLocator())
self.axes.tick_params(which="minor", length=3, width=1.0, colors=fg_color)
self.axes.grid(which="major", color=fg_color, alpha=0.18, linewidth=0.9)
self.axes.grid(which="minor", color=fg_color, alpha=0.08, linewidth=0.6)
self.axes.set_axisbelow(True)

def show(self):
"""Show the canvas."""
Expand All @@ -1471,23 +1493,65 @@ def on_resize(self, event):


class _AbstractBrainMplCanvas(_AbstractMplCanvas):
_legend_in_figure = True

def __init__(self, brain, width, height, dpi):
"""Initialize the MplCanvas."""
super().__init__(width, height, dpi)
self.brain = brain
self._hovered_line = None
self._trace_base_alpha = {}

def update_plot(self):
"""Update the plot."""
leg = self.axes.legend(
prop={"family": "monospace", "size": "small"},
framealpha=0.5,
handlelength=1.0,
facecolor=self.brain._bg_color,
)
for text in leg.get_texts():
text.set_color(self.brain._fg_color)
if self._legend_in_figure:
leg = self.axes.legend(
prop={"family": "monospace", "size": "small"},
framealpha=0.5,
handlelength=1.0,
facecolor=self.brain._bg_color,
)
for text in leg.get_texts():
text.set_color(self.brain._fg_color)
self.sync_traces()
super().update_plot()

def sync_traces(self):
"""Refresh a native trace-list widget; no-op unless a backend provides one."""

def set_trace_visible(self, line, visible):
"""Toggle one trace's visibility, in the plot and on its 3D glyph."""
line.set_visible(visible)
self.brain._set_trace_visible(line, visible)
self.update_plot()

def set_trace_highlight(self, line):
"""Highlight one trace (or none), dimming the plot's other traces."""
if line is not None and not line.get_visible():
line = None
if line is self._hovered_line:
return
time_line = getattr(self.brain, "time_line", None)
origlines = [
origline
for origline in self.axes.get_lines()
if origline is not time_line and origline.get_visible()
]
if self._hovered_line is None and line is not None:
self._trace_base_alpha = {
origline: origline.get_alpha() for origline in origlines
}
self._hovered_line = line
for origline in origlines:
if line is None:
origline.set_alpha(self._trace_base_alpha.get(origline))
else:
origline.set_alpha(1.0 if origline is line else 0.25)
if line is None:
self._trace_base_alpha = {}
self.canvas.draw_idle()
self.brain._set_trace_highlight(line)

def on_button_press(self, event):
"""Handle button presses."""
# left click (and maybe drag) in progress in axes
Expand All @@ -1501,6 +1565,8 @@ def clear(self):
"""Clear internal variables."""
super().clear()
self.brain = None
self._hovered_line = None
self._trace_base_alpha = {}


class _AbstractWindow(ABC):
Expand Down
Loading
Loading