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.

Drawing an MRSI voxel on an anatomical slice

I have one MRSI voxel’s index [i, j, k]. Which pixel does it land on in a T1 slice, and how big a box do I draw around it?

Both questions come down to one thing: a NIfTI affine maps a voxel index to the world-space center of that voxel, in millimeters — never a corner. Every function on this page exists to get that one mapping right, because the two ways to get it subtly wrong (an off-by-half-voxel offset, a double-applied shift) don’t crash — they draw a box next to the right answer, which is much harder to notice.

FunctionWhat it does here
get_voxel_visible_slice_range()which T1 slices a given MRSI voxel is visible on
draw_voxel_overlays_on_axdraws the voxel box, given an MRSI→display affine

Mapping an MRSI voxel index to a T1 voxel index

mrsi2anat = inv(T1.affine) @ MRSI.affine composes the two affines into one: MRSI voxel index in, T1 voxel index out. Applying it to voxel [0, 0, 0] should land exactly on the world-space center of that voxel, converted into T1’s index space — the same answer you’d get by hand from each affine separately.

[175.         205.          28.33333333]

Drawing the box: corner = center minus half-width

matplotlib.patches.Rectangle takes a corner, not a center — so the box’s corner has to be the voxel’s world center, offset back by half the voxel’s own display width. Get that offset wrong by even a little and the box still looks plausible; it’s just centered on the wrong point.

159.41 -> center 171.91 vs 171.91

❌ The bug this guards against: a hardcoded offset (affine[0, 0] - 13, tuned to look right for one dataset’s voxel size) instead of affine[0, 0] / 2. The two agree only by coincidence for the voxel size they were tuned against — 12 vs. the correct 12.5 above, half a T1 voxel off for every other dataset.

✅ The rule: the offset is always voxel_size / 2, derived from the affine itself, never a constant.

draw_voxel_overlays_on_ax gets this right even when the display affine transposes the axes (a common case: MRSI axis 0 maps to the display column, not the row) — the voxel extents then live off-diagonal, so reading affine[0, 0]/affine[1, 1] directly for the box size would silently give zero:

<Figure size 960x720 with 1 Axes>
25.0 25.0

Which T1 slices a voxel is visible on

An MRSI voxel is thick — it spans several thin T1 slices along the through-plane axis. get_voxel_visible_slice_range returns the (min, max) T1 slice indices the voxel covers, always including the slice nearest its own center, and clamped to [0, n_slices - 1] so a voxel near the edge of the volume never returns an out-of-bounds index.

voxel [5,5,5] visible on T1 slices (19, 22)
voxel [0,0,0] visible on T1 slices (0, 2) (clamped to 76 slices)

create_MRSI_affine: why the shift is half a voxel-size change

MRSISeries.create_MRSI_affine() builds the “blocky” grid affine (one cell per acquired voxel) from the fine, interpolated nii.affine it started from. Both affines use the standard NIfTI convention — index i’s center sits at world = voxel_size * i + translation — and both describe the same field of view, just at different resolutions. Scaling the voxel size alone keeps voxel 0’s world position pinned to the fine grid’s corner instead of its own center, so the origin needs a shift too: exactly half of how much the voxel size changed, (new_voxel - old_voxel) / 2.

Loading...
Loading...
True

MRSISeries.RAW_exp (the blocky NIfTI used for overlay display) is built from exactly this affine — the assertion above is what keeps them from drifting apart.

Cropping the display without breaking the voxel box

draw_voxel_overlays_on_ax always computes the box in full, uncropped display coordinates — it only depends on mrsi_to_display_affine, never on the image array you actually plotted. Crop the anatomical/MRSI images to a tighter figure and the box drawn by the un-cropped affine now points at the wrong pixel unless you shift it back by the crop’s own offset.

<Figure size 600x600 with 1 Axes>
[ True  True] [ True  True]

The recipe, generalized:

  1. Build mrsi_to_display_affine = inv(get_display_affine(T1)) @ MRSI.RAW_exp.nii.affine.

  2. Crop both anatomical and (resampled) MRSI images with the same (y_slice, x_slice, z) bounds.

  3. Plot the cropped images with overlay_image_data_on_T1(..., ax=...).

  4. Draw the voxel box with draw_voxel_overlays_on_ax(ax, voxel, mrsi_to_display_affine, ...) — still in full-image coordinates.

  5. Shift the patch: patch.set_xy(patch.get_xy() - np.array([x_offset, y_offset])).

For a single voxel with no cropping, overlay_voxel_on_T1() wraps steps 1, 3 and 4 into one call:

<Figure size 600x600 with 1 Axes>

ax= is one of three ways to call overlay_image_data_on_T1

The single-Axes draw above is what overlay_image_data_on_T1(..., ax=...) dispatches to whenever ax is given -- there is no client-side Axes for a fast raster widget to draw into, so ax always forces matplotlib. Leave ax out and the T1/data/overlay slice-scrubbing widget is the default, rendered without a Figure at all:

Loading...
None

engine="matplotlib" opts back into the pre-raster path, including its (Figure, axes) return for single-slice input:

<Figure size 1800x600 with 3 Axes>

The raster engine has no contour tracing, so mask_contour warns and is dropped rather than silently doing nothing or erroring:

Loading...