From 44ca6087386cd3f699b3cfb2e3c3f2fcfffc12cc Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 27 Aug 2026 17:15:38 +0200 Subject: [PATCH 1/2] Speed up dipole fit rendering --- mne/gui/_dipolefit.py | 11 +++- mne/gui/tests/test_dipolefit.py | 4 ++ mne/viz/_3d_overlay.py | 29 ++++++---- mne/viz/_brain/_brain.py | 23 +++++--- mne/viz/_brain/tests/test_brain.py | 48 +++++++++++++++++ mne/viz/backends/_abstract.py | 86 ++++++++++++++++++++++++++++++ mne/viz/backends/_pyvista.py | 66 ++++++++++++++++++++--- mne/viz/evoked_field.py | 30 +++++++++-- mne/viz/tests/test_3d.py | 14 +++++ 9 files changed, 282 insertions(+), 29 deletions(-) diff --git a/mne/gui/_dipolefit.py b/mne/gui/_dipolefit.py index 7a1d26b6877..cda0a774b63 100644 --- a/mne/gui/_dipolefit.py +++ b/mne/gui/_dipolefit.py @@ -387,6 +387,11 @@ def _configure_main_display(self, show_sensors=True): time_viewer=False, initial_time=self._current_time, time_label=None, # the traces plot shows the current time + # The source estimate is only a rough guide for where to put + # dipoles, so map each surface vertex to its nearest source rather + # than smoothing: the upsampling is then a gather instead of a + # sparse matrix product, which is cheaper on every time change. + smoothing_steps="nearest", brain_kwargs=dict(units="m", show=False), figure=fig_into, # the GUI renders on a white figure, so the Brain (and hence its @@ -707,7 +712,9 @@ def _on_time_change(self, event): if self._time_line is not None: self._time_line.set_xdata([new_time]) self._update_time_text() - self._renderer._mplcanvas.update_plot() + # only the time line and its label moved, so the traces can be blitted + # from the cached background instead of being redrawn + self._renderer._mplcanvas.update_blit_artists() self._update_arrows() def _update_time_text(self): @@ -1364,6 +1371,8 @@ def _setup_mplcanvas(self): fontsize=8, color="black", ) + # the label travels with the time line, so it is drawn along with it + canvas.add_blit_artist(self._time_text) return self._renderer._mplcanvas def close(self): diff --git a/mne/gui/tests/test_dipolefit.py b/mne/gui/tests/test_dipolefit.py index e6543747da3..cf38b718836 100644 --- a/mne/gui/tests/test_dipolefit.py +++ b/mne/gui/tests/test_dipolefit.py @@ -171,6 +171,10 @@ def process_and_reenter(): assert not hasattr(g._fig, "_time_label_actor") assert re.fullmatch(r"90 ms · GOF \d+%", g._time_text.get_text()) assert g._time_text.get_position()[0] == 0.09 + # both move with the time, so they are drawn on top of a cached background + # rather than triggering a full redraw of the traces plot + blit_artists = g._renderer._mplcanvas._blit_artists + assert blit_artists == [g._time_line, g._time_text] g.fit_dipole() assert len(g._dipoles) == len(g.dipoles) == 2 diff --git a/mne/viz/_3d_overlay.py b/mne/viz/_3d_overlay.py index d2815a8e35d..3634d9104fc 100644 --- a/mne/viz/_3d_overlay.py +++ b/mne/viz/_3d_overlay.py @@ -151,17 +151,28 @@ def _map(self): self._is_mapped = True def _compute_over(self, B, A): + # Alpha-composite A ("over") on top of B, both RGBA in [0, 1]. + # + # This runs on every time point of an interactive time course, on surfaces + # with >1e5 vertices, so it is written to touch the (n_vertices, 4) arrays + # as few times as possible and only ever whole: expressing it in terms of + # the RGB columns (``C[:, :3] *= ...``) makes every operation strided, + # which costs ~4x more than the same work on the full array. The alpha + # column is included in the arithmetic and simply overwritten at the end. assert A.ndim == B.ndim == 2 assert A.shape[1] == B.shape[1] == 4 - A_w = A[:, 3:] # * 1 - B_w = B[:, 3:] * (1 - A_w) - C = A.copy() - C[:, :3] *= A_w - C[:, :3] += B[:, :3] * B_w - C[:, 3:] += B_w - C_alpha_zero = C[:, 3] == 0 - C[~C_alpha_zero, :3] /= C[~C_alpha_zero, 3:] - C[C_alpha_zero, :3] = 0 + A_w = A[:, 3].copy() # copy: column slices of a (n, 4) array are strided + B_w = B[:, 3].copy() + B_w *= 1 - A_w + C = A * A_w[:, None] + C += B * B_w[:, None] + alpha = A_w + B_w + # Where the composite is fully transparent the color is undefined: divide + # by one there instead, and zero those rows out afterwards. + opaque = alpha != 0 + np.divide(C, np.where(opaque, alpha, 1)[:, None], out=C) + C *= opaque[:, None] + C[:, 3] = alpha return np.clip(C, 0, 1, out=C) def _compose_overlays(self): diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index b296fc0a446..22de3d819a3 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -1907,7 +1907,9 @@ def plot_time_line(self, update=True): ) self.time_line.set_xdata([current_time]) if update: - self.mpl_canvas.update_plot() + # only the time line moved, so the rest of the figure can be + # blitted from the cached background instead of being redrawn + self.mpl_canvas.update_blit_artists() def _configure_help(self): pairs = [ @@ -4295,7 +4297,7 @@ def _update_current_time_idx(self, time_idx): time_actor = active.get("time_actor", None) time_label = active.get("time_label", None) for hemi in ["lh", "rh", "vol"]: - hemi_needs_recompose = False + staged_keys = list() for data_key, key_data in self._all_data.items(): hemi_data = key_data.get(hemi) if hemi_data is None: @@ -4353,10 +4355,10 @@ def _update_current_time_idx(self, time_idx): key_data["fmax"], ] if data_key in mesh._overlays: - # Stage without recomposing; a single mesh.update() below - # handles all overlays in O(N) instead of O(N²). + # Stage without recomposing; a single update below handles + # all overlays in O(N) instead of O(N²). mesh.update_overlay(data_key, scalars=act_data, update=False) - hemi_needs_recompose = True + staged_keys.append(data_key) else: mesh.add_overlay( scalars=act_data, @@ -4371,8 +4373,15 @@ def _update_current_time_idx(self, time_idx): if vectors is not None and data_key == self._active_data_key: self._update_glyphs(hemi, vectors) - if hemi_needs_recompose and hemi in self.layered_meshes: - self.layered_meshes[hemi].update() + if staged_keys and hemi in self.layered_meshes: + if len(staged_keys) == 1: + # Let update_overlay pick the cached path when the overlay we + # staged is the topmost one: the layers below it (curvature, + # labels, ...) have not changed, so their composite can be + # reused instead of color-mapping them all again. + self.layered_meshes[hemi].update_overlay(staged_keys[0]) + else: + self.layered_meshes[hemi].update() active["time_idx"] = time_idx self._renderer._update() diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 23673f9ad8e..232c9c68234 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -190,6 +190,21 @@ def test_layered_mesh(renderer_interactive_pyvistaqt): opacity=np.array([0.1, 0.2, 0.3]), name="bad-opacity", ) + + # alpha compositing: transparent top keeps the bottom color, opaque top wins, + # and a half-transparent white over opaque black is grey + bottom = np.array([[0.0, 0, 0, 1]] * 3) + top = np.array([[1.0, 1, 1, 0], [1, 1, 1, 1], [1, 1, 1, 0.5]]) + assert_allclose( + mesh._compute_over(bottom, top), + [[0, 0, 0, 1], [1, 1, 1, 1], [0.5, 0.5, 0.5, 1]], + ) + # a fully transparent result is black, and the inputs are left alone + bottom, top = np.zeros((1, 4)), np.zeros((1, 4)) + assert_allclose(mesh._compute_over(bottom, top), [[0, 0, 0, 0]]) + assert_allclose(bottom, 0) + assert_allclose(top, 0) + mesh._clean() @@ -1661,6 +1676,39 @@ def row_text(row): assert_allclose(peak_line3.get_ydata(), 2.0 * y1) +@testing.requires_testing_data +def test_brain_time_line_blitting(renderer_interactive_pyvistaqt, brain_gc): + """Test that moving the time line blits instead of redrawing the traces.""" + brain = _create_testing_brain(hemi="lh", show_traces=True, initial_time=0) + canvas = brain.mpl_canvas + assert canvas.canvas.supports_blit + assert brain.time_line in canvas._blit_artists + assert brain.time_line.get_animated() + + n_draws = list() + canvas.canvas.mpl_connect("draw_event", lambda event: n_draws.append(event)) + canvas.update_plot() # a full redraw caches the background ... + assert canvas._blit_background is not None + assert len(n_draws) == 1 + + brain.set_time(brain._times[-1]) # ... so moving the time line only blits + assert brain.time_line.get_xdata()[0] == brain._times[-1] + assert len(n_draws) == 1 + + # adding a trace still redraws in full, and anything can be blitted + text = canvas.axes.text(0, 0, "hello") + canvas.add_blit_artist(text) + assert text.get_animated() + canvas.update_blit_artists() # background was dropped, so this redraws + assert len(n_draws) == 2 + + canvas.remove_blit_artist(text) + assert not text.get_animated() + assert text not in canvas._blit_artists + assert len(n_draws) == 3 # restored to the background by a full redraw + brain.close() + + def _send_mouse_move(widget, point, buttons=None): """Deliver a synthetic Qt mouse move (QTest.mouseMove warps the real cursor).""" from qtpy.QtCore import QEvent, QPointF, Qt diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index 7edb4c879c5..87518a1bf65 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -1429,6 +1429,12 @@ def __init__(self, width, height, dpi): self.axes = self.fig.add_subplot(111) self.axes.set(xlabel="Time (s)", ylabel="Activation (AU)") self.manager = None + # Artists that are redrawn on their own (see `add_blit_artist`), the + # background they are drawn onto, and the draw_event callback id that + # keeps that background up to date. + self._blit_artists = list() + self._blit_background = None + self._blit_cid = None def _connect(self): for event in ("button_press", "motion_notify") + self._extra_events: @@ -1444,10 +1450,85 @@ def plot(self, x, y, label, update=True, **kwargs): def plot_time_line(self, x, label, update=True, **kwargs): """Plot the vertical line.""" line = self.axes.axvline(x, label=label, **kwargs) + self.add_blit_artist(line) if update: self.update_plot() return line + def add_blit_artist(self, artist): + """Mark an artist as fast-updating, to be drawn by :meth:`update_blit_artists`. + + Such an artist is excluded from the canvas background, so that moving it + (e.g. the time line, or a label that travels with it) costs a blit of the + cached background rather than a full redraw of the figure. + + Parameters + ---------- + artist : instance of matplotlib.artist.Artist + The artist to draw separately. Must live in this canvas's axes: an + artist added to the figure itself would be left out of saved images, + because Matplotlib only exempts *Axes* children from the rule that + animated artists are not drawn (see ``_AxesBase.draw``). + """ + if not self.canvas.supports_blit: # e.g. ipympl in a notebook + return + if artist.axes is not self.axes: + raise RuntimeError( + f"{artist!r} must be an artist of this canvas's axes to be drawn " + "separately, got one in " + repr(artist.axes) + ) + if artist in self._blit_artists: + return + artist.set_animated(True) + self._blit_artists.append(artist) + # the cached background may already contain this artist, so drop it and + # let the next update redraw (and re-cache) the figure without it + self._blit_background = None + if self._blit_cid is None: + # Grab a fresh background after every full redraw, whatever caused it + # (update_plot, draw_idle, a resize, a DPI change, ...). + self._blit_cid = self.canvas.mpl_connect("draw_event", self._on_draw) + + def remove_blit_artist(self, artist): + """Stop drawing an artist separately, putting it back in the background. + + Parameters + ---------- + artist : instance of matplotlib.artist.Artist + The artist to stop drawing separately. Artists that were never added + are ignored. + """ + if artist not in self._blit_artists: + return + self._blit_artists.remove(artist) + artist.set_animated(False) + self.update_plot() # redraw so the artist becomes part of the background + + def update_blit_artists(self): + """Redraw only the artists added with :meth:`add_blit_artist`. + + This is the fast path taken while the time line moves; any other change + to the figure needs :meth:`update_plot` instead. + """ + if self._blit_background is None or not self._blit_artists: + self.update_plot() # nothing cached yet (or nothing to draw fast) + return + self.canvas.restore_region(self._blit_background) + self._draw_blit_artists() + self.canvas.blit(self.fig.bbox) + + def _draw_blit_artists(self): + for artist in self._blit_artists: + self.fig.draw_artist(artist) + + def _on_draw(self, event=None): + """Cache the background after a full redraw (draw_event callback).""" + self._blit_background = self.canvas.copy_from_bbox(self.fig.bbox) + if not self.canvas.is_saving(): + # When saving, Matplotlib draws animated artists itself; drawing them + # again here would just double up their antialiasing. + self._draw_blit_artists() + def update_plot(self): """Update the plot.""" with warnings.catch_warnings(record=True): @@ -1503,6 +1584,11 @@ def close(self): def clear(self): """Clear internal variables.""" self.close() + if self._blit_cid is not None: + self.canvas.mpl_disconnect(self._blit_cid) + self._blit_cid = None + self._blit_artists.clear() # the artists go away with the figure below + self._blit_background = None self.axes.clear() self.fig.clear() self.canvas = None diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index efc01dcba43..9a926c0de51 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -26,11 +26,11 @@ ) from pyvista.plotting.plotter import _ALL_PLOTTERS from pyvistaqt import BackgroundPlotter -from vtkmodules.util.numpy_support import numpy_to_vtk +from vtkmodules.util.numpy_support import numpy_to_vtk, vtk_to_numpy from vtkmodules.vtkCommonCore import VTK_UNSIGNED_CHAR, vtkCommand, vtkLookupTable from vtkmodules.vtkCommonDataModel import vtkPiecewiseFunction from vtkmodules.vtkCommonTransforms import vtkTransform -from vtkmodules.vtkFiltersCore import vtkGlyph3D +from vtkmodules.vtkFiltersCore import vtkContourFilter, vtkGlyph3D, vtkTubeFilter from vtkmodules.vtkFiltersGeneral import vtkMarchingContourFilter from vtkmodules.vtkFiltersHybrid import vtkPolyDataSilhouette from vtkmodules.vtkFiltersSources import ( @@ -479,14 +479,32 @@ def contour( triangles = np.c_[np.full(n_triangles, 3), triangles] mesh = PolyData(vertices, triangles) mesh.point_data["scalars"] = scalars - contour = mesh.contour(isosurfaces=contours) + # Leave the contour filter connected to the mesh instead of computing the + # contours once (as `mesh.contour()` would): the rendering pipeline is then + # attached to the filter, so pushing new scalars in with `_update_contour` + # re-runs it on the next render, with no actor to rebuild. + alg = vtkContourFilter() + alg.SetInputDataObject(mesh) + alg.SetComputeNormals(False) + alg.SetComputeGradients(False) + alg.SetComputeScalars(True) + # args: (idx, port, connection, field, name), field 0 being point data + alg.SetInputArrayToProcess(0, 0, 0, 0, "scalars") + _set_contour_values(alg, contours, mesh) + source = alg line_width = width if kind == "tube": - contour = contour.tube(radius=width, n_sides=self.tube_n_sides) + tube = vtkTubeFilter() + tube.SetInputConnection(alg.GetOutputPort()) + tube.SetCapping(True) + tube.SetRadius(width) + tube.SetNumberOfSides(max(self.tube_n_sides, 3)) + tube.SetRadiusFactor(10.0) + source = tube line_width = 1.0 actor = _add_mesh( plotter=self.plotter, - mesh=contour, + mesh=source, show_scalar_bar=False, line_width=line_width, color=color, @@ -495,7 +513,28 @@ def contour( opacity=opacity, smooth_shading=self.smooth_shading, ) - return actor, contour + return actor, alg + + def _update_contour(self, alg, *, scalars=None, contours=None): + """Update the data and/or the levels of a contour created by `contour`. + + Parameters + ---------- + alg : instance of vtkContourFilter + The contour filter returned by :meth:`contour`. + scalars : ndarray, shape (n_vertices,) | None + New scalar values for the vertices of the surface being contoured. + contours : int | list | None + New contour levels. + """ + mesh = alg.GetInputDataObject(0, 0) + if scalars is not None: + array = mesh.GetPointData().GetArray("scalars") + vtk_to_numpy(array)[:] = scalars + array.Modified() + mesh.Modified() # so that the filter re-runs on the next render + if contours is not None: + _set_contour_values(alg, contours, mesh) def surface( self, @@ -1252,6 +1291,18 @@ def _quat_to_vtk_wxyz(quat): return np.concatenate([w[..., np.newaxis], quat], axis=-1) +def _set_contour_values(alg, contours, mesh): + """Set the levels of a contour filter (mirroring ``PolyData.contour``).""" + if isinstance(contours, int): + rng = mesh.GetPointData().GetArray("scalars").GetRange() + alg.GenerateValues(contours, rng[0], rng[1]) + else: + contours = np.asarray(contours, dtype=float) + alg.SetNumberOfContours(len(contours)) + for idx, value in enumerate(contours): + alg.SetValue(idx, value) + + def _add_mesh(plotter, **kwargs): """Patch PyVista add_mesh.""" mesh = kwargs.get("mesh") @@ -1266,7 +1317,8 @@ def _add_mesh(plotter, **kwargs): if "reset_camera" not in kwargs: kwargs["reset_camera"] = False actor = plotter.add_mesh(**kwargs) - if smooth_shading and "Normals" in mesh.point_data: + # `mesh` can also be a vtkAlgorithm (see `contour`), which has no point data + if smooth_shading and "Normals" in getattr(mesh, "point_data", ()): prop = actor.GetProperty() prop.SetInterpolationToPhong() _hide_testing_actor(actor) diff --git a/mne/viz/evoked_field.py b/mne/viz/evoked_field.py index 5e3901dc79b..b814c4a63c9 100644 --- a/mne/viz/evoked_field.py +++ b/mne/viz/evoked_field.py @@ -353,7 +353,7 @@ def _prepare_surf_map(self, surf_map, color, alpha): # And the field lines on top if self._n_contours > 1: contours = np.linspace(-map_vmax, map_vmax, self._n_contours) - contours_actor, _ = self._renderer.contour( + contours_actor, contours_alg = self._renderer.contour( surface=surf, scalars=current_data, contours=contours, @@ -365,7 +365,7 @@ def _prepare_surf_map(self, surf_map, color, alpha): ) else: contours = None # noqa - contours_actor = None + contours_actor = contours_alg = None return dict( pick=pick, @@ -375,6 +375,7 @@ def _prepare_surf_map(self, surf_map, color, alpha): mesh=mesh, contours=contours, contours_actor=contours_actor, + contours_alg=contours_alg, surf=surf, map_vmax=map_vmax, ) @@ -385,12 +386,31 @@ def _update(self): current_data = surf_map["data_interp"](self._current_time) surf_map["mesh"].update_overlay(name="field", scalars=current_data) - if surf_map["contours"] is not None: + show_contours = surf_map["contours"] is not None and self._n_contours > 1 + if show_contours and surf_map["contours_alg"] is not None: + # The filter is still connected to its surface, so pushing the new + # values in re-runs it on the next render: no need to build a new + # actor for every time point. + self._renderer._update_contour( + surf_map["contours_alg"], + scalars=current_data, + contours=surf_map["contours"], + ) + actor = surf_map["contours_actor"] + actor.prop.line_width = self._contour_line_width + actor.prop.opacity = self._contour_line_opacity + elif show_contours or surf_map["contours_actor"] is not None: + # The contours appeared or disappeared entirely, which needs a + # new actor (or none at all). self._renderer.plotter.remove_actor( surf_map["contours_actor"], render=False ) - if self._n_contours > 1: - surf_map["contours_actor"], _ = self._renderer.contour( + surf_map["contours_actor"] = surf_map["contours_alg"] = None + if show_contours: + ( + surf_map["contours_actor"], + surf_map["contours_alg"], + ) = self._renderer.contour( surface=surf_map["surf"], scalars=current_data, contours=surf_map["contours"], diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index f0d98188c91..caf8367c8dd 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -247,6 +247,20 @@ def test_plot_evoked_field(renderer): fig.set_contour_line_width(3) assert fig._contour_line_width == 3 assert fig._widgets["contour_line_width"].get_value() == 3 + + # Moving through time pushes new values into the contour filter that is still + # connected to the surface, rather than building a new actor. + from vtkmodules.util.numpy_support import vtk_to_numpy + + surf_map = fig._surf_maps[1] # the MEG map + actor = surf_map["contours_actor"] + mesh = surf_map["contours_alg"].GetInputDataObject(0, 0) + scalars = vtk_to_numpy(mesh.GetPointData().GetArray("scalars")) + fig.set_time(0.06) + assert surf_map["contours_actor"] is actor # reused, not rebuilt + assert_allclose(scalars, surf_map["data_interp"](0.06)) + fig.set_time(0.08) + assert_allclose(scalars, surf_map["data_interp"](0.08)) fig.set_vmax(2e-12, kind="meg") assert fig._surf_maps[1]["contours"][-1] == 2e-12 assert ( From 1791af84075746a24617e7fdf00bf0ef79cbcfe5 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 28 Aug 2026 00:13:57 +0200 Subject: [PATCH 2/2] FIX: dont raise --- mne/viz/backends/_utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mne/viz/backends/_utils.py b/mne/viz/backends/_utils.py index 590b5a96665..40adf1d8f84 100644 --- a/mne/viz/backends/_utils.py +++ b/mne/viz/backends/_utils.py @@ -431,6 +431,14 @@ def _qt_get_stylesheet(theme): def _should_raise_window(): from matplotlib import rcParams + from . import renderer + + # The test suite opens a lot of 3D windows, and raising each one steals focus + # from whatever the developer is doing -- on macOS especially, where + # `activateWindow()` brings the whole application forward. The windows are + # still shown during tests, they just stay behind the active window. + if renderer.MNE_3D_BACKEND_TESTING: + return False return rcParams["figure.raise_window"]