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.

Displaying image arrays of any shape

I have an array — could be one 2D slice, could be a volume, could be several volumes — and I just want to look at it.

display_images() takes 2D, 3D or 4D input and picks a sensible layout automatically.

Imports & random number seed & 4D volume generation

The rule: the last axis is “how many images”; everything else is “one volume”

Loading...

A 4D array with one slice per volume is the grid case: seven panels, laid out five per row.

(90, 90, 1, 7)
Loading...

A list of same-shaped arrays works too — it’s stacked along a new last axis before the same rule applies (more on that below, since a list is often exactly how a multi-slice case shows up).

But a volume has more than one slice — can I see the rest?

The examples above all used a single slice. Real volumes don’t stop at one: a (H, W, S) array with S > 1 still shows only its middle slice by default — so what about the other S - 1?

display_images() shows them too, as a slider, right here on this page — not just in a live Jupyter session. Every call displays an interactive widget and returns None; with more than one slice that widget gains a slider, and with exactly one it simply doesn’t.

Loading...

The same rule applies one level down for a 4D grid: every panel shares a single slider, since they’re all still slices of the same underlying volume stack.

Loading...

This is also why a list of plain 2D arrays is a multi-slice case, not a static one: stacked along a new last axis, four 2D arrays become one volume with four slices — the same “not a grid” rule as above, just arrived at differently — so it engages the same interactive widget:

Loading...

A list of volumes (each already 3D) becomes 4D instead — a grid, one per volume:

Loading...

Where the titles and the colorbar come from

The panels you see above are plain images, rendered straight from the array at its own pixel size — no matplotlib figure is built at any point, which is why nothing is returned to post-process. Everything drawn around the pixels is HTML instead: fig_title becomes a heading, titles become captions, and colorbar=True becomes a gradient strip labelled with the bounds the panels were scaled to.

Loading...

Pass colorbar_kws={"mode": "each"} for one bar per panel instead of one for the grid. That mode autoscales every panel to its own slice, so the numbers beside each bar change as you scrub:

Loading...

Panels are the array’s own pixels, so a non-square array stays non-square — a (30, 120) slice is drawn four times wider than it is tall, not stretched to fill its share of the row:

(30, 90, 1, 7)
Loading...

Once there are enough panels, the columns wrap at --mnu-panel-min-w and the rows that don’t fit go behind a scrollbar — the block stays the same size on the page whether it holds three images or thirty:

Loading...

Low-resolution arrays keep their pixels

A (20, 20) mask blown up to a few hundred screen pixels is a different problem from a 512² anatomical shrunk to fit: the first wants to look like the twenty-by-twenty grid of values it actually is, the second wants smoothing. Both happen automatically — a panel scaled up is drawn with hard pixel edges, one scaled down keeps the browser’s smooth default — and small frames are encoded losslessly, so no compression ringing creeps in around the blocks.

Loading...

The same holds for a non-square low-resolution array, and for a grid of them:

Loading...
Loading...

Zeros vs. real signal

Background voxels are often exactly 0, not a small real value — treating them as data skews a colorbar built from vmin/vmax/percentile clipping toward the background instead of the signal you actually care about, and paints a slab of colormap-black over whatever you were overlaying onto. zeros_as_nan handles both, and is on by default:

Loading...

Masked-out voxels are transparent, not a colour: the panels are RGBA images, so the background of the page shows through them in both light and dark themes. Pass zeros_as_nan=False to scale against — and paint — every voxel including the zeros.

The flag is really two decisions that used to be tangled together, and each now lives with the code that acts on it. The numbers come from fast_bounds, which any caller can ask to skip zeros:

zeros counted:  (0.0, 78.01758659696651)
zeros excluded: (15.321770178044574, 97.0465364229024)

Percentiles are the wrong summary for a segmentation, though. Clipping the tails of a continuous image is what protects it from a handful of hot voxels, but a label map has no tails to clip — on a mask that is mostly background, the 1st and 99th percentile are both 0, and the whole colormap collapses onto the background with the labels invisible above it. So fast_bounds reads an array holding at most max_discrete_levels distinct whole numbers as a label map and takes its exact range instead:

labels: (0.0, 4.0)

The pixels come from the renderer, one layer at a time — which is what makes it work for the overlays too. overlay_image_data_on_T1, overlay_nifti_data_on_T1, inspect_MRSI_spectra and overlay_voxel_on_T1 mask the data layer’s zeros by default and leave the anatomical beneath alone, on either engine:

Loading...

Per layer rather than per call, because the two want opposite answers: an overlay’s zeros are outside its field of view and should drop out, while the T1’s zeros are the air around the head — mask those and the page background appears inside the skull outline. Pass zeros_as_nan=False to switch the data layer back off.