Skip to content

Commit c2ce141

Browse files
MAINT: tighten the browser backend after review
Drops a wrong claim about plot_bem, makes the drawing tests assert geometry rather than actor counts, and stops accepting arguments without saying why they cannot be honoured.
1 parent c08cff3 commit c2ce141

2 files changed

Lines changed: 152 additions & 49 deletions

File tree

mne/viz/backends/_lite.py

Lines changed: 82 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
"""
22
A pyvista-js drawing backend for MNE's 3D renderer.
33
4-
MNE's 3D functions (``plot_alignment``, ``plot_bem``,
5-
``plot_sparse_source_estimates``, ``SourceSpaces.plot``, ...) all build their
6-
figure the same way: they do their own geometry and coordinate-frame work in
7-
numpy, then hand the result to a renderer obtained from
8-
:func:`mne.viz.backends.renderer._get_renderer`. Only that last step needs VTK,
9-
and VTK cannot load in WebAssembly.
4+
MNE's 3D functions (``plot_alignment``, ``plot_sparse_source_estimates``,
5+
``SourceSpaces.plot``, ...) all build their figure the same way: they do their
6+
own geometry and coordinate-frame work in numpy, then hand the result to a
7+
renderer obtained from :func:`mne.viz.backends.renderer._get_renderer`. Only
8+
that last step needs VTK, and VTK cannot load in WebAssembly.
109
1110
Rather than reimplement those functions one by one, this module supplies a
1211
renderer that draws with `pyvista-js <https://github.com/tkoyama010/pyvista-js>`__
@@ -22,7 +21,9 @@
2221
time slider, and scalar colormaps: ``Plotter.add_mesh`` takes ``scalars`` and
2322
``cmap`` and writes them into the scene, but the vtk.js template it renders
2423
through builds no lookup table and never reads them, so a mesh carrying
25-
scalars draws in a solid color.
24+
scalars draws in a solid color. Figure size is fixed too: pyvista-js writes a
25+
600x400 canvas and offers no way to change it, so the ``size`` MNE asks for has
26+
no effect.
2627
2728
Importing this module needs pyvista-js, the same way importing ``_pyvista``
2829
needs VTK.
@@ -65,6 +66,26 @@
6566
_DEFAULT_COLOR = (0.5, 0.5, 0.5)
6667

6768

69+
def _lite_n_side(resolution):
70+
"""Return the side count to build a cone or cylinder with.
71+
72+
Callers give the side count VTK would use; take half of it, because
73+
``_tile`` stamps the template at every sensor and the side count multiplies
74+
straight into the WASM heap, and eight sides is smooth enough at the size
75+
these draw. Three is the fewest that still closes a ring, and eight is the
76+
default for callers that name no resolution at all.
77+
"""
78+
return 8 if resolution is None else max(3, int(resolution) // 2)
79+
80+
81+
def _lite_ring(n_side, radius):
82+
"""Return one ``n_side`` circle of radius ``radius``, in the x=0 plane."""
83+
angles = np.linspace(0.0, 2 * np.pi, n_side, endpoint=False)
84+
return np.column_stack(
85+
[np.zeros(n_side), radius * np.cos(angles), radius * np.sin(angles)]
86+
)
87+
88+
6889
def _rgb(color):
6990
"""Return an (r, g, b) 0-1 tuple, the only color form pyvista-js takes.
7091
@@ -135,30 +156,25 @@ def _lite_get_view(plotter):
135156
_lite_live_plotters = []
136157

137158

138-
def _lite_release_plotter(plotter, close=True):
159+
def _lite_release_plotter(plotter):
139160
"""Hand back a plotter's meshes, JS arrays and GPU buffers.
140161
141162
``clear()`` empties the actor list, which is where the geometry is held,
142-
so that is what frees the memory. ``close=False`` additionally says not to
143-
tear the render window down -- what trimming an older scene wants, since
144-
the notebook has already drawn it. pyvista-js 0.15 implements neither
145-
``deep_clean`` nor ``close``, so today the two paths do the same thing;
146-
the flag keeps the intent right if that changes.
163+
so that is what frees the memory, and it is the only teardown pyvista-js
164+
0.15 offers: there is no ``close()`` to tear the render window down as
165+
well, which is why closing a figure and clearing one do the same thing
166+
here.
167+
168+
Collecting is left to the caller, so draining a whole registry sweeps once
169+
rather than once per scene.
147170
"""
148171
if plotter is None:
149172
return None
150173
for idx in range(len(_lite_live_plotters) - 1, -1, -1):
151174
live = _lite_live_plotters[idx]()
152175
if live is None or live is plotter:
153176
del _lite_live_plotters[idx]
154-
# pyvista-js is someone else's surface, so use whichever teardown of these
155-
# it actually implements
156-
names = ("clear", "deep_clean", "close") if close else ("clear", "deep_clean")
157-
for name in names:
158-
teardown = getattr(plotter, name, None)
159-
if teardown is not None:
160-
teardown()
161-
gc.collect()
177+
plotter.clear()
162178
return None
163179

164180

@@ -173,13 +189,17 @@ def _lite_release_plotter(plotter, close=True):
173189

174190
def _lite_trim_live_plotters():
175191
"""Release everything but the most recent scenes."""
192+
trimmed = False
176193
while len(_lite_live_plotters) > _LITE_MAX_LIVE_SCENES:
177194
oldest = _lite_live_plotters[0]()
178195
if oldest is None:
179196
_lite_live_plotters.pop(0)
180197
else:
181198
# also drops it from the registry, so this terminates
182-
_lite_release_plotter(oldest, close=False)
199+
_lite_release_plotter(oldest)
200+
trimmed = True
201+
if trimmed:
202+
gc.collect()
183203
return None
184204

185205

@@ -195,6 +215,10 @@ class _LiteRenderer(_AbstractRenderer):
195215
_kind = "jupyterlite_notebook"
196216

197217
def __init__(self, fig=None, size=(600, 600), bgcolor="black", **kwargs):
218+
# `size` is named to match _PyVistaRenderer but cannot be honoured:
219+
# pv.Plotter takes only a lighting mode, and generate_standalone_html
220+
# emits a fixed 600x400 canvas with no knob for it.
221+
#
198222
# plot_alignment(fig=...) and plot_dipole_locations(fig=...) composite
199223
# into a scene the notebook already made, so draw into that plotter
200224
# rather than opening a second one and splitting the picture in two.
@@ -207,7 +231,11 @@ def __init__(self, fig=None, size=(600, 600), bgcolor="black", **kwargs):
207231
# _LITE_MAX_LIVE_SCENES is the number that actually stays live
208232
_lite_trim_live_plotters()
209233
self.plotter.background_color = _rgb(bgcolor)
210-
# even lighting, so a surface is not black when rotated
234+
# A scene light in vtk.js lights only what faces it, so a single one
235+
# leaves half of a head dark as soon as it is turned. Six along the axes
236+
# cover every side; each is well under full intensity because a surface
237+
# facing two of them at once would otherwise blow out. The distance only
238+
# has to sit outside the scene, which is metres-scale here.
211239
for direction in (
212240
(1, 0, 0),
213241
(-1, 0, 0),
@@ -272,12 +300,8 @@ def _glyph_template(
272300
# pyvista.Cone(center=(0.5, 0, 0)): base at x=0, apex at x=height
273301
rad = 0.15 if radius is None else float(radius)
274302
hgt = 1.0 if height is None else float(height)
275-
n_side = 8 if not resolution else max(3, int(resolution) // 2)
276-
angles = np.linspace(0.0, 2 * np.pi, n_side, endpoint=False)
277-
ring = np.column_stack(
278-
[np.zeros(n_side), rad * np.cos(angles), rad * np.sin(angles)]
279-
)
280-
rr = np.vstack([ring, [[hgt, 0, 0]], [[0.0, 0, 0]]])
303+
n_side = _lite_n_side(resolution)
304+
rr = np.vstack([_lite_ring(n_side, rad), [[hgt, 0, 0]], [[0.0, 0, 0]]])
281305
tris = []
282306
for this in range(n_side):
283307
nxt = (this + 1) % n_side
@@ -286,10 +310,7 @@ def _glyph_template(
286310
# cylinder along +x, matching _cylinder_geom's convention
287311
rad = 0.1 if radius is None else float(radius)
288312
hgt = 1.0 if height is None else float(height)
289-
# half the sides VTK would use: _tile stamps this template at every
290-
# sensor, so the side count multiplies straight into the WASM heap and
291-
# a 16-sided EEG cylinder is smooth enough at the size it draws
292-
n_side = 8 if not resolution else max(3, int(resolution) // 2)
313+
n_side = _lite_n_side(resolution)
293314
# _cylinder_geom builds the cylinder along y and turns it 90 degrees
294315
# about z to point it along x, which carries the center round with it:
295316
# (cx, cy, cz) lands at (-cy, cx, cz). _3d.py gives the EEG electrode
@@ -300,10 +321,7 @@ def _glyph_template(
300321
else:
301322
center = np.asarray(center, dtype=float)
302323
offset = np.array([-center[1], center[0], center[2]])
303-
angles = np.linspace(0.0, 2 * np.pi, n_side, endpoint=False)
304-
ring = np.column_stack(
305-
[np.zeros(n_side), rad * np.cos(angles), rad * np.sin(angles)]
306-
)
324+
ring = _lite_ring(n_side, rad)
307325
back = ring + np.array([-hgt / 2.0, 0, 0])
308326
front = ring + np.array([hgt / 2.0, 0, 0])
309327
rr = (
@@ -325,6 +343,8 @@ def _add(self, points, tris, color, opacity=1.0):
325343
drawing method here funnels through this, so translating it once covers
326344
all of them.
327345
"""
346+
# float32 halves what the merged glyph meshes cost in the WASM heap,
347+
# and vtk.js uses single precision on the GPU regardless
328348
mesh = pv.PolyData(
329349
points=np.asarray(points, dtype=np.float32), faces=_vtk_faces(tris)
330350
)
@@ -425,6 +445,11 @@ def sphere(
425445
radius=None,
426446
**kwargs,
427447
):
448+
# `resolution` has no equivalent here: _pyvista.py asks pyvista.Sphere
449+
# for that many theta and phi bands, while this template comes from a
450+
# subdivided octahedron, whose vertex count goes 6, 18, 66, 258. Level 3
451+
# is the one that lands near the default 8x8 sphere, and nothing in
452+
# mne/viz asks for another, so it is fixed rather than approximated.
428453
center = np.atleast_2d(np.asarray(center, dtype=float))
429454
if not len(center):
430455
return None, None
@@ -510,7 +535,8 @@ def quiver3d(
510535
n_pos = len(centers)
511536
if not n_pos:
512537
return None, None
513-
factor = float(np.asarray(scale).ravel()[0]) if np.size(scale) else 1.0
538+
# MNE always passes a scalar here; VTK's SetScaleFactor takes one too
539+
factor = float(scale)
514540
idx = np.arange(n_pos)
515541
u, v, w = (np.atleast_1d(np.asarray(q, dtype=float)) for q in (u, v, w))
516542
dirs = np.column_stack([u[idx % len(u)], v[idx % len(v)], w[idx % len(w)]])
@@ -562,14 +588,22 @@ def quiver3d(
562588
template_kw = dict(
563589
radius=glyph_radius, height=glyph_height, resolution=glyph_resolution
564590
)
565-
else: # arrow / 2darrow, both of which vtk draws with a shaft and a tip
591+
else:
592+
# "arrow" is vtkArrowSource, a shaft with a cone tip. "2darrow" is
593+
# really vtkGlyphSource2D with FilledOff, a flat outline; vtk.js has
594+
# no 2D glyph source, so it borrows the 3D arrow. Only Brain asks
595+
# for it, and Brain does not run here.
566596
kind, template_kw = "arrow", dict()
567597
rr, tris = self._glyph_template(kind, **template_kw)
568598
if solid_transform is not None:
569599
# _pyvista.py transforms the template before glyphing, and this is
570600
# where the fiducial markers get their size and 45 deg roll
571601
solid_transform = np.asarray(solid_transform, dtype=float)
572602
rr = rr @ solid_transform[:3, :3].T + solid_transform[:3, 3]
603+
# a sphere looks the same however it is turned, so skip the rotation
604+
# rather than build N matrices for it. "oct" joins it because the only
605+
# caller (the MRI fiducials) points every glyph along +x, which is the
606+
# identity; a future caller pointing them elsewhere would need this back
573607
rots = None if mode in ("sphere", "oct") else self._rots_from_dirs(dirs)
574608
points, faces = self._tile(rr, tris, centers, scales=sizes, rots=rots)
575609
return self._add(points, faces, color, opacity)
@@ -824,6 +858,9 @@ def show(self):
824858
def _set_3d_view(
825859
figure, azimuth=None, elevation=None, focalpoint=None, distance=None, roll=None
826860
):
861+
# distance, focalpoint and roll go unused here for the same reason they do
862+
# in _LiteRenderer.set_camera: vtk.js frames the scene with resetCamera()
863+
# on this path. See _lite_get_view.
827864
return _lite_set_view(figure, azimuth, elevation)
828865

829866

@@ -835,14 +872,16 @@ def _set_3d_title(figure, title, size=16, *, color="white", position="upper_left
835872

836873

837874
def _clear_3d_figure(figure):
838-
# close=False is already the "give the geometry back but keep the scene"
839-
# path, which is what clearing means
840-
_lite_release_plotter(figure, close=False)
875+
_lite_release_plotter(figure)
876+
gc.collect()
841877
return None
842878

843879

844880
def _close_3d_figure(figure):
881+
# the same as clearing: vtk.js draws into a canvas in an output cell, so
882+
# there is no window left to close once the geometry is gone
845883
_lite_release_plotter(figure)
884+
gc.collect()
846885
return None
847886

848887

@@ -856,4 +895,5 @@ def _close_all():
856895
_lite_live_plotters.pop()
857896
else:
858897
_lite_release_plotter(plotter)
898+
gc.collect() # once for the whole registry, not once per scene
859899
return None

mne/viz/backends/tests/test_lite.py

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import numpy as np
1111
import pytest
12+
from numpy.testing import assert_allclose
1213

1314
from mne.viz.backends._abstract import _AbstractRenderer
1415

@@ -141,28 +142,79 @@ def test_get_camera_matches_the_expected_order(renderer_lite):
141142

142143

143144
def test_draws_every_primitive(renderer_lite):
144-
"""Each drawing primitive must add exactly one actor to the scene."""
145+
"""Every primitive must add one actor holding the geometry it was asked for.
146+
147+
Counting actors alone would pass on empty or misplaced meshes, so each
148+
check below pins where the mesh actually landed.
149+
"""
145150
r = renderer_lite._get_renderer(size=(200, 200), bgcolor="white")
146151
assert len(r.plotter.actors) == 0
147152

148-
r.mesh(_RR[:, 0], _RR[:, 1], _RR[:, 2], _TRIS, color="red", opacity=0.5)
149-
r.surface(dict(rr=_RR, tris=_TRIS), color="#0000ff")
150-
r.sphere(np.array([[0.0, 0, 0]]), "green", 0.1)
151-
r.tube([[0.0, 0, 0]], [[1.0, 1, 1]], radius=0.01, color="black")
152-
r.quiver3d(
153+
# a flat unit square, drawn as given
154+
_, mesh = r.mesh(_RR[:, 0], _RR[:, 1], _RR[:, 2], _TRIS, color="red", opacity=0.5)
155+
assert_allclose(np.asarray(mesh.points), _RR, atol=1e-6)
156+
157+
# the same square, reached through the surface dict
158+
_, mesh = r.surface(dict(rr=_RR, tris=_TRIS), color="#0000ff")
159+
assert_allclose(np.asarray(mesh.points), _RR, atol=1e-6)
160+
161+
# scale 0.1 means radius 0.05, centered where it was asked for
162+
_, mesh = r.sphere(np.array([[1.0, 0, 0]]), "green", 0.1)
163+
points = np.asarray(mesh.points)
164+
assert_allclose(points.mean(axis=0), [1, 0, 0], atol=1e-6)
165+
assert np.linalg.norm(points - [1, 0, 0], axis=1).max() == pytest.approx(0.05)
166+
167+
# a tube spans origin to destination, no further
168+
_, mesh = r.tube([[0.0, 0, 0]], [[0.0, 0, 1.0]], radius=0.01, color="black")
169+
points = np.asarray(mesh.points)
170+
assert points[:, 2].min() == pytest.approx(0.0)
171+
assert points[:, 2].max() == pytest.approx(1.0)
172+
assert np.linalg.norm(points[:, :2], axis=1).max() == pytest.approx(0.01)
173+
174+
# an arrow of length `scale` pointing the way it was given
175+
_, mesh = r.quiver3d(
153176
np.r_[0.0],
154177
np.r_[0.0],
155178
np.r_[0.0],
156-
np.r_[1.0],
157179
np.r_[0.0],
180+
np.r_[1.0],
158181
np.r_[0.0],
159182
color=(1.0, 0.5, 0.0),
160183
scale=0.1,
161184
mode="arrow",
162185
)
186+
points = np.asarray(mesh.points)
187+
assert points[:, 1].max() == pytest.approx(0.1) # along +y, at `scale`
188+
# and no wider than its own tip, which is 0.1 of the scaled length
189+
assert np.linalg.norm(points[:, [0, 2]], axis=1).max() <= 0.01 + 1e-9
190+
163191
assert len(r.plotter.actors) == 5
164192

165193

194+
def test_tube_stretches_each_segment_on_its_own(renderer_lite):
195+
"""``tube`` scales along the template axis alone, per segment.
196+
197+
That is the one place ``_tile`` scales anisotropically, and getting it
198+
wrong would fatten the tubes as they lengthen.
199+
"""
200+
r = renderer_lite._get_renderer(size=(200, 200))
201+
_, mesh = r.tube(
202+
[[0.0, 0, 0], [0.0, 0, 0]], # one 1 m segment and one 2 m segment
203+
[[1.0, 0, 0], [0.0, 2.0, 0]],
204+
radius=0.01,
205+
color="black",
206+
)
207+
points = np.asarray(mesh.points)
208+
assert points[:, 0].max() == pytest.approx(1.0)
209+
assert points[:, 1].max() == pytest.approx(2.0)
210+
211+
# neither got thicker for being longer: the two segments are stamped in
212+
# order, so split them and measure each one away from its own axis
213+
first, second = points.reshape(2, -1, 3)
214+
assert np.linalg.norm(first[:, 1:], axis=1).max() == pytest.approx(0.01)
215+
assert np.linalg.norm(second[:, [0, 2]], axis=1).max() == pytest.approx(0.01)
216+
217+
166218
def test_glyphs_scale_by_their_scalars(renderer_lite):
167219
"""``mode="arrow"`` must size each glyph by its scalar, as the filter does.
168220
@@ -381,6 +433,8 @@ def test_renders_in_a_notebook_kernel(nbexec):
381433
takes, and checks the scene serialises to the vtk.js HTML the browser
382434
consumes. The body below is executed by that kernel rather than here.
383435
"""
436+
import json
437+
384438
import numpy as np
385439

386440
from mne.viz.backends import renderer
@@ -395,5 +449,14 @@ def test_renders_in_a_notebook_kernel(nbexec):
395449
r.mesh(rr[:, 0], rr[:, 1], rr[:, 2], tris, color="red")
396450
assert len(r.plotter.actors) == 1
397451

452+
# the html must carry this mesh, not merely be a vtk.js page: an empty
453+
# scene still ships the script tag, so look for the points themselves
398454
html = r.plotter.generate_standalone_html()
399455
assert "<script" in html and "vtk" in html.lower()
456+
scene = r.plotter._renderer._build_scene_data()
457+
assert len(scene["actors"]) == 1
458+
drawn = np.asarray(scene["actors"][0]["source"]["points"], float).reshape(-1, 3)
459+
assert drawn.shape == rr.shape
460+
np.testing.assert_allclose(drawn, rr, atol=1e-6)
461+
packed = json.dumps(scene["actors"][0]["source"]["points"]).replace(" ", "")
462+
assert packed in html.replace(" ", "") # whatever spacing json chose

0 commit comments

Comments
 (0)