Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

The grid builds a whole Figure just to throw it away

Last edited: 2026-08-18 · #13

inspect_MRSI_spectra() stopped going through matplotlib for its anatomical frames and got ~130 ms per slice back, plus sharper images at the volume’s native resolution — the composite is really cmap(norm(arr)) and an alpha blend, and a Figure/Agg canvas is an expensive way to create these images (see the slice-slider entry). display_images() does the same arithmetic and still pays the same tax: a Figure, a gridspec, per-axis imshow, a PNG encode, once per slice. The reason it wasn’t converted with the MRSI path is that its frames carry things the MRSI frames don’t — panel titles, a figure title, a colorbar. Those are what keep the Figure alive.

What the split looks like

matplotlib is not gone — it still supplies the colormap through ScalarMappable, so the colorbar gradient and the pixels come from one source and cannot drift. What it stops doing is laying out and rasterising. _render_frames() does the rasterising for every widget in the module (see below); _render_image_grid_frames() asks it for one frame per panel per slice and returns them slice-major together with the (vmin, vmax) each was normalised with; _widgets/image_grid.{py,js,css} turns that into the page.

Annotation todayWhere it lands
per-panel titles<figcaption> under each panel
fig_titleheading above the grid
colorbar, mode="single"one 64-stop CSS linear-gradient strip + tick labels
colorbar, mode="each"per-panel strip, bounds shipped per slice (this mode autoscales)
xlabel/xticks/aspect/imshow_kws/fig_kwsnothing — there is no Axes left
# 4D input, four panels, a shared colorbar: displays, returns nothing.
mnutils.plotting.display_images(
    [t1, pdff, r2s, mask], titles=["T1", "PDFF", "R2*", "Mask"], colorbar=True
)

That last line is the breaking part. display_images() used to hand back (Figure, axes) for 2D input and None for a multi-slice stack; with no Figure anywhere it returns None always (and so does display_nifti(), and NiiBase.display()). Callers that post-processed the returned axes lose that hook — deliberately, since keeping it means keeping a second full rendering path for one input shape. A single-slice input is now the same widget with no slider — just the bar’s Border checkbox, which draws each frame’s real extent back in, since NaN pixels are transparent and a mostly-masked panel otherwise has no visible edge.

The measurement that settled it, on the 90×90×100×7 volume docs/plotting/images.md generates — 700 panels against the old path’s 100 whole-grid PNGs:

payloadrender
Figure + PNG per slice14.6 MB17.2 s
PIL + WebP per panel0.85 MB0.9 s

Native resolution being cheaper here is the opposite of what the MRSI frames found, and for a plain reason: an MRSI frame is one 512² image where the dpi-derived alternative was 400², while a grid frame is one panel out of seven that the old path rendered inside a 1400×600 figure canvas padded with white margins, ticks and text. Dropping the canvas drops most of the pixels.

The second renderer that shouldn’t have been

Porting the grid produced a renderer that was, line for line, the one the MRSI inspector already had: normalise, colormap through ScalarMappable, encode WebP on a thread pool. They differed only in their layers — one opaque panel, against a gray T1 under a half-transparent magma overlay — and in which axes an index walked. Two copies of that is how the panels and the inspector drift apart later.

So there is one frame renderer, _render_frames(), and the difference is data. A _Layer names a volume, its colormap, its bounds and its alpha; a job is the trailing index, so volume[:, :, *job] is the image that frame colormaps. Layers composite in order, reproducing what imshow(alpha=…) does — the layer’s alpha scaled by the colormap’s own per-pixel alpha, so “bad” pixels let the layer below through untouched instead of blending toward the bad colour.

CallerLayersOne job is
display_images()one, opaque, RGBA out(slice, panel)
inspect_MRSI_spectra()gray T1, magma overlay at α 0.5, RGB out(slice,)

_render_image_grid_frames() survives, but it no longer renders: it resolves the bounds each panel is normalised with — one shared pair, or one per panel and slice under colorbar_kws={"mode": "each"} — and nests the flat result the way the widget indexes it.

Why not fold the overlay slice viewer’s renderer in as well?

_render_overlay_frames() draws contours, three titled axes and a mask outline onto a matplotlib Figure. That is a different mechanism, not a third variant of colormap-and-blend: absorbing it would mean reimplementing contour(), which is exactly the “matplotlib supplies, we compose” line this port was careful not to cross.

Handing the layout to CSS means saying what “too big” means

A Figure came with a size. A raster doesn’t, so every bound the figure used to imply had to be written down — and the useful version of each turned out to be the one expressed in the other axis from the one it names.

Sizing panels by height was the first attempt and it fails in a narrow pane: height is not a thing CSS can hold constant while width has to give. So the panel fills its column and the column is what yields — and the floor becomes a width too, --mnu-panel-min-h × aspect, so a row of 5×20 frames wraps sooner instead of flattening into strips. --mnu-grid-max-h likewise bounds the scroll box rather than the panels: dividing a fixed height budget between rows made a twelve-panel grid twelve stamps, where scrolling keeps twelve readable panels.

Two decisions on the raster side follow from the same “the browser is doing the drawing now” shift. Frames scaled up get image-rendering: pixelated, set per image by the JS once it can compare naturalWidth against the rendered width, so a 20×20 mask reads as 400 square pixels rather than a blur — and frames under 64×64 are encoded losslessly, because lossy WebP rings around hard edges. The threshold stays low deliberately: at 90×90, lossless costs 4.4× the payload to fix artifacts nobody can see at that scale.

Why not draw the annotations into the raster with PIL?

PIL.ImageDraw could paint titles and a colorbar strip onto the frame, which keeps the client side trivial. But it makes us own font discovery and text metrics, bakes the layout at render resolution, and multiplies payload for mode="each" where only the tick labels change per slice. Text in the DOM stays sharp at any zoom and lets a slice change rewrite three text nodes.

Why not keep matplotlib whenever annotations are requested?

A titled grid with a colorbar is the common call, so a fast path that excludes it optimises the case nobody makes — while freezing two rendering implementations in place forever.

Why RGBA, when the MRSI frames are RGB?

An MRSI frame is a T1 with an overlay blended onto it: every pixel has something behind it. A grid panel doesn’t, and zeros_as_nan=True deliberately produces pixels with nothing to show. matplotlib gave those the figure’s face colour, which is a light-theme decision baked into the image. Alpha 0 defers it to the page, so masked voxels read correctly in both themes.

What changed from the plan