Skip to content

Commit 44ca608

Browse files
committed
Speed up dipole fit rendering
1 parent a0eb925 commit 44ca608

9 files changed

Lines changed: 282 additions & 29 deletions

File tree

mne/gui/_dipolefit.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,11 @@ def _configure_main_display(self, show_sensors=True):
387387
time_viewer=False,
388388
initial_time=self._current_time,
389389
time_label=None, # the traces plot shows the current time
390+
# The source estimate is only a rough guide for where to put
391+
# dipoles, so map each surface vertex to its nearest source rather
392+
# than smoothing: the upsampling is then a gather instead of a
393+
# sparse matrix product, which is cheaper on every time change.
394+
smoothing_steps="nearest",
390395
brain_kwargs=dict(units="m", show=False),
391396
figure=fig_into,
392397
# the GUI renders on a white figure, so the Brain (and hence its
@@ -707,7 +712,9 @@ def _on_time_change(self, event):
707712
if self._time_line is not None:
708713
self._time_line.set_xdata([new_time])
709714
self._update_time_text()
710-
self._renderer._mplcanvas.update_plot()
715+
# only the time line and its label moved, so the traces can be blitted
716+
# from the cached background instead of being redrawn
717+
self._renderer._mplcanvas.update_blit_artists()
711718
self._update_arrows()
712719

713720
def _update_time_text(self):
@@ -1364,6 +1371,8 @@ def _setup_mplcanvas(self):
13641371
fontsize=8,
13651372
color="black",
13661373
)
1374+
# the label travels with the time line, so it is drawn along with it
1375+
canvas.add_blit_artist(self._time_text)
13671376
return self._renderer._mplcanvas
13681377

13691378
def close(self):

mne/gui/tests/test_dipolefit.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,10 @@ def process_and_reenter():
171171
assert not hasattr(g._fig, "_time_label_actor")
172172
assert re.fullmatch(r"90 ms · GOF \d+%", g._time_text.get_text())
173173
assert g._time_text.get_position()[0] == 0.09
174+
# both move with the time, so they are drawn on top of a cached background
175+
# rather than triggering a full redraw of the traces plot
176+
blit_artists = g._renderer._mplcanvas._blit_artists
177+
assert blit_artists == [g._time_line, g._time_text]
174178

175179
g.fit_dipole()
176180
assert len(g._dipoles) == len(g.dipoles) == 2

mne/viz/_3d_overlay.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,17 +151,28 @@ def _map(self):
151151
self._is_mapped = True
152152

153153
def _compute_over(self, B, A):
154+
# Alpha-composite A ("over") on top of B, both RGBA in [0, 1].
155+
#
156+
# This runs on every time point of an interactive time course, on surfaces
157+
# with >1e5 vertices, so it is written to touch the (n_vertices, 4) arrays
158+
# as few times as possible and only ever whole: expressing it in terms of
159+
# the RGB columns (``C[:, :3] *= ...``) makes every operation strided,
160+
# which costs ~4x more than the same work on the full array. The alpha
161+
# column is included in the arithmetic and simply overwritten at the end.
154162
assert A.ndim == B.ndim == 2
155163
assert A.shape[1] == B.shape[1] == 4
156-
A_w = A[:, 3:] # * 1
157-
B_w = B[:, 3:] * (1 - A_w)
158-
C = A.copy()
159-
C[:, :3] *= A_w
160-
C[:, :3] += B[:, :3] * B_w
161-
C[:, 3:] += B_w
162-
C_alpha_zero = C[:, 3] == 0
163-
C[~C_alpha_zero, :3] /= C[~C_alpha_zero, 3:]
164-
C[C_alpha_zero, :3] = 0
164+
A_w = A[:, 3].copy() # copy: column slices of a (n, 4) array are strided
165+
B_w = B[:, 3].copy()
166+
B_w *= 1 - A_w
167+
C = A * A_w[:, None]
168+
C += B * B_w[:, None]
169+
alpha = A_w + B_w
170+
# Where the composite is fully transparent the color is undefined: divide
171+
# by one there instead, and zero those rows out afterwards.
172+
opaque = alpha != 0
173+
np.divide(C, np.where(opaque, alpha, 1)[:, None], out=C)
174+
C *= opaque[:, None]
175+
C[:, 3] = alpha
165176
return np.clip(C, 0, 1, out=C)
166177

167178
def _compose_overlays(self):

mne/viz/_brain/_brain.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1907,7 +1907,9 @@ def plot_time_line(self, update=True):
19071907
)
19081908
self.time_line.set_xdata([current_time])
19091909
if update:
1910-
self.mpl_canvas.update_plot()
1910+
# only the time line moved, so the rest of the figure can be
1911+
# blitted from the cached background instead of being redrawn
1912+
self.mpl_canvas.update_blit_artists()
19111913

19121914
def _configure_help(self):
19131915
pairs = [
@@ -4295,7 +4297,7 @@ def _update_current_time_idx(self, time_idx):
42954297
time_actor = active.get("time_actor", None)
42964298
time_label = active.get("time_label", None)
42974299
for hemi in ["lh", "rh", "vol"]:
4298-
hemi_needs_recompose = False
4300+
staged_keys = list()
42994301
for data_key, key_data in self._all_data.items():
43004302
hemi_data = key_data.get(hemi)
43014303
if hemi_data is None:
@@ -4353,10 +4355,10 @@ def _update_current_time_idx(self, time_idx):
43534355
key_data["fmax"],
43544356
]
43554357
if data_key in mesh._overlays:
4356-
# Stage without recomposing; a single mesh.update() below
4357-
# handles all overlays in O(N) instead of O(N²).
4358+
# Stage without recomposing; a single update below handles
4359+
# all overlays in O(N) instead of O(N²).
43584360
mesh.update_overlay(data_key, scalars=act_data, update=False)
4359-
hemi_needs_recompose = True
4361+
staged_keys.append(data_key)
43604362
else:
43614363
mesh.add_overlay(
43624364
scalars=act_data,
@@ -4371,8 +4373,15 @@ def _update_current_time_idx(self, time_idx):
43714373
if vectors is not None and data_key == self._active_data_key:
43724374
self._update_glyphs(hemi, vectors)
43734375

4374-
if hemi_needs_recompose and hemi in self.layered_meshes:
4375-
self.layered_meshes[hemi].update()
4376+
if staged_keys and hemi in self.layered_meshes:
4377+
if len(staged_keys) == 1:
4378+
# Let update_overlay pick the cached path when the overlay we
4379+
# staged is the topmost one: the layers below it (curvature,
4380+
# labels, ...) have not changed, so their composite can be
4381+
# reused instead of color-mapping them all again.
4382+
self.layered_meshes[hemi].update_overlay(staged_keys[0])
4383+
else:
4384+
self.layered_meshes[hemi].update()
43764385

43774386
active["time_idx"] = time_idx
43784387
self._renderer._update()

mne/viz/_brain/tests/test_brain.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,21 @@ def test_layered_mesh(renderer_interactive_pyvistaqt):
190190
opacity=np.array([0.1, 0.2, 0.3]),
191191
name="bad-opacity",
192192
)
193+
194+
# alpha compositing: transparent top keeps the bottom color, opaque top wins,
195+
# and a half-transparent white over opaque black is grey
196+
bottom = np.array([[0.0, 0, 0, 1]] * 3)
197+
top = np.array([[1.0, 1, 1, 0], [1, 1, 1, 1], [1, 1, 1, 0.5]])
198+
assert_allclose(
199+
mesh._compute_over(bottom, top),
200+
[[0, 0, 0, 1], [1, 1, 1, 1], [0.5, 0.5, 0.5, 1]],
201+
)
202+
# a fully transparent result is black, and the inputs are left alone
203+
bottom, top = np.zeros((1, 4)), np.zeros((1, 4))
204+
assert_allclose(mesh._compute_over(bottom, top), [[0, 0, 0, 0]])
205+
assert_allclose(bottom, 0)
206+
assert_allclose(top, 0)
207+
193208
mesh._clean()
194209

195210

@@ -1661,6 +1676,39 @@ def row_text(row):
16611676
assert_allclose(peak_line3.get_ydata(), 2.0 * y1)
16621677

16631678

1679+
@testing.requires_testing_data
1680+
def test_brain_time_line_blitting(renderer_interactive_pyvistaqt, brain_gc):
1681+
"""Test that moving the time line blits instead of redrawing the traces."""
1682+
brain = _create_testing_brain(hemi="lh", show_traces=True, initial_time=0)
1683+
canvas = brain.mpl_canvas
1684+
assert canvas.canvas.supports_blit
1685+
assert brain.time_line in canvas._blit_artists
1686+
assert brain.time_line.get_animated()
1687+
1688+
n_draws = list()
1689+
canvas.canvas.mpl_connect("draw_event", lambda event: n_draws.append(event))
1690+
canvas.update_plot() # a full redraw caches the background ...
1691+
assert canvas._blit_background is not None
1692+
assert len(n_draws) == 1
1693+
1694+
brain.set_time(brain._times[-1]) # ... so moving the time line only blits
1695+
assert brain.time_line.get_xdata()[0] == brain._times[-1]
1696+
assert len(n_draws) == 1
1697+
1698+
# adding a trace still redraws in full, and anything can be blitted
1699+
text = canvas.axes.text(0, 0, "hello")
1700+
canvas.add_blit_artist(text)
1701+
assert text.get_animated()
1702+
canvas.update_blit_artists() # background was dropped, so this redraws
1703+
assert len(n_draws) == 2
1704+
1705+
canvas.remove_blit_artist(text)
1706+
assert not text.get_animated()
1707+
assert text not in canvas._blit_artists
1708+
assert len(n_draws) == 3 # restored to the background by a full redraw
1709+
brain.close()
1710+
1711+
16641712
def _send_mouse_move(widget, point, buttons=None):
16651713
"""Deliver a synthetic Qt mouse move (QTest.mouseMove warps the real cursor)."""
16661714
from qtpy.QtCore import QEvent, QPointF, Qt

mne/viz/backends/_abstract.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,6 +1429,12 @@ def __init__(self, width, height, dpi):
14291429
self.axes = self.fig.add_subplot(111)
14301430
self.axes.set(xlabel="Time (s)", ylabel="Activation (AU)")
14311431
self.manager = None
1432+
# Artists that are redrawn on their own (see `add_blit_artist`), the
1433+
# background they are drawn onto, and the draw_event callback id that
1434+
# keeps that background up to date.
1435+
self._blit_artists = list()
1436+
self._blit_background = None
1437+
self._blit_cid = None
14321438

14331439
def _connect(self):
14341440
for event in ("button_press", "motion_notify") + self._extra_events:
@@ -1444,10 +1450,85 @@ def plot(self, x, y, label, update=True, **kwargs):
14441450
def plot_time_line(self, x, label, update=True, **kwargs):
14451451
"""Plot the vertical line."""
14461452
line = self.axes.axvline(x, label=label, **kwargs)
1453+
self.add_blit_artist(line)
14471454
if update:
14481455
self.update_plot()
14491456
return line
14501457

1458+
def add_blit_artist(self, artist):
1459+
"""Mark an artist as fast-updating, to be drawn by :meth:`update_blit_artists`.
1460+
1461+
Such an artist is excluded from the canvas background, so that moving it
1462+
(e.g. the time line, or a label that travels with it) costs a blit of the
1463+
cached background rather than a full redraw of the figure.
1464+
1465+
Parameters
1466+
----------
1467+
artist : instance of matplotlib.artist.Artist
1468+
The artist to draw separately. Must live in this canvas's axes: an
1469+
artist added to the figure itself would be left out of saved images,
1470+
because Matplotlib only exempts *Axes* children from the rule that
1471+
animated artists are not drawn (see ``_AxesBase.draw``).
1472+
"""
1473+
if not self.canvas.supports_blit: # e.g. ipympl in a notebook
1474+
return
1475+
if artist.axes is not self.axes:
1476+
raise RuntimeError(
1477+
f"{artist!r} must be an artist of this canvas's axes to be drawn "
1478+
"separately, got one in " + repr(artist.axes)
1479+
)
1480+
if artist in self._blit_artists:
1481+
return
1482+
artist.set_animated(True)
1483+
self._blit_artists.append(artist)
1484+
# the cached background may already contain this artist, so drop it and
1485+
# let the next update redraw (and re-cache) the figure without it
1486+
self._blit_background = None
1487+
if self._blit_cid is None:
1488+
# Grab a fresh background after every full redraw, whatever caused it
1489+
# (update_plot, draw_idle, a resize, a DPI change, ...).
1490+
self._blit_cid = self.canvas.mpl_connect("draw_event", self._on_draw)
1491+
1492+
def remove_blit_artist(self, artist):
1493+
"""Stop drawing an artist separately, putting it back in the background.
1494+
1495+
Parameters
1496+
----------
1497+
artist : instance of matplotlib.artist.Artist
1498+
The artist to stop drawing separately. Artists that were never added
1499+
are ignored.
1500+
"""
1501+
if artist not in self._blit_artists:
1502+
return
1503+
self._blit_artists.remove(artist)
1504+
artist.set_animated(False)
1505+
self.update_plot() # redraw so the artist becomes part of the background
1506+
1507+
def update_blit_artists(self):
1508+
"""Redraw only the artists added with :meth:`add_blit_artist`.
1509+
1510+
This is the fast path taken while the time line moves; any other change
1511+
to the figure needs :meth:`update_plot` instead.
1512+
"""
1513+
if self._blit_background is None or not self._blit_artists:
1514+
self.update_plot() # nothing cached yet (or nothing to draw fast)
1515+
return
1516+
self.canvas.restore_region(self._blit_background)
1517+
self._draw_blit_artists()
1518+
self.canvas.blit(self.fig.bbox)
1519+
1520+
def _draw_blit_artists(self):
1521+
for artist in self._blit_artists:
1522+
self.fig.draw_artist(artist)
1523+
1524+
def _on_draw(self, event=None):
1525+
"""Cache the background after a full redraw (draw_event callback)."""
1526+
self._blit_background = self.canvas.copy_from_bbox(self.fig.bbox)
1527+
if not self.canvas.is_saving():
1528+
# When saving, Matplotlib draws animated artists itself; drawing them
1529+
# again here would just double up their antialiasing.
1530+
self._draw_blit_artists()
1531+
14511532
def update_plot(self):
14521533
"""Update the plot."""
14531534
with warnings.catch_warnings(record=True):
@@ -1503,6 +1584,11 @@ def close(self):
15031584
def clear(self):
15041585
"""Clear internal variables."""
15051586
self.close()
1587+
if self._blit_cid is not None:
1588+
self.canvas.mpl_disconnect(self._blit_cid)
1589+
self._blit_cid = None
1590+
self._blit_artists.clear() # the artists go away with the figure below
1591+
self._blit_background = None
15061592
self.axes.clear()
15071593
self.fig.clear()
15081594
self.canvas = None

0 commit comments

Comments
 (0)