Skip to content
1 change: 1 addition & 0 deletions doc/changes/dev/14062.newfeature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +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`_.
1 change: 1 addition & 0 deletions mne/icons/dark/actions/information.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions mne/icons/light/actions/information.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
97 changes: 95 additions & 2 deletions mne/viz/_brain/_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ def __init__(
else:
self.silhouette = silhouette
self._scalar_bar = None
self._scalar_bar_ticks = None
# for now only one time label can be added
# since it is the same for all figures
self._time_label_added = False
Expand Down Expand Up @@ -589,6 +590,8 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True):
self._picked_patches = {key: list() for key in all_keys}
self._picked_points = dict()
self._mouse_no_mvt = -1
self._show_hover_info = False
self._hover_caption = None

# Derived parameters:
self.playback_speed = self.default_playback_speed_value
Expand Down Expand Up @@ -624,6 +627,7 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True):
self._configure_scalar_bar()
self._configure_shortcuts()
self._configure_picking()
self._configure_hover()
self._configure_dock()
self._configure_tool_bar()
self._configure_status_bar()
Expand Down Expand Up @@ -685,6 +689,8 @@ def _clean(self):
"picked_renderer",
"act_data_smooth",
"_scalar_bar",
"_scalar_bar_ticks",
"_hover_caption",
"actions",
"widgets",
"geo",
Expand Down Expand Up @@ -773,6 +779,8 @@ def _configure_scalar_bar(self):
self._scalar_bar.SetHeight(0.6)
self._scalar_bar.SetWidth(0.05)
self._scalar_bar.SetPosition(0.02, 0.2)
# the tick actor repositions itself on every render (see
# _add_scalarbar_ticks), so no explicit update is needed here

def _configure_dock_playback_widget(self, name):
len_time = len(self._data["time"]) - 1
Expand Down Expand Up @@ -1196,6 +1204,74 @@ def _configure_picking(self):
)
subscribe(self, "vertex_select", self._on_vertex_select)

def _configure_hover(self):
self._hover_caption = self._create_caption()
self.plotter.add_actor(
self._hover_caption,
name=None,
culling=False,
pickable=False,
reset_camera=False,
render=False,
)

@_auto_weakref
def on_surface_hover(iren, event):
self._on_surface_hover(iren, event)

self.plotter.iren.add_observer("MouseMoveEvent", on_surface_hover)

def _on_surface_hover(self, iren, event): # event == "MouseMoveEvent"
if not self._show_hover_info:
return
from pyvista import DataSetMapper

x, y = iren.GetEventPosition()
picked_renderer = iren.FindPokedRenderer(x, y)
vtk_picker = self._renderer._picker
vtk_picker.Pick(x, y, 0, picked_renderer)
cell_id = vtk_picker.GetCellId()
mapper = vtk_picker.GetMapper()
if not isinstance(mapper, DataSetMapper) or cell_id == -1:
if self._hover_caption.GetVisibility():
self._hover_caption.SetVisibility(False)
self._renderer._update()
return # didn't find a mesh
for _, this_mesh in self.layered_meshes.items():
if this_mesh._polydata is mapper.dataset:
mesh = this_mesh._polydata
break
else:
return
pos = np.array(vtk_picker.GetPickPosition())
vtk_cell = mesh.GetCell(cell_id)
cell = [
vtk_cell.GetPointId(point_id)
for point_id in range(vtk_cell.GetNumberOfPoints())
]
vert_pos = mesh.points[cell]
vertex_id = cell[np.argmin(np.linalg.norm(vert_pos - pos, axis=1))]
_, _, azimuth, elevation, _ = self._renderer.get_camera(rigid=self._rigid)
text = (
f"vertex {vertex_id}\n"
f"({pos[0]:.1f}, {pos[1]:.1f}, {pos[2]:.1f}) mm\n"
f"az {azimuth:.0f}\N{DEGREE SIGN} el {elevation:.0f}\N{DEGREE SIGN}"
)
self._hover_caption.SetCaption(text)
self._hover_caption.SetAttachmentPoint(*pos)
self._hover_caption.SetVisibility(True)
actor = self._hover_caption.GetTextActor()
wh = np.zeros(2)
actor.GetSize(self.plotter.renderer, wh)
self._hover_caption.SetPosition2(wh)
self._renderer._update()

def _toggle_hover_info(self):
self._show_hover_info = not self._show_hover_info
if not self._show_hover_info and self._hover_caption is not None:
self._hover_caption.SetVisibility(False)
self._renderer._update()

def _configure_tool_bar(self):
if not hasattr(self._renderer, "_tool_bar") or self._renderer._tool_bar is None:
self._renderer._tool_bar_initialize(name="Toolbar")
Expand Down Expand Up @@ -1238,6 +1314,12 @@ def save_movie(filename):
desc="Clear traces",
func=self.clear_glyphs,
)
self._renderer._tool_bar_add_button(
name="hover_info",
desc="Toggle vertex/camera hover info",
func=self._toggle_hover_info,
icon_name="information",
)
self._renderer._tool_bar_add_spacer()
self._renderer._tool_bar_add_button(
name="help",
Expand All @@ -1261,13 +1343,14 @@ def _rotate_camera(self, which, value):

def _configure_shortcuts(self):
# Remove the default key binding
if getattr(self, "iren", None) is not None:
if getattr(self.plotter, "iren", None) is not None:
self.plotter.iren.clear_key_event_callbacks()
# Then, we add our own:
self.plotter.add_key_event("i", self.toggle_interface)
self.plotter.add_key_event("s", self.apply_auto_scaling)
self.plotter.add_key_event("r", self.restore_user_scaling)
self.plotter.add_key_event("c", self.clear_glyphs)
self.plotter.add_key_event("v", self._toggle_hover_info)
for key, which, amt in (
("Left", "azimuth", 10),
("Right", "azimuth", -10),
Expand Down Expand Up @@ -1656,6 +1739,7 @@ def _configure_help(self):
("s", "Apply auto-scaling"),
("r", "Restore original clim"),
("c", "Clear all traces"),
("v", "Toggle vertex/camera hover info"),
("n", "Shift the time forward by the playback speed"),
("b", "Shift the time backward by the playback speed"),
("Space", "Start/Pause playback"),
Expand Down Expand Up @@ -2011,6 +2095,9 @@ def add_data(
self._all_data[key]["fmid"] = fmid
self._all_data[key]["fmax"] = fmax
self._all_data[key]["colorbar_fmt"] = (colorbar_kwargs or {}).get("fmt")
self._all_data[key]["colorbar_title"] = (colorbar_kwargs or {}).get(
"title", key if key != "data" else None
)
self.set_time_interpolation(self.time_interpolation)
self._update_colormap_range()

Expand Down Expand Up @@ -2065,7 +2152,9 @@ def add_data(
fmt=_auto_scalar_bar_fmt(self._cmap_range),
)
kwargs.update(colorbar_kwargs or {})
self._scalar_bar = self._renderer.scalarbar(**kwargs)
self._scalar_bar, self._scalar_bar_ticks = self._renderer.scalarbar(
**kwargs
)
self._set_camera(**views_dicts[hemi][v])

# 4) update the scalar bar and opacity (and render)
Expand Down Expand Up @@ -3575,6 +3664,10 @@ def _update_colormap_range(self, fmin=None, fmid=None, fmax=None, alpha=None):
rng = self._cmap_range
ctable = self._data["ctable"]
fmt = self._data["colorbar_fmt"] or _auto_scalar_bar_fmt(rng)
if self._scalar_bar is not None:
self._renderer.set_scalarbar_title(
self._scalar_bar, self._data["colorbar_title"]
)
for hemi in ["lh", "rh", "vol"]:
hemi_data = self._data.get(hemi)
if hemi_data is not None:
Expand Down
56 changes: 56 additions & 0 deletions mne/viz/_brain/tests/test_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,62 @@ def GetPickPosition(self):
brain.close()


@testing.requires_testing_data
def test_scalar_bar_ticks_title_and_hover(renderer_interactive_pyvistaqt, brain_gc):
"""Test scalar bar tick marks, title truncation, and hover info toggle."""
long_title = "a" * 40
brain = _create_testing_brain(
hemi="lh",
show_traces=False,
add_data_kwargs=dict(colorbar_kwargs=dict(title=long_title)),
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a fixed overhead to creating these brain windows (~0.5s locally and longer on CIs), so as much as possible try to reuse some existing brain for tests if you can

n_labels = brain._scalar_bar.GetNumberOfLabels()
ticks = brain._scalar_bar_ticks
assert ticks.GetNumberOfLabels() == n_labels
assert ticks.GetTickVisibility()
assert not ticks.GetLabelVisibility()
title = brain._scalar_bar.GetTitle()
assert title.endswith("…")
assert len(title) <= 20

assert brain._show_hover_info is False

class MockIren:
def GetEventPosition(self):
return 50, 50

def FindPokedRenderer(self, x, y):
return brain.plotter.renderers[0]

class MockPicker:
def Pick(self, x, y, z, renderer):
pass

def GetCellId(self):
return 0

def GetMapper(self):
return brain.plotter.mapper

def GetPickPosition(self):
return np.zeros(3)

brain._renderer._picker = MockPicker()
brain._on_surface_hover(MockIren(), "MouseMoveEvent")
assert not brain._hover_caption.GetVisibility() # toggle is off

brain._toggle_hover_info()
assert brain._show_hover_info is True
brain._on_surface_hover(MockIren(), "MouseMoveEvent")
assert brain._hover_caption.GetVisibility()
assert "vertex" in brain._hover_caption.GetCaption()

brain._toggle_hover_info()
assert brain._show_hover_info is False
assert not brain._hover_caption.GetVisibility()
brain.close()


@testing.requires_testing_data
def test_add_sensors_scales(renderer_interactive_pyvistaqt):
"""Test sensor_scales parameter."""
Expand Down
7 changes: 7 additions & 0 deletions mne/viz/backends/_abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,13 @@ def scalarbar(self, source, color="white", title=None, n_labels=4, bgcolor=None)
The number of labels to display on the scalar bar.
bgcolor : tuple | str
The color of the background when there is transparency.

Returns
-------
actor
The scalar bar actor.
tick_actor
The actor drawing tick marks along the scalar bar.
"""
pass

Expand Down
1 change: 1 addition & 0 deletions mne/viz/backends/_notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -1374,6 +1374,7 @@ def _window_load_icons(self):
"visibility_on",
"visibility_off",
"folder",
"information",
): # noqa: E501
self._icons[key] = _ICON_LUT[key]
self._icons["play"] = None
Expand Down
60 changes: 57 additions & 3 deletions mne/viz/backends/_pyvista.py
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ def scalarbar(
self,
source,
color="white",
title=None,
n_labels=4,
bgcolor=None,
**extra_kwargs,
Expand All @@ -811,7 +812,7 @@ def scalarbar(
mapper = None
kwargs = dict(
color=color,
title="",
title=_truncate_scalar_bar_title(title),
n_labels=n_labels,
use_opacity=False,
n_colors=256,
Expand All @@ -826,12 +827,59 @@ def scalarbar(
background_color=bgcolor,
mapper=mapper,
)
extra_kwargs.pop("title", None)
kwargs.update(extra_kwargs)
actor = self.plotter.add_scalar_bar(**kwargs)
actor.SetTextPad(10)
_hide_testing_actor(actor)
return actor
tick_actor = self._add_scalarbar_ticks(actor, kwargs["n_labels"])
return actor, tick_actor

def _add_scalarbar_ticks(self, bar_actor, n_labels):
from vtkmodules.vtkRenderingAnnotation import vtkAxisActor2D

axis = vtkAxisActor2D()
axis.GetPositionCoordinate().SetCoordinateSystemToDisplay()
axis.GetPosition2Coordinate().SetCoordinateSystemToDisplay()
axis.SetNumberOfLabels(n_labels)
# otherwise VTK rounds the tick count to "nice" values, desyncing the
# marks from the scalar bar's own label positions
axis.SetAdjustLabels(False)
axis.SetTickLength(5)
axis.SetLabelVisibility(False)
axis.SetTitleVisibility(False)
axis.SetAxisVisibility(False) # only the tick marks, no connecting line
axis.SetTickVisibility(True)
axis.GetProperty().SetColor(*bar_actor.GetLabelTextProperty().GetColor())

def reposition(_caller, _event):
self.reposition_scalarbar_ticks(bar_actor, axis)

self.reposition_scalarbar_ticks(bar_actor, axis)
if self.plotter.iren is not None:
self.plotter.iren.add_observer(vtkCommand.RenderEvent, reposition)
self.plotter.renderer.AddActor(axis)
_hide_testing_actor(axis)
return axis

def set_scalarbar_title(self, bar_actor, title):
bar_actor.SetTitle(_truncate_scalar_bar_title(title))

def reposition_scalarbar_ticks(self, bar_actor, tick_actor):
rect = [0, 0, 0, 0]
bar_actor.GetScalarBarRect(rect, self.plotter.renderer)
x0, y0, width, height = rect
horizontal = bar_actor.GetOrientation() == 0
inset_low, inset_high = 4, 22
if horizontal:
tick_actor.GetPositionCoordinate().SetValue(x0 + inset_low, y0 + height)
tick_actor.GetPosition2Coordinate().SetValue(
x0 + width - inset_high, y0 + height
)
else:
tick_actor.GetPositionCoordinate().SetValue(x0 + width, y0 + inset_low)
tick_actor.GetPosition2Coordinate().SetValue(
x0 + width, y0 + height - inset_high
)

def show(self):
self.plotter.show()
Expand Down Expand Up @@ -1138,6 +1186,12 @@ def _hide_testing_actor(actor):
actor.SetVisibility(False)


def _truncate_scalar_bar_title(title, max_chars=20):
if title is None or len(title) <= max_chars:
return title
return title[: max_chars - 1] + "…"


def _to_pos(azimuth, elevation):
theta = azimuth * np.pi / 180.0
phi = (90.0 - elevation) * np.pi / 180.0
Expand Down
1 change: 1 addition & 0 deletions mne/viz/backends/_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -1572,6 +1572,7 @@ def _window_load_icons(self):
self._icons["visibility_on"] = _qicon("visibility_on")
self._icons["visibility_off"] = _qicon("visibility_off")
self._icons["folder"] = _qicon("folder")
self._icons["information"] = _qicon("information")

def _window_clean(self):
self.figure._plotter = None
Expand Down
Loading