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 slice slider dies the moment the docs stop running

Last edited: 2026-08-17 · PR #12

display_images(), overlay_image_data_on_T1() and inspect_MRSI_spectra() once scrubbed through slices with ipywidgets.interact(), redrawn live by the ipympl matplotlib backend. That redraw needs a running Python kernel behind the slider. MyST docs are executed once (myst build --html --execute) and nothing here keeps a kernel alive afterwards, so the slider that shipped to a reader was dead on arrival. ipympl and ipywidgets were dev-only dependencies too, yet images.py imported ipywidgets at module load — a plain install couldn’t import mnutils.plotting.images at all.

Why not the widget protocol

The first attempt used anywidget, on the theory that mystmd’s handling of the widget-state+json / widget-view+json MIME types would keep a built page interactive. Recognising an output type turns out not to be the same as supporting the protocol that produces it. Both backends were built and measured side by side, and a real myst build --html --execute against tests/datasets/HeVo-18 says:

Evidence in the built pageResult
application/vnd.jupyter.widget-state+json0
application/vnd.jupyter.widget-view+json1, containing only {"model_id": "dc405eee…"}
Exception opening new comm in build stderronce per widget-displaying cell
self-contained HTML widgets written3 of 3

mystmd’s execution engine registers no jupyter.widget comm target, so comm_open throws and the state channel carrying the frames and spectra never opens. A model ID with nothing behind it reaches the page. The rule that generalises: ship interactivity as text/html whenever the interaction doesn’t need a live kernel round-trip — none of these three ever did.

The anywidget backend was kept for a while as a control that kept the claim falsifiable. It has since been deleted, along with the backend= argument, the traitlets adapter and the transport-tagging fields the frontend needed to tell the two apart: once the measurement had been made and written down here, a second backend meant making every widget change twice.

What the frames actually cost

Baking every slice in Python is only viable if baking is cheap, and at first it wasn’t: 15.5 s and a 41.9 MB output for a 512×512×100 T1 over a 16×16×16×700 grid. Reusing one Figure across slices helped, which left fig.savefig(png) as ~130 ms of the remaining per-slice cost — spent rasterizing an axes with no ticks, labels, titles, colorbar or contours, only two stacked imshows. The entire Agg pipeline was being paid for cmap(norm(arr)) plus an alpha blend.

So the frames are baked with that arithmetic directly — _render_frames(), which the image grid later came to share (see the grid entry). Matplotlib still supplies the colormaps through ScalarMappable, so nothing is reimplemented and the two cannot drift; only the canvas is gone. Frames are WebP q92 (41 dB PSNR against the exact composite, ~7× smaller than PNG) at the volume’s native resolution, and encoding releases the GIL so slices go through a thread pool.

15.5 s → 3.0 s, 41.9 MB → 12.8 MB.

The spectra buffer

Every voxel’s spectrum travels with the widget, which was 11.5 MB of float32 inflating to 15.3 MB of base64. It is now float16 + zlib, and two details there were not obvious:

Worst per-voxel error is 0.039% of that voxel’s own peak. The browser inflates it with the built-in DecompressionStream — no JS dependency — and converts only the selected voxel’s points rather than the whole grid.

What travels is the whole acquired sweep. Cropping to ±20 ppm first (a 2H sweep at 3 T spans ~255 ppm, of which that window is a sixth) shipped for a while and did what it promised — 1.1 MB of buffer against 6.7 on HeVo-18 — but it fixed the reachable ppm range at call time, so no slider position could ever show a sample the caller hadn’t anticipated. At a 12.2 MB page that trade stopped being worth it, and ppm_range is gone; _encode_spectra still warns past 32 MB of float32, which is where a grid genuinely gets unwieldy.

Who owns the y axis

Reading one voxel wants the axis scaled to that voxel. Comparing two wants it held still. Chasing a small peak beside a huge one wants neither. Three modes, and the question is how a reader moves between them without a settings panel:

Taking hold of the vertical slider ticks the fixed-y box itself: hand-set limits mean nothing on an axis that keeps rescaling under them. Unticking is the one way back, and it drops the manual limits with the envelope. With no manual limits the slider reads as fully open rather than tracking the automatic bounds — tracking them is tidier right up to the first quiet voxel, whose window is a couple of percent of the grid’s amplitude range, putting both handles inside the same handful of pixels where they read as one.

Why float16 and not int16?

int16 with a global scale compresses slightly better, but the grid spans 0.15 to 6 × 10⁷, so a single scale leaves a weak voxel with ~9% error. float16 holds ~0.1% relative precision at every magnitude, which is what matters when you view one autoscaled voxel at a time.

Why not Plotly frames, or colormapping in JS?

Plotly’s slider is client-side too, but means re-plotting in a second, Plotly-specific way — a maintenance fork of rendering logic that already lives in matplotlib. Sending raw pixel arrays and colormapping in JS is smaller still, but means reimplementing matplotlib’s colormap and contour handling and keeping the two in lockstep forever. Baking matplotlib’s own colormap output keeps one implementation.

Why not one frame per (slice, voxel) pair?

Only the voxel outline changes per voxel, not the image under it. For a 16×16 grid across ~100 anatomical slices that would be tens of thousands of frames; sending the affine and projecting the outline in JS keeps it at one frame per slice.

What changed from the plan