From c2145c42371318e30d6da5f2c61346bc92ecf930 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Tue, 22 Sep 2026 12:35:11 -0400 Subject: [PATCH 01/13] Make the js backend a proper notebook backend --- doc/changes/dev/14330.newfeature.rst | 1 + mne/conftest.py | 9 +- mne/gui/_coreg.py | 2 +- mne/gui/_dipolefit.py | 2 +- mne/viz/_3d.py | 5 +- mne/viz/_brain/_brain.py | 23 +-- mne/viz/_brain/tests/test_brain.py | 12 +- mne/viz/backends/_jupyterlite.py | 2 +- mne/viz/backends/_lite.py | 220 +++++++++++++++++++++--- mne/viz/backends/_notebook.py | 117 +++---------- mne/viz/backends/_pyvista.py | 2 +- mne/viz/backends/_trame.py | 107 ++++++++++++ mne/viz/backends/_utils.py | 2 +- mne/viz/backends/renderer.py | 30 +++- mne/viz/backends/tests/test_abstract.py | 12 +- mne/viz/backends/tests/test_renderer.py | 79 ++++++++- mne/viz/tests/test_3d.py | 10 +- pyproject.toml | 3 + tools/vulture_allowlist.py | 6 + 19 files changed, 475 insertions(+), 169 deletions(-) create mode 100644 doc/changes/dev/14330.newfeature.rst create mode 100644 mne/viz/backends/_trame.py diff --git a/doc/changes/dev/14330.newfeature.rst b/doc/changes/dev/14330.newfeature.rst new file mode 100644 index 00000000000..93429f30ca3 --- /dev/null +++ b/doc/changes/dev/14330.newfeature.rst @@ -0,0 +1 @@ +The ``jupyterlite_notebook`` 3D backend was renamed ``notebook_js`` (the old name still works with a warning) and now offers the same ipywidgets GUI as the ``notebook`` backend, so :class:`mne.viz.Brain` can be given a time viewer in the browser with ``time_viewer=True``, by `Eric Larson`_. diff --git a/mne/conftest.py b/mne/conftest.py index 7ed220cc8af..015afd7db4b 100644 --- a/mne/conftest.py +++ b/mne/conftest.py @@ -755,7 +755,7 @@ def browser_backend(request, garbage_collect, monkeypatch): @pytest.fixture( params=[ pytest.param("pyvistaqt", marks=pytest.mark.pvtest), - pytest.param("jupyterlite_notebook", marks=pytest.mark.pvtest), + pytest.param("notebook_js", marks=pytest.mark.pvtest), ] ) def renderer(request, options_3d, garbage_collect): @@ -778,7 +778,7 @@ def renderer_notebook(request, options_3d): yield renderer -@pytest.fixture(params=[pytest.param("jupyterlite_notebook", marks=pytest.mark.pvtest)]) +@pytest.fixture(params=[pytest.param("notebook_js", marks=pytest.mark.pvtest)]) def renderer_lite(request, options_3d): """Yield the JupyterLite (vtk.js) renderer alone, for its own tests.""" with _use_backend(request.param, interactive=False) as renderer: @@ -834,9 +834,12 @@ def _use_backend(backend_name, interactive): def _check_skip_backend(name): from mne.viz.backends._utils import _notebook_vtk_works - if name == "jupyterlite_notebook": + if name == "notebook_js": # draws with vtk.js in a browser: no VTK, no Qt, no ffmpeg pytest.importorskip("pyvista_js") + # ... but the same ipywidgets GUI as the notebook backend + pytest.importorskip("ipympl") + pytest.importorskip("ipyevents") return pytest.importorskip("pyvista") pytest.importorskip("imageio_ffmpeg") diff --git a/mne/gui/_coreg.py b/mne/gui/_coreg.py index 54c7991a282..135ef58ea56 100644 --- a/mne/gui/_coreg.py +++ b/mne/gui/_coreg.py @@ -1716,7 +1716,7 @@ def _update_fiducials_label(self): self._forward_widget_command("mri_fiducials_label", "set_value", text) def _configure_dock(self): - if self._renderer._kind == "notebook": + if self._renderer._kind.startswith("notebook"): # either notebook backend collapse = True # collapsible and collapsed else: collapse = None # not collapsible diff --git a/mne/gui/_dipolefit.py b/mne/gui/_dipolefit.py index 4b19b2784a6..b9e9f581991 100644 --- a/mne/gui/_dipolefit.py +++ b/mne/gui/_dipolefit.py @@ -358,7 +358,7 @@ def __init__( # window has actually appeared on screen (see `_qt_safe_window`). self._renderer.figure.splash = self._splash self._renderer.show() - if block and self._renderer._kind != "notebook": + if block and not self._renderer._kind.startswith("notebook"): _qt_app_exec(self._renderer.figure.store["app"]) @property diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index de13e212e01..4e3bf18f480 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -2752,8 +2752,9 @@ def _check_st_tv(show_traces, time_viewer, times): if time_viewer == "auto": from .backends.renderer import _get_3d_backend - # the browser backend writes a static scene, so there is no slider to show - time_viewer = _get_3d_backend() != "jupyterlite_notebook" + # the browser backend has to serialize the whole scene again for every + # step of the slider, so it offers one only when asked for explicitly + time_viewer = _get_3d_backend() != "notebook_js" if show_traces == "auto": show_traces = time_viewer and times is not None and len(times) > 1 if show_traces and not time_viewer: diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index b5f27cfb783..c6c94a3698f 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -641,9 +641,13 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True): self._active_data_key = next(iter(self._all_data)) self._configure_time_label() self._configure_scalar_bar() - self._configure_shortcuts() - self._configure_picking() - self._configure_hover() + # keyboard shortcuts, picking and hover all need mouse and key events + # from the interactor, plus VTK actors (see the TODO in _add_volume_data), + # which a page drawn by the notebook_js backend has neither of + if self._renderer._kind != "notebook_js": + self._configure_shortcuts() + self._configure_picking() + self._configure_hover() self._configure_dock() self._configure_tool_bar() self._configure_status_bar() @@ -839,7 +843,9 @@ def current_time_func(): def _configure_dock_orientation_widget(self, 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))] + # one entry per subplot, which is what VTK's renderers are (asking the + # renderer for them would reach past its interface, see _add_volume_data) + rends = [str(i) for i in range(int(np.prod(self._subplot_shape)))] if len(rends) > 1: @_auto_weakref @@ -1646,8 +1652,7 @@ def _on_time_change(self, event): self.widgets["time"].set_value(time_idx) if "current_time" in self.widgets: self.widgets["current_time"].set_value(f"{self._current_time: .3f}") - self.plot_time_line(update=True) - self._renderer._update() + self.plot_time_line(update=True) # _update_current_time_idx rendered def _on_colormap_range(self, event): """Respond to the colormap_range UI event.""" @@ -1666,9 +1671,7 @@ def _on_colormap_range(self, event): entry_key = "entry_" + key if entry_key in self.widgets: self.widgets[entry_key].set_value(val * self._data["fscale"]) - # Update the render. - self._update_colormap_range(**lims) - self._renderer._update() + self._update_colormap_range(**lims) # renders def _on_vertex_select(self, event): """Respond to vertex_select UI event.""" @@ -2671,7 +2674,7 @@ def _add_volume_data(self, hemi, src, volume_options): scalars = np.zeros(np.prod(dimensions)) scalars[vertices] = 1.0 # for the outer mesh # TODO: reaches into VTK through the renderer, which the - # jupyterlite_notebook backend cannot offer (its pages are excluded + # notebook_js backend cannot offer (its pages are excluded # in doc/conf.py); refactor the renderer interface so Brain only # uses its abstract methods. Same for the time label and glyph # actors below. diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index d34c3a25a40..d4135c820e8 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -2264,13 +2264,20 @@ def test_auto_scalar_bar_fmt(): def test_brain_ui_events(renderer_interactive_pyvistaqt, brain_gc): """Test responding to Brain related UI events.""" brain = _create_testing_brain(hemi="lh", show_traces="vertex") + # each event must render once: a notebook backend redraws the whole scene + renders, update = list(), brain._renderer._update + brain._renderer._update = lambda: (renders.append(1), update()) - ui_events.publish(brain, ui_events.TimeChange(time=1)) - assert brain._current_time == 1 + new_time = brain._times[-1] + assert brain._current_time != new_time # so this is a change + ui_events.publish(brain, ui_events.TimeChange(time=new_time)) + assert brain._current_time == new_time + assert len(renders) == 1 ui_events.publish(brain, ui_events.VertexSelect(hemi="lh", vertex_id=1)) assert 1 in brain.get_picked_points()["lh"] + n_renders = len(renders) ui_events.publish( brain, ui_events.ColormapRange( @@ -2278,6 +2285,7 @@ def test_brain_ui_events(renderer_interactive_pyvistaqt, brain_gc): ), ) assert_array_equal(brain._data["ctable"][:3, 3], [0, 2, 4]) + assert len(renders) == n_renders + 1 # This event should be ignored. ui_events.publish( diff --git a/mne/viz/backends/_jupyterlite.py b/mne/viz/backends/_jupyterlite.py index 73516996994..f7e6a09354a 100644 --- a/mne/viz/backends/_jupyterlite.py +++ b/mne/viz/backends/_jupyterlite.py @@ -60,7 +60,7 @@ def setup_notebook(data_path="/tmp/mne_data"): # nosec B108 from ...viz import set_3d_backend try: - set_3d_backend("jupyterlite_notebook") + set_3d_backend("notebook_js") except Exception as exc: print(f"[JupyterLite] could not select the pyvista-js renderer: {exc!r}") diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index ff920898282..2473fb0e49a 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -3,26 +3,30 @@ MNE's 3D functions do their geometry and coordinate-frame work in numpy and only hand the result to a renderer, so swapping that last step is enough to draw in a browser kernel, where VTK cannot load. Selected with -``mne.viz.set_3d_backend("jupyterlite_notebook")``. +``mne.viz.set_3d_backend("notebook_js")``. Supported: meshes, surfaces, spheres, tubes and glyphs, plus per-vertex RGB(A) colors, which is how :class:`mne.viz.Brain` paints its surface, so ``stc.plot()`` -gives a static picture (one time point, no time viewer, colorbar or split -layout). Not supported: scalar colormaps and contours, subplots (``hemi="split"``, -several views), volume source estimates, vector glyphs, time labels, screenshots, -and figure size (pyvista-js writes a 600x400 canvas). +gives a picture of one time point, and the ipywidgets GUI of the ``notebook`` +backend (``_notebook.py``), which the scene is drawn into as a whole whenever +it changes. Not supported: scalar colormaps and contours, subplots +(``hemi="split"``, several views), volume source estimates, vector glyphs, +screenshots, and mouse and key events. """ # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. +import asyncio +import html import inspect import weakref from contextlib import nullcontext import numpy as np import pyvista_js as pv +from ipywidgets import HTML, Button, GridBox, Layout from matplotlib.colors import to_rgb from ...surface import _tessellate_sphere @@ -35,6 +39,7 @@ ) from ...utils import _check_option, _validate_type from ._abstract import Figure3D, _AbstractRenderer +from ._notebook import _IpyRenderer from ._utils import ALLOWED_QUIVER_MODES, LIGHTS, _to_pos, _vtk_faces # vtk.js places text in normalized window coordinates; PyVista takes these names @@ -45,6 +50,45 @@ "upper_right": (0.65, 0.90), } _DEFAULT_COLOR = (0.5, 0.5, 0.5) # what color=None draws as +# pyvista-js writes a 600x400 box into its page; this fills the frame instead +_LITE_SIZE_CSS = """ +""" +# Seconds to wait for a page to report it painted before giving up on it +_LITE_ACK_TIMEOUT = 10.0 +# Appended to each page: once painted, show its frame (same origin, so it can +# reach it) and hide the other, unless a newer page (SEQ) already did, then +# tell the kernel by clicking its (hidden) button +_LITE_SWAP_JS = """ +""" +# Dims the frames with a spinner from a redraw request until the page paints +_LITE_BUSY_HTML = """ +
+
+
+""" # Nothing in a notebook closes figures, and each live scene holds its meshes in # the WASM heap, JS and GPU buffers (20_source_alignment makes six, enough to run # the tab out of memory), so only the newest few stay live @@ -61,8 +105,32 @@ def _lite_unsupported(what): raise NotImplementedError(f"{what} is not supported in the browser.") +class _LiteText(pv.Text): + """A text actor answering the VTK calls Brain makes on its time label. + + Brain reaches into VTK to place, style and update that label (see the TODO in + ``_brain.py``); here only the text ever changes, and the scene is drawn again + when it does. + """ + + def SetInput(self, text): + self.input = str(text) + + def SetPosition(self, x, y): + self.position = (float(x), float(y)) + + def GetTextProperty(self): + return self # the style calls below are no-ops, so they can land here + + def SetJustificationToCentered(self): + pass # vtk.js draws at a point, so there is nothing to justify + + def BoldOn(self): + pass # the page font is the page font + + def _lite_add_text(plotter, text, position, size, color): - actor = pv.Text(str(text), position=tuple(float(coord) for coord in position)) + actor = _LiteText(str(text), position=tuple(float(coord) for coord in position)) actor.prop.font_size = 14 if size is None else int(size) # Brain passes None actor.prop.color = _rgb(color) plotter.add_text(actor) @@ -189,7 +257,7 @@ class _LiteRenderer(_AbstractRenderer): """MNE 3D renderer backed by pyvista-js.""" # not "notebook": that backend has VTK, a filesystem and OS threads - _kind = "jupyterlite_notebook" + _kind = "notebook_js" def __init__( self, @@ -205,11 +273,13 @@ def __init__( splash=False, multi_samples=None, ): - # _PyVistaRenderer's signature, but size, shape, name and show cannot - # be honored: the canvas is fixed and written only when show() runs + # _PyVistaRenderer's signature, but shape, name and show cannot be + # honored: the page is written only when show() runs # an int is a figure number, which just means a new figure here _validate_type(fig, (None, int, _LiteFigure), "fig") + self._size = (size, size) if np.isscalar(size) else tuple(size) self._close_callbacks = {False: [], True: []} # keyed by ``after`` + self._viewer = None # the widget the scene is drawn into, once shown if isinstance(fig, _LiteFigure): # plot_alignment(fig=...) composites into it self._figure = fig return @@ -238,9 +308,6 @@ def figure(self): def scene(self): return self._figure - def show(self): - self.plotter.show() - # -- geometry ----------------------------------------------------------- def _glyph_template( self, kind, radius=None, height=None, center=None, resolution=None @@ -583,8 +650,16 @@ def subplot(self, x, y): if (x, y) != (0, 0): _lite_unsupported("Subplots") # one scene is one canvas - def _update(self): - pass # the page paints after the cell finishes + def _index_to_loc(self, idx): + assert idx == 0, idx # so there is only ever the one view + return (0, 0) + + def _loc_to_index(self, loc): + assert tuple(loc) == (0, 0), loc + return 0 + + def _process_events(self, *args, **kwargs): + pass # the page runs the event loop def _window_close_connect(self, func, *, after=True): # an output cell has no close event, so close() runs these; ui_events @@ -620,15 +695,6 @@ def contour(self, *args, **kwargs): def legend(self, *args, **kwargs): _lite_unsupported("Drawing a legend") - def _process_events(self, *args, **kwargs): - _lite_unsupported("Draining the event loop") # the page runs it - - def _window_set_cursor(self, *args, **kwargs): - _lite_unsupported("Setting the cursor") - - def _enable_time_interaction(self, *args, **kwargs): - _lite_unsupported("The time slider") # needs dock widgets - def project(self, xyz, ch_names): _lite_unsupported("Projecting 3D positions onto the scene") @@ -654,8 +720,114 @@ def set_camera( _lite_set_view(self.plotter, azimuth, elevation, rigid) +class _Renderer(_IpyRenderer, _LiteRenderer): + """pyvista-js drawing with the ipywidgets GUI of the notebook backend.""" + + # an HTML widget has no close event, so keep _LiteRenderer's real callbacks + # rather than the no-ops _IpyWindow provides for a window-less backend + _window_close_connect = _LiteRenderer._window_close_connect + _window_close_disconnect = _LiteRenderer._window_close_disconnect + + def __init__(self, *args, **kwargs): + fullscreen = kwargs.pop("fullscreen", False) + super().__init__(*args, **kwargs) + self._window_initialize(fullscreen=fullscreen) + + def _display_default_tool_bar(self): + pass # its one button takes a screenshot, which pyvista-js cannot + + def _viewer_widget(self): + """Return the widget the scene is drawn into, as ``show`` asks for it.""" + # Two frames in one grid cell, in HTML widgets: a redraw loads into the + # hidden one, which shows itself once painted (_LITE_SWAP_JS), so + # nothing blank is seen. Not Output widgets: the front end echoes what + # those capture back to the kernel, and the server drops the websocket + # for a message over 10 MiB, which a Brain surface is several times over + self._pages = [HTML(layout=Layout(grid_area="view")) for _ in range(2)] + self._busy = HTML( # last, so on top + _LITE_BUSY_HTML, layout=Layout(grid_area="view", visibility="hidden") + ) + self._painted = Button(layout=Layout(display="none")) # the page's ack + self._painted.add_class("mne-lite-painted") + self._painted.on_click(self._on_painted) + self._viewer = GridBox( + [*self._pages, self._busy, self._painted], + layout=Layout(grid_template_areas='"view"'), + ) + self._dirty = False # a change since the last draw + self._in_flight = None # the timeout handle while a page is on its way + self._n_drawn = 0 + self._last_page = None + self._draw_scene() + return self._viewer + + def _loop(self): + """Return the kernel's event loop, or None outside a kernel.""" + try: + return asyncio.get_running_loop() + except RuntimeError: + return None + + def _schedule_draw(self): + # once the kernel is done with the message (a cell or a widget event) + # that made the change, since Brain asks several times per change + loop = self._loop() + if loop is None: + self._draw_scene() + else: + loop.call_soon(self._draw_scene) + + def _on_painted(self, *args): + """Take the page's report that it painted, or the timeout in its place.""" + if self._in_flight is not None: + self._in_flight.cancel() + self._in_flight = None + if self._dirty: + self._schedule_draw() + else: + self._busy.layout.visibility = "hidden" + + def _draw_scene(self): + # the page pyvista-js writes for a browser, framed so the vtk.js it + # loads and the scene data it embeds stay out of the notebook document + width, height = self._size + self._dirty = False + page = self.plotter.generate_standalone_html() + if page == self._last_page: # Brain asks again for the same time + if self._in_flight is None: + self._busy.layout.visibility = "hidden" + return + self._last_page = page + self._n_drawn += 1 + page = page.replace("", "" + _LITE_SIZE_CSS, 1) + swap = _LITE_SWAP_JS.replace("SEQ", str(self._n_drawn)) + page = html.escape(page.replace("", swap + ""), quote=True) + # into the frame that is hidden (or not yet drawn), until it has painted + self._pages[self._n_drawn % 2].value = ( + f'' + ) + loop = self._loop() # no kernel, no browser to hear back from + if loop is not None: + self._in_flight = loop.call_later(_LITE_ACK_TIMEOUT, self._on_painted) + + def _window_get_size(self): + return self._size + + def _update(self): + # pyvista-js serializes the whole scene into the page once, with no way + # to update it afterward, so a change means drawing it again: now, or + # once the page on its way has painted (_on_painted), so that the frame + # on screen is never the one drawn into + if self._viewer is None or self._dirty: # a draw is already coming + return + self._dirty = True + self._busy.layout.visibility = "visible" + if self._in_flight is None: + self._schedule_draw() + + # -- the module surface renderer.py expects of a 3D backend ----------------- -_Renderer = _LiteRenderer _testing_context = nullcontext # nothing draws differently under test diff --git a/mne/viz/backends/_notebook.py b/mne/viz/backends/_notebook.py index eabc8431fbd..45cbf9f5f7b 100644 --- a/mne/viz/backends/_notebook.py +++ b/mne/viz/backends/_notebook.py @@ -1,4 +1,4 @@ -"""Notebook implementation of _Renderer and GUI.""" +"""ipywidgets GUI shared by the notebook 3D backends (see _trame.py, _lite.py).""" # Authors: The MNE-Python contributors. # License: BSD-3-Clause @@ -6,8 +6,7 @@ import os import os.path as op -import warnings -from contextlib import contextmanager, nullcontext +from contextlib import contextmanager from ipyevents import Event from IPython.display import clear_output, display @@ -75,18 +74,6 @@ _AbstractWidgetList, _AbstractWindow, ) -from ._pyvista import ( - Plotter, - _check_3d_figure, # noqa: F401 - _clear_3d_figure, # noqa: F401 - _close_3d_figure, # noqa: F401 - _close_all, # noqa: F401 - _PyVistaRenderer, - _set_3d_title, # noqa: F401 - _set_3d_view, # noqa: F401 - _take_3d_screenshot, # noqa: F401 -) -from ._utils import _notebook_vtk_works from .renderer import _TimeInteraction # dict values are icon names from: https://fontawesome.com/icons @@ -112,46 +99,6 @@ _BASE_MIN_SIZE = "20px" _BASE_KWARGS = dict(layout=Layout(min_width=_BASE_MIN_SIZE, min_height=_BASE_MIN_SIZE)) -_JUPYTER_BACKEND = "trame" - - -class _NotebookPlotter(Plotter): - """PyVista ``Plotter`` for the notebook backend. - - Validate the object returned by show, as PyVista silently falls back static PIL - when the trame Jupyter backend cannot be loaded. - """ - - def show( - self, *args, jupyter_backend=_JUPYTER_BACKEND, return_viewer=False, **kwargs - ): - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - viewer = super().show( - *args, - jupyter_backend=jupyter_backend, - return_viewer=return_viewer, - **kwargs, - ) - if not isinstance(viewer, Widget): - reasons = "\n".join( - f"- {w.message}" - for w in caught - if any(key in str(w.message) for key in ("backend", "trame", "static")) - ) - raise RuntimeError( - f'The notebook 3D backend is not functional: the "{jupyter_backend}" ' - "PyVista Jupyter backend returned a " - f"{type(viewer).__module__}.{type(viewer).__qualname__} instead of an " - "interactive widget. This usually means the installed trame packages " - "(trame, trame-vtk, trame-vuetify, trame-pyvista) are missing or " - "mutually incompatible." - + (f"\n\nPyVista reported:\n{reasons}" if reasons else "") - ) - if return_viewer: - return viewer - - # %% # Widgets # ------- @@ -670,8 +617,7 @@ def _handle_scroll(self, scroll=None): return kwargs def _add_widget(self, widget): - # if pyvista plotter, needs to be shown - if isinstance(widget, Plotter): + if not isinstance(widget, Widget): # a PyVista plotter, which needs showing widget = widget.show(return_viewer=True) if hasattr(widget, "layout"): widget.layout.width = None # unlock the fixed layout @@ -767,25 +713,6 @@ def _close(self): clear_output() -class _3DRenderer(_PyVistaRenderer): - _kind = "notebook" - - def __init__(self, *args, **kwargs): - kwargs["notebook"] = True - super().__init__(*args, **kwargs) - if "show" in kwargs and kwargs["show"]: - self.show() - - @contextmanager - def _ensure_minimum_sizes(self): - yield - - def show(self): - viewer = self.plotter.show(return_viewer=True) - viewer.layout.width = None # unlock the fixed layout - display(viewer) - - # ------------------------------------ # Non-object-based Widget Abstractions # ------------------------------------ @@ -1345,9 +1272,12 @@ def _playback_initialize(self, func, timeout, value, rng, time_widget, play_widg class _IpyMplInterface(_AbstractMplInterface): def _mpl_initialize(self): - ipympl = _soft_import("ipympl", "Drawing figures into a notebook.", strict=True) - self.canvas = ipympl.backend_nbagg.Canvas(self.fig) - self.manager = ipympl.backend_nbagg.FigureManager(self.canvas, 0) + _soft_import("ipympl", "Drawing figures into a notebook.", strict=True) + # the submodule, as `import ipympl` alone does not bring it in + from ipympl.backend_nbagg import Canvas, FigureManager + + self.canvas = Canvas(self.fig) + self.manager = FigureManager(self.canvas, 0) class _IpyMplCanvas(_AbstractMplCanvas, _IpyMplInterface): @@ -1546,8 +1476,7 @@ def set_shortcut(self, shortcut): pass -class _Renderer( - _PyVistaRenderer, +class _IpyRenderer( _IpyDock, _IpyToolBar, _IpyMenuBar, @@ -1558,24 +1487,21 @@ class _Renderer( _IpyKeyPress, _TimeInteraction, ): - _kind = "notebook" + """The ipywidgets GUI the notebook backends put around their drawing renderer. - def __init__(self, *args, **kwargs): + A variant mixes this in *before* its drawing ``_AbstractRenderer``, so that + ``show`` here wins over the drawing one, and supplies the widget the scene is + drawn into through ``_viewer_widget``. Nothing else here collides, so the + drawing renderer is reached by inheritance as usual. + """ + + def _window_initialize(self, *, window=None, central_layout=None, fullscreen=False): self._docks = None self._menu_bar = None self._tool_bar = None self._status_bar = None self._file_picker = _FilePckr(rows=10) - kwargs["notebook"] = True - fullscreen = kwargs.pop("fullscreen", False) - if not _notebook_vtk_works(): - raise RuntimeError( - "Using the notebook backend on Linux requires a compatible " - "VTK setup. Consider using Xfvb or xvfb-run to set up a " - "working virtual display, or install VTK with OSMesa enabled." - ) - super().__init__(*args, **kwargs) - self._window_initialize(fullscreen=fullscreen) + super()._window_initialize(fullscreen=fullscreen) def _display_default_tool_bar(self): self._tool_bar_initialize() @@ -1596,7 +1522,7 @@ def show(self): else: self._display_default_tool_bar() # viewer - viewer = self.plotter.show(return_viewer=True) + viewer = self._viewer_widget() rendering_row = list() if self._docks is not None and "left" in self._docks: rendering_row.append(self._docks["left"][0]) @@ -1612,6 +1538,3 @@ def show(self): self._file_picker.hide() display(self._file_picker._widget) return self.scene() - - -_testing_context = nullcontext diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index b22ab27b2c4..23f36a1fc22 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -134,7 +134,7 @@ def _init( if "app_window_class" in signature(_SafeBackgroundPlotter).parameters: self.store["app_window_class"] = _MNEMainWindow else: - from ._notebook import _NotebookPlotter + from ._trame import _NotebookPlotter self._plotter_class = _NotebookPlotter diff --git a/mne/viz/backends/_trame.py b/mne/viz/backends/_trame.py new file mode 100644 index 00000000000..424ee9f5de9 --- /dev/null +++ b/mne/viz/backends/_trame.py @@ -0,0 +1,107 @@ +"""The ``notebook`` 3D backend: VTK drawn through PyVista and trame.""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import warnings +from contextlib import contextmanager, nullcontext + +from IPython.display import display +from ipywidgets import Widget + +from ._notebook import _IpyRenderer +from ._pyvista import ( + Plotter, + _check_3d_figure, # noqa: F401 + _clear_3d_figure, # noqa: F401 + _close_3d_figure, # noqa: F401 + _close_all, # noqa: F401 + _PyVistaRenderer, + _set_3d_title, # noqa: F401 + _set_3d_view, # noqa: F401 + _take_3d_screenshot, # noqa: F401 +) +from ._utils import _notebook_vtk_works + +_JUPYTER_BACKEND = "trame" + + +class _NotebookPlotter(Plotter): + """PyVista ``Plotter`` for the notebook backend. + + Validate the object returned by show, as PyVista silently falls back static PIL + when the trame Jupyter backend cannot be loaded. + """ + + def show( + self, *args, jupyter_backend=_JUPYTER_BACKEND, return_viewer=False, **kwargs + ): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + viewer = super().show( + *args, + jupyter_backend=jupyter_backend, + return_viewer=return_viewer, + **kwargs, + ) + if not isinstance(viewer, Widget): + reasons = "\n".join( + f"- {w.message}" + for w in caught + if any(key in str(w.message) for key in ("backend", "trame", "static")) + ) + raise RuntimeError( + f'The notebook 3D backend is not functional: the "{jupyter_backend}" ' + "PyVista Jupyter backend returned a " + f"{type(viewer).__module__}.{type(viewer).__qualname__} instead of an " + "interactive widget. This usually means the installed trame packages " + "(trame, trame-vtk, trame-vuetify, trame-pyvista) are missing or " + "mutually incompatible." + + (f"\n\nPyVista reported:\n{reasons}" if reasons else "") + ) + if return_viewer: + return viewer + + +class _3DRenderer(_PyVistaRenderer): + _kind = "notebook" + + def __init__(self, *args, **kwargs): + kwargs["notebook"] = True + super().__init__(*args, **kwargs) + if "show" in kwargs and kwargs["show"]: + self.show() + + @contextmanager + def _ensure_minimum_sizes(self): + yield + + def show(self): + viewer = self.plotter.show(return_viewer=True) + viewer.layout.width = None # unlock the fixed layout + display(viewer) + + +class _Renderer(_IpyRenderer, _PyVistaRenderer): + """VTK drawing through PyVista and trame, with the shared ipywidgets GUI.""" + + _kind = "notebook" + + def __init__(self, *args, **kwargs): + kwargs["notebook"] = True + fullscreen = kwargs.pop("fullscreen", False) + if not _notebook_vtk_works(): + raise RuntimeError( + "Using the notebook backend on Linux requires a compatible " + "VTK setup. Consider using Xfvb or xvfb-run to set up a " + "working virtual display, or install VTK with OSMesa enabled." + ) + super().__init__(*args, **kwargs) + self._window_initialize(fullscreen=fullscreen) + + def _viewer_widget(self): + return self.plotter.show(return_viewer=True) + + +_testing_context = nullcontext diff --git a/mne/viz/backends/_utils.py b/mne/viz/backends/_utils.py index e57bb46b5a3..29485bb38f7 100644 --- a/mne/viz/backends/_utils.py +++ b/mne/viz/backends/_utils.py @@ -29,7 +29,7 @@ VALID_3D_BACKENDS = ( "pyvistaqt", # default 3d backend "notebook", - "jupyterlite_notebook", + "notebook_js", ) # The backends _get_3d_backend() falls back to when none has been set. The # JupyterLite one is left out on purpose: it draws through vtk.js and only diff --git a/mne/viz/backends/renderer.py b/mne/viz/backends/renderer.py index 905f9dacf88..24fba8d5208 100644 --- a/mne/viz/backends/renderer.py +++ b/mne/viz/backends/renderer.py @@ -19,6 +19,7 @@ get_config, logger, verbose_static, + warn, ) from .._3d import _get_3d_option from ..utils import safe_event @@ -30,8 +31,8 @@ _backend_name_map = dict( pyvistaqt="._qt", - notebook="._notebook", - jupyterlite_notebook="._lite", + notebook="._trame", + notebook_js="._lite", ) backend = None @@ -57,6 +58,13 @@ def _get_renderer(*args, **kwargs): def _check_3d_backend_name(backend_name): _validate_type(backend_name, str, "backend_name") backend_name = "pyvistaqt" if backend_name == "pyvista" else backend_name + if backend_name == "jupyterlite_notebook": + warn( + 'The "jupyterlite_notebook" 3D backend was renamed "notebook_js" in ' + "1.14 and the old name will be removed in 1.16.", + FutureWarning, + ) + backend_name = "notebook_js" _check_option("backend_name", backend_name, VALID_3D_BACKENDS) return backend_name @@ -73,10 +81,12 @@ def set_3d_backend(backend_name, verbose=None): backend_name : str The 3d backend to select. See Notes for the capabilities of each backend (``'pyvistaqt'``, ``'notebook'`` and - ``'jupyterlite_notebook'``). + ``'notebook_js'``). .. versionchanged:: 0.24 The ``'pyvista'`` backend was renamed ``'pyvistaqt'``. + .. versionchanged:: 1.14 + The ``'jupyterlite_notebook'`` backend was renamed ``'notebook_js'``. verbose : bool | str | int | None Control verbosity of the logging output. If ``None``, use the default verbosity level. See the :ref:`logging documentation ` and @@ -93,13 +103,16 @@ def set_3d_backend(backend_name, verbose=None): To use PyVista, set ``backend_name`` to ``pyvistaqt`` but the value ``pyvista`` is still supported for backward compatibility. - The ``jupyterlite_notebook`` backend is not in the table below because it is + The ``notebook_js`` backend is not in the table below because it is not a desktop choice: it draws with vtk.js rather than VTK, which has no WebAssembly build, and it is what the documentation's browser notebooks run on. It covers the static 3D figures, so :func:`plot_alignment` (without channel-name labels) and :func:`plot_sparse_source_estimates` work, and - :class:`mne.viz.Brain` draws a single time point with no time viewer, - colorbar or split-hemisphere layout, while :func:`plot_evoked_field` and + :class:`mne.viz.Brain` draws a single time point with no colorbar, + split-hemisphere layout or (by default) time viewer, although + ``time_viewer=True`` does build the same ipywidgets GUI as the ``notebook`` + backend, minus the keyboard shortcuts, picking and hover that need mouse and + key events from VTK. :func:`plot_evoked_field` and :func:`snapshot_brain_montage` do not work. On a desktop the other two are better in every way, so it is never selected automatically. @@ -226,7 +239,7 @@ def use_3d_backend(backend_name): # numpydoc ignore=YD01 Parameters ---------- - backend_name : {'pyvistaqt', 'notebook', 'jupyterlite_notebook'} + backend_name : {'pyvistaqt', 'notebook', 'notebook_js'} The 3d backend to use in the context. """ old_backend = set_3d_backend(backend_name) @@ -570,7 +583,8 @@ def shift_time(direction): TimeChange(time=self._current_time_func() + direction * amount), ) - if self.plotter.iren is not None: + # a plotter drawing for a browser has no interactor to bind keys on + if getattr(self.plotter, "iren", None) is not None: self.plotter.add_key_event("n", partial(shift_time, direction=1)) self.plotter.add_key_event("b", partial(shift_time, direction=-1)) diff --git a/mne/viz/backends/tests/test_abstract.py b/mne/viz/backends/tests/test_abstract.py index 9209cd52b2a..1cf2edaf55c 100644 --- a/mne/viz/backends/tests/test_abstract.py +++ b/mne/viz/backends/tests/test_abstract.py @@ -7,7 +7,8 @@ from mne.viz.backends.renderer import _get_backend -def _do_widget_tests(backend): +def _do_widget_tests(backend, renderer_class=None): + """Test a backend's widgets; its 3D renderer can live in another module.""" # testing utils widget_checks = set() @@ -21,7 +22,8 @@ def callback(x=None): window = backend._AppWindow() central_layout = backend._VBoxLayout(scroll=(500, 500)) - renderer = backend._3DRenderer(name="test", size=(200, 200)) + renderer_class = renderer_class or backend._3DRenderer + renderer = renderer_class(name="test", size=(200, 200)) renderer.sphere([0, 0, 0], "red", 1) central_layout._add_widget(renderer.plotter) canvas = backend._Canvas(5, 5, 96) @@ -119,13 +121,15 @@ def test_widget_abstraction_notebook(renderer_notebook, nbexec): from IPython import get_ipython from mne.viz import set_3d_backend + from mne.viz.backends import _notebook from mne.viz.backends.renderer import _get_backend from mne.viz.backends.tests.test_abstract import _do_widget_tests set_3d_backend("notebook") backend = _get_backend() - assert Path(backend.__file__).stem == "_notebook" + assert Path(backend.__file__).stem == "_trame" ipython = get_ipython() ipython.run_line_magic(magic_name="matplotlib", line="widget") - _do_widget_tests(backend) + # the widgets are shared with the notebook_js backend + _do_widget_tests(_notebook, backend._3DRenderer) diff --git a/mne/viz/backends/tests/test_renderer.py b/mne/viz/backends/tests/test_renderer.py index 61c846e62f9..6f233278243 100644 --- a/mne/viz/backends/tests/test_renderer.py +++ b/mne/viz/backends/tests/test_renderer.py @@ -2,11 +2,13 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +import asyncio import os import platform import subprocess import sys from contextlib import nullcontext +from functools import partial import numpy as np import pytest @@ -18,14 +20,14 @@ from mne.transforms import quat_to_rot, rot_to_quat from mne.viz import Figure3D, get_3d_backend, set_3d_backend from mne.viz.backends._utils import ALLOWED_QUIVER_MODES -from mne.viz.backends.renderer import _get_renderer +from mne.viz.backends.renderer import _check_3d_backend_name, _get_renderer _data_path = testing.data_path(download=False) def _unsupported(renderer): """Return a context for what the browser backend says it cannot draw.""" - if renderer.get_3d_backend() == "jupyterlite_notebook": + if renderer.get_3d_backend() == "notebook_js": return pytest.raises(NotImplementedError, match="browser") return nullcontext() @@ -185,7 +187,7 @@ def test_3d_backend(renderer): # highlighting/hover) without rebuilding the actor or its geometry; the # browser backend merges instances into solid meshes, so it has no such # per-instance colors to update - if renderer.get_3d_backend() != "jupyterlite_notebook": + if renderer.get_3d_backend() != "notebook_js": inst_cloud.point_data["colors"][0] = [0, 0, 255, 255] inst_cloud.Modified() @@ -296,7 +298,7 @@ def test_renderer(renderer, monkeypatch): "backend = mne.viz.get_3d_backend(); " f"assert backend == {repr(backend)}, backend; " # the browser backend must never import VTK, since there is none there - f"assert backend != 'jupyterlite_notebook' or 'vtk' not in sys.modules", + f"assert backend != 'notebook_js' or 'vtk' not in sys.modules", ] monkeypatch.setenv("MNE_3D_BACKEND", backend) proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60) @@ -305,9 +307,13 @@ def test_renderer(renderer, monkeypatch): def test_set_3d_backend_bad(monkeypatch, tmp_path): """Test that the error emitted when a bad backend name is used.""" - match = "Allowed values are 'pyvistaqt', 'notebook', and 'jupyterlite_notebook'" + match = "Allowed values are 'pyvistaqt', 'notebook', and 'notebook_js'" with pytest.raises(ValueError, match=match): set_3d_backend("invalid") + with pytest.warns( + FutureWarning, match='renamed "notebook_js"' + ): # TODO VERSION: 1.16 + assert _check_3d_backend_name("jupyterlite_notebook") == "notebook_js" # gh-9607 def fail(x): @@ -358,7 +364,7 @@ def test_3d_warning(renderer_pyvistaqt, monkeypatch): assert not _pyvista._is_osmesa(plotter) -# -- jupyterlite_notebook (pyvista-js) backend -------------------------------- +# -- notebook_js (pyvista-js) backend -------------------------------- # What the shared tests above cannot pin down, mostly geometry, since nothing # here can be screenshotted. A unit square, split into two triangles: _RR = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], float) @@ -512,7 +518,7 @@ def test_lite_notebook_kernel(renderer_lite, nbexec): from mne.viz.backends import renderer - renderer.set_3d_backend("jupyterlite_notebook") + renderer.set_3d_backend("notebook_js") rend = renderer._get_renderer(bgcolor="white") rr = np.array([[0.0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) rend.mesh(*rr.T, [[0, 1, 2], [0, 2, 3]], color="red") @@ -527,8 +533,10 @@ def test_lite_notebook_kernel(renderer_lite, nbexec): @testing.requires_testing_data -def test_lite_brain(renderer_lite): +def test_lite_brain(renderer_lite, monkeypatch): """Test Brain draws a static, per-vertex-colored surface through the backend.""" + from ipywidgets import GridBox + import mne stc = mne.read_source_estimate( @@ -555,6 +563,59 @@ def test_lite_brain(renderer_lite): mne.viz.Brain(surf="inflated", **{**kwargs, "hemi": "split"}) assert list(mne.viz.Brain._instances) == [brain] # the failed one died + # time_viewer=True builds the ipywidgets GUI shared with the notebook backend + brain = stc.plot(views="lat", initial_time=0.1, time_viewer=True, **kwargs) + rend = brain._renderer + assert "left" in rend._docks and "time_slider" in rend._widgets + assert isinstance(rend._viewer, GridBox) # the widget the scene is drawn into + assert rend._pages[rend._n_drawn % 2].value.startswith("