Skip to content

Commit 102939d

Browse files
authored
Brain GUI modernization (Phase 3) (#14085)
1 parent dfb8429 commit 102939d

4 files changed

Lines changed: 186 additions & 44 deletions

File tree

mne/viz/_brain/_brain.py

Lines changed: 111 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ class Brain:
137137
Add support for non-string arguments.
138138
alpha : float in [0, 1]
139139
Alpha level to control opacity of the cortical surface.
140+
141+
See :meth:`set_cortex_alpha` to change this after creation.
140142
size : int | array-like, shape (2,)
141143
The size of the window, in pixels. can be one number to specify
142144
a square window, or a length-2 sequence to specify (width, height).
@@ -182,6 +184,9 @@ class Brain:
182184
183185
.. versionchanged:: 1.13
184186
The default ``decimate`` value changed from ``0.9`` to ``"ico5"``.
187+
188+
See :meth:`set_silhouette_line_width` to change the line width (or
189+
show/hide the silhouette) after creation.
185190
%(theme_3d)s
186191
show : bool
187192
Display the window as soon as it is ready. Defaults to True.
@@ -399,6 +404,7 @@ def __init__(
399404
self.silhouette = True
400405
else:
401406
self.silhouette = silhouette
407+
self._silhouette_actors = []
402408
self._scalar_bar = None
403409
self._scalar_bar_ticks = None
404410
# for now only one time label can be added
@@ -474,35 +480,11 @@ def __init__(
474480
else:
475481
actor = self.layered_meshes[h]._actor
476482
self._renderer.plotter.add_actor(actor, render=False)
477-
if self.silhouette:
478-
mesh = self.layered_meshes[h]
479-
decimate = self._silhouette["decimate"]
480-
if isinstance(decimate, str):
481-
import pyvista as pv
482-
483-
vertno, tris = _decimate_surface_ico_oct(
484-
self._subject,
485-
self._subjects_dir,
486-
h,
487-
self.geo[h].surf,
488-
decimate,
489-
)
490-
sil_mesh = pv.PolyData(
491-
self.geo[h].coords[vertno],
492-
np.c_[np.full(len(tris), 3), tris],
493-
)
494-
decimate = None # already decimated
495-
else:
496-
sil_mesh = mesh._polydata
497-
self._renderer._silhouette(
498-
mesh=sil_mesh,
499-
color=self._silhouette["color"],
500-
line_width=self._silhouette["line_width"],
501-
alpha=self._silhouette["alpha"],
502-
decimate=decimate,
503-
)
504483
self._set_camera(**views_dicts[h][v])
505484

485+
if self.silhouette:
486+
self._add_silhouette()
487+
506488
self.interaction = interaction
507489
self._closed = False
508490
if show:
@@ -658,6 +640,9 @@ def _clean(self):
658640
# clear init actors
659641
for hemi in self.layered_meshes:
660642
self.layered_meshes[hemi]._clean()
643+
for actor in getattr(self, "_silhouette_actors", None) or ():
644+
self.plotter.remove_actor(actor)
645+
self._silhouette_actors = None
661646
self._clear_callbacks()
662647
self._clear_widgets()
663648
if getattr(self, "mpl_canvas", None) is not None:
@@ -820,7 +805,7 @@ def current_time_func():
820805
del current_time
821806

822807
def _configure_dock_orientation_widget(self, name):
823-
layout = self._renderer._dock_add_group_box(name)
808+
layout = self._renderer._dock_add_group_box(name, collapse=True)
824809
# Renderer widget
825810
rends = [str(i) for i in range(len(self._renderer._all_renderers))]
826811
if len(rends) > 1:
@@ -876,13 +861,31 @@ def set_orientation(value, orientation_data=orientation_data):
876861
layout=layout,
877862
)
878863

864+
def _configure_dock_surface_widget(self, name):
865+
layout = self._renderer._dock_add_group_box(name, collapse=True)
866+
self.widgets["cortex_alpha"] = self._renderer._dock_add_slider(
867+
name="Alpha",
868+
value=self._alpha,
869+
rng=[0.0, 1.0],
870+
callback=self.set_cortex_alpha,
871+
double=True,
872+
layout=layout,
873+
)
874+
self.widgets["silhouette"] = self._renderer._dock_add_spin_box(
875+
name="Silhouette",
876+
value=self._silhouette["line_width"] if self.silhouette else 0.0,
877+
rng=[0.0, 10.0],
878+
callback=self.set_silhouette_line_width,
879+
layout=layout,
880+
)
881+
879882
def _configure_dock_colormap_widget(self, name):
880883
self._active_data_key = next(iter(self._all_data))
881884
fmax, fscale, fscale_power = _get_range(self)
882885
rng = [0, fmax * fscale]
883886
self._data["fscale"] = fscale
884887

885-
layout = self._renderer._dock_add_group_box(name)
888+
layout = self._renderer._dock_add_group_box(name, collapse=False)
886889

887890
@_auto_weakref
888891
def select_data_key(value):
@@ -969,6 +972,16 @@ def fplus():
969972
style="toolbutton",
970973
)
971974
self._renderer._layout_add_widget(layout, hlayout)
975+
976+
self.widgets["smoothing"] = self._renderer._dock_add_spin_box(
977+
name="Smoothing",
978+
value=self._data["smoothing_steps"],
979+
rng=self.default_smoothing_range,
980+
callback=self.set_data_smoothing,
981+
double=False,
982+
layout=layout,
983+
)
984+
972985
self._update_colormap_range()
973986

974987
def _refresh_colormap_widgets(self):
@@ -1006,7 +1019,7 @@ def _configure_dock_trace_widget(self, name):
10061019
self._configure_vertex_time_course()
10071020
return
10081021

1009-
layout = self._renderer._dock_add_group_box(name)
1022+
layout = self._renderer._dock_add_group_box(name, collapse=True)
10101023

10111024
# setup candidate annots
10121025
@_auto_weakref
@@ -1078,19 +1091,10 @@ def _set_label_mode(mode):
10781091
def _configure_dock(self):
10791092
self._renderer._dock_initialize()
10801093
self._configure_dock_playback_widget(name="Playback")
1081-
self._configure_dock_orientation_widget(name="Orientation")
10821094
self._configure_dock_colormap_widget(name="Color Limits")
1095+
self._configure_dock_orientation_widget(name="Orientation")
1096+
self._configure_dock_surface_widget(name="Surface")
10831097
self._configure_dock_trace_widget(name="Trace")
1084-
1085-
# Smoothing widget
1086-
self.widgets["smoothing"] = self._renderer._dock_add_spin_box(
1087-
name="Smoothing",
1088-
value=self._data["smoothing_steps"],
1089-
rng=self.default_smoothing_range,
1090-
callback=self.set_data_smoothing,
1091-
double=False,
1092-
)
1093-
10941098
self._renderer._dock_finalize()
10951099

10961100
def _configure_mplcanvas(self):
@@ -3746,6 +3750,72 @@ def screenshot(self, mode="rgb", time_viewer=False):
37463750
)
37473751
return img
37483752

3753+
def set_cortex_alpha(self, alpha):
3754+
"""Set the opacity of the cortical surface.
3755+
3756+
Parameters
3757+
----------
3758+
alpha : float
3759+
The opacity of the cortical surface, between 0 and 1.
3760+
"""
3761+
self._alpha = float(alpha)
3762+
for hemi in self._hemis:
3763+
self.layered_meshes[hemi].update_overlay("curv", opacity=self._alpha)
3764+
self._renderer._update()
3765+
3766+
def _add_silhouette(self):
3767+
self._silhouette_actors = []
3768+
for h in self._hemis:
3769+
mesh = self.layered_meshes[h]
3770+
for _, _, v in self._iter_views(h):
3771+
self._set_camera(**views_dicts[h][v])
3772+
decimate = self._silhouette["decimate"]
3773+
if isinstance(decimate, str):
3774+
import pyvista as pv
3775+
3776+
vertno, tris = _decimate_surface_ico_oct(
3777+
self._subject, self._subjects_dir, h, self.geo[h].surf, decimate
3778+
)
3779+
sil_mesh = pv.PolyData(
3780+
self.geo[h].coords[vertno],
3781+
np.c_[np.full(len(tris), 3), tris],
3782+
)
3783+
decimate = None # already decimated
3784+
else:
3785+
sil_mesh = mesh._polydata
3786+
actor = self._renderer._silhouette(
3787+
mesh=sil_mesh,
3788+
color=self._silhouette["color"],
3789+
line_width=self._silhouette["line_width"],
3790+
alpha=self._silhouette["alpha"],
3791+
decimate=decimate,
3792+
)
3793+
self._silhouette_actors.append(actor)
3794+
3795+
def set_silhouette_line_width(self, line_width):
3796+
"""Set the width of the cortical surface silhouette outline.
3797+
3798+
Parameters
3799+
----------
3800+
line_width : float
3801+
The silhouette line width. A value of ``0`` hides the
3802+
silhouette entirely.
3803+
"""
3804+
line_width = float(line_width)
3805+
self._silhouette["line_width"] = line_width
3806+
if line_width <= 0:
3807+
self.silhouette = False
3808+
for actor in self._silhouette_actors:
3809+
actor.SetVisibility(False)
3810+
else:
3811+
self.silhouette = True
3812+
if not self._silhouette_actors:
3813+
self._add_silhouette()
3814+
for actor in self._silhouette_actors:
3815+
actor.GetProperty().SetLineWidth(line_width)
3816+
actor.SetVisibility(True)
3817+
self._renderer._update()
3818+
37493819
@fill_doc
37503820
def update_lut(self, fmin=None, fmid=None, fmax=None, alpha=None):
37513821
"""Update the range of the color map.

mne/viz/_brain/tests/test_brain.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,32 @@ def __init__(self):
586586
brain.close()
587587

588588

589+
@testing.requires_testing_data
590+
def test_surface_controls(renderer_interactive_pyvistaqt, brain_gc):
591+
"""Test live cortex alpha and silhouette line width."""
592+
brain = _create_testing_brain(hemi="lh", show_traces=False)
593+
594+
brain.set_cortex_alpha(0.5)
595+
assert brain._alpha == 0.5
596+
597+
assert not brain.silhouette
598+
brain.set_silhouette_line_width(3.0)
599+
assert brain.silhouette
600+
actors = brain._silhouette_actors
601+
assert len(actors) > 0
602+
assert all(a.GetVisibility() for a in actors)
603+
604+
brain.set_silhouette_line_width(0.0)
605+
assert not brain.silhouette
606+
assert all(not a.GetVisibility() for a in actors)
607+
608+
brain.set_silhouette_line_width(5.0)
609+
assert brain._silhouette_actors is actors
610+
assert all(a.GetVisibility() for a in actors)
611+
612+
brain.close()
613+
614+
589615
def test_add_annotation(renderer_interactive_pyvistaqt, brain_gc):
590616
"""Test add_annotation."""
591617
annots = [

mne/viz/backends/_qt.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -930,13 +930,18 @@ def _dock_initialize(
930930
self._dock, self._dock_layout = _create_dock_widget(
931931
window, name, qt_area, max_width=max_width
932932
)
933+
self._dock_collapsibles = []
933934
if area == "left":
934935
window.setCorner(Qt.BottomLeftCorner, Qt.LeftDockWidgetArea)
935936
else:
936937
window.setCorner(Qt.BottomRightCorner, Qt.RightDockWidgetArea)
937938

938939
def _dock_finalize(self):
940+
for content, _ in self._dock_collapsibles:
941+
content.setVisible(True)
939942
self._dock.setMinimumSize(self._dock.sizeHint().width(), 0)
943+
for content, expanded in self._dock_collapsibles:
944+
content.setVisible(expanded)
940945
self._dock_add_stretch(self._dock_layout)
941946

942947
def _dock_show(self):
@@ -1108,8 +1113,49 @@ def func(button):
11081113
def _dock_add_group_box(self, name, *, collapse=None, layout=None):
11091114
layout = self._dock_layout if layout is None else layout
11101115
hlayout = QVBoxLayout()
1111-
widget = QGroupBox(name)
1112-
widget.setLayout(hlayout)
1116+
if collapse is None:
1117+
widget = QGroupBox(name)
1118+
widget.setLayout(hlayout)
1119+
widget.setStyleSheet(
1120+
"QGroupBox::title { font-size: 11pt; font-weight: bold; }"
1121+
)
1122+
self._layout_add_widget(layout, widget)
1123+
return hlayout
1124+
1125+
assert isinstance(collapse, bool)
1126+
content = QGroupBox()
1127+
content.setLayout(hlayout)
1128+
content.setVisible(not collapse)
1129+
self._dock_collapsibles.append((content, not collapse))
1130+
1131+
toggle = QToolButton()
1132+
toggle.setText(f"{'▾' if not collapse else '▸'} {name}")
1133+
toggle.setCheckable(True)
1134+
toggle.setChecked(not collapse)
1135+
toggle.setCursor(Qt.PointingHandCursor)
1136+
toggle.setStyleSheet(
1137+
"QToolButton {"
1138+
" border: none;"
1139+
" font-size: 13pt;"
1140+
" font-weight: 600;"
1141+
" color: palette(mid);"
1142+
" }"
1143+
"QToolButton:hover { color: palette(text); }"
1144+
)
1145+
1146+
def _toggle_visibility(checked, content=content, toggle=toggle, name=name):
1147+
content.setVisible(checked)
1148+
toggle.setText(f"{'▾' if checked else '▸'} {name}")
1149+
1150+
toggle.toggled.connect(_toggle_visibility)
1151+
1152+
outer = QVBoxLayout()
1153+
outer.setContentsMargins(0, 0, 0, 0)
1154+
outer.setSpacing(0)
1155+
outer.addWidget(toggle)
1156+
outer.addWidget(content)
1157+
widget = QWidget()
1158+
widget.setLayout(outer)
11131159
self._layout_add_widget(layout, widget)
11141160
return hlayout
11151161

mne/viz/backends/renderer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,7 @@ def publish_time_change(time_index):
450450
TimeChange(time=np.interp(time_index, np.arange(len(times)), times)),
451451
)
452452

453-
layout = self._dock_add_group_box("")
453+
layout = self._dock_add_group_box("Playback", collapse=False)
454454
self._widgets["time_slider"] = self._dock_add_slider(
455455
name="Time (s)",
456456
value=np.interp(current_time_func(), times, np.arange(len(times))),

0 commit comments

Comments
 (0)