From d5e6fc25e47dde75b2620c8c88e5cab4b2da6203 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sat, 29 Aug 2026 15:31:15 +0200 Subject: [PATCH 1/5] Speed up evoked time plotting --- doc/changes/dev/14249.newfeature.rst | 1 + mne/gui/tests/test_dipolefit.py | 2 +- mne/report/report.py | 57 ++++++------ mne/report/tests/test_report.py | 4 + mne/viz/_brain/tests/test_brain.py | 19 ++-- mne/viz/backends/_abstract.py | 64 ++----------- mne/viz/evoked.py | 29 ++++-- mne/viz/tests/test_topomap.py | 15 ++- mne/viz/topo.py | 9 +- mne/viz/topomap.py | 72 +++++++++++---- mne/viz/utils.py | 131 +++++++++++++++++++++++++++ 11 files changed, 280 insertions(+), 123 deletions(-) create mode 100644 doc/changes/dev/14249.newfeature.rst diff --git a/doc/changes/dev/14249.newfeature.rst b/doc/changes/dev/14249.newfeature.rst new file mode 100644 index 00000000000..465c4f25b28 --- /dev/null +++ b/doc/changes/dev/14249.newfeature.rst @@ -0,0 +1 @@ +Sped up various evoked plotting functions by taking advantage of blitting, by `Eric Larson`_ diff --git a/mne/gui/tests/test_dipolefit.py b/mne/gui/tests/test_dipolefit.py index cf38b718836..5858d14aa03 100644 --- a/mne/gui/tests/test_dipolefit.py +++ b/mne/gui/tests/test_dipolefit.py @@ -173,7 +173,7 @@ def process_and_reenter(): 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 + blit_artists = g._renderer._mplcanvas._blit._artists assert blit_artists == [g._time_line, g._time_text] g.fit_dipole() diff --git a/mne/report/report.py b/mne/report/report.py index 17e1a499682..ed4d4a07ea9 100644 --- a/mne/report/report.py +++ b/mne/report/report.py @@ -23,7 +23,6 @@ import matplotlib import numpy as np -from matplotlib.animation import AbstractMovieWriter from .. import __version__ as MNE_VERSION from .._fiff.meas_info import Info, read_info @@ -85,7 +84,7 @@ from ..viz._brain.view import views_dicts from ..viz._scraper import _mne_qt_browser_screenshot from ..viz.misc import _get_bem_plotting_surfaces, _plot_mri_contours -from ..viz.utils import _ndarray_to_fig +from ..viz.utils import _BlitManager, _ndarray_to_fig _BEM_VIEWS = ("axial", "sagittal", "coronal") @@ -369,27 +368,6 @@ def _check_tags(tags) -> tuple[str]: # PLOTTING FUNCTIONS -class _NdArrayCapture(AbstractMovieWriter): - def __init__(self, frames: list): - super().__init__(fps=1, metadata={}, bitrate=0) - self.frames = frames - - def grab_frame(self, **savefig_kwargs): - img = _fig_to_img( - fig=self.fig, image_format="ndarray", pad_inches=0, **savefig_kwargs - ) - self.frames.append(img) - - def save(self, filename, *args, **kwargs): - pass - - def finish(self): - pass - - def setup(self, fig, outfile, dpi=None): - self.fig = fig - - def _use_agg(func): @functools.wraps(func) def wrapper(*args, **kwargs): @@ -486,7 +464,17 @@ def _fig_to_img( logger.debug( f"Saving figure with dimension {fig.get_size_inches()} inches with {dpi} dpi" ) - mpl_format = "svg" if image_format == "svg" else "png" + if image_format == "ndarray": + # Raw RGBA: the caller wants the rendered pixels, so encoding them as PNG + # only to decode them again below is pure overhead. + mpl_format = "rgba" + # Agg truncates the figure size to whole pixels (`RendererAgg.__init__`), + # so rounding here would mis-shape the buffer at fractional DPI + shape = (int(fig.bbox.size[1]), int(fig.bbox.size[0]), 4) + elif image_format == "svg": + mpl_format = "svg" + else: + mpl_format = "png" fig.savefig(output, format=mpl_format, dpi=dpi, **mpl_kwargs) if own_figure: @@ -512,8 +500,9 @@ def _fig_to_img( new.save(output, format=image_format, dpi=(dpi, dpi), **pil_kwargs) if image_format == "ndarray": - output.seek(0) - output = plt.imread(output, format="png") + # float in [0, 1], like the PNG this used to go through + output = np.frombuffer(output.getbuffer(), np.uint8).reshape(shape) + output = output.astype(np.float32) / 255 else: output = output.getvalue() if image_format == "svg": @@ -3893,8 +3882,6 @@ def _plot_evoked_topomap_timepoints( fig.delaxes(axes[1, 1]) axes = axes.ravel()[:3] axes[0].set_title(ch_type) - frames[ch_type] = list() - this_writer = _NdArrayCapture(frames[ch_type]) _, ch_anim = evoked.animate_topomap( times=times, ch_type=ch_type, @@ -3903,10 +3890,22 @@ def _plot_evoked_topomap_timepoints( show=False, time_format="", # we impose our own in HTML butterfly=True, + blit=False, # we do our own, `Animation.save` cannot blit at all **topomap_kwargs, ) + _constrain_fig_resolution(fig, max_width=MAX_IMG_WIDTH, max_res=MAX_IMG_RES) + fig.canvas.draw() # the animation does its initial draw here ch_anim.pause() - ch_anim.save("", writer=this_writer) + # Only the topomap image, its contours and the butterfly cursor change + # from one frame to the next, so blit those onto a cached picture of the + # rest of the figure and read the pixels straight out of the canvas. + blit = _BlitManager(fig) + frames[ch_type] = list() + for frame in range(len(times)): + blit.update(ch_anim.mne_frame_func(frame)) + frames[ch_type].append( + np.asarray(fig.canvas.buffer_rgba(), dtype=np.float32) / 255 + ) plt.close(fig) del ( fig, diff --git a/mne/report/tests/test_report.py b/mne/report/tests/test_report.py index 96af2bc37e5..630b52d85fc 100644 --- a/mne/report/tests/test_report.py +++ b/mne/report/tests/test_report.py @@ -33,6 +33,7 @@ from mne.report.report import ( _ALLOWED_IMAGE_FORMATS, CONTENT_ORDER, + _fig_to_img, ) from mne.utils import Bunch, _record_warnings from mne.utils._testing import assert_object_equal @@ -207,6 +208,9 @@ def test_render_report(renderer_pyvistaqt, tmp_path, invisible_fig): # ndarray support smoke test report.add_figure(fig=np.zeros((2, 3, 3)), title="title") + # ... and the reverse: a figure whose size is not a whole number of pixels + fig = plt.figure(figsize=(2.8, 2.8), dpi=89.6) + assert _fig_to_img(fig, image_format="ndarray").shape == (250, 250, 4) with pytest.raises(TypeError, match="It seems you passed a path"): report.add_figure(fig="foo", title="title") diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 232c9c68234..a40bb471d26 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -1682,29 +1682,26 @@ def test_brain_time_line_blitting(renderer_interactive_pyvistaqt, brain_gc): 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() + assert brain.time_line in canvas._blit._artists 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 + brain.set_time(brain._times[-1]) # one redraw caches the background ... + assert brain.time_line.get_xdata()[0] == brain._times[-1] + 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] + brain.set_time(brain._times[len(brain._times) // 2]) # ... then it only blits assert len(n_draws) == 1 - # adding a trace still redraws in full, and anything can be blitted + # a full redraw invalidates the background, 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 + canvas.update_blit_artists() # the 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 text not in canvas._blit._artists assert len(n_draws) == 3 # restored to the background by a full redraw brain.close() diff --git a/mne/viz/backends/_abstract.py b/mne/viz/backends/_abstract.py index 87518a1bf65..d0a507c9560 100644 --- a/mne/viz/backends/_abstract.py +++ b/mne/viz/backends/_abstract.py @@ -8,6 +8,7 @@ from abc import ABC, abstractmethod from ..ui_events import TimeChange, publish +from ..utils import _BlitManager class Figure3D(ABC): @@ -1429,12 +1430,7 @@ 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 + self._blit = _BlitManager(self.fig, draw=self.update_plot) def _connect(self): for event in ("button_press", "motion_notify") + self._extra_events: @@ -1458,36 +1454,19 @@ def plot_time_line(self, x, label, update=True, **kwargs): 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``). + The artist to draw separately. Must live in this canvas's axes, and be + drawn on top of the curves, as blitting draws it over a cached picture + of the rest of the figure. """ - 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) + self._blit.add(artist) def remove_blit_artist(self, artist): """Stop drawing an artist separately, putting it back in the background. @@ -1498,11 +1477,7 @@ def remove_blit_artist(self, 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 + self._blit.remove(artist) def update_blit_artists(self): """Redraw only the artists added with :meth:`add_blit_artist`. @@ -1510,24 +1485,7 @@ def update_blit_artists(self): 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() + self._blit.update() def update_plot(self): """Update the plot.""" @@ -1584,11 +1542,7 @@ 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._blit.close() self.axes.clear() self.fig.clear() self.canvas = None diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py index 401c06a41a0..4046b05b49c 100644 --- a/mne/viz/evoked.py +++ b/mne/viz/evoked.py @@ -49,6 +49,7 @@ from .ui_events import TimeChange, publish, subscribe from .utils import ( DraggableColorbar, + _BlitManager, _check_cov, _check_delayed_ssp, _check_option, @@ -587,6 +588,10 @@ def _plot_lines( sphere = _check_sphere(sphere, info) path_effects = [patheffects.withStroke(linewidth=2, foreground="w", alpha=0.75)] gfp_path_effects = [patheffects.withStroke(linewidth=5, foreground="w", alpha=0.75)] + # The time cursors and the hover label are the only artists that move, so draw + # them on top of a cached background rather than redrawing every channel's trace. + blit_manager = _BlitManager(fig) + if selectable: selectables = np.ones(len(ch_types_used), dtype=bool) for type_idx, this_type in enumerate(ch_types_used): @@ -632,22 +637,29 @@ def _on_hover(event): else: text.set_alpha(0.0) text.set_path_effects([]) + blit_manager.add(text) # vertical line to indicate time point for ax in axes: line = getattr(ax, "_cursorline", None) if line is None: - ax._cursorline = ax.axvline(event.xdata, color="black", alpha=0.2) + # zorder: blitting draws the cursor over a cached picture of the + # rest of the figure, so it has to be on top of the traces for + # the blitted figure to match a full redraw + line = ax._cursorline = ax.axvline( + event.xdata, color="black", alpha=0.2, zorder=len(ax.lines) + ) + blit_manager.add(line) else: line.set_xdata([event.xdata, event.xdata]) - ax.figure.canvas.draw_idle() + line.set_visible(True) + blit_manager.update() def _rm_cursor(event): for ax in axes: if getattr(ax, "_cursorline", None) is not None: - ax._cursorline.remove() - ax._cursorline = None - ax.figure.canvas.draw_idle() + ax._cursorline.set_visible(False) + blit_manager.update() def _select_time(event): for ax in axes: @@ -886,10 +898,13 @@ def on_time_change(event): for ax in axes: line = getattr(ax, "_selectline", None) if line is None: - ax._selectline = ax.axvline(event.time, color="black", alpha=1) + ax._selectline = ax.axvline( + event.time, color="black", alpha=1, zorder=len(ax.lines) + ) + blit_manager.add(ax._selectline) else: line.set_xdata([event.time, event.time]) - ax.figure.canvas.draw() + blit_manager.update() subscribe(fig, "time_change", on_time_change) diff --git a/mne/viz/tests/test_topomap.py b/mne/viz/tests/test_topomap.py index 9bd88662b2a..bc2d0e8d2f4 100644 --- a/mne/viz/tests/test_topomap.py +++ b/mne/viz/tests/test_topomap.py @@ -184,6 +184,17 @@ def test_plot_topomap_animation(capsys, tmp_path): assert "extrapolation mode local to mean" in out assert fig.axes[0].images[0].get_cmap().name == "viridis" + # everything drawn on top of the topomap image must be returned by the animation + # function, otherwise blitting leaves it frozen at the first frame (gh-14242) + items = anim.mne_frame_func(1) # has to be tested separately on the 'Agg' backend + ax = fig.axes[0] + zorder = ax.images[0].get_zorder() + on_top = [ + a for a in ax.lines + ax.collections + ax.texts if a.get_zorder() > zorder + ] + assert len(on_top) > 2 # at least the time label, head outlines and sensors + assert set(on_top).issubset(items) + # saving PIL = pytest.importorskip("PIL") gif_path = tmp_path / "test.gif" @@ -222,7 +233,7 @@ def test_plot_topomap_animation_csd(capsys): _, anim = evoked_csd.animate_topomap( ch_type="csd", times=[0, 0.1], butterfly=False, time_unit="s", verbose="debug" ) - anim._func(1) # _animate has to be tested separately on 'Agg' backend. + anim.mne_frame_func(1) # has to be tested separately on the 'Agg' backend out, _ = capsys.readouterr() assert "extrapolation mode head to mean" in out @@ -964,7 +975,7 @@ def test_plot_projs_topomap_opm(triaxial_evoked): def test_animate_topomap_opm(triaxial_evoked): """Test animate_topomap does not crash on colocated OPM channels (gh-13866).""" fig, anim = triaxial_evoked.animate_topomap(ch_type="mag", times=[0.0], show=False) - anim._func(0) + anim.mne_frame_func(0) assert len(fig.axes) >= 1 diff --git a/mne/viz/topo.py b/mne/viz/topo.py index 4b2449e8b32..2fba17584ea 100644 --- a/mne/viz/topo.py +++ b/mne/viz/topo.py @@ -17,6 +17,7 @@ from .utils import ( DraggableColorbar, SelectFromCollection, + _BlitManager, _check_cov, _check_delayed_ssp, _draw_proj_checkbox, @@ -1172,7 +1173,7 @@ def _plot_evoked_topo( setattr(fig, "_current_time", None) - def _on_time_change(event, fig, tmin, tmax): + def _on_time_change(event, fig, blit, tmin, tmax): """Respond to a time change UI event.""" fig._current_time = event.time @@ -1188,11 +1189,14 @@ def _on_time_change(event, fig, tmin, tmax): color=font_color, linewidth=0.5, ) + blit.add(subax.time_cursor) else: subax.time_cursor.set_xdata([time, time]) # Hide the vertical line when the time is out of bounds. subax.time_cursor.set_visible(tmin <= event.time <= tmax) - fig.canvas.draw() + # the cursors are the only thing that moves, so blit them onto a cached + # background instead of redrawing every channel's traces + blit.update() subscribe( fig, @@ -1200,6 +1204,7 @@ def _on_time_change(event, fig, tmin, tmax): partial( _on_time_change, fig=fig, + blit=_BlitManager(fig), tmin=np.min([t[0] for t in times]), tmax=np.max([t[-1] for t in times]), ), diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index b4282d9b9db..085e28a7a63 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -7,10 +7,13 @@ import copy import itertools import warnings -from functools import partial +from functools import cache, partial from numbers import Integral import matplotlib.artist +import matplotlib.axes +import matplotlib.contour +import matplotlib.figure import matplotlib.patches import numpy as np @@ -419,24 +422,44 @@ def _plot_update_evoked_topomap(params, bools): ): Zi = interp.set_values(d)() im.set_data(Zi) - new_contours.append(_update_contours(cont, ax, Xi, Yi, Zi, params["contours"])) + new_contours.append(_update_contours(cont, Xi, Yi, Zi, params["contours"])) params["contours_"][:] = new_contours params["fig"].canvas.draw() -def _update_contours(cont, ax, Xi, Yi, Zi, contours): +class _NoOpAxes(matplotlib.axes.Axes): + """Axes that throws away whatever is drawn on it. + + `~matplotlib.contour.QuadContourSet` attaches itself to an Axes on construction, + but :func:`_update_contours` only wants the geometry it computes. + """ + + def add_collection(self, collection, autolim=True): + return collection + + def update_datalim(self, *args, **kwargs): + pass + + def autoscale_view(self, *args, **kwargs): + pass + + +@cache +def _no_op_axes(): + """Get the one throwaway Axes used to compute contour geometry.""" + return _NoOpAxes(matplotlib.figure.Figure(), [0, 0, 1, 1]) + + +def _update_contours(cont, Xi, Yi, Zi, contours): if cont is None: return cont - lw = cont.get_linewidth() - visible = cont.get_visible() - patch_ = cont.get_clip_path() - color = cont.get_edgecolors() - zorder = _TOPOMAP_ZORDER["contours"] - if cont in ax.collections: - cont.remove() - cont = ax.contour(Xi, Yi, Zi, contours, colors=color, linewidths=lw, zorder=zorder) - cont.set_visible(visible) - cont.set_clip_path(patch_) + # Swap the new geometry into the existing artist rather than replacing it: adding + # an artist marks the figure stale, and an interactive backend then services that + # pending draw from inside ``canvas.blit()`` -- a full redraw, which omits every + # animated artist and so undoes the blit. Keeping the artist also keeps its color, + # linewidth, zorder and clip path, which used to have to be copied over. + new = matplotlib.contour.QuadContourSet(_no_op_axes(), Xi, Yi, Zi, levels=contours) + cont.set_paths(new.get_paths()) return cont @@ -3644,9 +3667,21 @@ def _topomap_animation( if butterfly: ax_line.plot(all_times, all_data.T, color="k", lw=0.5, alpha=0.5) ax_line.set_xlim(all_times[0], all_times[-1]) - butterfly_vline = ax_line.axvline(used_times[0], color="r") + # zorder: above the axes spines, otherwise drawing the cursor on top of a + # cached background (blitting) does not match a full redraw + butterfly_vline = ax_line.axvline(used_times[0], color="r", zorder=3) params = dict(frame=0, frames=list(range(len(used_times))), pause=False, cont=cont) + # Blitting draws only the artists that ``animate`` returns, on top of a cached + # background, so everything sitting on top of the topomap image (time label, head + # outlines, sensor markers, channel names, ...) has to be redrawn along with it. + overdrawn = [ + artist + for artist in ax.lines + ax.collections + ax.texts + if artist.get_zorder() > im.get_zorder() and artist is not cont + ] + if butterfly: + overdrawn.append(butterfly_vline) del cont def animate(frame): @@ -3655,10 +3690,12 @@ def animate(frame): im.set_data(Zi) if time_format: text.set_text(time_format % (used_times[frame] * scaling_time)) - params["cont"] = _update_contours(params["cont"], ax, Xi, Yi, Zi, contours) - items = [im] + params["cont"] = _update_contours(params["cont"], Xi, Yi, Zi, contours) if butterfly: butterfly_vline.set_xdata([used_times[frame]]) + items = [im] + overdrawn + if params["cont"] is not None: + items.append(params["cont"]) return _validate_artists(items) interval = 1000 / frame_rate # interval is in ms @@ -3696,6 +3733,9 @@ def key_press(event): fig.canvas.mpl_connect("key_press_event", key_press) fig.mne_animation = anim # to make sure anim is not garbage collected + # Matplotlib only keeps the frame function privately (as ``anim._func``), and + # drawing a single frame without the timer is useful (e.g. in Report) + anim.mne_frame_func = animate plt_show(show, block=False) return fig, anim diff --git a/mne/viz/utils.py b/mne/viz/utils.py index 80a5b146ec8..126d1fd245a 100644 --- a/mne/viz/utils.py +++ b/mne/viz/utils.py @@ -676,6 +676,137 @@ def _key_press(event): plt.close(event.canvas.figure) +class _BlitManager: + """Redraw a few fast-changing artists without redrawing the whole figure. + + Artists added with :meth:`add` are left out of the cached figure background, so + that moving them (a time cursor, a label that travels with it, ...) costs a blit + of that background rather than a full redraw of the figure. They are drawn on top + of that background, so they should be the topmost artists of their axes for the + blitted figure to match a full redraw. + + Parameters + ---------- + fig : instance of matplotlib.figure.Figure + The figure to blit. + draw : callable | None + What to call for a full, *synchronous* redraw of the figure. Defaults to the + figure canvas' ``draw``. + """ + + def __init__(self, fig, draw=None): + self._fig = fig + self._draw = fig.canvas.draw if draw is None else draw + self._artists = list() + self._background = None + self._capturing = False + self._cid = None + # Connect as early as possible: Matplotlib's blitting widgets redraw the + # figure from inside their own draw_event callback (see + # `SpanSelector.update_background`), so a manager connected after one of + # those would see a canvas they had already drawn on. + self._connect() + + def add(self, artist): + """Mark an artist as fast-updating, to be drawn by :meth:`update`. + + Parameters + ---------- + artist : instance of matplotlib.artist.Artist + The artist to draw separately. + """ + self._connect() + if not self._fig.canvas.supports_blit: # e.g. ipympl in a notebook + return + if artist in self._artists: + return + self._artists.append(artist) + self._background = None # the cached background may contain this artist + + def remove(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._artists: + return + self._artists.remove(artist) + self._background = None + self._draw() + + def update(self, artists=None): + """Redraw only the artists added with :meth:`add`. + + This is the fast path taken while the artists move; any other change to the + figure needs a full redraw instead. + + Parameters + ---------- + artists : list of matplotlib.artist.Artist | None + Artists to draw instead of the ones added so far, for callers that + rebuild some of them for every frame (e.g. a contour set, which + Matplotlib cannot update in place). The cached background stays valid, + as it never contained any of them. + """ + if artists is not None and self._fig.canvas.supports_blit: + self._artists = list(artists) + if not self._artists: # nothing to draw fast (e.g. blitting unsupported) + self._draw() + return + if self._background is None: + self._capture() + self._fig.canvas.restore_region(self._background) + self._draw_artists() + self._fig.canvas.blit(self._fig.bbox) + + def close(self): + """Forget the artists and stop tracking the figure background.""" + if self._cid is not None: + self._fig.canvas.mpl_disconnect(self._cid) + self._cid = None + self._artists.clear() # the artists go away with the figure + self._background = None + + def _connect(self): + # Drop the cached background after every full redraw, whatever caused it (an + # explicit draw, a resize, a DPI change, ...). The canvas can still be missing + # when the manager is built alongside its figure, hence the retry from + # :meth:`add`. + if self._cid is None and self._fig.canvas is not None: + self._cid = self._fig.canvas.mpl_connect("draw_event", self._on_draw) + + def _capture(self): + """Cache a picture of the figure without the fast-updating artists.""" + # Hiding the artists for one redraw is how Matplotlib's own blitting widgets + # keep themselves out of their background, see `SpanSelector.update_background`. + # Marking them ``animated`` instead would keep them out of *every* redraw, + # which costs those same widgets a full redraw of the figure per draw event. + visible = [artist.get_visible() for artist in self._artists] + self._capturing = True + try: + for artist in self._artists: + artist.set_visible(False) + self._draw() + self._background = self._fig.canvas.copy_from_bbox(self._fig.bbox) + finally: + for artist, was_visible in zip(self._artists, visible): + artist.set_visible(was_visible) + self._capturing = False + + def _draw_artists(self): + for artist in sorted(self._artists, key=lambda artist: artist.get_zorder()): + self._fig.draw_artist(artist) + + def _on_draw(self, event=None): + """Drop the cached background after a full redraw (draw_event callback).""" + if not self._capturing: # ... except the one :meth:`_capture` just asked for + self._background = None + + class ClickableImage: """Display an image so you can click on it and store x/y positions. From 6e6b795d968b7603a4e3fc5de47bee5943db72eb Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sat, 29 Aug 2026 19:38:52 -0400 Subject: [PATCH 2/5] FIX: Vulture --- mne/viz/topomap.py | 2 +- tools/vulture_allowlist.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index 085e28a7a63..1186e3f07e8 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -434,7 +434,7 @@ class _NoOpAxes(matplotlib.axes.Axes): but :func:`_update_contours` only wants the geometry it computes. """ - def add_collection(self, collection, autolim=True): + def add_collection(self, collection, *args, **kwargs): return collection def update_datalim(self, *args, **kwargs): diff --git a/tools/vulture_allowlist.py b/tools/vulture_allowlist.py index 5d529ceb9b1..c603ab1b607 100644 --- a/tools/vulture_allowlist.py +++ b/tools/vulture_allowlist.py @@ -192,3 +192,6 @@ # Accessed through an attribute-path string by the _qt_safe_window decorator _._init_renderer + +# Called by Matplotlib on the _NoOpAxes a ContourSet attaches itself to +_.update_datalim From fc3f9182960124709723255404817c7931442bf5 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sun, 30 Aug 2026 07:19:31 -0400 Subject: [PATCH 3/5] FIX: Faster img compression too --- doc/changes/dev/14249.newfeature.rst | 2 +- mne/report/report.py | 46 ++++++++++++++++++++-------- tutorials/intro/70_report.py | 9 +++--- 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/doc/changes/dev/14249.newfeature.rst b/doc/changes/dev/14249.newfeature.rst index 465c4f25b28..8b72b87a418 100644 --- a/doc/changes/dev/14249.newfeature.rst +++ b/doc/changes/dev/14249.newfeature.rst @@ -1 +1 @@ -Sped up various evoked plotting functions by taking advantage of blitting, by `Eric Larson`_ +Sped up various evoked plotting functions by taking advantage of blitting, and :class:`mne.Report` image embedding by compressing rendered pixels directly, by `Eric Larson`_ diff --git a/mne/report/report.py b/mne/report/report.py index ed4d4a07ea9..2eef38d84c4 100644 --- a/mne/report/report.py +++ b/mne/report/report.py @@ -408,6 +408,26 @@ def _constrain_fig_resolution(fig, *, max_width, max_res): fig.set_dpi(dpi) +def _compress_img(img, image_format, dpi): + """Drop the alpha channel and compress, for space and to avoid rendering issues.""" + from PIL import Image + + # https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html + pil_kwargs = dict() + if image_format == "webp": + # Here quality means speed/size tradeoff (either way the result is lossless); + # 20 rather than 50 encodes ~1.5x faster for a few percent more bytes + pil_kwargs.update(lossless=True, quality=20) + else: + assert image_format == "png", image_format + pil_kwargs.update(optimize=True, compress_level=9) + background = Image.new("RGBA", img.size, (255, 255, 255)) + img = Image.alpha_composite(background, img).convert("RGB") + output = BytesIO() + img.save(output, format=image_format, dpi=(dpi, dpi), **pil_kwargs) + return output + + def _fig_to_img( fig, *, @@ -425,10 +445,22 @@ def _fig_to_img( if isinstance(fig, np.ndarray): # In this case, we are creating the fig, so we might as well # auto-close in all cases - fig = _ndarray_to_fig(fig) + img, fig = fig, _ndarray_to_fig(fig) + dpi = fig.get_dpi() if own_figure: _constrain_fig_resolution(fig, max_width=max_width, max_res=max_res) own_figure = True # close the figure we just created + if fig.get_dpi() == dpi and image_format in ("png", "webp"): + # Nothing rescaled the pixels, so compress them as they are rather than + # rendering them back through the figure only to read them out again + from PIL import Image + + plt.close(fig) + if img.dtype.kind == "f": # float in [0, 1], as _fig_to_img returns + img = np.clip(img, 0, 1) * 255 + img = Image.fromarray(img.astype(np.uint8)).convert("RGBA") + output = _compress_img(img, image_format, dpi) + return base64.b64encode(output.getvalue()).decode("ascii") elif isinstance(fig, Figure): if own_figure: _constrain_fig_resolution(fig, max_width=max_width, max_res=max_res) @@ -484,20 +516,10 @@ def _fig_to_img( if image_format not in ("svg", "ndarray"): from PIL import Image - # https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html - pil_kwargs = dict() - if image_format == "webp": - # Here quality means speed/size tradeoff (either way the result is lossless) - pil_kwargs.update(lossless=True, quality=50) - elif image_format == "png": - pil_kwargs.update(optimize=True, compress_level=9) output.seek(0) orig = Image.open(output) if orig.mode == "RGBA": - background = Image.new("RGBA", orig.size, (255, 255, 255)) - new = Image.alpha_composite(background, orig).convert("RGB") - output = BytesIO() - new.save(output, format=image_format, dpi=(dpi, dpi), **pil_kwargs) + output = _compress_img(orig, image_format, dpi) if image_format == "ndarray": # float in [0, 1], like the PNG this used to go through diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py index eb2bbc33f0a..ac93b1ba6e1 100644 --- a/tutorials/intro/70_report.py +++ b/tutorials/intro/70_report.py @@ -156,11 +156,10 @@ # Adding `~mne.Covariance` # ^^^^^^^^^^^^^^^^^^^^^^^^ # -# (Noise) covariance objects can be added via -# :meth:`mne.Report.add_covariance`. The method accepts `~mne.Covariance` -# objects and the path to a file on disk. It also expects us to pass an -# `~mne.Info` object or the path to a file to read the measurement info from, -# as well as a title. +# (Noise) covariance objects can be added via :meth:`mne.Report.add_covariance`. The +# method accepts `~mne.Covariance` objects and the path to a file on disk. It also +# expects us to pass an `~mne.Info` object or the path to a file to read the measurement +# info from, as well as a title. cov_path = sample_dir / "sample_audvis-cov.fif" From 574ccd21d5fca9cd72e7361742664a3718674850 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sun, 30 Aug 2026 07:39:21 -0400 Subject: [PATCH 4/5] FIX: More --- mne/report/report.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/mne/report/report.py b/mne/report/report.py index 2eef38d84c4..cb6e0ee195b 100644 --- a/mne/report/report.py +++ b/mne/report/report.py @@ -496,17 +496,19 @@ def _fig_to_img( logger.debug( f"Saving figure with dimension {fig.get_size_inches()} inches with {dpi} dpi" ) - if image_format == "ndarray": - # Raw RGBA: the caller wants the rendered pixels, so encoding them as PNG - # only to decode them again below is pure overhead. + if image_format == "svg": + mpl_format = "svg" + elif image_format != "ndarray" and "bbox_inches" in mpl_kwargs: + # bbox_inches changes the rendered size, so the raw buffer could no longer be + # reshaped from the figure's own bbox + mpl_format = "png" + else: + # Raw RGBA: the pixels are all that is wanted below, so encoding them as PNG + # only to decode them again is pure overhead. mpl_format = "rgba" # Agg truncates the figure size to whole pixels (`RendererAgg.__init__`), # so rounding here would mis-shape the buffer at fractional DPI shape = (int(fig.bbox.size[1]), int(fig.bbox.size[0]), 4) - elif image_format == "svg": - mpl_format = "svg" - else: - mpl_format = "png" fig.savefig(output, format=mpl_format, dpi=dpi, **mpl_kwargs) if own_figure: @@ -516,8 +518,13 @@ def _fig_to_img( if image_format not in ("svg", "ndarray"): from PIL import Image - output.seek(0) - orig = Image.open(output) + if mpl_format == "rgba": + orig = Image.frombuffer( + "RGBA", shape[1::-1], output.getbuffer(), "raw", "RGBA", 0, 1 + ) + else: + output.seek(0) + orig = Image.open(output) if orig.mode == "RGBA": output = _compress_img(orig, image_format, dpi) From f5da8ba421a546ae7956a38c125415c840f6eaf6 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Sun, 30 Aug 2026 08:20:02 -0400 Subject: [PATCH 5/5] FIX: Better --- mne/report/report.py | 8 +++++++- mne/report/tests/test_report.py | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/mne/report/report.py b/mne/report/report.py index cb6e0ee195b..30797428be7 100644 --- a/mne/report/report.py +++ b/mne/report/report.py @@ -419,7 +419,7 @@ def _compress_img(img, image_format, dpi): # 20 rather than 50 encodes ~1.5x faster for a few percent more bytes pil_kwargs.update(lossless=True, quality=20) else: - assert image_format == "png", image_format + assert image_format == "png", image_format # _fig_to_img checks this pil_kwargs.update(optimize=True, compress_level=9) background = Image.new("RGBA", img.size, (255, 255, 255)) img = Image.alpha_composite(background, img).convert("RGB") @@ -442,6 +442,12 @@ def _fig_to_img( import matplotlib.pyplot as plt from matplotlib.figure import Figure + # Report validates its own image_format, but add_figure and friends pass whatever + # they are given straight through, and e.g. "PNG" is used in the docs + _validate_type(image_format, str, "image_format") + image_format = image_format.lower() + _check_option("image_format", image_format, _ALLOWED_IMAGE_FORMATS + ("ndarray",)) + if isinstance(fig, np.ndarray): # In this case, we are creating the fig, so we might as well # auto-close in all cases diff --git a/mne/report/tests/test_report.py b/mne/report/tests/test_report.py index 630b52d85fc..52d9aaa4bd4 100644 --- a/mne/report/tests/test_report.py +++ b/mne/report/tests/test_report.py @@ -211,6 +211,10 @@ def test_render_report(renderer_pyvistaqt, tmp_path, invisible_fig): # ... and the reverse: a figure whose size is not a whole number of pixels fig = plt.figure(figsize=(2.8, 2.8), dpi=89.6) assert _fig_to_img(fig, image_format="ndarray").shape == (250, 250, 4) + # add_figure does not validate image_format, so _fig_to_img normalizes and checks + report.add_figure(fig=fig, title="upper", image_format="PNG") # used in the docs + with pytest.raises(ValueError, match="Invalid value for the 'image_format'"): + report.add_figure(fig=fig, title="bad", image_format="jpeg") with pytest.raises(TypeError, match="It seems you passed a path"): report.add_figure(fig="foo", title="title")