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..a63b322b8f4 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", @@ -246,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, ) @@ -257,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/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 48ab44d8785..fbb369e9064 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 @@ -1952,8 +2026,17 @@ 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: + 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,12 +2046,11 @@ 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 # _current_time - self.set_time_interpolation(self.time_interpolation) self.set_data_smoothing(self._all_data[key]["smoothing_steps"]) # 3) add the other actors @@ -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,10 @@ 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 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( scalars=act_data, @@ -3699,6 +3787,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 +3908,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..828348bfffe 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"].is_visible() + 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"].is_visible() + 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..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 @@ -1406,6 +1410,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..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 @@ -1501,6 +1504,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..af26d5a8a45 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 @@ -1694,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: @@ -1713,6 +1721,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/intro/10_overview.py b/tutorials/intro/10_overview.py index f61745b0024..d9fd1cef8d2 100644 --- a/tutorials/intro/10_overview.py +++ b/tutorials/intro/10_overview.py @@ -404,6 +404,44 @@ 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", + transparent=True, + 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: