Skip to content

Commit dfb8429

Browse files
authored
Brain GUI modernization (Phase 2) (#14062)
1 parent ea71d45 commit dfb8429

9 files changed

Lines changed: 220 additions & 5 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The colorbar in the :class:`mne.viz.Brain` viewer now shows tick marks aligned with its labels, and its title now defaults to the active overlay's ``key`` (see :meth:`~mne.viz.Brain.add_data`). The interactive viewer can show vertex coordinates and camera orientation, toggled via the :kbd:`v` key or a new toolbar button, by `Payam Sadeghi-Shabestari`_.
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading

mne/viz/_brain/_brain.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ def __init__(
400400
else:
401401
self.silhouette = silhouette
402402
self._scalar_bar = None
403+
self._scalar_bar_ticks = None
403404
# for now only one time label can be added
404405
# since it is the same for all figures
405406
self._time_label_added = False
@@ -591,6 +592,8 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True):
591592
self._picked_patches = {key: list() for key in all_keys}
592593
self._picked_points = dict()
593594
self._mouse_no_mvt = -1
595+
self._show_hover_info = False
596+
self._hover_caption = None
594597

595598
# Derived parameters:
596599
self.playback_speed = self.default_playback_speed_value
@@ -626,6 +629,7 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True):
626629
self._configure_scalar_bar()
627630
self._configure_shortcuts()
628631
self._configure_picking()
632+
self._configure_hover()
629633
self._configure_dock()
630634
self._configure_tool_bar()
631635
self._configure_status_bar()
@@ -687,6 +691,8 @@ def _clean(self):
687691
"picked_renderer",
688692
"act_data_smooth",
689693
"_scalar_bar",
694+
"_scalar_bar_ticks",
695+
"_hover_caption",
690696
"actions",
691697
"widgets",
692698
"geo",
@@ -775,6 +781,8 @@ def _configure_scalar_bar(self):
775781
self._scalar_bar.SetHeight(0.6)
776782
self._scalar_bar.SetWidth(0.05)
777783
self._scalar_bar.SetPosition(0.02, 0.2)
784+
# the tick actor repositions itself on every render (see
785+
# _add_scalarbar_ticks), so no explicit update is needed here
778786

779787
def _configure_dock_playback_widget(self, name):
780788
len_time = len(self._data["time"]) - 1
@@ -1198,6 +1206,74 @@ def _configure_picking(self):
11981206
)
11991207
subscribe(self, "vertex_select", self._on_vertex_select)
12001208

1209+
def _configure_hover(self):
1210+
self._hover_caption = self._create_caption()
1211+
self.plotter.add_actor(
1212+
self._hover_caption,
1213+
name=None,
1214+
culling=False,
1215+
pickable=False,
1216+
reset_camera=False,
1217+
render=False,
1218+
)
1219+
1220+
@_auto_weakref
1221+
def on_surface_hover(iren, event):
1222+
self._on_surface_hover(iren, event)
1223+
1224+
self.plotter.iren.add_observer("MouseMoveEvent", on_surface_hover)
1225+
1226+
def _on_surface_hover(self, iren, event): # event == "MouseMoveEvent"
1227+
if not self._show_hover_info:
1228+
return
1229+
from pyvista import DataSetMapper
1230+
1231+
x, y = iren.GetEventPosition()
1232+
picked_renderer = iren.FindPokedRenderer(x, y)
1233+
vtk_picker = self._renderer._picker
1234+
vtk_picker.Pick(x, y, 0, picked_renderer)
1235+
cell_id = vtk_picker.GetCellId()
1236+
mapper = vtk_picker.GetMapper()
1237+
if not isinstance(mapper, DataSetMapper) or cell_id == -1:
1238+
if self._hover_caption.GetVisibility():
1239+
self._hover_caption.SetVisibility(False)
1240+
self._renderer._update()
1241+
return # didn't find a mesh
1242+
for _, this_mesh in self.layered_meshes.items():
1243+
if this_mesh._polydata is mapper.dataset:
1244+
mesh = this_mesh._polydata
1245+
break
1246+
else:
1247+
return
1248+
pos = np.array(vtk_picker.GetPickPosition())
1249+
vtk_cell = mesh.GetCell(cell_id)
1250+
cell = [
1251+
vtk_cell.GetPointId(point_id)
1252+
for point_id in range(vtk_cell.GetNumberOfPoints())
1253+
]
1254+
vert_pos = mesh.points[cell]
1255+
vertex_id = cell[np.argmin(np.linalg.norm(vert_pos - pos, axis=1))]
1256+
_, _, azimuth, elevation, _ = self._renderer.get_camera(rigid=self._rigid)
1257+
text = (
1258+
f"vertex {vertex_id}\n"
1259+
f"({pos[0]:.1f}, {pos[1]:.1f}, {pos[2]:.1f}) mm\n"
1260+
f"az {azimuth:.0f}\N{DEGREE SIGN} el {elevation:.0f}\N{DEGREE SIGN}"
1261+
)
1262+
self._hover_caption.SetCaption(text)
1263+
self._hover_caption.SetAttachmentPoint(*pos)
1264+
self._hover_caption.SetVisibility(True)
1265+
actor = self._hover_caption.GetTextActor()
1266+
wh = np.zeros(2)
1267+
actor.GetSize(self.plotter.renderer, wh)
1268+
self._hover_caption.SetPosition2(wh)
1269+
self._renderer._update()
1270+
1271+
def _toggle_hover_info(self):
1272+
self._show_hover_info = not self._show_hover_info
1273+
if not self._show_hover_info and self._hover_caption is not None:
1274+
self._hover_caption.SetVisibility(False)
1275+
self._renderer._update()
1276+
12011277
def _configure_tool_bar(self):
12021278
if not hasattr(self._renderer, "_tool_bar") or self._renderer._tool_bar is None:
12031279
self._renderer._tool_bar_initialize(name="Toolbar")
@@ -1240,6 +1316,12 @@ def save_movie(filename):
12401316
desc="Clear traces",
12411317
func=self.clear_glyphs,
12421318
)
1319+
self._renderer._tool_bar_add_button(
1320+
name="hover_info",
1321+
desc="Toggle vertex/camera hover info",
1322+
func=self._toggle_hover_info,
1323+
icon_name="information",
1324+
)
12431325
self._renderer._tool_bar_add_spacer()
12441326
self._renderer._tool_bar_add_button(
12451327
name="help",
@@ -1263,13 +1345,14 @@ def _rotate_camera(self, which, value):
12631345

12641346
def _configure_shortcuts(self):
12651347
# Remove the default key binding
1266-
if getattr(self, "iren", None) is not None:
1348+
if getattr(self.plotter, "iren", None) is not None:
12671349
self.plotter.iren.clear_key_event_callbacks()
12681350
# Then, we add our own:
12691351
self.plotter.add_key_event("i", self.toggle_interface)
12701352
self.plotter.add_key_event("s", self.apply_auto_scaling)
12711353
self.plotter.add_key_event("r", self.restore_user_scaling)
12721354
self.plotter.add_key_event("c", self.clear_glyphs)
1355+
self.plotter.add_key_event("v", self._toggle_hover_info)
12731356
for key, which, amt in (
12741357
("Left", "azimuth", 10),
12751358
("Right", "azimuth", -10),
@@ -1658,6 +1741,7 @@ def _configure_help(self):
16581741
("s", "Apply auto-scaling"),
16591742
("r", "Restore original clim"),
16601743
("c", "Clear all traces"),
1744+
("v", "Toggle vertex/camera hover info"),
16611745
("n", "Shift the time forward by the playback speed"),
16621746
("b", "Shift the time backward by the playback speed"),
16631747
("Space", "Start/Pause playback"),
@@ -2013,6 +2097,9 @@ def add_data(
20132097
self._all_data[key]["fmid"] = fmid
20142098
self._all_data[key]["fmax"] = fmax
20152099
self._all_data[key]["colorbar_fmt"] = (colorbar_kwargs or {}).get("fmt")
2100+
self._all_data[key]["colorbar_title"] = (colorbar_kwargs or {}).get(
2101+
"title", key if key != "data" else None
2102+
)
20162103
self.set_time_interpolation(self.time_interpolation)
20172104
self._update_colormap_range()
20182105

@@ -2067,7 +2154,9 @@ def add_data(
20672154
fmt=_auto_scalar_bar_fmt(self._cmap_range),
20682155
)
20692156
kwargs.update(colorbar_kwargs or {})
2070-
self._scalar_bar = self._renderer.scalarbar(**kwargs)
2157+
self._scalar_bar, self._scalar_bar_ticks = self._renderer.scalarbar(
2158+
**kwargs
2159+
)
20712160
self._set_camera(**views_dicts[hemi][v])
20722161

20732162
# 4) update the scalar bar and opacity (and render)
@@ -3706,6 +3795,10 @@ def _update_colormap_range(self, fmin=None, fmid=None, fmax=None, alpha=None):
37063795
rng = self._cmap_range
37073796
ctable = self._data["ctable"]
37083797
fmt = self._data["colorbar_fmt"] or _auto_scalar_bar_fmt(rng)
3798+
if self._scalar_bar is not None:
3799+
self._renderer.set_scalarbar_title(
3800+
self._scalar_bar, self._data["colorbar_title"]
3801+
)
37093802
for hemi in ["lh", "rh", "vol"]:
37103803
hemi_data = self._data.get(hemi)
37113804
if hemi_data is not None:

mne/viz/_brain/tests/test_brain.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,62 @@ def GetPickPosition(self):
661661
brain.close()
662662

663663

664+
@testing.requires_testing_data
665+
def test_scalar_bar_ticks_title_and_hover(renderer_interactive_pyvistaqt, brain_gc):
666+
"""Test scalar bar tick marks, title truncation, and hover info toggle."""
667+
long_title = "a" * 40
668+
brain = _create_testing_brain(
669+
hemi="lh",
670+
show_traces=False,
671+
add_data_kwargs=dict(colorbar_kwargs=dict(title=long_title)),
672+
)
673+
n_labels = brain._scalar_bar.GetNumberOfLabels()
674+
ticks = brain._scalar_bar_ticks
675+
assert ticks.GetNumberOfLabels() == n_labels
676+
assert ticks.GetTickVisibility()
677+
assert not ticks.GetLabelVisibility()
678+
title = brain._scalar_bar.GetTitle()
679+
assert title.endswith("…")
680+
assert len(title) <= 20
681+
682+
assert brain._show_hover_info is False
683+
684+
class MockIren:
685+
def GetEventPosition(self):
686+
return 50, 50
687+
688+
def FindPokedRenderer(self, x, y):
689+
return brain.plotter.renderers[0]
690+
691+
class MockPicker:
692+
def Pick(self, x, y, z, renderer):
693+
pass
694+
695+
def GetCellId(self):
696+
return 0
697+
698+
def GetMapper(self):
699+
return brain.plotter.mapper
700+
701+
def GetPickPosition(self):
702+
return np.zeros(3)
703+
704+
brain._renderer._picker = MockPicker()
705+
brain._on_surface_hover(MockIren(), "MouseMoveEvent")
706+
assert not brain._hover_caption.GetVisibility() # toggle is off
707+
708+
brain._toggle_hover_info()
709+
assert brain._show_hover_info is True
710+
brain._on_surface_hover(MockIren(), "MouseMoveEvent")
711+
assert brain._hover_caption.GetVisibility()
712+
assert "vertex" in brain._hover_caption.GetCaption()
713+
714+
brain._toggle_hover_info()
715+
assert brain._show_hover_info is False
716+
assert not brain._hover_caption.GetVisibility()
717+
brain.close()
718+
719+
664720
@testing.requires_testing_data
665721
def test_add_sensors_scales(renderer_interactive_pyvistaqt):
666722
"""Test sensor_scales parameter."""

mne/viz/backends/_abstract.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,13 @@ def scalarbar(self, source, color="white", title=None, n_labels=4, bgcolor=None)
619619
The number of labels to display on the scalar bar.
620620
bgcolor : tuple | str
621621
The color of the background when there is transparency.
622+
623+
Returns
624+
-------
625+
actor
626+
The scalar bar actor.
627+
tick_actor
628+
The actor drawing tick marks along the scalar bar.
622629
"""
623630
pass
624631

mne/viz/backends/_notebook.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,6 +1367,7 @@ def _window_load_icons(self):
13671367
"visibility_on",
13681368
"visibility_off",
13691369
"folder",
1370+
"information",
13701371
): # noqa: E501
13711372
self._icons[key] = _ICON_LUT[key]
13721373
self._icons["play"] = None

mne/viz/backends/_pyvista.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -802,6 +802,7 @@ def scalarbar(
802802
self,
803803
source,
804804
color="white",
805+
title=None,
805806
n_labels=4,
806807
bgcolor=None,
807808
**extra_kwargs,
@@ -814,7 +815,7 @@ def scalarbar(
814815
mapper = None
815816
kwargs = dict(
816817
color=color,
817-
title="",
818+
title=_truncate_scalar_bar_title(title),
818819
n_labels=n_labels,
819820
use_opacity=False,
820821
n_colors=256,
@@ -829,12 +830,59 @@ def scalarbar(
829830
background_color=bgcolor,
830831
mapper=mapper,
831832
)
832-
extra_kwargs.pop("title", None)
833833
kwargs.update(extra_kwargs)
834834
actor = self.plotter.add_scalar_bar(**kwargs)
835835
actor.SetTextPad(10)
836836
_hide_testing_actor(actor)
837-
return actor
837+
tick_actor = self._add_scalarbar_ticks(actor, kwargs["n_labels"])
838+
return actor, tick_actor
839+
840+
def _add_scalarbar_ticks(self, bar_actor, n_labels):
841+
from vtkmodules.vtkRenderingAnnotation import vtkAxisActor2D
842+
843+
axis = vtkAxisActor2D()
844+
axis.GetPositionCoordinate().SetCoordinateSystemToDisplay()
845+
axis.GetPosition2Coordinate().SetCoordinateSystemToDisplay()
846+
axis.SetNumberOfLabels(n_labels)
847+
# otherwise VTK rounds the tick count to "nice" values, desyncing the
848+
# marks from the scalar bar's own label positions
849+
axis.SetAdjustLabels(False)
850+
axis.SetTickLength(5)
851+
axis.SetLabelVisibility(False)
852+
axis.SetTitleVisibility(False)
853+
axis.SetAxisVisibility(False) # only the tick marks, no connecting line
854+
axis.SetTickVisibility(True)
855+
axis.GetProperty().SetColor(*bar_actor.GetLabelTextProperty().GetColor())
856+
857+
def reposition(_caller, _event):
858+
self.reposition_scalarbar_ticks(bar_actor, axis)
859+
860+
self.reposition_scalarbar_ticks(bar_actor, axis)
861+
if self.plotter.iren is not None:
862+
self.plotter.iren.add_observer(vtkCommand.RenderEvent, reposition)
863+
self.plotter.renderer.AddActor(axis)
864+
_hide_testing_actor(axis)
865+
return axis
866+
867+
def set_scalarbar_title(self, bar_actor, title):
868+
bar_actor.SetTitle(_truncate_scalar_bar_title(title))
869+
870+
def reposition_scalarbar_ticks(self, bar_actor, tick_actor):
871+
rect = [0, 0, 0, 0]
872+
bar_actor.GetScalarBarRect(rect, self.plotter.renderer)
873+
x0, y0, width, height = rect
874+
horizontal = bar_actor.GetOrientation() == 0
875+
inset_low, inset_high = 4, 22
876+
if horizontal:
877+
tick_actor.GetPositionCoordinate().SetValue(x0 + inset_low, y0 + height)
878+
tick_actor.GetPosition2Coordinate().SetValue(
879+
x0 + width - inset_high, y0 + height
880+
)
881+
else:
882+
tick_actor.GetPositionCoordinate().SetValue(x0 + width, y0 + inset_low)
883+
tick_actor.GetPosition2Coordinate().SetValue(
884+
x0 + width, y0 + height - inset_high
885+
)
838886

839887
def show(self):
840888
self.plotter.show()
@@ -1141,6 +1189,12 @@ def _hide_testing_actor(actor):
11411189
actor.SetVisibility(False)
11421190

11431191

1192+
def _truncate_scalar_bar_title(title, max_chars=20):
1193+
if title is None or len(title) <= max_chars:
1194+
return title
1195+
return title[: max_chars - 1] + "…"
1196+
1197+
11441198
def _to_pos(azimuth, elevation):
11451199
theta = azimuth * np.pi / 180.0
11461200
phi = (90.0 - elevation) * np.pi / 180.0

mne/viz/backends/_qt.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,6 +1501,7 @@ def _window_load_icons(self):
15011501
self._icons["visibility_on"] = _qicon("visibility_on")
15021502
self._icons["visibility_off"] = _qicon("visibility_off")
15031503
self._icons["folder"] = _qicon("folder")
1504+
self._icons["information"] = _qicon("information")
15041505

15051506
def _window_clean(self):
15061507
self.figure._plotter = None

0 commit comments

Comments
 (0)