diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 677268d626d..991619538e1 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -135,6 +135,8 @@ class Brain: Add support for non-string arguments. alpha : float in [0, 1] Alpha level to control opacity of the cortical surface. + + See :meth:`set_cortex_alpha` to change this after creation. size : int | array-like, shape (2,) The size of the window, in pixels. can be one number to specify a square window, or a length-2 sequence to specify (width, height). @@ -180,6 +182,9 @@ class Brain: .. versionchanged:: 1.13 The default ``decimate`` value changed from ``0.9`` to ``"ico5"``. + + See :meth:`set_silhouette_line_width` to change the line width (or + show/hide the silhouette) after creation. %(theme_3d)s show : bool Display the window as soon as it is ready. Defaults to True. @@ -397,6 +402,7 @@ def __init__( self.silhouette = True else: self.silhouette = silhouette + self._silhouette_actors = [] self._scalar_bar = None # for now only one time label can be added # since it is the same for all figures @@ -471,35 +477,11 @@ def __init__( else: actor = self.layered_meshes[h]._actor self._renderer.plotter.add_actor(actor, render=False) - if self.silhouette: - mesh = self.layered_meshes[h] - decimate = self._silhouette["decimate"] - if isinstance(decimate, str): - import pyvista as pv - - vertno, tris = _decimate_surface_ico_oct( - self._subject, - self._subjects_dir, - h, - self.geo[h].surf, - decimate, - ) - sil_mesh = pv.PolyData( - self.geo[h].coords[vertno], - np.c_[np.full(len(tris), 3), tris], - ) - decimate = None # already decimated - else: - sil_mesh = mesh._polydata - self._renderer._silhouette( - mesh=sil_mesh, - color=self._silhouette["color"], - line_width=self._silhouette["line_width"], - alpha=self._silhouette["alpha"], - decimate=decimate, - ) self._set_camera(**views_dicts[h][v]) + if self.silhouette: + self._add_silhouette() + self.interaction = interaction self._closed = False if show: @@ -652,6 +634,9 @@ def _clean(self): # clear init actors for hemi in self.layered_meshes: self.layered_meshes[hemi]._clean() + for actor in getattr(self, "_silhouette_actors", None) or (): + self.plotter.remove_actor(actor) + self._silhouette_actors = None self._clear_callbacks() self._clear_widgets() if getattr(self, "mpl_canvas", None) is not None: @@ -810,7 +795,7 @@ def current_time_func(): del current_time def _configure_dock_orientation_widget(self, name): - layout = self._renderer._dock_add_group_box(name) + layout = self._renderer._dock_add_group_box(name, collapse=True) # Renderer widget rends = [str(i) for i in range(len(self._renderer._all_renderers))] if len(rends) > 1: @@ -866,13 +851,31 @@ def set_orientation(value, orientation_data=orientation_data): layout=layout, ) + def _configure_dock_surface_widget(self, name): + layout = self._renderer._dock_add_group_box(name, collapse=True) + self.widgets["cortex_alpha"] = self._renderer._dock_add_slider( + name="Alpha", + value=self._alpha, + rng=[0.0, 1.0], + callback=self.set_cortex_alpha, + double=True, + layout=layout, + ) + self.widgets["silhouette"] = self._renderer._dock_add_spin_box( + name="Silhouette", + value=self._silhouette["line_width"] if self.silhouette else 0.0, + rng=[0.0, 10.0], + callback=self.set_silhouette_line_width, + layout=layout, + ) + 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) + layout = self._renderer._dock_add_group_box(name, collapse=False) @_auto_weakref def select_data_key(value): @@ -959,6 +962,16 @@ def fplus(): style="toolbutton", ) self._renderer._layout_add_widget(layout, hlayout) + + self.widgets["smoothing"] = self._renderer._dock_add_spin_box( + name="Smoothing", + value=self._data["smoothing_steps"], + rng=self.default_smoothing_range, + callback=self.set_data_smoothing, + double=False, + layout=layout, + ) + self._update_colormap_range() def _refresh_colormap_widgets(self): @@ -996,7 +1009,7 @@ def _configure_dock_trace_widget(self, name): self._configure_vertex_time_course() return - layout = self._renderer._dock_add_group_box(name) + layout = self._renderer._dock_add_group_box(name, collapse=True) # setup candidate annots @_auto_weakref @@ -1068,19 +1081,10 @@ def _set_label_mode(mode): def _configure_dock(self): self._renderer._dock_initialize() self._configure_dock_playback_widget(name="Playback") - self._configure_dock_orientation_widget(name="Orientation") 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") - - # Smoothing widget - self.widgets["smoothing"] = self._renderer._dock_add_spin_box( - name="Smoothing", - value=self._data["smoothing_steps"], - rng=self.default_smoothing_range, - callback=self.set_data_smoothing, - double=False, - ) - self._renderer._dock_finalize() def _configure_mplcanvas(self): @@ -3526,6 +3530,72 @@ def screenshot(self, mode="rgb", time_viewer=False): ) return img + def set_cortex_alpha(self, alpha): + """Set the opacity of the cortical surface. + + Parameters + ---------- + alpha : float + The opacity of the cortical surface, between 0 and 1. + """ + self._alpha = float(alpha) + for hemi in self._hemis: + self.layered_meshes[hemi].update_overlay("curv", opacity=self._alpha) + self._renderer._update() + + def _add_silhouette(self): + self._silhouette_actors = [] + for h in self._hemis: + mesh = self.layered_meshes[h] + for _, _, v in self._iter_views(h): + self._set_camera(**views_dicts[h][v]) + decimate = self._silhouette["decimate"] + if isinstance(decimate, str): + import pyvista as pv + + vertno, tris = _decimate_surface_ico_oct( + self._subject, self._subjects_dir, h, self.geo[h].surf, decimate + ) + sil_mesh = pv.PolyData( + self.geo[h].coords[vertno], + np.c_[np.full(len(tris), 3), tris], + ) + decimate = None # already decimated + else: + sil_mesh = mesh._polydata + actor = self._renderer._silhouette( + mesh=sil_mesh, + color=self._silhouette["color"], + line_width=self._silhouette["line_width"], + alpha=self._silhouette["alpha"], + decimate=decimate, + ) + self._silhouette_actors.append(actor) + + def set_silhouette_line_width(self, line_width): + """Set the width of the cortical surface silhouette outline. + + Parameters + ---------- + line_width : float + The silhouette line width. A value of ``0`` hides the + silhouette entirely. + """ + line_width = float(line_width) + self._silhouette["line_width"] = line_width + if line_width <= 0: + self.silhouette = False + for actor in self._silhouette_actors: + actor.SetVisibility(False) + else: + self.silhouette = True + if not self._silhouette_actors: + self._add_silhouette() + for actor in self._silhouette_actors: + actor.GetProperty().SetLineWidth(line_width) + actor.SetVisibility(True) + self._renderer._update() + @fill_doc def update_lut(self, fmin=None, fmid=None, fmax=None, alpha=None): """Update the range of the color map. diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 27057abf782..0efb53fb201 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -585,6 +585,32 @@ def __init__(self): brain.close() +@testing.requires_testing_data +def test_surface_controls(renderer_interactive_pyvistaqt, brain_gc): + """Test live cortex alpha and silhouette line width.""" + brain = _create_testing_brain(hemi="lh", show_traces=False) + + brain.set_cortex_alpha(0.5) + assert brain._alpha == 0.5 + + assert not brain.silhouette + brain.set_silhouette_line_width(3.0) + assert brain.silhouette + actors = brain._silhouette_actors + assert len(actors) > 0 + assert all(a.GetVisibility() for a in actors) + + brain.set_silhouette_line_width(0.0) + assert not brain.silhouette + assert all(not a.GetVisibility() for a in actors) + + brain.set_silhouette_line_width(5.0) + assert brain._silhouette_actors is actors + assert all(a.GetVisibility() for a in actors) + + brain.close() + + def test_add_annotation(renderer_interactive_pyvistaqt, brain_gc): """Test add_annotation.""" annots = [ diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index 33f5ab04967..2051a8e38ab 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -944,13 +944,18 @@ def _dock_initialize( self._dock, self._dock_layout = _create_dock_widget( window, name, qt_area, max_width=max_width ) + self._dock_collapsibles = [] if area == "left": window.setCorner(Qt.BottomLeftCorner, Qt.LeftDockWidgetArea) else: window.setCorner(Qt.BottomRightCorner, Qt.RightDockWidgetArea) def _dock_finalize(self): + for content, _ in self._dock_collapsibles: + content.setVisible(True) self._dock.setMinimumSize(self._dock.sizeHint().width(), 0) + for content, expanded in self._dock_collapsibles: + content.setVisible(expanded) self._dock_add_stretch(self._dock_layout) def _dock_show(self): @@ -1122,8 +1127,49 @@ def func(button): def _dock_add_group_box(self, name, *, collapse=None, layout=None): layout = self._dock_layout if layout is None else layout hlayout = QVBoxLayout() - widget = QGroupBox(name) - widget.setLayout(hlayout) + if collapse is None: + widget = QGroupBox(name) + widget.setLayout(hlayout) + widget.setStyleSheet( + "QGroupBox::title { font-size: 11pt; font-weight: bold; }" + ) + self._layout_add_widget(layout, widget) + return hlayout + + assert isinstance(collapse, bool) + content = QGroupBox() + content.setLayout(hlayout) + content.setVisible(not collapse) + self._dock_collapsibles.append((content, not collapse)) + + toggle = QToolButton() + toggle.setText(f"{'▾' if not collapse else '▸'} {name}") + toggle.setCheckable(True) + toggle.setChecked(not collapse) + toggle.setCursor(Qt.PointingHandCursor) + toggle.setStyleSheet( + "QToolButton {" + " border: none;" + " font-size: 13pt;" + " font-weight: 600;" + " color: palette(mid);" + " }" + "QToolButton:hover { color: palette(text); }" + ) + + def _toggle_visibility(checked, content=content, toggle=toggle, name=name): + content.setVisible(checked) + toggle.setText(f"{'▾' if checked else '▸'} {name}") + + toggle.toggled.connect(_toggle_visibility) + + outer = QVBoxLayout() + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + outer.addWidget(toggle) + outer.addWidget(content) + widget = QWidget() + widget.setLayout(outer) self._layout_add_widget(layout, widget) return hlayout diff --git a/mne/viz/backends/renderer.py b/mne/viz/backends/renderer.py index a96239a4aed..01f9371f903 100644 --- a/mne/viz/backends/renderer.py +++ b/mne/viz/backends/renderer.py @@ -450,7 +450,7 @@ def publish_time_change(time_index): TimeChange(time=np.interp(time_index, np.arange(len(times)), times)), ) - layout = self._dock_add_group_box("") + layout = self._dock_add_group_box("Playback", collapse=False) self._widgets["time_slider"] = self._dock_add_slider( name="Time (s)", value=np.interp(current_time_func(), times, np.arange(len(times))),