diff --git a/doc/changes/dev/14144.newfeature.rst b/doc/changes/dev/14144.newfeature.rst new file mode 100644 index 00000000000..dce99387f96 --- /dev/null +++ b/doc/changes/dev/14144.newfeature.rst @@ -0,0 +1 @@ +Add a vtk.js drawing backend for MNE's 3D renderer, selected with ``mne.viz.set_3d_backend("jupyterlite_notebook")`` and used by the JupyterLite documentation where VTK cannot load, by `Natneal B`_. diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py new file mode 100644 index 00000000000..76584ec1ec3 --- /dev/null +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -0,0 +1,23 @@ +"""Turn on MNE's pyvista-js 3D renderer inside the JupyterLite kernel. + +VTK has no WebAssembly build, so the browser draws with pyvista-js instead. The +renderer itself is ordinary library code in ``mne/viz/backends/_lite.py``; this +module only exposes the few lines of notebook code that switch MNE over to it. +``LITE_RENDERER_CELL`` is appended to ``LITE_SETUP_CELL`` in +``jupyterlite_setup_cell.py``, which the docs build prepends to each notebook. +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +LITE_RENDERER_CELL = """ +# Using pyvista-js (vtk.js) to draw MNE's 3D rendering in JupyterLite. +# See mne/viz/backends/_lite.py for more details. +try: + import mne.viz + + mne.viz.set_3d_backend("jupyterlite_notebook") +except Exception as _e: + print("[JupyterLite] could not select the pyvista-js renderer: " + repr(_e)) +""" diff --git a/mne/conftest.py b/mne/conftest.py index 623d4a8e316..3d4df0c491d 100644 --- a/mne/conftest.py +++ b/mne/conftest.py @@ -740,7 +740,12 @@ def browser_backend(request, garbage_collect, monkeypatch): mne_qt_browser._browser_instances.clear() -@pytest.fixture(params=[pytest.param("pyvistaqt", marks=pytest.mark.pvtest)]) +@pytest.fixture( + params=[ + pytest.param("pyvistaqt", marks=pytest.mark.pvtest), + pytest.param("jupyterlite_notebook", marks=pytest.mark.pvtest), + ] +) def renderer(request, options_3d, garbage_collect): """Yield the 3D backends.""" with _use_backend(request.param, interactive=False) as renderer: @@ -761,6 +766,13 @@ def renderer_notebook(request, options_3d): yield renderer +@pytest.fixture(params=[pytest.param("jupyterlite_notebook", 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: + yield renderer + + @pytest.fixture(params=[pytest.param("pyvistaqt", marks=pytest.mark.pvtest)]) def renderer_interactive_pyvistaqt(request, options_3d, qt_windows_closed): """Yield the interactive PyVista backend.""" @@ -788,15 +800,21 @@ def _use_backend(backend_name, interactive): # figure-count test (in other modules) fails. Restore it on teardown. mpl_backend = matplotlib.get_backend() _check_skip_backend(backend_name) + from mne.viz.backends import renderer + + # use_3d_backend only puts a backend back if one was already selected, so + # the first renderer test of a session would otherwise decide the backend + # every later test inherits; the JupyterLite one draws for a browser, so + # that must never be it + was = (renderer.MNE_3D_BACKEND, renderer.backend) try: with _use_test_3d_backend(backend_name, interactive=interactive): - from mne.viz.backends import renderer - try: yield renderer finally: renderer.backend._close_all() finally: + renderer.MNE_3D_BACKEND, renderer.backend = was if matplotlib.get_backend() != mpl_backend: matplotlib.use(mpl_backend, force=True) @@ -804,6 +822,10 @@ def _use_backend(backend_name, interactive): def _check_skip_backend(name): from mne.viz.backends._utils import _notebook_vtk_works + if name == "jupyterlite_notebook": + # draws with vtk.js in a browser: no VTK, no Qt, no ffmpeg + pytest.importorskip("pyvista_js") + return pytest.importorskip("pyvista") pytest.importorskip("imageio_ffmpeg") if name == "pyvistaqt": diff --git a/mne/report/tests/test_report.py b/mne/report/tests/test_report.py index 44a6bd8781f..340e90c906e 100644 --- a/mne/report/tests/test_report.py +++ b/mne/report/tests/test_report.py @@ -401,7 +401,7 @@ def test_report_raw_psd_and_date(tmp_path): @pytest.mark.slowtest # slow on Azure @testing.requires_testing_data -def test_render_add_sections(renderer, tmp_path): +def test_render_add_sections(renderer_pyvistaqt, tmp_path): """Test adding figures/images to section.""" pytest.importorskip("nibabel") try: @@ -451,7 +451,7 @@ def test_render_add_sections(renderer, tmp_path): @pytest.mark.slowtest @testing.requires_testing_data -def test_render_mri(renderer, tmp_path): +def test_render_mri(renderer_pyvistaqt, tmp_path): """Test rendering MRI for mne report.""" pytest.importorskip("nibabel") trans_fname_new = tmp_path / "temp-trans.fif" @@ -1247,7 +1247,7 @@ def test_report_backward_compat(tmp_path): @pytest.mark.slowtest # 30 s on Azure @testing.requires_testing_data -def test_manual_report_3d(tmp_path, renderer): +def test_manual_report_3d(tmp_path, renderer_pyvistaqt): """Simulate adding 3D sections.""" pytest.importorskip("nibabel") r = Report(title="My Report") diff --git a/mne/tests/test_transforms.py b/mne/tests/test_transforms.py index 9a90629f042..105c18b0975 100644 --- a/mne/tests/test_transforms.py +++ b/mne/tests/test_transforms.py @@ -386,6 +386,19 @@ def test_vector_rotation(): quat_1 = rot_to_quat(rot) quat_2 = rot_to_quat(np.eye(3)) assert_allclose(_angle_between_quats(quat_1, quat_2), np.pi / 2.0) + # many at once, including the parallel, antiparallel and nearly antiparallel + # cases, the first two of which have no rotation axis of their own + b = np.random.default_rng(0).normal(size=(20, 3)) + b = np.concatenate([b, [x, -x, [-1, 1e-8, 0]]]) + b /= np.linalg.norm(b, axis=1, keepdims=True) + rots = _find_vector_rotation(x, b) + assert_allclose(rots @ x, b, atol=1e-12) + eye = np.broadcast_to(np.eye(3), rots.shape) + assert_allclose(rots @ rots.transpose(0, 2, 1), eye, atol=1e-12) + assert_allclose(rots[-2], np.diag([-1, -1, 1]), atol=1e-12) + for rot, this_b in zip(rots[:3], b): # each is the minimal rotation + angle = _angle_between_quats(rot_to_quat(rot), np.zeros(3)) + assert_allclose(angle, np.arccos(x @ this_b)) def test_average_quats(): diff --git a/mne/transforms.py b/mne/transforms.py index 011bf64925d..7358032c248 100644 --- a/mne/transforms.py +++ b/mne/transforms.py @@ -1413,26 +1413,68 @@ def _quat_mult(one, two): def _skew_symmetric_cross(a): - """Compute the skew-symmetric cross product of a vector.""" - return np.array([[0.0, -a[2], a[1]], [a[2], 0.0, -a[0]], [-a[1], a[0], 0.0]]) + """Compute the skew-symmetric cross product matrix of (..., 3) vector(s).""" + a = np.asarray(a, float) + ax = np.zeros(a.shape + (3,)) + ax[..., 0, 1], ax[..., 0, 2] = -a[..., 2], a[..., 1] + ax[..., 1, 0], ax[..., 1, 2] = a[..., 2], -a[..., 0] + ax[..., 2, 0], ax[..., 2, 1] = -a[..., 1], a[..., 0] + return ax def _find_vector_rotation(a, b): - """Find the rotation matrix that maps unit vector a to b.""" + """Find the rotation matrix that maps unit vector a to unit vector(s) b. + + Parameters + ---------- + a : array, shape (3,) + The unit vector to rotate. + b : array, shape (3,) | shape (..., 3) + The unit vector(s) to rotate ``a`` onto. + + Returns + ------- + R : array, shape (3, 3) | shape (..., 3, 3) + The rotation(s) about ``a x b`` by the angle between them, so that + ``R @ a`` is ``b``. Antiparallel vectors, where that axis vanishes, + get a half turn about an arbitrary axis perpendicular to ``a``. + + Notes + ----- + Mapping one vector onto another leaves a free parameter: the roll about + ``b``. Any rotation about ``b`` composed with the result maps ``a`` onto + ``b`` just as well, and this function settles it by taking the minimal + rotation, about ``a x b``. So it is right for things that look the same + however they are rolled about their axis, like arrows, tubes and the EEG + electrode cylinders, and wrong for a flat MEG coil, whose orientation + needs the full rotation from ``_loc_to_coil_trans``. + """ # Rodrigues' rotation formula: # https://en.wikipedia.org/wiki/Rodrigues%27_rotation_formula # http://math.stackexchange.com/a/476311 + a = np.asarray(a, float) + b = np.asarray(b, float) + assert a.shape == (3,), a.shape assert np.isclose(np.linalg.norm(a), 1.0), np.linalg.norm(a) - assert np.isclose(np.linalg.norm(b), 1.0), np.linalg.norm(b) - R = np.eye(3) - v = np.cross(a, b) - if np.allclose(v, 0.0): # identical - return R - s = np.dot(v, v) # sine of the angle between them - c = np.dot(a, b) # cosine of the angle between them + assert b.shape[-1:] == (3,), b.shape + assert np.allclose(np.linalg.norm(b, axis=-1), 1.0), np.linalg.norm(b, axis=-1) + v = np.cross(a, b) # rotation axis, with the sine of the angle as its length + s = (v * v).sum(-1) # sine squared + c = b @ a # cosine vx = _skew_symmetric_cross(v) - R += vx + np.dot(vx, vx) * (1 - c) / s - # Now we have: np.allclose(R @ a, b) + # (1 - c) / s is 1 / (1 + c), but written this way it stays accurate as b + # approaches -a, where 1 + c cancels and s does not. Only an s that has + # vanished outright needs special handling below. + degenerate = s < np.finfo(float).tiny + factor = (1.0 - c) / np.where(degenerate, 1.0, s) + R = np.eye(3) + vx + vx @ vx * factor[..., np.newaxis, np.newaxis] + if degenerate.any(): + # parallel is the identity (vx is zero); antiparallel is a half turn + # about a unit vector k perpendicular to a, for which the coordinate + # axis least aligned with a serves, since it is never parallel to it + k = np.cross(a, np.eye(3)[np.argmin(np.abs(a))]) + k /= np.linalg.norm(k) + R[degenerate & (c < 0)] = 2 * np.outer(k, k) - np.eye(3) return R diff --git a/mne/utils/config.py b/mne/utils/config.py index 305b1112460..7f6230bb5f6 100644 --- a/mne/utils/config.py +++ b/mne/utils/config.py @@ -942,6 +942,7 @@ def sys_info( "nbclient", "nbformat", "nitime", + "pyvista-js", "imageio", "imageio-ffmpeg", "snirf", diff --git a/mne/utils/tests/test_config.py b/mne/utils/tests/test_config.py index 60783c70ce6..81f4c468542 100644 --- a/mne/utils/tests/test_config.py +++ b/mne/utils/tests/test_config.py @@ -167,7 +167,7 @@ def test_sys_info_complete(): ] missing = [] for dep in deps: - dep = dep.split("[")[0].split(">")[0].strip() + dep = dep.split(";")[0].split("[")[0].split(">")[0].strip() if f" {dep}" not in out: missing.append(dep) if missing: diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index fcad2f95657..8a3856dc665 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -841,7 +841,7 @@ def plot_alignment( # initialize figure renderer = _get_renderer( - fig, + fig=fig, name=f"Sensor alignment: {subject}", bgcolor=(0.5, 0.5, 0.5), size=(800, 800), @@ -1442,8 +1442,7 @@ def _plot_glyphs( ) x_axis = np.array([1.0, 0.0, 0.0]) nn = vectors / np.linalg.norm(vectors, axis=1, keepdims=True) - rots = np.array([_find_vector_rotation(x_axis, this_nn) for this_nn in nn]) - quats = rot_to_quat(rots) + quats = rot_to_quat(_find_vector_rotation(x_axis, nn)) rr, tris = renderer._glyph_template(kind, **template_kw) actor, cloud = renderer.instanced_mesh( rr=rr, @@ -3953,7 +3952,7 @@ def snapshot_brain_montage(fig, montage, hide_sensors=True): ) # initialize figure - renderer = _get_renderer(fig, show=True) + renderer = _get_renderer(fig=fig, show=True) xyz = np.vstack(xyz) proj = renderer.project(xyz=xyz, ch_names=ch_names) diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index a40bb471d26..fe1eb1a5d8e 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -223,9 +223,9 @@ def test_brain_data_gc(renderer_interactive_pyvistaqt, brain_gc): @testing.requires_testing_data -def test_brain_routines(renderer, brain_gc): +def test_brain_routines(renderer_pyvistaqt, brain_gc): """Test backend agnostic Brain routines.""" - brain_klass = renderer.get_brain_class() + brain_klass = renderer_pyvistaqt.get_brain_class() from mne.viz._brain import Brain assert brain_klass == Brain @@ -996,7 +996,7 @@ def test_single_hemi(hemi, renderer_interactive_pyvistaqt, brain_gc): @testing.requires_testing_data @pytest.mark.slowtest @pytest.mark.parametrize("interactive_state", (False, True)) -def test_brain_save_movie(tmp_path, renderer, brain_gc, interactive_state): +def test_brain_save_movie(tmp_path, renderer_pyvistaqt, brain_gc, interactive_state): """Test saving a movie of a Brain instance.""" pytest.importorskip("imageio") imageio_ffmpeg = pytest.importorskip("imageio_ffmpeg") diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py new file mode 100644 index 00000000000..6d5309834b2 --- /dev/null +++ b/mne/viz/backends/_lite.py @@ -0,0 +1,634 @@ +"""A pyvista-js (vtk.js) drawing backend for MNE's 3D renderer. + +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")``. + +Supported: meshes, surfaces, spheres, tubes and glyphs, which covers the static +figures. Not supported: :class:`mne.viz.Brain` (needs dock widgets), scalars, +colormaps and contours (the vtk.js template builds no lookup table, so every +mesh is one solid color), and figure size (pyvista-js writes a 600x400 canvas). +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import weakref +from contextlib import nullcontext + +import numpy as np +import pyvista_js as pv +from matplotlib.colors import to_rgb + +from ...surface import _tessellate_sphere +from ...transforms import ( + _cart_to_sph, + _find_vector_rotation, + _sph_to_cart, + quat_to_rot, +) +from ...utils import _check_option, _validate_type +from ._abstract import Figure3D, _AbstractRenderer +from ._utils import ALLOWED_QUIVER_MODES, _vtk_faces + +# vtk.js places text in normalized window coordinates; PyVista takes these names +_TITLE_POSITIONS = { + "lower_left": (0.05, 0.05), + "lower_right": (0.65, 0.05), + "upper_left": (0.05, 0.90), + "upper_right": (0.65, 0.90), +} +_DEFAULT_COLOR = (0.5, 0.5, 0.5) # what color=None draws as +# 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 +_LITE_MAX_LIVE_SCENES = 2 +_lite_live_plotters = [] # weakrefs, so scenes stay collectable + + +def _rgb(color): + """Return the (r, g, b) tuple pyvista-js takes; it cannot parse hex itself.""" + return _DEFAULT_COLOR if color is None else to_rgb(color) + + +def _lite_unsupported(what): + raise NotImplementedError(f"{what} is not supported in the browser.") + + +def _lite_add_text(plotter, text, position, size, color): + actor = pv.Text(str(text), position=tuple(float(coord) for coord in position)) + actor.prop.font_size = int(size) + actor.prop.color = _rgb(color) + plotter.add_text(actor) + return actor + + +def _lite_view_angles(plotter): + """Return the (azimuth, elevation) in degrees the plotter looks from, or None. + + The view is kept as ``view_vector``, a camera position that vtk.js aims at + the origin and then frames with ``resetCamera()``, rather than as a camera + object, which would need the distance that MNE mostly passes as None. + """ + view_vector = plotter._renderer._view_vector # pyvista-js 0.15 + if view_vector is None: # nothing set yet, so vtk.js chooses + return None + _, phi, theta = _cart_to_sph(np.asarray(view_vector, float)[np.newaxis])[0] + return float(np.rad2deg(phi)) % 360, float(np.rad2deg(theta)) % 180 + + +def _lite_set_view(plotter, azimuth=None, elevation=None): + """Point the plotter, keeping the angle not given as _pyvista._set_3d_view does.""" + if azimuth is None and elevation is None: + return + current = _lite_view_angles(plotter) or (90.0, 90.0) # plot_alignment's view + phi = np.deg2rad(current[0] if azimuth is None else azimuth) + theta = np.deg2rad(current[1] if elevation is None else elevation) + # view up flips near the poles, matching _set_3d_view + up = ( + (0.0, 0.0, 1.0) + if elevation is None or 5 <= abs(elevation) <= 175 + else (0.0, 1.0, 0.0) + ) + plotter.view_vector( + tuple(_sph_to_cart(np.array([[1.0, phi, theta]]))[0]), viewup=up + ) + + +def _lite_get_view(plotter): + """Return (roll, distance, azimuth, elevation, focalpoint) as _get_3d_view does.""" + azimuth, elevation = _lite_view_angles(plotter) or (0.0, 0.0) + return (0.0, 1.0, azimuth, elevation, np.zeros(3)) + + +def _lite_release_plotter(plotter): + """Drop a plotter's geometry and forget it; clear() is all the teardown there is.""" + _lite_live_plotters[:] = [ + ref for ref in _lite_live_plotters if ref() not in (None, plotter) + ] + if plotter is not None: + plotter.clear() + + +def _lite_opacity(color, opacity): + """Fold RGBA alpha into the opacity, since each mesh here is one solid color. + + _PyVistaRenderer draws instance colors as RGBA scalars, and the alpha is + how plot_alignment fades the MEG coils. + """ + opacity = 1.0 if opacity is None else float(opacity) + if not isinstance(color, str) and np.shape(color) == (4,): + opacity *= float(color[3]) + return opacity + + +def _lite_revolve(profile, n_side): + """Return (rr, tris) of rings of ``radius`` at ``x`` about +x, joined and capped.""" + angles = np.linspace(0.0, 2 * np.pi, n_side, endpoint=False) + rr = [ + np.column_stack([np.full(n_side, x), r * np.cos(angles), r * np.sin(angles)]) + for x, r in profile + ] + idx = np.arange(n_side) + nxt = (idx + 1) % n_side + tris = [] + for a in range(0, (len(profile) - 1) * n_side, n_side): + b = a + n_side + tris += [np.c_[a + idx, a + nxt, b + nxt], np.c_[a + idx, b + nxt, b + idx]] + n_points = len(profile) * n_side + ends = ((0, profile[0], True), (n_points - n_side, profile[-1], False)) + for ring, (x, r), flip in ends: + if r > 0: # cap it, wound to face outward + rr.append([[x, 0.0, 0.0]]) + cap = np.c_[np.full(n_side, n_points), ring + idx, ring + nxt] + tris.append(cap[:, ::-1] if flip else cap) + n_points += 1 + return np.vstack(rr), np.vstack(tris).astype(int) + + +class _LiteFigure(Figure3D): + """pyvista-js-based 3D figure; ``.plotter`` is the pyvista-js plotter.""" + + def __init__(self): + pass + + def _init(self, plotter): + self._plotter = plotter + return self + + +class _LiteRenderer(_AbstractRenderer): + """MNE 3D renderer backed by pyvista-js.""" + + # not "notebook": that backend has VTK, a filesystem and OS threads + _kind = "jupyterlite_notebook" + + def __init__( + self, + fig=None, + size=(600, 600), + bgcolor="black", + *, + name=None, + show=False, + shape=(1, 1), + notebook=None, + smooth_shading=True, + 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 + _validate_type(fig, (None, _LiteFigure), "fig") + if fig is not None: # plot_alignment(fig=...) composites into it + self._figure = fig + return + self._figure = _LiteFigure()._init(pv.Plotter()) + _lite_live_plotters.append(weakref.ref(self.plotter)) + while len(_lite_live_plotters) > _LITE_MAX_LIVE_SCENES: + _lite_release_plotter(_lite_live_plotters[0]()) + self.plotter.background_color = _rgb(bgcolor) + # one light per axis direction, since a vtk.js light only lights what + # faces it, each dim enough that a surface facing two does not blow out + for position in np.vstack([np.eye(3), -np.eye(3)]) * 300.0: + self.plotter.add_light( + pv.Light( + position=tuple(position), focal_point=(0.0,) * 3, intensity=0.4 + ) + ) + + @property + def plotter(self): + return self._figure.plotter + + @property + def figure(self): + return self._figure # 20_source_alignment passes this to set_3d_view + + 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 + ): + """Return (rr, tris) of a glyph along +x, sized like _pyvista.py's templates.""" + # half of VTK's side count: these get stamped at every sensor + n_side = 8 if resolution is None else max(3, int(resolution) // 2) + if kind in ("sphere", "oct"): + # level 1 is the octahedron vtkPlatonicSolidSource draws; level 3 + # is round enough for dig points at 66 vertices + rr, tris = _tessellate_sphere(1 if kind == "oct" else 3) + return rr * (0.5 if radius is None else radius), tris + if kind == "arrow": # vtkArrowSource: 0.03 shaft under a 0.1 tip from 0.65 + return _lite_revolve([(0, 0.03), (0.65, 0.03), (0.65, 0.1), (1, 0)], 12) + height = 1.0 if height is None else float(height) + if kind == "cone": # base at the position, apex along +x + return _lite_revolve( + [(0, 0.15 if radius is None else radius), (height, 0)], n_side + ) + assert kind == "cylinder", kind + radius = 0.1 if radius is None else float(radius) + rr, tris = _lite_revolve([(-height / 2, radius), (height / 2, radius)], n_side) + if center is not None: + # _cylinder_geom builds along y and turns 90 degrees about z, so the + # (EEG electrode) offset it is given lands at (-cy, cx, cz) + rr = rr + np.array([-center[1], center[0], center[2]], float) + return rr, tris + + def _tile(self, rr, tris, positions, scales=None, rots=None, axis_scales=None): + """Stamp a template at every position into one mesh, as VTK's glyph filter does. + + One mesh per position runs the tab out of memory for an oct-6 source + space. ``axis_scales`` stretches the template along x alone (tubes). + """ + rr = np.asarray(rr, float) + positions = np.atleast_2d(np.asarray(positions, float))[:, :3] + n_pos = len(positions) + idx = np.arange(n_pos) + points = np.repeat(rr[np.newaxis], n_pos, axis=0) + if axis_scales is not None: + axis_scales = np.atleast_1d(np.asarray(axis_scales, float)) + points[:, :, 0] *= axis_scales[idx % len(axis_scales)][:, np.newaxis] + if scales is not None: + scales = np.atleast_1d(np.asarray(scales, float)) + points *= scales[idx % len(scales)][:, np.newaxis, np.newaxis] + if rots is not None: + points = np.einsum( + "nij,nkj->nki", np.asarray(rots)[idx % len(rots)], points + ) + points += positions[:, np.newaxis] + offsets = (idx * len(rr))[:, np.newaxis, np.newaxis] + tris = np.asarray(tris, int)[np.newaxis] + offsets + return points.reshape(-1, 3), tris.reshape(-1, 3) + + def _add(self, points, tris, color, opacity=1.0): + """Draw one solid-color mesh and return MNE's (actor, mesh) pair.""" + # float32 halves the WASM cost, and vtk.js is single precision anyway; + # the faces go over flat because vtk.js reads one VTK cell array + mesh = pv.PolyData( + points=np.asarray(points, np.float32), faces=_vtk_faces(tris).ravel() + ) + actor = self.plotter.add_mesh( + mesh, + color=_rgb(color), + opacity=1.0 if opacity is None else float(opacity), + smooth_shading=True, + ) + return actor, mesh + + # -- drawing ------------------------------------------------------------ + # The signatures follow _PyVistaRenderer's so positional calls bind alike; + # scalars, colormaps, culling, normals and names are accepted and ignored. + def mesh( + self, + x, + y, + z, + triangles, + color, + opacity=1.0, + *, + backface_culling=False, + scalars=None, + colormap=None, + vmin=None, + vmax=None, + interpolate_before_map=True, + representation="surface", + line_width=1.0, + normals=None, + name=None, + **kwargs, + ): + points = np.column_stack([np.ravel(x), np.ravel(y), np.ravel(z)]) + return self._add(points, triangles, color, opacity) + + def surface( + self, + surface, + color=None, + opacity=1.0, + vmin=None, + vmax=None, + colormap=None, + normalized_colormap=False, + scalars=None, + backface_culling=False, + *, + name=None, + ): + return self._add(surface["rr"], surface["tris"], color, opacity) + + def sphere( + self, + center, + color=None, + scale=1.0, + opacity=1.0, + resolution=8, + backface_culling=False, + radius=None, + ): + # _pyvista.py glyphs a radius-0.5 sphere by `scale`, or `radius` by 1 + center = np.atleast_2d(np.asarray(center, float)) + if not len(center): + return None, None + rr, tris = self._glyph_template( + "sphere", radius=0.5 * scale if radius is None else radius + ) + return self.instanced_mesh(rr, tris, center, colors=color, opacity=opacity) + + def tube( + self, + origin, + destination, + radius=0.001, + color="white", + scalars=None, + vmin=None, + vmax=None, + colormap="RdBu", + normalized_colormap=False, + reverse_lut=False, + opacity=None, + ): + origin = np.atleast_2d(np.asarray(origin, float))[:, :3] + destination = np.atleast_2d(np.asarray(destination, float))[:, :3] + vec = destination - origin + length = np.linalg.norm(vec, axis=1) + keep = length > 0 + if not keep.any(): + return None, None + vec, length, origin = vec[keep], length[keep], origin[keep] + # a unit cylinder stretched along its axis to each segment + rr, tris = self._glyph_template("cylinder", radius=radius, resolution=20) + rots = _find_vector_rotation(np.array([1.0, 0, 0]), vec / length[:, np.newaxis]) + points, faces = self._tile( + rr, tris, origin + vec / 2, rots=rots, axis_scales=length + ) + return self._add(points, faces, color, opacity) + + def quiver3d( + self, + x, + y, + z, + u, + v, + w, + color, + scale, + mode, + *, + glyph_height=None, + glyph_center=None, + glyph_resolution=None, + opacity=1.0, + scale_mode="none", + scalars=None, + colormap=None, + backface_culling=False, + glyph_radius=0.15, + solid_transform=None, + clim=None, + ): + _check_option("mode", mode, ALLOWED_QUIVER_MODES) + _check_option("scale_mode", scale_mode, ("none", "scalar", "vector")) + x, y, z, u, v, w = (np.ravel(np.asarray(q, float)) for q in (x, y, z, u, v, w)) + n_pos = len(x) + if not n_pos: + return None, None + idx = np.arange(n_pos) + dirs = np.column_stack([q[idx % len(q)] for q in (u, v, w)]) + norms = np.linalg.norm(dirs, axis=1) + dirs[norms == 0] = (1.0, 0.0, 0.0) + dirs /= np.where(norms == 0, 1.0, norms)[:, np.newaxis] + # _pyvista.py sizes "arrow" by the scalars (plot_alignment's axes rely + # on it) and "2darrow" by the vector, whatever scale_mode says + scale_mode = {"arrow": "scalar", "2darrow": "vector"}.get(mode, scale_mode) + if scale_mode == "scalar": + values = np.ones(n_pos) if scalars is None else np.ravel(scalars) + sizes = scale * np.asarray(values, float)[idx % len(values)] + else: + sizes = scale * norms if scale_mode == "vector" else float(scale) + # the templates _pyvista.py feeds the glyph filter, with `scale` as its + # factor; the unit "oct" is sized by solid_transform (MRI fiducials), + # and "2darrow" borrows the 3D arrow since vtk.js has no 2D glyphs + template_kw = dict( + oct=dict(radius=1.0), + sphere=dict(radius=0.5), + cylinder=dict( + radius=glyph_radius, + height=glyph_height, + center=glyph_center, + resolution=glyph_resolution, + ), + cone=dict( + radius=glyph_radius, height=glyph_height, resolution=glyph_resolution + ), + ).get(mode, dict()) + rr, tris = self._glyph_template( + "arrow" if mode == "2darrow" else mode, **template_kw + ) + if solid_transform is not None: + solid_transform = np.asarray(solid_transform, float) + rr = rr @ solid_transform[:3, :3].T + solid_transform[:3, 3] + # spheres look the same however turned, and the fiducial "oct" is + # always asked for along +x, so neither needs rotations + rots = None + if mode not in ("sphere", "oct"): + rots = _find_vector_rotation(np.array([1.0, 0, 0]), dirs) + points, faces = self._tile(rr, tris, np.column_stack([x, y, z]), sizes, rots) + return self._add(points, faces, color, opacity) + + def instanced_mesh( + self, + rr, + tris, + positions, + quats=None, + colors=None, + scales=None, + opacity=1.0, + backface_culling=False, + *, + name=None, + ): + """Stamp the template at every position, one merged mesh per distinct color. + + vtk.js cannot color per instance, so several colors give a list of + actors. The cloud handed back second is what _3d.py writes channel + names onto, and is always one object. + """ + positions = np.atleast_2d(np.asarray(positions, float))[:, :3] + n_pos = len(positions) + cloud = pv.PolyData(points=positions) + cloud.field_data = dict() + if not n_pos: + return None, cloud + rots = None + if quats is not None: + quats = np.atleast_2d(np.asarray(quats, float)) + assert quats.shape[-1] == 3, quats.shape # (w, x, y, z) would misread + rots = quat_to_rot(quats) + idx = np.arange(n_pos) + if colors is not None and np.ndim(colors) > 1: + colors = np.asarray(colors, float)[idx % len(colors)] + uniq, inverse = np.unique(colors, axis=0, return_inverse=True) + groups = [(uniq[k], idx[np.ravel(inverse) == k]) for k in range(len(uniq))] + else: + groups = [(colors, idx)] + actors = list() + for color, sel in groups: + group_scales = None + if scales is not None: + scales = np.atleast_1d(np.asarray(scales, float)) + group_scales = scales[sel % len(scales)] + group_rots = None if rots is None else rots[sel % len(rots)] + points, faces = self._tile( + rr, tris, positions[sel], group_scales, group_rots + ) + actors.append( + self._add(points, faces, color, _lite_opacity(color, opacity))[0] + ) + return (actors[0] if len(actors) == 1 else actors), cloud + + def text2d( + self, + x_window, + y_window, + text, + size=14, + color="white", + justification=None, + font_file=None, + ): + if justification is not None or font_file is not None: + _lite_unsupported("Justified text and custom fonts") + return _lite_add_text(self.plotter, text, (x_window, y_window), size, color) + + def remove_mesh(self, mesh_data): + # the renderer keeps the actor dict and the plotter a dict pointing at + # it, so drop both; instanced_mesh hands back one actor per color + actor, _ = mesh_data + actors = actor if isinstance(actor, list) else [actor] + plotter = self.plotter + plotter.actors[:] = [a for a in plotter.actors if a["actor"] not in actors] + plotter._renderer.actors[:] = [ + a for a in plotter._renderer.actors if a not in actors + ] + + # -- nothing to do in a browser ----------------------------------------- + def set_interaction(self, interaction): + pass # vtk.js ships one trackball style + + def _update(self): + pass # the page paints after the cell finishes + + def _window_close_connect(self, func, *, after=True): + pass # an output cell has no close event + + def text3d(self, x, y, z, text, scale, color="white"): + pass # no camera-facing 3D text, so sensors go unlabeled + + def close(self): + _lite_release_plotter(self.plotter) + + # -- things pyvista-js cannot do ---------------------------------------- + def contour(self, *args, **kwargs): + _lite_unsupported("Drawing contours") # one color would mislead + + def scalarbar(self, *args, **kwargs): + _lite_unsupported("Drawing a scalar bar") + + def legend(self, *args, **kwargs): + _lite_unsupported("Drawing a legend") + + def subplot(self, *args, **kwargs): + _lite_unsupported("Subplots") + + 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") + + def screenshot(self, mode="rgb", filename=None): + return _take_3d_screenshot(self._figure, mode=mode, filename=filename) + + # -- camera ------------------------------------------------------------- + def get_camera(self, *, rigid=None): + return _lite_get_view(self.plotter) + + def set_camera( + self, + azimuth=None, + elevation=None, + distance=None, + focalpoint=None, + roll=None, + *, + rigid=None, + update=True, + ): + # distance, focalpoint and roll go unused: vtk.js frames the scene + _lite_set_view(self.plotter, azimuth, elevation) + + +# -- the module surface renderer.py expects of a 3D backend ----------------- +_Renderer = _LiteRenderer +_testing_context = nullcontext # nothing draws differently under test + + +def _set_3d_view( + figure, + azimuth=None, + elevation=None, + focalpoint=None, + distance=None, + roll=None, + rigid=None, + update=True, +): + _lite_set_view(figure.plotter, azimuth, elevation) + + +def _set_3d_title(figure, title, size=16, *, color="white", position="upper_left"): + if isinstance(position, str): + _check_option("position", position, sorted(_TITLE_POSITIONS)) + position = _TITLE_POSITIONS[position] + return _lite_add_text(figure.plotter, title, position, size, color) + + +def _check_3d_figure(figure): + _validate_type(figure, _LiteFigure, "figure") + + +def _take_3d_screenshot(figure, mode="rgb", filename=None): + # pyvista-js can, but only by driving a headless browser from outside + _lite_unsupported("Taking a screenshot") + + +def _clear_3d_figure(figure): + figure.plotter.clear() + + +def _close_3d_figure(figure): + _lite_release_plotter(figure.plotter) # there is no window to close + + +def _close_all(): + while _lite_live_plotters: + _lite_release_plotter(_lite_live_plotters[-1]()) diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index 9a926c0de51..0f611535953 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -62,6 +62,7 @@ _alpha_blend_background, _get_colormap_from_array, _init_mne_qtapp, + _vtk_faces, ) try: @@ -438,7 +439,7 @@ def mesh( **kwargs, ): vertices = np.c_[x, y, z].astype(float) - triangles = np.c_[np.full(len(triangles), 3), triangles] + triangles = _vtk_faces(triangles) mesh = PolyData(vertices, triangles) return self.polydata( mesh=mesh, @@ -475,8 +476,7 @@ def contour( colormap = _get_colormap_from_array(colormap, normalized_colormap) vertices = np.array(surface["rr"]) triangles = np.array(surface["tris"]) - n_triangles = len(triangles) - triangles = np.c_[np.full(n_triangles, 3), triangles] + triangles = _vtk_faces(triangles) mesh = PolyData(vertices, triangles) mesh.point_data["scalars"] = scalars # Leave the contour filter connected to the mesh instead of computing the @@ -553,7 +553,7 @@ def surface( normals = surface.get("nn", None) vertices = np.array(surface["rr"]) triangles = np.array(surface["tris"]) - triangles = np.c_[np.full(len(triangles), 3), triangles] + triangles = _vtk_faces(triangles) mesh = PolyData(vertices, triangles) colormap = _get_colormap_from_array(colormap, normalized_colormap) if scalars is not None: @@ -744,7 +744,7 @@ def instanced_mesh( *, name=None, ): - faces = np.c_[np.full(len(tris), 3), tris] + faces = _vtk_faces(tris) geom = PolyData(np.asarray(rr, float), faces) _compute_normals(geom) diff --git a/mne/viz/backends/_utils.py b/mne/viz/backends/_utils.py index 40adf1d8f84..3edc61c8505 100644 --- a/mne/viz/backends/_utils.py +++ b/mne/viz/backends/_utils.py @@ -29,7 +29,13 @@ VALID_3D_BACKENDS = ( "pyvistaqt", # default 3d backend "notebook", + "jupyterlite_notebook", ) +# 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 +# displays inside a browser kernel, so picking it on a desktop that happens to +# have pyvista-js installed would quietly produce figures nothing can show. +_AUTO_3D_BACKENDS = ("pyvistaqt", "notebook") ALLOWED_QUIVER_MODES = ("2darrow", "arrow", "cone", "cylinder", "sphere", "oct") _ICONS_PATH = Path(__file__).parents[2] / "icons" @@ -50,6 +56,16 @@ def _get_colormap_from_array( return cmap +def _vtk_faces(tris): + """Return triangles as the (n, 4) face array VTK and vtk.js both accept. + + Each row is ``(3, i, j, k)``: the leading 3 is the vertex count the VTK cell + format expects ahead of every triangle. + """ + tris = np.asarray(tris) + return np.c_[np.full(len(tris), 3), tris] + + def _check_color(color): from matplotlib.colors import colorConverter diff --git a/mne/viz/backends/renderer.py b/mne/viz/backends/renderer.py index ed9261bbd76..c3acdcab528 100644 --- a/mne/viz/backends/renderer.py +++ b/mne/viz/backends/renderer.py @@ -22,7 +22,7 @@ ) from .._3d import _get_3d_option from ..utils import safe_event -from ._utils import VALID_3D_BACKENDS +from ._utils import _AUTO_3D_BACKENDS, VALID_3D_BACKENDS MNE_3D_BACKEND = None MNE_3D_BACKEND_TESTING = False @@ -31,6 +31,7 @@ _backend_name_map = dict( pyvistaqt="._qt", notebook="._notebook", + jupyterlite_notebook="._lite", ) backend = None @@ -71,7 +72,8 @@ 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'`` and ``'notebook'``). + backend (``'pyvistaqt'``, ``'notebook'`` and + ``'jupyterlite_notebook'``). .. versionchanged:: 0.24 The ``'pyvista'`` backend was renamed ``'pyvistaqt'``. @@ -87,6 +89,15 @@ 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 + 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, while + :class:`mne.viz.Brain`, :func:`plot_evoked_field` and + :func:`snapshot_brain_montage` do not. On a desktop the other two are better + in every way, so it is never selected automatically. + This table shows the capabilities of each backend ("✓" for full support, and "-" for partial support): @@ -169,7 +180,7 @@ def _get_3d_backend(): MNE_3D_BACKEND = get_config(key="MNE_3D_BACKEND", default=None) if MNE_3D_BACKEND is None: # try them in order errors = dict() - for name in VALID_3D_BACKENDS: + for name in _AUTO_3D_BACKENDS: try: _reload_backend(name) except ImportError as exc: @@ -210,7 +221,7 @@ def use_3d_backend(backend_name): # numpydoc ignore=YD01 Parameters ---------- - backend_name : {'pyvistaqt', 'notebook'} + backend_name : {'pyvistaqt', 'notebook', 'jupyterlite_notebook'} The 3d backend to use in the context. """ old_backend = set_3d_backend(backend_name) diff --git a/mne/viz/backends/tests/test_renderer.py b/mne/viz/backends/tests/test_renderer.py index 9e67955bf6e..802db2ab79a 100644 --- a/mne/viz/backends/tests/test_renderer.py +++ b/mne/viz/backends/tests/test_renderer.py @@ -5,6 +5,7 @@ import os import platform import sys +from contextlib import nullcontext import numpy as np import pytest @@ -18,6 +19,13 @@ from mne.viz.backends.renderer import _get_renderer +def _unsupported(renderer): + """Return a context for what the browser backend says it cannot draw.""" + if renderer.get_3d_backend() == "jupyterlite_notebook": + return pytest.raises(NotImplementedError, match="browser") + return nullcontext() + + @pytest.mark.parametrize( "backend", [ @@ -57,13 +65,14 @@ def test_3d_functions(renderer): distance=None, ) renderer.set_3d_title(figure=fig, title="foo") - renderer.backend._take_3d_screenshot(figure=fig) - assert len(fig.plotter.renderer.actors) > 0 + with _unsupported(renderer): + renderer.backend._take_3d_screenshot(figure=fig) + assert len(fig.plotter.actors) > 0 renderer.clear_3d_figure(fig) - assert len(fig.plotter.renderer.actors) == 0 + assert len(fig.plotter.actors) == 0 # the (empty) figure can be reused renderer.backend._Renderer(fig=fig).sphere(np.array([0.0, 0.0, 0.0]), "w", 1.0) - assert len(fig.plotter.renderer.actors) > 0 + assert len(fig.plotter.actors) > 0 renderer.close_3d_figure(fig) renderer.close_all_3d_figures() @@ -130,12 +139,11 @@ def test_3d_backend(renderer): rend.remove_mesh(mesh_data) # use contour - rend.contour( - surface=ct_surface, scalars=ct_scalars, contours=ct_levels, kind="line" - ) - rend.contour( - surface=ct_surface, scalars=ct_scalars, contours=ct_levels, kind="tube" - ) + for kind in ("line", "tube"): + with _unsupported(renderer): + rend.contour( + surface=ct_surface, scalars=ct_scalars, contours=ct_levels, kind=kind + ) # use sphere rend.sphere(center=sph_center, color=sph_color, scale=sph_scale, radius=1.0) @@ -159,17 +167,6 @@ def test_3d_backend(renderer): rend.quiver3d(mode="foo", **kwargs) # use instanced_mesh - # VTK must interpret our wxyz quaternions with the same convention as - # quat_to_rot, otherwise instanced sensors render with wrong rotations - from vtkmodules.vtkCommonCore import vtkMath - - from mne.viz.backends._pyvista import _quat_to_vtk_wxyz - - quat = np.array([0.1, -0.2, 0.3]) - mat = [[0.0] * 3 for _ in range(3)] - vtkMath.QuaternionToMatrix3x3(_quat_to_vtk_wxyz(quat[np.newaxis])[0], mat) - assert_allclose(mat, quat_to_rot(quat), atol=1e-12) - inst_positions = np.array([[0.0, 0.0, 0.0], [tet_size, 0.0, 0.0]]) inst_quats = np.array([rot_to_quat(np.eye(3)), rot_to_quat(np.eye(3))]) inst_colors = np.array([[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]]) @@ -181,9 +178,12 @@ def test_3d_backend(renderer): colors=inst_colors, ) # colors can be updated in place (e.g. for future sensor - # highlighting/hover) without rebuilding the actor or its geometry - inst_cloud.point_data["colors"][0] = [0, 0, 255, 255] - inst_cloud.Modified() + # 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": + inst_cloud.point_data["colors"][0] = [0, 0, 255, 255] + inst_cloud.Modified() # use tube rend.tube(origin=np.array([[0, 0, 0]]), destination=np.array([[0, 1, 0]])) @@ -194,24 +194,27 @@ def test_3d_backend(renderer): ) # scalar bar - rend.scalarbar(source=tube, title="Scalar Bar", bgcolor=[1, 1, 1]) + with _unsupported(renderer): + rend.scalarbar(source=tube, title="Scalar Bar", bgcolor=[1, 1, 1]) # use text - rend.text2d( - x_window=txt_x, - y_window=txt_y, - text=txt_text, - size=txt_size, - justification="right", - ) + with _unsupported(renderer): + rend.text2d( + x_window=txt_x, + y_window=txt_y, + text=txt_text, + size=txt_size, + justification="right", + ) # test font_file passthrough with a real font from matplotlib font_path = findfont("serif") - rend.text2d( - x_window=txt_x + 0.1, - y_window=txt_y + 0.1, - text="font test", - font_file=font_path, - ) + with _unsupported(renderer): + rend.text2d( + x_window=txt_x + 0.1, + y_window=txt_y + 0.1, + text="font test", + font_file=font_path, + ) rend.text3d(x=0, y=0, z=0, text=txt_text, scale=1.0) rend.set_camera( azimuth=180.0, elevation=90.0, distance=cam_distance, focalpoint=center @@ -219,8 +222,23 @@ def test_3d_backend(renderer): rend.show() -def test_renderer_internal_helpers(renderer): +def test_quat_to_vtk_wxyz(): + """Test that VTK reads our quaternions the way quat_to_rot does. + + Otherwise instanced sensors render with wrong rotations. + """ + vtkMath = pytest.importorskip("vtkmodules.vtkCommonCore").vtkMath + from mne.viz.backends._pyvista import _quat_to_vtk_wxyz + + quat = np.array([0.1, -0.2, 0.3]) + mat = [[0.0] * 3 for _ in range(3)] + vtkMath.QuaternionToMatrix3x3(_quat_to_vtk_wxyz(quat[np.newaxis])[0], mat) + assert_allclose(mat, quat_to_rot(quat), atol=1e-12) + + +def test_renderer_internal_helpers(renderer_pyvistaqt): """Test internal helper methods used by mne.gui.coregistration.""" + renderer = renderer_pyvistaqt rend = renderer.create_3d_figure((300, 300), scene=False) # _remove_actors accepts a single actor or a list of actors @@ -273,9 +291,11 @@ def test_renderer(renderer, monkeypatch): cmd = [ sys.executable, "-uc", - "import mne; mne.viz.create_3d_figure((800, 600), show=True); " + "import sys, mne; mne.viz.create_3d_figure((800, 600), show=True); " "backend = mne.viz.get_3d_backend(); " - f"assert backend == {repr(backend)}, 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", ] monkeypatch.setenv("MNE_3D_BACKEND", backend) run_subprocess(cmd) @@ -283,7 +303,7 @@ 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' and 'notebook'" + match = "Allowed values are 'pyvistaqt', 'notebook', and 'jupyterlite_notebook'" with pytest.raises(ValueError, match=match): set_3d_backend("invalid") @@ -334,3 +354,171 @@ def test_3d_warning(renderer_pyvistaqt, monkeypatch): ) monkeypatch.setattr(_pyvista, "_GPU_REPORT", None) assert not _pyvista._is_osmesa(plotter) + + +# -- jupyterlite_notebook (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) +_TRIS = np.array([[0, 1, 2], [0, 2, 3]]) + + +def _scene(rend): + """Return the scene as vtk.js will receive it.""" + return rend.plotter._renderer._build_scene_data() + + +def test_lite_camera(renderer_lite): + """Test the camera lands where _pyvista._set_3d_view would put it.""" + rend = renderer_lite._get_renderer() + assert rend.get_camera()[2:4] == (0.0, 0.0) # nothing set: vtk.js frames it + rend.set_camera(azimuth=90, elevation=90) # plot_alignment's view is from +y + assert_allclose(_scene(rend)["camera"]["viewVector"], [0, 1, 0], atol=1e-12) + rend.set_camera(elevation=0) # one angle keeps the other; poles flip view up + assert_allclose(_scene(rend)["camera"]["viewVector"], [0, 0, 1], atol=1e-12) + assert _scene(rend)["camera"]["viewUp"] == [0, 1, 0] + rend.set_camera(azimuth=180) + assert rend.get_camera()[2:4] == pytest.approx((180.0, 0.0)) + renderer_lite.set_3d_view(rend.scene(), elevation=45) + assert rend.get_camera()[2:4] == pytest.approx((180.0, 45.0)) + + +def test_lite_primitives(renderer_lite): + """Test each primitive draws the geometry asked for, sized as PyVista's.""" + rend = renderer_lite._get_renderer(bgcolor="white") + _, mesh = rend.mesh(*_RR.T, _TRIS, color="red", opacity=0.5) + assert_allclose(mesh.points, _RR, atol=1e-6) + # faces reach vtk.js as one flat cell array; rows would draw nothing at all + assert _scene(rend)["actors"][-1]["source"]["polys"] == [3, 0, 1, 2, 3, 0, 2, 3] + _, mesh = rend.surface(dict(rr=_RR, tris=_TRIS), color="#0000ff") + assert_allclose(mesh.points, _RR, atol=1e-6) + # scale sizes a radius-0.5 sphere, and an explicit radius is used as-is + for kwargs, want in ((dict(scale=0.1), 0.05), (dict(scale=1, radius=0.02), 0.02)): + rend.sphere(np.array([[1.0, 0, 0]]), "green", **kwargs) + pts = rend.plotter.actors[-1]["mesh"].points - [1, 0, 0] + assert np.linalg.norm(pts, axis=1).max() == pytest.approx(want, abs=1e-6) + # tubes span origin to destination, stretched along their axis alone, and + # default to white like PyVista's (gray would vanish into plot_alignment) + _, mesh = rend.tube([[0.0, 0, 0]] * 2, [[1.0, 0, 0], [0, 2.0, 0]], radius=0.01) + first, second = mesh.points.reshape(2, -1, 3) + assert (first[:, 0].max(), second[:, 1].max()) == pytest.approx((1.0, 2.0)) + assert np.linalg.norm(first[:, 1:], axis=1).max() == pytest.approx(0.01) + assert np.linalg.norm(second[:, [0, 2]], axis=1).max() == pytest.approx(0.01) + actor = rend.plotter.actors[-1] + assert (actor["color"], actor["opacity"]) == ((1.0, 1.0, 1.0), 1.0) + assert len(rend.plotter.actors) == 5 + + +@pytest.mark.parametrize("mode", ("arrow", "cone", "cylinder", "sphere")) +def test_lite_glyphs(mode, renderer_lite): + """Test glyph templates match VTK's and are turned onto their direction.""" + rend = renderer_lite._get_renderer() + if mode == "cylinder": # the EEG offset is given in _cylinder_geom's pre-turn frame + rr, _ = rend._glyph_template(mode, 0.5, 3.0, center=(0.0, -0.75, 0.0)) + assert_allclose(rr.min(axis=0), [-0.75, -0.5, -0.5], atol=1e-12) + assert_allclose(rr.max(axis=0), [2.25, 0.5, 0.5], atol=1e-12) + # glyphs along +x, +y and -x (antiparallel to the template, which has no + # rotation axis), sized by their scalars as plot_alignment's axes rely on + dirs = np.array([[1, 0, 0], [0, 1, 0], [-1, 0, 0]], float) + sizes = np.array([1.0, 2.0, 2.0]) + _, mesh = rend.quiver3d( + *np.zeros((3, 3)), + *dirs.T, + color="red", + scale=2.0, + mode=mode, + scale_mode="scalar", + scalars=sizes / 2, + ) + span, radius = dict( + arrow=((0, 1), 0.1), # vtkArrowSource: 0.1 tip over a 0.03 shaft + cone=((0, 1), 0.15), + cylinder=((-0.5, 0.5), 0.15), + sphere=((-0.5, 0.5), 0.5), + )[mode] + for pts, d, size in zip(mesh.points.reshape(3, -1, 3), dirs, sizes): + along = pts @ d + across = np.linalg.norm(pts - np.outer(along, d), axis=1) + assert (along.min(), along.max()) == pytest.approx( + np.array(span) * size, abs=1e-5 + ) + assert across.max() == pytest.approx(radius * size, abs=1e-5) + if mode == "arrow": + assert across[along < 0.6 * size].max() == pytest.approx( + 0.03 * size, abs=1e-5 + ) + + +def test_lite_instanced_mesh(renderer_lite): + """Test instances merge per color, alpha becomes opacity, and the cloud.""" + rend = renderer_lite._get_renderer() + positions = np.array([[0.0, 0, 0], [1.0, 0, 0], [2.0, 0, 0]]) + colors = np.array([[1.0, 0, 0, 0.25], [0, 1.0, 0, 1.0], [1.0, 0, 0, 0.25]]) + quats = np.zeros((3, 3)) # identity, in MNE's (x, y, z) convention + actors, cloud = rend.instanced_mesh( + _RR, _TRIS, positions, quats, colors, opacity=0.5 + ) + # vtk.js has no per-instance color: one solid mesh per distinct color, with + # its alpha (how plot_alignment fades MEG coils) folded into the opacity + got = {a["color"]: a["opacity"] for a in actors} + assert got == {(1.0, 0.0, 0.0): 0.125, (0.0, 1.0, 0.0): 0.5} + assert_allclose(cloud.points, positions) # what _3d.py hangs names on + cloud.field_data["ch_names"] = np.array(["a", "b", "c"]) + # one color hands back the actor itself, as sphere's callers expect + actor, _ = rend.instanced_mesh(_RR, _TRIS, positions[:1], colors=(0, 0, 1.0)) + assert not isinstance(actor, list) + actor, cloud = rend.instanced_mesh(_RR, _TRIS, np.zeros((0, 3))) + assert actor is None and cloud.points.shape == (0, 3) + with pytest.raises(AssertionError, match=r"\(3, 4\)"): # (w, x, y, z) + rend.instanced_mesh(_RR, _TRIS, positions, np.zeros((3, 4)), colors) + rend.remove_mesh((actors, cloud)) # takes the whole color-split set out + assert len(rend.plotter.actors) == len(_scene(rend)["actors"]) == 1 + + +def test_lite_scenes(renderer_lite): + """Test figures compose, old scenes are released, and closing frees them.""" + lite = renderer_lite.backend + first = renderer_lite._get_renderer() + assert isinstance(first.scene(), Figure3D) and first.figure is first.scene() + second = renderer_lite._get_renderer(fig=first.scene()) # plot_alignment(fig=) + second.sphere(np.zeros((1, 3)), "red", 1.0) + assert len(first.plotter.actors) == 1 + with pytest.raises(TypeError, match="instance of None or _LiteFigure"): + renderer_lite._get_renderer(fig=first.plotter) + # nothing in a notebook closes figures, so only the newest few stay live + kept = [ + renderer_lite._get_renderer() for _ in range(lite._LITE_MAX_LIVE_SCENES + 2) + ] + live = [ref() for ref in lite._lite_live_plotters] + assert live == [k.plotter for k in kept[-lite._LITE_MAX_LIVE_SCENES :]] + assert len(first.plotter.actors) == 0 + kept[-1].sphere(np.zeros((1, 3)), "red", 1.0) + renderer_lite.clear_3d_figure(kept[-1].scene()) # cleared, still usable + kept[-1].sphere(np.zeros((1, 3)), "red", 1.0) + assert len(kept[-1].plotter.actors) == 1 + renderer_lite.close_all_3d_figures() + assert lite._lite_live_plotters == [] and len(kept[-1].plotter.actors) == 0 + text = renderer_lite.set_3d_title(kept[-1].scene(), "t", 20, position="lower_right") + assert (text.input, text.position, text.prop.font_size) == ("t", (0.65, 0.05), 20) + + +def test_lite_notebook_kernel(renderer_lite, nbexec): + """Test drawing through _get_renderer in a live kernel serializes for vtk.js.""" + import json + + import numpy as np + + from mne.viz.backends import renderer + + renderer.set_3d_backend("jupyterlite_notebook") + 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") + renderer.set_3d_view(rend.scene(), azimuth=90, elevation=90) + scene = rend.plotter._renderer._build_scene_data() + source = scene["actors"][0]["source"] + np.testing.assert_allclose(np.reshape(source["points"], (-1, 3)), rr, atol=1e-6) + assert source["polys"] == [3, 0, 1, 2, 3, 0, 2, 3] + np.testing.assert_allclose(scene["camera"]["viewVector"], [0, 1, 0], atol=1e-12) + html = rend.plotter.generate_standalone_html() # what the page will run + assert json.dumps(source["points"]).replace(" ", "") in html.replace(" ", "") diff --git a/mne/viz/backends/tests/test_utils.py b/mne/viz/backends/tests/test_utils.py index cabdccb6b62..a9529f29cc8 100644 --- a/mne/viz/backends/tests/test_utils.py +++ b/mne/viz/backends/tests/test_utils.py @@ -11,6 +11,7 @@ import numpy as np import pytest +from numpy.testing import assert_array_equal from mne import create_info from mne.io import RawArray @@ -22,6 +23,7 @@ _pixmap_to_ndarray, _qt_block, _qt_is_dark, + _vtk_faces, ) from mne.viz.utils import _is_dark @@ -68,6 +70,20 @@ def _assert_correct_darkness(widget, want_dark): assert dark == want_dark, f"{widget} pixmap dark={dark} want_dark={want_dark}" +def test_vtk_faces(): + """Test building the VTK cell array both 3D renderers draw from.""" + tris = np.array([[0, 1, 2], [0, 2, 3]]) + faces = _vtk_faces(tris) + assert faces.shape == (2, 4) + # each row is the vertex count followed by the triangle + assert_array_equal(faces[:, 0], 3) + assert_array_equal(faces[:, 1:], tris) + # an empty surface must stay empty rather than raise + assert _vtk_faces(np.zeros((0, 3), int)).shape == (0, 4) + # and a list is as good as an array + assert_array_equal(_vtk_faces([[0, 1, 2]]), [[3, 0, 1, 2]]) + + @pytest.mark.pgtest @pytest.mark.parametrize("theme", ("auto", "light", "dark")) def test_theme_colors(pg_backend, theme, monkeypatch, tmp_path): diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index 1724d26c941..3b6aa1c5c6b 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -185,6 +185,7 @@ def test_plot_evoked_field(renderer): """Test plotting evoked field.""" evoked = read_evokeds(evoked_fname, condition="Left Auditory", baseline=(-0.2, 0.0)) evoked.pick(evoked.ch_names[::10]) # speed + lite = renderer.get_3d_backend() == "jupyterlite_notebook" for t, n_contours, up in zip(["meg", None], [21, 0], [2, 1]): with pytest.warns(RuntimeWarning, match="projection"), catch_logging() as log: maps = make_field_map( @@ -203,8 +204,14 @@ def test_plot_evoked_field(renderer): assert "Upsampling" not in log else: assert "Upsampling" in log + if lite: # field maps need contours, which the browser cannot color + with pytest.raises(NotImplementedError, match="browser"): + evoked.plot_field(maps, time=0.1, n_contours=n_contours) + continue evoked.plot_field(maps, time=0.1, n_contours=n_contours) renderer.backend._close_all() + if lite: # and Brain needs the dock widgets the browser does not draw + return # Test plotting inside an existing Brain figure. Check that units are taken into # account. @@ -331,7 +338,7 @@ def test_plot_evoked_field_notebook(renderer_notebook, nbexec): def _assert_n_actors(fig, renderer, n_actors): __tracebackhide__ = True assert isinstance(fig, Figure3D) - assert len(fig.plotter.renderer.actors) == n_actors + assert len(fig.plotter.actors) == n_actors @pytest.mark.slowtest # can be slow on OSX @@ -571,7 +578,12 @@ def test_plot_alignment_meg(renderer, system): eeg=False, sensor_colors=dict(meg=rng.random((n_meg, 4))), ) - _assert_n_actors(fig2, renderer, n_shapes + 2) + # ... except in the browser, which cannot color per instance and so + # draws one solid mesh per distinct color + if renderer.get_3d_backend() == "jupyterlite_notebook": + _assert_n_actors(fig2, renderer, n_meg + 2) + else: + _assert_n_actors(fig2, renderer, n_shapes + 2) # check error raising for wrong meg value: info = read_info(evoked_fname) @@ -593,18 +605,16 @@ def test_plot_alignment_meg_coil_orientation(renderer, monkeypatch): so the per-instance quaternion must encode the full rotation from ``_loc_to_coil_trans``, not just the coil normal direction. """ - from mne.viz.backends._pyvista import _PyVistaRenderer - info = read_info(evoked_fname) info = pick_info(info, pick_types(info, meg="grad")[:4]) calls = list() - orig = _PyVistaRenderer.instanced_mesh + orig = renderer.backend._Renderer.instanced_mesh def capture(self, *args, **kwargs): calls.append((kwargs["positions"], kwargs["quats"])) return orig(self, *args, **kwargs) - monkeypatch.setattr(_PyVistaRenderer, "instanced_mesh", capture) + monkeypatch.setattr(renderer.backend._Renderer, "instanced_mesh", capture) plot_alignment(info, meg="sensors", coord_frame="meg") # all four grads share one coil shape, so they form a single instanced # actor whose instance order follows the channel order @@ -697,11 +707,12 @@ def test_plot_alignment_info(renderer, evoked): fig = plot_alignment(info) # works: surfaces='auto' default # set_view=False keeps the view of the figure it is given, True resets it set_3d_view(fig, azimuth=11, elevation=22, distance=0.33) - pos = np.array(fig.plotter.camera.position, float) + view = renderer.backend._Renderer(fig=fig).get_camera()[2:4] + assert_allclose(view, (11, 22), atol=1e-4) plot_alignment(info, fig=fig, set_view=False) - assert_allclose(fig.plotter.camera.position, pos, atol=1e-4) + assert_allclose(renderer.backend._Renderer(fig=fig).get_camera()[2:4], view) plot_alignment(info, fig=fig) - assert not np.allclose(fig.plotter.camera.position, pos, atol=1e-4) + assert not np.allclose(renderer.backend._Renderer(fig=fig).get_camera()[2:4], view) # check error raised if incorrect info provided with pytest.raises(TypeError, match="instance of Info"): plot_alignment("foo", trans_fname, subject="sample", subjects_dir=subjects_dir) diff --git a/pyproject.toml b/pyproject.toml index ee227f2b9f5..86b196c7e72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,11 @@ test_extra = [ "nbclient", # requires jupyter_client "nitime >= 0.7", "pymef", + # drawing backend for the browser docs, see mne/viz/backends/_lite.py. The + # marker is pyvista-js's own requires-python (>= 3.12, < 3.15) rather than + # anything about JupyterLite: without it this group cannot resolve at all on + # the 3.11 MNE still supports. Every CI job that installs it is on 3.12+. + "pyvista-js >= 0.15; python_version >= '3.12'", "statsmodels", {include-group = "test_extra_ft"}, ] diff --git a/tools/vulture_allowlist.py b/tools/vulture_allowlist.py index 45196a74af6..297c6f55a5d 100644 --- a/tools/vulture_allowlist.py +++ b/tools/vulture_allowlist.py @@ -17,6 +17,7 @@ windows_like_datetime garbage_collect renderer_notebook +renderer_lite qt_windows_closed download_is_error exitstatus