From 5a08cdcf57793ddf84abb52fd71bfcf562295d9b Mon Sep 17 00:00:00 2001 From: payam Date: Tue, 11 Aug 2026 09:27:37 +0200 Subject: [PATCH 1/6] modify trace in brain gui --- mne/viz/_brain/_brain.py | 71 ++++++++++-- mne/viz/_brain/tests/test_brain.py | 94 ++++++++++++++++ mne/viz/backends/_abstract.py | 96 ++++++++++++++--- mne/viz/backends/_qt.py | 167 ++++++++++++++++++++++++++++- 4 files changed, 403 insertions(+), 25 deletions(-) diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 3f935ec682c..51884ba2275 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -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 @@ -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( @@ -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 = [] @@ -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) @@ -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): @@ -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): @@ -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: @@ -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() @@ -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 @@ -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]) diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 7cddbfcd4ef..044e4bb0277 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -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( diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index 5634cd126e4..f86e4d47ca8 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -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.""" @@ -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 @@ -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): diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index a0ae27387ba..3bdd14e33f2 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -16,6 +16,7 @@ import pyvista from matplotlib.backends.backend_qtagg import FigureCanvas +from matplotlib.colors import to_hex from matplotlib.figure import Figure from pyvistaqt.plotting import FileDialog, MainWindow from qtpy.QtCore import ( @@ -23,12 +24,13 @@ QLibraryInfo, QLocale, QObject, + QSize, Qt, QTimer, # non-object-based-abstraction-only, remove Signal, ) -from qtpy.QtGui import QCursor, QGuiApplication, QIcon, QKeyEvent +from qtpy.QtGui import QCursor, QFont, QGuiApplication, QIcon, QKeyEvent from qtpy.QtWidgets import ( QButtonGroup, QCheckBox, @@ -39,6 +41,8 @@ QDoubleSpinBox, QFileDialog, QFormLayout, + QFrame, + QGraphicsOpacityEffect, QGridLayout, QGroupBox, QHBoxLayout, @@ -1166,6 +1170,19 @@ def _toggle_visibility(checked, content=content, toggle=toggle, name=name): self._layout_add_widget(layout, widget) return hlayout + def _dock_add_trace_list(self, name, *, collapse=True, layout=None): + """Add a collapsible group box holding the live trace-visibility list. + + Unlike the other ``_dock_add_*`` widgets this isn't backed by a single + value, it mirrors ``self._mplcanvas``'s current traces and grows or + shrinks, so it's Qt-specific rather than part + of the cross-backend :class:`_AbstractDock` interface. + """ + group_layout = self._dock_add_group_box(name, collapse=collapse, layout=layout) + trace_list = _QtTraceList(self._mplcanvas) + self._layout_add_widget(group_layout, trace_list) + return trace_list + def _dock_add_text(self, name, value, placeholder, *, callback=None, layout=None): layout = self._dock_layout if layout is None else layout widget = QLineEdit(str(value)) @@ -1427,16 +1444,145 @@ def __init__(self, width, height, dpi): self._mpl_initialize() +class _QtTraceRow(QWidget): + """One row of the trace list: a color swatch, a label, a visibility toggle.""" + + def __init__(self, canvas, line): + super().__init__() + self._canvas = canvas + self._line = line + + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(8) + + swatch = QLabel() + swatch.setFixedSize(13, 13) + radius = 3 if line.get_label().startswith("RMS") else 6 + swatch.setStyleSheet( + f"background-color: {to_hex(line.get_color())}; border-radius: {radius}px;" + ) + layout.addWidget(swatch) + + brain = canvas.brain + text = QLabel(brain._trace_display_label(line) if brain else line.get_label()) + text.setObjectName("trace_label") + text.setStyleSheet("font-size: 12pt;") + text.setToolTip(line.get_label()) + text.setWordWrap(True) + layout.addWidget(text, 1) + + self._toggle = QToolButton() + self._toggle.setAutoRaise(True) + self._toggle.setIconSize(QSize(18, 18)) + self._toggle.setFixedSize(28, 28) + self._toggle.setCursor(Qt.PointingHandCursor) + self._toggle.setToolTip("Show/hide this trace") + self._toggle.setStyleSheet( + "QToolButton { border: none; border-radius: 4px; }" + "QToolButton:hover { background-color: palette(midlight); }" + ) + self._toggle.clicked.connect(self._on_toggle) + layout.addWidget(self._toggle) + + self._opacity = QGraphicsOpacityEffect(self) + self.setGraphicsEffect(self._opacity) + self._sync_visibility() + + def _sync_visibility(self): + visible = self._line.get_visible() + self._toggle.setIcon(_qicon("visibility_on" if visible else "visibility_off")) + self._opacity.setOpacity(1.0 if visible else 0.45) + + def _on_toggle(self): + self._canvas.set_trace_visible(self._line, not self._line.get_visible()) + self._sync_visibility() + + def _repolish(self): + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def enterEvent(self, event): + """Highlight this trace when the row is hovered.""" + self.setStyleSheet("_QtTraceRow { background-color: palette(alternate-base); }") + self._repolish() + self._canvas.set_trace_highlight(self._line) + super().enterEvent(event) + + def leaveEvent(self, event): + """Clear the highlight when the mouse leaves the row.""" + self.setStyleSheet("") + self._repolish() + self._canvas.set_trace_highlight(None) + super().leaveEvent(event) + + +class _QtTraceList(QWidget): + """Live-updating list of the trace panel's traces, for the "Trace List" dock. + + A plain widget so it reads as part of the dock's + normal flow, matching the other collapsible sections, the side dock as + a whole already scrolls if its total content outgrows the window. + """ + + def __init__(self, canvas): + super().__init__() + self._canvas = canvas + self._rows_layout = QVBoxLayout(self) + self._rows_layout.setContentsMargins(0, 0, 0, 0) + self._rows_layout.setSpacing(2) + self._synced_lines = None + + def sync(self, lines): + """Rebuild the row list to match the canvas's current lines. + + A no-op unless the set of traces actually changed (added/removed), + called on every plot update, including once per time step during + playback, so a per-row visibility/color change must not pay for a + full rebuild here; rows refresh themselves directly instead. + """ + if lines == self._synced_lines: + return + self._synced_lines = list(lines) + while self._rows_layout.count(): + widget = self._rows_layout.takeAt(0).widget() + if widget is not None: + widget.deleteLater() + if not lines: + placeholder = QLabel( + "Set Atlas to None to see\nvertex and RMS traces here." + ) + placeholder.setStyleSheet( + "color: palette(disabled-text); font-style: italic; font-size: 9pt;" + ) + self._rows_layout.addWidget(placeholder) + return + for line in lines: + self._rows_layout.addWidget(_QtTraceRow(self._canvas, line)) + + class _QtBrainMplCanvas(_AbstractBrainMplCanvas, _QtMplInterface): + _legend_in_figure = False + def __init__(self, brain, width, height, dpi): super().__init__(brain, width, height, dpi) self._mpl_initialize() + self._trace_list = None if brain.separate_canvas: self.canvas.setParent(None) else: self.canvas.setParent(brain._renderer._window) self._connect() + def sync_traces(self): + """Refresh the trace-list dock widget with the canvas's current lines.""" + if self._trace_list is None: + return + time_line = getattr(self.brain, "time_line", None) + lines = [line for line in self.axes.get_lines() if line is not time_line] + self._trace_list.sync(lines) + class _QtHelpDialog(QDialog): """Non-modal dialog listing keyboard shortcuts. @@ -1894,19 +2040,34 @@ def _create_dock_widget(window, name, area, *, max_width=None): dock = QDockWidget(name) # add scroll area scroll = QScrollArea(dock) + scroll.setFrameShape(QFrame.NoFrame) dock.setWidget(scroll) # give the scroll area a child widget widget = QWidget(scroll) scroll.setWidget(widget) scroll.setWidgetResizable(True) dock.setAllowedAreas(area) - dock.setTitleBarWidget(QLabel(name)) + + title = QLabel(name.upper()) + title_font = title.font() + title_font.setBold(True) + title_font.setPointSize(max(title_font.pointSize() - 1, 8)) + title_font.setLetterSpacing(QFont.AbsoluteSpacing, 1.1) + title.setFont(title_font) + title.setStyleSheet( + "QLabel {" + " color: palette(mid);" + " padding: 7px 10px 6px 10px;" + " border-bottom: 1px solid palette(midlight);" + " }" + ) + dock.setTitleBarWidget(title) window.addDockWidget(area, dock) dock_layout = QVBoxLayout() widget.setLayout(dock_layout) # Fix resize grip size # https://stackoverflow.com/a/65050468/2175965 - styles = ["margin: 4px;"] + styles = ["margin: 4px;", "border: none;"] if max_width is not None: styles.append(f"max-width: {max_width};") style_sheet = "QDockWidget { " + " \n".join(styles) + "\n}" From 4a55892b388ecbf2df10e92a3bed5cb70188e6eb Mon Sep 17 00:00:00 2001 From: payam Date: Tue, 11 Aug 2026 11:34:15 +0200 Subject: [PATCH 2/6] whitelist trace methods --- tools/vulture_allowlist.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/vulture_allowlist.py b/tools/vulture_allowlist.py index 02c903f209d..2bc97a3e092 100644 --- a/tools/vulture_allowlist.py +++ b/tools/vulture_allowlist.py @@ -127,6 +127,9 @@ _.set_fmax _.set_fmid _.set_fmin +_._set_trace_visible +_._set_trace_highlight +_._trace_display_label _.EnterEvent _.MouseMoveEvent _.LeaveEvent From c44b9e4d1429f3d884243f225efbcc2da676f2be Mon Sep 17 00:00:00 2001 From: payam Date: Tue, 11 Aug 2026 11:35:36 +0200 Subject: [PATCH 3/6] changelog --- doc/changes/dev/14149.newfeature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/14149.newfeature.rst diff --git a/doc/changes/dev/14149.newfeature.rst b/doc/changes/dev/14149.newfeature.rst new file mode 100644 index 00000000000..b75f9d030ba --- /dev/null +++ b/doc/changes/dev/14149.newfeature.rst @@ -0,0 +1 @@ +Added a trace-list sidebar to the :class:`mne.viz.Brain` GUI, replacing legend by `Payam Sadeghi-Shabestari`_. From c00b76d04d1a519991d5b9e51943571389dec151 Mon Sep 17 00:00:00 2001 From: payam Date: Fri, 14 Aug 2026 07:49:08 +0200 Subject: [PATCH 4/6] thiner traces + MNI coord --- mne/viz/_brain/_brain.py | 18 ++++++++++-------- mne/viz/_brain/tests/test_brain.py | 2 +- mne/viz/backends/_qt.py | 19 ++++++++++++++++--- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 51884ba2275..432859a9627 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -1704,17 +1704,17 @@ 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 + "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, 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"Peak ({hemi_names[hemi]}) {vertex_id}" return f"{hemi_names[hemi]} {vertex_id}" def clear_glyphs(self): @@ -1778,10 +1778,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] @@ -1791,12 +1793,12 @@ def plot_time_course(self, hemi, vertex_id, color, update=True): time, act_data, label=label, - lw=2.4, + lw=1.8, color=color, zorder=4, update=False, ) - self._trace_meta[line] = (hemi, vertex_id) + self._trace_meta[line] = (hemi, vertex_id, mni_str) if update: self.mpl_canvas.update_plot() return line diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 044e4bb0277..0fad702eec6 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -1491,7 +1491,7 @@ def row_text(row): 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) == f"Peak (LH) {brain._peak_vertices['lh']}" assert row_text(peak_row) != peak_line.get_label() # picking a new vertex should grow the sidebar to match, and the new diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index 3bdd14e33f2..1ba7f500ff1 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -1465,12 +1465,25 @@ def __init__(self, canvas, line): layout.addWidget(swatch) brain = canvas.brain + text_col = QVBoxLayout() + text_col.setContentsMargins(0, 0, 0, 0) + text_col.setSpacing(0) + text = QLabel(brain._trace_display_label(line) if brain else line.get_label()) text.setObjectName("trace_label") - text.setStyleSheet("font-size: 12pt;") text.setToolTip(line.get_label()) text.setWordWrap(True) - layout.addWidget(text, 1) + text_col.addWidget(text) + + meta = brain._trace_meta.get(line) if brain else None + coords = meta[2] if meta is not None else None + if coords: + coord_label = QLabel(f"MNI: {coords}") + coord_label.setStyleSheet("color: palette(disabled-text); font-size: 8pt;") + coord_label.setWordWrap(True) + text_col.addWidget(coord_label) + + layout.addLayout(text_col, 1) self._toggle = QToolButton() self._toggle.setAutoRaise(True) @@ -1551,7 +1564,7 @@ def sync(self, lines): widget.deleteLater() if not lines: placeholder = QLabel( - "Set Atlas to None to see\nvertex and RMS traces here." + "Set Annotation to None to see\nvertex and RMS traces here." ) placeholder.setStyleSheet( "color: palette(disabled-text); font-style: italic; font-size: 9pt;" From 6f4a88e42952fb8e0244c4e8f7882cba5dcc08c8 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 24 Aug 2026 17:53:27 +0200 Subject: [PATCH 5/6] Fix display --- mne/viz/_brain/_brain.py | 68 ++++++++++++++++++++++++++---- mne/viz/_brain/tests/test_brain.py | 16 +++++++ mne/viz/backends/_pyvista.py | 3 ++ mne/viz/backends/_qt.py | 36 ++++++++++++---- mne/viz/backends/_utils.py | 7 +++ 5 files changed, 113 insertions(+), 17 deletions(-) diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 75d31266634..b670d767735 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -616,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() @@ -670,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", @@ -1046,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() @@ -1062,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": @@ -1087,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"] @@ -1128,7 +1138,7 @@ def _configure_dock_trace_list_widget(self, name): 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._trace_list = add_trace_list(name, collapse=False) self.mpl_canvas.sync_traces() def _configure_mplcanvas(self): @@ -1274,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() @@ -1593,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) @@ -2192,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 @@ -2561,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 @@ -3527,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: diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 089cee2b396..d319e043e03 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -1677,6 +1677,17 @@ def test_brain_click_picking(renderer_interactive_pyvistaqt, brain_gc, qtbot, sr point = _world_to_widget_point(brain, widget, center) QTest.mouseClick(widget, Qt.LeftButton, Qt.NoModifier, point) assert list(brain._picked_points) == [peak_key] + if src == "surface": + # label mode must work with directly-passed data too: an stc gets + # synthesized for extract_label_time_course, and the default extract + # mode must be valid (not None) with src=None + assert brain.label_extract_mode is not None + brain.widgets["annotation"].set_value("aparc") + assert brain.traces_mode == "label" + assert brain.widgets["extract_mode"].get_value() == brain.label_extract_mode + _, point = _closest_vertex_point(brain, widget) + QTest.mouseClick(widget, Qt.LeftButton, Qt.NoModifier, point) + assert len(brain._picked_patches[hemi]) == 1 brain.close() @@ -1704,6 +1715,11 @@ def test_brain_click_picking_label(renderer_interactive_pyvistaqt, brain_gc, qtb brain.show() widget = brain.plotter.interactor _, point = _closest_vertex_point(brain, widget) + # hovering with no prior click must not pick labels: the hover handlers + # used to Pick() with the picker carrying the EndPickEvent observer, so + # every hover movement acted like a click + for dx in range(-40, 41, 10): + _send_mouse_move(widget, point + QPoint(dx, 0)) assert len(brain._picked_patches["lh"]) == 0 QTest.mouseClick(widget, Qt.LeftButton, Qt.NoModifier, point) assert len(brain._picked_patches["lh"]) == 1 diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index 966c1f5e95d..66fe27a2404 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -261,6 +261,9 @@ def __init__( self._toggle_antialias() self._enable_depth_peeling() self._picker = vtkCellPicker() + # separate picker for hover: Pick() on _picker would fire its + # EndPickEvent observer and act like a click + self._hover_picker = vtkCellPicker() # FIX: https://github.com/pyvista/pyvistaqt/pull/68 if not hasattr(self.plotter, "iren"): diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index cbc93250e79..1776e878569 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -1150,7 +1150,7 @@ def _dock_add_group_box(self, name, *, collapse=None, layout=None): " border: none;" " font-size: 13pt;" " font-weight: 600;" - " color: palette(mid);" + " color: palette(placeholder-text);" " }" "QToolButton:hover { color: palette(text); }" ) @@ -1480,7 +1480,9 @@ def __init__(self, canvas, line): coords = meta[2] if meta is not None else None if coords: coord_label = QLabel(f"MNI: {coords}") - coord_label.setStyleSheet("color: palette(disabled-text); font-size: 8pt;") + coord_label.setStyleSheet( + "color: palette(placeholder-text); font-size: 8pt;" + ) coord_label.setWordWrap(True) text_col.addWidget(coord_label) @@ -1568,7 +1570,7 @@ def sync(self, lines): "Set Annotation to None to see\nvertex and RMS traces here." ) placeholder.setStyleSheet( - "color: palette(disabled-text); font-style: italic; font-size: 9pt;" + "color: palette(placeholder-text); font-style: italic; font-size: 9pt;" ) self._rows_layout.addWidget(placeholder) return @@ -1804,6 +1806,19 @@ def _window_ensure_minimum_sizes(self): # 1. Settle the layout self._window.ensurePolished() _qt_activate_layouts(self._window, self._interactor) + # Never grow the shown window beyond the available screen + # geometry below: on macOS the compositor can stop presenting + # such a window entirely (fully blank until the user resizes + # it). Measure the true frame overhead from the live window + # rather than guessing at decoration sizes. + screen = self._window.screen() + if screen is None: + max_w = max_h = 10**6 + else: + frame = self._window.frameGeometry() + avail = screen.availableGeometry() + max_w = avail.width() - (frame.width() - self._window.width()) + max_h = avail.height() - (frame.height() - self._window.height()) # 2. Get the window and interactor sizes that work win_sz = self._window.size() ren_sz = self._interactor.size() @@ -1818,7 +1833,10 @@ def _window_ensure_minimum_sizes(self): if adjust_mpl: win_h += max(self._mpl_dock.widget().size().height() - mpl_h, 0) # 5. Resize the window to the size that gave us ren_sz - self._interactor.window_size = (win_sz.width(), win_h) + self._interactor.window_size = ( + min(win_sz.width(), max_w), + min(win_h, max_h), + ) _qt_activate_layouts(self._window, self._interactor) # 6. Zeroing the frame's layout margins above avoids the interactor # drifting on most platforms, but not always (e.g. CI's macOS @@ -1829,9 +1847,11 @@ def _window_ensure_minimum_sizes(self): err_h = ren_sz.height() - self._interactor.height() if not (err_w or err_h): break - self._window.resize( - self._window.width() + err_w, self._window.height() + err_h - ) + new_w = min(self._window.width() + err_w, max_w) + new_h = min(self._window.height() + err_h, max_h) + if (new_w, new_h) == (self._window.width(), self._window.height()): + break # cannot converge without leaving the screen + self._window.resize(new_w, new_h) _qt_activate_layouts(self._window, self._interactor) def _window_set_theme(self, theme=None): @@ -2074,7 +2094,7 @@ def _create_dock_widget(window, name, area, *, max_width=None): title.setFont(title_font) title.setStyleSheet( "QLabel {" - " color: palette(mid);" + " color: palette(placeholder-text);" " padding: 7px 10px 6px 10px;" " border-bottom: 1px solid palette(midlight);" " }" diff --git a/mne/viz/backends/_utils.py b/mne/viz/backends/_utils.py index b631bacd8fc..43ce4690a55 100644 --- a/mne/viz/backends/_utils.py +++ b/mne/viz/backends/_utils.py @@ -113,6 +113,13 @@ def event(self, e): @contextmanager def _qt_disable_paint(widget): + if hasattr(widget, "paintGL"): + # QOpenGLWidget-based interactor (PyVistaQt >= 0.13): paintEvent drives + # the GL compositing of the whole window there, and suppressing it + # while the window is first shown leaves the entire window blank on + # macOS until a resize forces a fresh frame + yield + return paintEvent = widget.paintEvent widget.paintEvent = lambda *args, **kwargs: None try: From b4e0f3b03376f34ef39ef5ab1a53e0a5bedc0629 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 25 Aug 2026 12:06:34 +0200 Subject: [PATCH 6/6] FIX: Maybe --- mne/viz/backends/_qt.py | 12 +++++++++++- tools/github_actions_dependencies.sh | 7 +++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index 1776e878569..e49a8fea222 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -24,6 +24,7 @@ QLibraryInfo, QLocale, QObject, + QPoint, QSize, Qt, QTimer, @@ -1811,7 +1812,16 @@ def _window_ensure_minimum_sizes(self): # such a window entirely (fully blank until the user resizes # it). Measure the true frame overhead from the live window # rather than guessing at decoration sizes. - screen = self._window.screen() + # NB: not self._window.screen(): PySide's wrapper for + # QWidget.screen() can end up owning -- and later deleting -- the + # application's QScreen, which crashes (segfault) on the next + # window creation. Instead find the screen from the window's + # position (as in mne-qt-browser's _screen) + screen = QGuiApplication.screenAt( + self._window.mapToGlobal(QPoint(self._window.width() // 2, 0)) + ) + if screen is None: + screen = QGuiApplication.primaryScreen() if screen is None: max_w = max_h = 10**6 else: diff --git a/tools/github_actions_dependencies.sh b/tools/github_actions_dependencies.sh index 13fee17afa3..3702c1284d6 100755 --- a/tools/github_actions_dependencies.sh +++ b/tools/github_actions_dependencies.sh @@ -68,3 +68,10 @@ if [[ "${MNE_CI_KIND}" == "pip-ft" ]]; then python -m pip install --pre --upgrade --only-binary=:all: "lxml>=7.0.0a3" echo "::endgroup::" fi + +# TODO VERSION: remove once a pytest-xdist release includes the fix for the +# loadscope scheduler deadlocking after a worker crash +# (pytest-dev/pytest-xdist#1363) +echo "::group::Installing pytest-xdist branch with worker-crash deadlock fix" +python -m pip install --progress-bar off --upgrade "pytest-xdist @ git+https://github.com/larsoner/pytest-xdist@lock" +echo "::endgroup::"