diff --git a/doc/changes/dev/14062.newfeature.rst b/doc/changes/dev/14062.newfeature.rst new file mode 100644 index 00000000000..b3b2b65d464 --- /dev/null +++ b/doc/changes/dev/14062.newfeature.rst @@ -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`_. diff --git a/mne/icons/dark/actions/information.svg b/mne/icons/dark/actions/information.svg new file mode 100644 index 00000000000..9618932d7cd --- /dev/null +++ b/mne/icons/dark/actions/information.svg @@ -0,0 +1 @@ + diff --git a/mne/icons/light/actions/information.svg b/mne/icons/light/actions/information.svg new file mode 100644 index 00000000000..bd40cd613eb --- /dev/null +++ b/mne/icons/light/actions/information.svg @@ -0,0 +1 @@ + diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 677268d626d..cbc02caef8c 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -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 @@ -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 @@ -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() @@ -685,6 +689,8 @@ def _clean(self): "picked_renderer", "act_data_smooth", "_scalar_bar", + "_scalar_bar_ticks", + "_hover_caption", "actions", "widgets", "geo", @@ -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 @@ -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") @@ -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", @@ -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), @@ -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"), @@ -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() @@ -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) @@ -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: diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 27057abf782..45810f30862 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -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)), + ) + 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.""" diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index 4e88b47a8ac..abe00c1af81 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -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 diff --git a/mne/viz/backends/_notebook.py b/mne/viz/backends/_notebook.py index badd15ef64a..bef1a7bcf1d 100644 --- a/mne/viz/backends/_notebook.py +++ b/mne/viz/backends/_notebook.py @@ -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 diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index f7bb794ded5..46c8ab78882 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -799,6 +799,7 @@ def scalarbar( self, source, color="white", + title=None, n_labels=4, bgcolor=None, **extra_kwargs, @@ -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, @@ -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() @@ -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 diff --git a/mne/viz/backends/_qt.py b/mne/viz/backends/_qt.py index 013c6bcc9e0..7299218080e 100644 --- a/mne/viz/backends/_qt.py +++ b/mne/viz/backends/_qt.py @@ -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