From 23931fc9ef69ddc7507a21f35b9ea7154b482c34 Mon Sep 17 00:00:00 2001 From: payam Date: Tue, 7 Jul 2026 13:36:43 +0200 Subject: [PATCH 1/7] ENH: add overlay Brain GUI From c9d69da71c2553b3ae03ecda37a791681d166ec8 Mon Sep 17 00:00:00 2001 From: payam Date: Fri, 10 Jul 2026 09:34:05 +0200 Subject: [PATCH 2/7] overlay per SL method --- doc/changes/dev/14037.newfeature.rst | 1 + examples/visualization/brain.py | 7 +- mne/viz/_brain/_brain.py | 165 +++++++++++++++++++----- mne/viz/_brain/tests/test_brain.py | 38 ++++++ mne/viz/backends/_abstract.py | 4 + mne/viz/backends/_notebook.py | 3 + mne/viz/backends/_qt.py | 11 ++ tutorials/inverse/30_mne_dspm_loreta.py | 72 +++++++++++ 8 files changed, 268 insertions(+), 33 deletions(-) create mode 100644 doc/changes/dev/14037.newfeature.rst diff --git a/doc/changes/dev/14037.newfeature.rst b/doc/changes/dev/14037.newfeature.rst new file mode 100644 index 00000000000..488eff31f02 --- /dev/null +++ b/doc/changes/dev/14037.newfeature.rst @@ -0,0 +1 @@ +When multiple overlays are active (via ``remove_existing=False`` in :meth:`~mne.viz.Brain.add_data`), the interactive Brain viewer now shows an "Overlay" drop-down in the *Color Limits* dock panel that lets the user switch which overlay's color limits and smoothing the sliders control. By `Payam Sadeghi-Shabestari`_. diff --git a/examples/visualization/brain.py b/examples/visualization/brain.py index dc2a89779d9..bb12ba8f282 100644 --- a/examples/visualization/brain.py +++ b/examples/visualization/brain.py @@ -219,8 +219,11 @@ # alpha-composited by :class:`~mne.viz.LayeredMesh` so both datasets appear # at the same time. # -# Here we simulate two focal patches of activity in different brain regions: -# a temporal source (red/hot) and a frontal source (blue). +# Here we simulate two focal patches of activity evolving over time in +# different brain regions: a temporal source (red/hot) that peaks early and +# a frontal source (blue) that peaks later. In the interactive viewer an +# "Overlay" drop-down appears in the *Color Limits* panel — use it to switch +# which overlay's limits and smoothing the sliders control. brain = mne.viz.Brain( "sample", diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 48ab44d8785..bcbab566e2e 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -551,6 +551,12 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True): 'Down': Increase camera elevation angle 'Left': Decrease camera azimuth angle 'Right': Increase camera azimuth angle + + When multiple overlays are active (added via + :meth:`add_data` with ``remove_existing=False``), an **Overlay** + drop-down menu appears in the *Color Limits* dock panel. Selecting + an entry from that menu switches which overlay's ``fmin`` / ``fmid`` + / ``fmax`` sliders and smoothing control are active. """ if self.time_viewer: return @@ -719,6 +725,8 @@ def toggle_interface(self, value=None): def apply_auto_scaling(self): """Detect automatically fitting scaling parameters.""" + if self._data is not None: + self._update_current_time_idx(self._data["time_idx"]) self._update_auto_scaling() def restore_user_scaling(self): @@ -859,11 +867,28 @@ def set_orientation(value, orientation_data=orientation_data): ) def _configure_dock_colormap_widget(self, name): + self._active_data_key = next(iter(self._all_data)) fmax, fscale, fscale_power = _get_range(self) rng = [0, fmax * fscale] self._data["fscale"] = fscale layout = self._renderer._dock_add_group_box(name) + + @_auto_weakref + def select_data_key(value): + self._active_data_key = value + self._refresh_colormap_widgets() + + self.widgets["data_key"] = self._renderer._dock_add_combo_box( + name="Overlay", + value=self._active_data_key, + rng=list(self._all_data.keys()), + callback=select_data_key, + layout=layout, + ) + if len(self._all_data) <= 1: + self.widgets["data_key"].hide() + text = "min / mid / max" if fscale_power != 0: text += f" (×1e{fscale_power:d})" @@ -907,7 +932,7 @@ def update_single_lut_value(value, key): ) self.widgets["reset"] = self._renderer._dock_add_button( name="↺", - callback=self.restore_user_scaling, + callback=self.apply_auto_scaling, layout=hlayout, style="toolbutton", ) @@ -934,6 +959,31 @@ def fplus(): style="toolbutton", ) self._renderer._layout_add_widget(layout, hlayout) + self._update_colormap_range() + + def _refresh_colormap_widgets(self): + """Sync colormap dock widgets with the currently active overlay.""" + if self._data is None or "fmin" not in self.widgets: + return + fmax, fscale, fscale_power = _get_range(self) + self._data["fscale"] = fscale + rng = [0, fmax * fscale] + with disable_ui_events(self): + for key in ("fmin", "fmid", "fmax"): + val = self._data[key] * fscale + self.widgets[key].set_range(rng) + self.widgets[key].set_value(val) + self.widgets[f"entry_{key}"].set_range(rng) + self.widgets[f"entry_{key}"].set_value(val) + if "smoothing" in self.widgets: + self.widgets["smoothing"].set_value(self._data["smoothing_steps"]) + # Force the brain and colorbar to reflect the newly active overlay. + self._update_colormap_range( + fmin=self._data["fmin"], + fmid=self._data["fmid"], + fmax=self._data["fmax"], + ) + self._renderer._process_events() def _configure_dock_trace_widget(self, name): if not self.show_traces: @@ -1060,23 +1110,38 @@ def _configure_vertex_time_course(self): else: self.clear_glyphs() - # plot RMS of the activation - y = np.concatenate( - list(v[0] for v in self.act_data_smooth.values() if v[0] is not None) - ) - rms = np.linalg.norm(y, axis=0) / np.sqrt(len(y)) - del y - - (self.rms,) = self.mpl_canvas.axes.plot( - self._data["time"], - rms, - lw=3, - label="RMS", - zorder=3, - color=self._fg_color, - alpha=0.5, - ls=":", - ) + # Plot one RMS curve per overlay so the viewer shows all overlays. + self.rms = [] + multi = len(self._all_data) > 1 + for overlay_key, overlay_data in self._all_data.items(): + y_parts = [] + for hemi_key in ["lh", "rh", "vol"]: + hemi_data = overlay_data.get(hemi_key) + if hemi_data is None: + continue + arr = hemi_data["array"] + if arr.ndim == 1: + continue # static data — no time axis + if arr.ndim == 3: + arr = np.linalg.norm(arr, axis=1) + y_parts.append(arr) + if not y_parts: + continue + y = np.concatenate(y_parts) + rms = np.linalg.norm(y, axis=0) / np.sqrt(len(y)) + del y + label = f"RMS ({overlay_key})" if multi else "RMS" + (line,) = self.mpl_canvas.axes.plot( + overlay_data["time"], + rms, + lw=3, + label=label, + zorder=3, + color=next(self.color_cycle), + alpha=0.5, + ls=":", + ) + self.rms.append(line) # now plot the time line self.plot_time_line(update=False) @@ -1340,6 +1405,7 @@ def _on_time_change(self, event): if "current_time" in self.widgets: self.widgets["current_time"].set_value(f"{self._current_time: .3f}") self.plot_time_line(update=True) + self._renderer._process_events() def _on_colormap_range(self, event): """Respond to the colormap_range UI event.""" @@ -1360,9 +1426,12 @@ def _on_colormap_range(self, event): self.widgets[entry_key].set_value(val * self._data["fscale"]) # Update the render. self._update_colormap_range(**lims) + self._renderer._process_events() def _on_vertex_select(self, event): """Respond to vertex_select UI event.""" + if self._data is None: + return if event.hemi == "vol": try: mesh = self._data[event.hemi]["grid"] @@ -1500,7 +1569,8 @@ def clear_glyphs(self): self._remove_label_glyph(hemi, label_id) assert sum(len(v) for v in self._picked_patches.values()) == 0 if self.rms is not None: - self.rms.remove() + for line in self.rms: + line.remove() self.rms = None self._renderer._update() @@ -1819,7 +1889,11 @@ def add_data( (e.g., ``dict(title_font_size=10)``). key : str Key used to identify this data overlay in - ``Brain.layered_meshes``. Defaults to ``"data"``. + ``Brain.layered_meshes``. Defaults to ``"data"``. When multiple + overlays are present (``remove_existing=False``), each overlay + must have a distinct key; the key also becomes the label shown in + the **Overlay** drop-down in the interactive time viewer (see + :meth:`setup_time_viewer`). .. versionadded:: 1.12 %(verbose)s @@ -1954,6 +2028,14 @@ def add_data( self._all_data[key]["fmax"] = fmax self._update_colormap_range() + if "data_key" in self.widgets: + keys = list(self._all_data.keys()) + self.widgets["data_key"].set_items(keys) + self.widgets["data_key"].set_value(key) + if len(keys) > 1: + self.widgets["data_key"].show() + self._refresh_colormap_widgets() + # 1) add the surfaces first actor = None for _ in self._iter_views(hemi): @@ -1963,7 +2045,7 @@ def add_data( src_vol = src[2:] if src.kind == "mixed" else src actor, _ = self._add_volume_data(hemi, src_vol, volume_options) assert actor is not None # should have added one - self._add_actor("data", actor) + self._add_actor(key, actor) # 2) update time and smoothing properties # set_data_smoothing calls "_update_current_time_idx" for us, which will set @@ -3145,7 +3227,7 @@ def _on_annotation_hover(self, iren, event): # event == "MouseMoveEvent" if not isinstance(mapper, DataSetMapper) or cell_id == -1: do_update = False for annot in self._annots.values(): - if "caption" not in annot[-1]: + if not annot or "caption" not in annot[-1]: continue caption = annot[-1]["caption"] if caption.GetVisibility(): @@ -3188,7 +3270,7 @@ def _on_annotation_hover(self, iren, event): # event == "MouseMoveEvent" centroid, ) other_hemi = "lh" if hemi == "rh" else "rh" - if other_hemi in self._annots: + if self._annots.get(other_hemi): self._annots[other_hemi][-1]["caption"].SetVisibility(False) caption.SetCaption(label.name) caption.SetAttachmentPoint(*centroid) @@ -3350,6 +3432,7 @@ def show_view( self._set_camera(**view_params, align=align) if update: self._renderer._update() + self._renderer._process_events() def _set_camera( self, @@ -3551,14 +3634,16 @@ def set_data_smoothing(self, n_steps): from ...morph import _hemi_morph for hemi in ["lh", "rh"]: - hemi_data = self._data.get(hemi) - if hemi_data is not None: + for data_key, key_data in self._all_data.items(): + hemi_data = key_data.get(hemi) + if hemi_data is None: + continue if len(hemi_data["array"]) >= self.geo[hemi].x.shape[0]: continue vertices = hemi_data["vertices"] if vertices is None: raise ValueError( - f"len(data) < nvtx ({len(hemi_data)} < " + f"len(data) < nvtx ({len(hemi_data['array'])} < " f"{self.geo[hemi].x.shape[0]}): the vertices " "parameter must not be None" ) @@ -3572,9 +3657,7 @@ def set_data_smoothing(self, n_steps): maps=None, warn=False, ) - self._data[hemi]["smooth_mat"] = smooth_mat - if hemi in self.layered_meshes: - self.layered_meshes[hemi].smooth_mat = smooth_mat + hemi_data["smooth_mat"] = smooth_mat self._update_current_time_idx(self._data["time_idx"]) self._data["smoothing_steps"] = n_steps @@ -3633,6 +3716,7 @@ def _update_current_time_idx(self, time_idx): time_actor = active.get("time_actor", None) time_label = active.get("time_label", None) for hemi in ["lh", "rh", "vol"]: + hemi_needs_recompose = False for data_key, key_data in self._all_data.items(): hemi_data = key_data.get(hemi) if hemi_data is None: @@ -3674,9 +3758,10 @@ def _update_current_time_idx(self, time_idx): # if 21334 in vertices: # grid.point_data["values"][21334] = values.max() - # update the mesh scalar values (LayeredMesh applies smooth_mat) + # update the mesh scalar values if hemi in self.layered_meshes: mesh = self.layered_meshes[hemi] + mesh.smooth_mat = hemi_data.get("smooth_mat") key_rng = [ -key_data["fmax"] if key_data["center"] is not None @@ -3684,7 +3769,15 @@ def _update_current_time_idx(self, time_idx): key_data["fmax"], ] if data_key in mesh._overlays: - mesh.update_overlay(name=data_key, scalars=act_data) + # Stage scalars directly without triggering a per-overlay + # recompose; a single mesh.update() below handles all of them + # in O(N) instead of O(N²). + overlay = mesh._overlays[data_key] + scalars = np.asarray(act_data) + if mesh.smooth_mat is not None: + scalars = mesh.smooth_mat.dot(scalars) + overlay._scalars = scalars + hemi_needs_recompose = True else: mesh.add_overlay( scalars=act_data, @@ -3699,6 +3792,9 @@ def _update_current_time_idx(self, time_idx): if vectors is not None and data_key == self._active_data_key: self._update_glyphs(hemi, vectors) + if hemi_needs_recompose and hemi in self.layered_meshes: + self.layered_meshes[hemi].update() + active["time_idx"] = time_idx self._renderer._update() @@ -3817,6 +3913,13 @@ def _update_auto_scaling(self, restore=False): allow_pos_lims = True if user_clim is not None and restore: clim = user_clim + elif restore: + self.update_lut( + fmin=self._data["fmin"], + fmid=self._data["fmid"], + fmax=self._data["fmax"], + ) + return else: clim = "auto" colormap = self._data["colormap"] diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 245d45e6ea2..ec49c649434 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -1052,6 +1052,44 @@ def test_brain_time_viewer(renderer_interactive_pyvistaqt, pixel_ratio, brain_gc brain.close() +@testing.requires_testing_data +def test_brain_overlay_selector(renderer_interactive_pyvistaqt, brain_gc): + """Test the Overlay dropdown widget shown when multiple overlays are active.""" + brain = _create_testing_brain(hemi="lh", show_traces=False) + + # with a single overlay the selector widget should exist but be hidden + assert "data_key" in brain.widgets + assert not brain.widgets["data_key"].isVisible() + assert brain._active_data_key == "data" + + # add a second overlay — widget should become visible and list both keys + stc = read_source_estimate(fname_stc) + hemi_data = stc.data[: len(stc.vertices[0]), 0] + brain.add_data( + hemi_data, + fmin=stc.data.min(), + fmax=stc.data.max(), + hemi="lh", + colormap="Blues", + vertices=stc.vertices[0], + smoothing_steps="nearest", + colorbar=False, + key="overlay2", + remove_existing=False, + ) + assert brain.widgets["data_key"].isVisible() + assert brain._active_data_key == "overlay2" + + # switching the dropdown updates the active key and refreshes sliders + brain.widgets["data_key"].set_value("data") + assert brain._active_data_key == "data" + + brain.widgets["data_key"].set_value("overlay2") + assert brain._active_data_key == "overlay2" + + brain.close() + + @testing.requires_testing_data @pytest.mark.parametrize( "hemi, src", diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index 9a4d764eb7e..6365ff84fb2 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -1406,6 +1406,10 @@ def set_tooltip(self, tooltip: str): def set_style(self, style): pass + @abstractmethod + def set_items(self, items): + pass + class _AbstractAction(ABC): def __init__(self, action): diff --git a/mne/viz/backends/_notebook.py b/mne/viz/backends/_notebook.py index ea831a32150..d8ab6508ebd 100644 --- a/mne/viz/backends/_notebook.py +++ b/mne/viz/backends/_notebook.py @@ -1501,6 +1501,9 @@ def set_style(self, style): for key, val in style.items(): setattr(self._widget.layout, key, val) + def set_items(self, items): + self._widget.options = tuple(items) + class _IpyAction(_AbstractAction): def trigger(self): diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index 78b02a6d05b..95b6a2328fd 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -1285,6 +1285,11 @@ def setMaximum(self, value): """Set the maximum.""" super().setMaximum(int(value * self._precision)) + def setRange(self, minimum, maximum): + """Set the range using float values.""" + self.setMinimum(minimum) + self.setMaximum(maximum) + def value(self): """Get the current value.""" return super().value() / self._precision @@ -1713,6 +1718,12 @@ def set_style(self, style): stylesheet = stylesheet + f"{key}:{val};" self._widget.setStyleSheet(stylesheet) + def set_items(self, items): + self._widget.blockSignals(True) + self._widget.clear() + self._widget.addItems(items) + self._widget.blockSignals(False) + class _QtDialogCommunicator(QObject): signal_show = Signal() diff --git a/tutorials/inverse/30_mne_dspm_loreta.py b/tutorials/inverse/30_mne_dspm_loreta.py index 923ee534d71..05561f803a8 100644 --- a/tutorials/inverse/30_mne_dspm_loreta.py +++ b/tutorials/inverse/30_mne_dspm_loreta.py @@ -181,6 +181,78 @@ # brain.save_movie(..., tmin=0.05, tmax=0.15, interpolation='linear', # time_dilation=20, framerate=10, time_viewer=True) +# %% +# Overlay all four methods simultaneously +# ---------------------------------------- +# +# We compute all four inverse solutions and overlay them on the same +# :class:`~mne.viz.Brain`. Because the four methods operate on very different +# amplitude scales (MNE/eLORETA in pA·m, dSPM/sLORETA as pseudo-Z scores), +# we normalize each method's data to ``[0, 1]`` using its own 5th–95th +# percentile range before overlaying so the colormaps are +# comparable. +# +# :func:`~mne.viz.Brain.add_data` defaults to ``key="data"`` when no key is +# supplied, which is what :func:`~mne.minimum_norm.apply_inverse` uses +# internally. We pass an explicit key for every layer so the "Overlay" +# dropdown in the *Color Limits* panel shows meaningful names. + +overlay_configs = [ + dict(method="dSPM", colormap="hot", alpha=0.6), + dict(method="MNE", colormap="Blues", alpha=0.6), + dict(method="sLORETA", colormap="Greens", alpha=0.6), + dict(method="eLORETA", colormap="Oranges", alpha=0.6), +] + +surfer_kwargs_overlay = {k: v for k, v in surfer_kwargs.items() if k != "clim"} + +brain2 = None +for i, cfg in enumerate(overlay_configs): + stc_i, _ = apply_inverse( + evoked, + inverse_operator, + lambda2, + method=cfg["method"], + pick_ori=None, + return_residual=True, + verbose=True, + ) + + data_rh = stc_i.rh_data + p5, p95 = np.percentile(data_rh, [5, 95]) + data_rh_norm = np.clip((data_rh - p5) / (p95 - p5), 0, 1) + + if brain2 is None: + # stc.plot() always adds data with the default key="data". We create + # the Brain here, then immediately replace that overlay with a + # normalized version under the method's own key. + brain2 = stc_i.plot( + **surfer_kwargs_overlay, + clim=dict(kind="value", lims=[0.2, 0.5, 1.0]), + colormap=cfg["colormap"], + alpha=cfg["alpha"], + ) + + brain2.add_data( + data_rh_norm, + vertices=stc_i.rh_vertno, + hemi="rh", + fmin=0.96, + fmid=0.98, + fmax=1.0, + colormap=cfg["colormap"], + alpha=cfg["alpha"], + smoothing_steps=surfer_kwargs["smoothing_steps"], + time=stc_i.times, + initial_time=surfer_kwargs["initial_time"], + key=cfg["method"].lower(), + remove_existing=(i == 0), # first pass removes the default "data" layer + ) + +brain2.add_text( + 0.1, 0.9, "MNE · dSPM · sLORETA · eLORETA overlay", "title", font_size=14 +) + # %% # There are many other ways to visualize and work with source data, see # for example: From 44d220a5d2320fe4ab2ac2996a77a027127310a0 Mon Sep 17 00:00:00 2001 From: payam Date: Fri, 10 Jul 2026 10:00:31 +0200 Subject: [PATCH 3/7] add is_visible() option for the test --- mne/viz/_brain/tests/test_brain.py | 4 ++-- mne/viz/backends/_abstract.py | 4 ++++ mne/viz/backends/_notebook.py | 3 +++ mne/viz/backends/_qt.py | 3 +++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index ec49c649434..828348bfffe 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -1059,7 +1059,7 @@ def test_brain_overlay_selector(renderer_interactive_pyvistaqt, brain_gc): # with a single overlay the selector widget should exist but be hidden assert "data_key" in brain.widgets - assert not brain.widgets["data_key"].isVisible() + assert not brain.widgets["data_key"].is_visible() assert brain._active_data_key == "data" # add a second overlay — widget should become visible and list both keys @@ -1077,7 +1077,7 @@ def test_brain_overlay_selector(renderer_interactive_pyvistaqt, brain_gc): key="overlay2", remove_existing=False, ) - assert brain.widgets["data_key"].isVisible() + assert brain.widgets["data_key"].is_visible() assert brain._active_data_key == "overlay2" # switching the dropdown updates the active key and refreshes sliders diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index 6365ff84fb2..2c4ad2e7174 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -1390,6 +1390,10 @@ def set_enabled(self, state): def is_enabled(self): pass + @abstractmethod + def is_visible(self): + pass + @abstractmethod def update(self, repaint=True): pass diff --git a/mne/viz/backends/_notebook.py b/mne/viz/backends/_notebook.py index d8ab6508ebd..1771203b823 100644 --- a/mne/viz/backends/_notebook.py +++ b/mne/viz/backends/_notebook.py @@ -1486,6 +1486,9 @@ def set_enabled(self, state): def is_enabled(self): return not self._widget.disabled + def is_visible(self): + return self._widget.layout.visibility != "hidden" + def update(self, repaint=True): pass diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index 95b6a2328fd..af26d5a8a45 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -1699,6 +1699,9 @@ def set_enabled(self, state): def is_enabled(self): return self._widget.isEnabled() + def is_visible(self): + return self._widget.isVisible() + def update(self, repaint=True): self._widget.update() if repaint: From 2d068bf4a2d368d9e68d921003e7174b9aeea368 Mon Sep 17 00:00:00 2001 From: payam Date: Sat, 11 Jul 2026 15:44:13 +0200 Subject: [PATCH 4/7] a bit better example viz --- tutorials/inverse/30_mne_dspm_loreta.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/tutorials/inverse/30_mne_dspm_loreta.py b/tutorials/inverse/30_mne_dspm_loreta.py index 05561f803a8..e2e6f72c3b4 100644 --- a/tutorials/inverse/30_mne_dspm_loreta.py +++ b/tutorials/inverse/30_mne_dspm_loreta.py @@ -189,19 +189,19 @@ # :class:`~mne.viz.Brain`. Because the four methods operate on very different # amplitude scales (MNE/eLORETA in pA·m, dSPM/sLORETA as pseudo-Z scores), # we normalize each method's data to ``[0, 1]`` using its own 5th–95th -# percentile range before overlaying so the colormaps are -# comparable. +# percentile range before overlaying so the colormaps are comparable. # # :func:`~mne.viz.Brain.add_data` defaults to ``key="data"`` when no key is # supplied, which is what :func:`~mne.minimum_norm.apply_inverse` uses # internally. We pass an explicit key for every layer so the "Overlay" # dropdown in the *Color Limits* panel shows meaningful names. + overlay_configs = [ - dict(method="dSPM", colormap="hot", alpha=0.6), - dict(method="MNE", colormap="Blues", alpha=0.6), - dict(method="sLORETA", colormap="Greens", alpha=0.6), - dict(method="eLORETA", colormap="Oranges", alpha=0.6), + dict(method="dSPM", colormap="Reds", alpha=1.0), + dict(method="MNE", colormap="Greens", alpha=0.7), + dict(method="sLORETA", colormap="Blues", alpha=0.4), + dict(method="eLORETA", colormap="RdPu", alpha=0.1), ] surfer_kwargs_overlay = {k: v for k, v in surfer_kwargs.items() if k != "clim"} @@ -223,12 +223,9 @@ data_rh_norm = np.clip((data_rh - p5) / (p95 - p5), 0, 1) if brain2 is None: - # stc.plot() always adds data with the default key="data". We create - # the Brain here, then immediately replace that overlay with a - # normalized version under the method's own key. brain2 = stc_i.plot( **surfer_kwargs_overlay, - clim=dict(kind="value", lims=[0.2, 0.5, 1.0]), + clim=dict(kind="value", lims=[0.5, 0.75, 1.0]), colormap=cfg["colormap"], alpha=cfg["alpha"], ) @@ -237,8 +234,8 @@ data_rh_norm, vertices=stc_i.rh_vertno, hemi="rh", - fmin=0.96, - fmid=0.98, + fmin=0.5, + fmid=0.75, fmax=1.0, colormap=cfg["colormap"], alpha=cfg["alpha"], From 359bd1a66fdec1ae9c87513a0c3a2c072c4b90da Mon Sep 17 00:00:00 2001 From: payam Date: Sun, 12 Jul 2026 11:00:00 +0200 Subject: [PATCH 5/7] add extra section to viz audio and vis on brain --- mne/viz/_3d_overlay.py | 11 +++- mne/viz/_brain/_brain.py | 13 ++--- tutorials/intro/10_overview.py | 43 +++++++++++++-- tutorials/inverse/30_mne_dspm_loreta.py | 69 ------------------------- 4 files changed, 54 insertions(+), 82 deletions(-) diff --git a/mne/viz/_3d_overlay.py b/mne/viz/_3d_overlay.py index 78c3b83d42e..7a241969646 100644 --- a/mne/viz/_3d_overlay.py +++ b/mne/viz/_3d_overlay.py @@ -263,7 +263,9 @@ def _clean(self): self._polydata = None self._renderer = None - def update_overlay(self, name, scalars=None, colormap=None, opacity=None, rng=None): + def update_overlay( + self, name, scalars=None, colormap=None, opacity=None, rng=None, update=True + ): """Update an existing overlay in-place. Parameters @@ -281,6 +283,11 @@ def update_overlay(self, name, scalars=None, colormap=None, opacity=None, rng=No New opacity in ``[0, 1]``. If ``None``, opacity is unchanged. rng : array-like, shape (2,) | None New ``[min, max]`` colormap range. If ``None``, range is unchanged. + update : bool + If ``True`` (default), recompose overlays and refresh the mesh + immediately. Pass ``False`` to stage the change without + triggering a recompose; the caller is then responsible for + calling :meth:`update` once all overlays have been staged. """ overlay = self._overlays.get(name, None) if overlay is None: @@ -304,6 +311,8 @@ def update_overlay(self, name, scalars=None, colormap=None, opacity=None, rng=No overlay._opacity = opacity if rng is not None: overlay._rng = rng + if not update: + return # partial update: use cache if possible if name == list(self._overlays.keys())[-1]: self.update(colors=overlay.to_colors()) diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index bcbab566e2e..fbb369e9064 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -2026,6 +2026,7 @@ def add_data( self._all_data[key]["fmin"] = fmin self._all_data[key]["fmid"] = fmid self._all_data[key]["fmax"] = fmax + self.set_time_interpolation(self.time_interpolation) self._update_colormap_range() if "data_key" in self.widgets: @@ -2050,7 +2051,6 @@ def add_data( # 2) update time and smoothing properties # set_data_smoothing calls "_update_current_time_idx" for us, which will set # _current_time - self.set_time_interpolation(self.time_interpolation) self.set_data_smoothing(self._all_data[key]["smoothing_steps"]) # 3) add the other actors @@ -3769,14 +3769,9 @@ def _update_current_time_idx(self, time_idx): key_data["fmax"], ] if data_key in mesh._overlays: - # Stage scalars directly without triggering a per-overlay - # recompose; a single mesh.update() below handles all of them - # in O(N) instead of O(N²). - overlay = mesh._overlays[data_key] - scalars = np.asarray(act_data) - if mesh.smooth_mat is not None: - scalars = mesh.smooth_mat.dot(scalars) - overlay._scalars = scalars + # Stage without recomposing; a single mesh.update() below + # handles all overlays in O(N) instead of O(N²). + mesh.update_overlay(data_key, scalars=act_data, update=False) hemi_needs_recompose = True else: mesh.add_overlay( diff --git a/tutorials/intro/10_overview.py b/tutorials/intro/10_overview.py index f61745b0024..13f509d8a1b 100644 --- a/tutorials/intro/10_overview.py +++ b/tutorials/intro/10_overview.py @@ -82,7 +82,7 @@ # bad channel marking, annotations, projector toggling, etc. raw.compute_psd(fmax=50).plot(picks="data", exclude="bads", amplitude=False) -raw.plot(duration=5, n_channels=30) +# raw.plot(duration=5, n_channels=30) # %% # Preprocessing @@ -140,8 +140,8 @@ "EEG 008", ] chan_idxs = [raw.ch_names.index(ch) for ch in chs] -orig_raw.plot(order=chan_idxs, start=12, duration=4) -raw.plot(order=chan_idxs, start=12, duration=4) +# orig_raw.plot(order=chan_idxs, start=12, duration=4) +# raw.plot(order=chan_idxs, start=12, duration=4) # %% # .. _overview-tut-events-section: @@ -404,6 +404,43 @@ initial_time=0.1, hemi="split", views=["lat", "med"], subjects_dir=subjects_dir ) +############################################################################## +# We can also display multiple conditions on the same brain. Here we compare +# auditory (red) and visual (blue) MNE solutions on the right hemisphere. +# The "Overlay" dropdown in the time viewer lets you inspect each layer +# independently: + +stc_aud = mne.minimum_norm.apply_inverse( + aud_evoked, inv_operator, lambda2=lambda2, method="dSPM" +) +stc_vis = mne.minimum_norm.apply_inverse( + vis_evoked, inv_operator, lambda2=lambda2, method="dSPM" +) +brain = stc_aud.plot( + initial_time=0.1, + hemi="rh", + views="lat", + subjects_dir=subjects_dir, + colormap="Reds", + alpha=0.8, + add_data_kwargs=dict(key="auditory"), +) +fmin_vis, fmid_vis, fmax_vis = np.percentile(stc_vis.rh_data, [50, 90, 99]) +brain.add_data( + stc_vis.rh_data, + vertices=stc_vis.rh_vertno, + hemi="rh", + fmin=fmin_vis, + fmid=fmid_vis, + fmax=fmax_vis, + colormap="Blues", + alpha=0.8, + time=stc_vis.times, + initial_time=0.1, + key="visual", + remove_existing=False, +) + ############################################################################## # The remaining tutorials have *much more detail* on each of these topics (as # well as many other capabilities of MNE-Python not mentioned here: diff --git a/tutorials/inverse/30_mne_dspm_loreta.py b/tutorials/inverse/30_mne_dspm_loreta.py index e2e6f72c3b4..923ee534d71 100644 --- a/tutorials/inverse/30_mne_dspm_loreta.py +++ b/tutorials/inverse/30_mne_dspm_loreta.py @@ -181,75 +181,6 @@ # brain.save_movie(..., tmin=0.05, tmax=0.15, interpolation='linear', # time_dilation=20, framerate=10, time_viewer=True) -# %% -# Overlay all four methods simultaneously -# ---------------------------------------- -# -# We compute all four inverse solutions and overlay them on the same -# :class:`~mne.viz.Brain`. Because the four methods operate on very different -# amplitude scales (MNE/eLORETA in pA·m, dSPM/sLORETA as pseudo-Z scores), -# we normalize each method's data to ``[0, 1]`` using its own 5th–95th -# percentile range before overlaying so the colormaps are comparable. -# -# :func:`~mne.viz.Brain.add_data` defaults to ``key="data"`` when no key is -# supplied, which is what :func:`~mne.minimum_norm.apply_inverse` uses -# internally. We pass an explicit key for every layer so the "Overlay" -# dropdown in the *Color Limits* panel shows meaningful names. - - -overlay_configs = [ - dict(method="dSPM", colormap="Reds", alpha=1.0), - dict(method="MNE", colormap="Greens", alpha=0.7), - dict(method="sLORETA", colormap="Blues", alpha=0.4), - dict(method="eLORETA", colormap="RdPu", alpha=0.1), -] - -surfer_kwargs_overlay = {k: v for k, v in surfer_kwargs.items() if k != "clim"} - -brain2 = None -for i, cfg in enumerate(overlay_configs): - stc_i, _ = apply_inverse( - evoked, - inverse_operator, - lambda2, - method=cfg["method"], - pick_ori=None, - return_residual=True, - verbose=True, - ) - - data_rh = stc_i.rh_data - p5, p95 = np.percentile(data_rh, [5, 95]) - data_rh_norm = np.clip((data_rh - p5) / (p95 - p5), 0, 1) - - if brain2 is None: - brain2 = stc_i.plot( - **surfer_kwargs_overlay, - clim=dict(kind="value", lims=[0.5, 0.75, 1.0]), - colormap=cfg["colormap"], - alpha=cfg["alpha"], - ) - - brain2.add_data( - data_rh_norm, - vertices=stc_i.rh_vertno, - hemi="rh", - fmin=0.5, - fmid=0.75, - fmax=1.0, - colormap=cfg["colormap"], - alpha=cfg["alpha"], - smoothing_steps=surfer_kwargs["smoothing_steps"], - time=stc_i.times, - initial_time=surfer_kwargs["initial_time"], - key=cfg["method"].lower(), - remove_existing=(i == 0), # first pass removes the default "data" layer - ) - -brain2.add_text( - 0.1, 0.9, "MNE · dSPM · sLORETA · eLORETA overlay", "title", font_size=14 -) - # %% # There are many other ways to visualize and work with source data, see # for example: From beab5ef81fa9d79cd7e02f2bc7ac4a582ee4793b Mon Sep 17 00:00:00 2001 From: payam Date: Sun, 12 Jul 2026 11:09:48 +0200 Subject: [PATCH 6/7] my mistake, now back to original by uncommenting raw.plot lines --- tutorials/intro/10_overview.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tutorials/intro/10_overview.py b/tutorials/intro/10_overview.py index 13f509d8a1b..c3a52db8ca7 100644 --- a/tutorials/intro/10_overview.py +++ b/tutorials/intro/10_overview.py @@ -82,7 +82,7 @@ # bad channel marking, annotations, projector toggling, etc. raw.compute_psd(fmax=50).plot(picks="data", exclude="bads", amplitude=False) -# raw.plot(duration=5, n_channels=30) +raw.plot(duration=5, n_channels=30) # %% # Preprocessing @@ -140,8 +140,8 @@ "EEG 008", ] chan_idxs = [raw.ch_names.index(ch) for ch in chs] -# orig_raw.plot(order=chan_idxs, start=12, duration=4) -# raw.plot(order=chan_idxs, start=12, duration=4) +orig_raw.plot(order=chan_idxs, start=12, duration=4) +raw.plot(order=chan_idxs, start=12, duration=4) # %% # .. _overview-tut-events-section: From 6b3a32c341aa75295b445ab1523edff042f37888 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sun, 12 Jul 2026 08:54:56 -0400 Subject: [PATCH 7/7] FIX: Transparency [skip azp] [skip actions] --- examples/visualization/brain.py | 2 ++ tutorials/intro/10_overview.py | 1 + 2 files changed, 3 insertions(+) diff --git a/examples/visualization/brain.py b/examples/visualization/brain.py index bb12ba8f282..a63b322b8f4 100644 --- a/examples/visualization/brain.py +++ b/examples/visualization/brain.py @@ -249,6 +249,7 @@ def gaussian_patch(coords, center, sigma=15.0): fmin=0.1, fmax=1.5, colormap="hot", + transparent=True, key="temporal", smoothing_steps=5, ) @@ -260,6 +261,7 @@ def gaussian_patch(coords, center, sigma=15.0): fmin=0.1, fmax=0.6, colormap="Blues", + transparent=True, alpha=0.5, key="frontal", remove_existing=False, diff --git a/tutorials/intro/10_overview.py b/tutorials/intro/10_overview.py index c3a52db8ca7..d9fd1cef8d2 100644 --- a/tutorials/intro/10_overview.py +++ b/tutorials/intro/10_overview.py @@ -434,6 +434,7 @@ fmid=fmid_vis, fmax=fmax_vis, colormap="Blues", + transparent=True, alpha=0.8, time=stc_vis.times, initial_time=0.1,