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.

How much of this MRSI voxel is inside the mask?

I have a segmentation at sub-millimetre resolution and a spectroscopic grid at ten-plus millimetres, on a different field of view. What fraction of each spectrum’s voxel does the segmentation actually cover?

mask_occupancy() answers that in one call. This page takes the call apart and rebuilds it by hand on the synthetic brain exam, one step at a time, checking each step against the library as it goes — so the number it returns is something you can audit rather than trust.

That is four volumes but only two grids, and the split between them is the whole problem. Look at each one before any arithmetic happens — display() draws an interactive grid with a slice slider rather than a static figure, so scrub through a volume instead of trusting whichever slice a figure happened to pick.

The T1 is the fine grid, and the anatomy every other volume on this page is defined against:

Loading...

The same head, as the spectroscopic acquisition sees it. One voxel per spectrum, a couple of dozen across the brain — each of those blocks is what the rest of this page has to attach a fraction to:

Loading...

Back on the fine grid, brain_mask is that T1 reduced to a binary in-or-out. This is the mask in “how much of this MRSI voxel is inside the mask”: everything below is about pushing it onto the blocky grid above without losing or inventing volume. A bare nibabel image gets wrapped in NiiBase to borrow the same viewer:

Loading...

tissue_seg divides that same interior further into three categories. A single mask is the easier story, so most of the page uses brain_mask; the segmentation returns at the end, where the per-label fractions have to add back up to the whole voxel:

Loading...

Fine grid and blocky grid, side by side as numbers — this is the mismatch to reconcile:

mask   (188, 256, 186)  voxel [0.88 0.88 0.88] mm
target (16, 16, 16)  voxel [10.34 14.08 10.23] mm

Anisotropic target voxels, a mask an order of magnitude finer, and two different fields of view. Nothing below special-cases any of that — it all lives in one matrix.

Step 1 — one matrix absorbs every difference between the grids

Both images carry an affine mapping their own voxel indices to world millimetres. Composing one with the inverse of the other gives a single transform from mask index to target index, and it absorbs the differing voxel size, field of view, position and obliquity at once:

[[ 0.0851  0.      0.     -0.4574]
 [ 0.      0.0625  0.     -0.4688]
 [ 0.      0.      0.086  -0.457 ]
 [ 0.      0.      0.      1.    ]]

Read the diagonal as “how many mask voxels fit along one target voxel” (inverted): about 11.8, 16.0 and 11.6 respectively. Those non-integer ratios matter later.

The matrix is easier to believe when you draw it. Push every T1 voxel of one slice through tgt_from_mask, take the target voxel each lands in, and colour it as a checkerboard: that is the MRSI lattice, rendered on the anatomy by the same arithmetic the algorithm uses. Anything mapping off the target grid is hatched — that is the overhang which will come back as low coverage:

0.0% of this T1 slice maps off the MRSI grid (shown in red)
<Figure size 630x630 with 1 Axes>

Each check is one MRSI voxel. They are visibly wider than they are tall — that is the anisotropy from the printout above — and the brain does not sit neatly inside them.

Step 2 — a voxel index names a centre, so the bin is floor(x + 0.5)

Map a mask voxel through tgt_from_mask and you get a fractional target coordinate. Turning that into a bin is where the half-voxel bugs live: a NIfTI index names the centre of its voxel, so target voxel n spans [n - 0.5, n + 0.5) and the bin is floor(x + 0.5) — not floor(x).

mapped to target coordinate [7.543 7.531 7.543]
correct bin  floor(x + 0.5) = [8 8 8]
wrong bin    floor(x)       = [7 7 7]

Step 3 — one mask voxel is a box, and boxes straddle bins

Dropping each mask voxel whole into its nearest bin is the obvious thing to do, and it is wrong here in a way that is worth seeing rather than being told.

A mask voxel is not a point: it is a box. Under tgt_from_mask its half-extent along each target axis is half the sum of the absolute values in that row — a sum, not just the diagonal, because an oblique transform tilts the box across all three axes:

one mask voxel spans [0.0851 0.0625 0.086 ] of a target voxel, per axis

In one dimension the whole argument fits in a picture. A mask voxel is the shaded box; the target bins are the cells between the dashed edges. Nearest-bin gives the entire box to whichever bin holds its centre. Splitting gives each bin the share it actually covers:

drawn 0.340 wide; the true box is 0.085 of a target voxel
<Figure size 1050x300 with 1 Axes>

The box is well under one target voxel wide, so it reaches at most two bins per axis — eight combinations in 3-D, which is exactly what the implementation loops over.

Now the failure. Take a mask that is 1 everywhere, so every target voxel sitting fully inside the mask’s field of view must come out at exactly 100 % coverage. Assign each mask voxel to its nearest bin only, and count:

a full target voxel should hold 2185.5 mask voxels

Restrict to target voxels whose eight corners all fall inside the mask grid — those are provably 100 % covered — and look at what nearest-bin counting reports for them:

3375 fully-interior target voxels
nearest-bin coverage spans 0.886 to 1.054
that is a spread of 16.8 points on voxels that are 100% covered

Those ratios from step 1 are the cause: at 11.75 mask voxels per target voxel, a target voxel catches either 11 or 12 of them along that axis depending on where the lattice happens to fall. The count quantises, so the fraction wobbles by roughly ±8 % — enough that a min_coverage=0.9 threshold would throw away hundreds of completely covered voxels.

The fix is to stop pretending the box is a point. Split each mask voxel between the bins it overlaps, in proportion to how much of it lands in each, separably per axis:

overlap-split coverage spans 1.000000 to 1.000000

Exactly 1.0 everywhere it should be. The weights of one mask voxel sum to 1 by construction, so nothing is created or lost — the mass simply lands in the right proportions.

Side by side on a mid-grid slice, with the same colour scale, the difference is not subtle. Both maps should be a flat field of 1.0 across the interior:

<Figure size 1650x495 with 4 Axes>

The left panel is the lattice beating against the anatomy grid; the middle panel is the constant it should have been all along. The histogram is the same story in one dimension — a smear across 0.89–1.05 collapsing to a single spike at 1.0.

Step 4 — the denominator is a determinant, not a ratio of voxel sizes

Turning a weighted count into a fraction needs to know how many mask voxels a full target voxel holds. That is the ratio of the two voxel volumes, which is exactly 1 / |det(tgt_from_mask)| — and unlike a per-axis ratio it stays correct for anisotropic and oblique grids, where “voxel size” is not a single number per axis:

mask voxel   0.6815 mm^3
target voxel 1489.4 mm^3
ratio 2185.500  ==  1/|det| 2185.500

The call, and why it returns two arrays

Everything above is what one call does internally:

Loading...

occupancy and coverage answer different questions, and collapsing them into one number loses the difference between no brain here and no anatomy here:

VariableAnswersMasked by min_coverage
occupancyhow much of this voxel is inside the maskyes — NaN below the threshold
coveragehow much of this voxel the mask grid reached at allno — always readable

The MRSI grid overhangs the T1’s field of view, so some voxels genuinely have no anatomy to measure against. Those come back NaN in occupancy and are explained by coverage:

0 of 4096 voxels are NaN (coverage < 0.9)
every one of them has low coverage: True

The result carries world coordinates as 3-D x/y/z coordinates rather than one array per axis. For an oblique acquisition world x depends on all three indices, so per-axis coordinates would be quietly wrong exactly when obliquity matters:

('i', 'j', 'k') (16, 16, 16)
voxel (0,0,0) sits at (-78.4, -111.5, -72.4) mm

Both affines travel with the result in .attrs, flattened row-major rather than nested — netCDF attributes must be one-dimensional, and a nested 4×4 would make the whole Dataset impossible to save. Reshape to get one back:

True

Verifying it: three properties that must hold

Mass is conserved. The split only repartitions volume, so for a mask that lands entirely on the target grid, volume in must equal volume out as an identity rather than a tolerance. A sphere well inside both fields of view shows it cleanly:

sphere in  182.451 mL
sphere out 182.451 mL
relative error 3.4e-08

The brain mask is a different story, and the difference is worth knowing about: the MRSI grid here spans almost exactly the same extent as the T1, so the outermost rim of the brain maps off the target grid and is dropped. Conservation becomes an upper bound, and the shortfall tells you how much mask never made it onto the grid at all:

mask volume in 2699.25 mL
volume out     2699.25 mL
lost off the edge of the MRSI grid: 0.00%

A known edge lands where geometry says. A half-space mask has an analytically exact answer, so the occupancy of the straddling voxel can be predicted rather than eyeballed.

The mask’s region is the union of its voxel boxes, not the set of its centres, so where that region begins depends on the grid’s phase. Offsetting the mask affine by half a voxel puts a voxel boundary exactly on world zero, which makes the expected answer a clean 0.5. Without that offset the region would start half a mask voxel early and the voxel would read 0.53125 — correct, but a worse thing to assert against:

occupancy along x: [0.  0.  0.  0.  0.5 1.  1.  1. ]
the voxel centred on the edge reads 0.5000 (expected 0.5)

A hard 0-to-1 step with exactly one half-valued voxel on the boundary, and no smearing into its neighbours — which is what a correctly centred, volume-conserving split looks like:

<Figure size 1275x450 with 2 Axes>

Labels partition the voxel. With the background counted as a label, the per-label occupancies must sum to the coverage exactly — there is nowhere else for the volume to go:

labels present: ['background', 'csf', 'gm', 'wm']
max |sum(labels) - coverage| = 1.19e-07

Left to auto-detect, mask_occupancy finds the non-zero labels itself and the sum then falls short of coverage by exactly the background fraction:

auto-detected labels: ['1', '2', '3']
largest shortfall vs coverage (the background): 1.000

Each label gets its own map, and they add up. The rightmost panel is the sum of the three tissue maps — every voxel’s remaining share is background:

<Figure size 1800x465 with 5 Axes>
Does it survive an oblique grid?

Nothing above assumed axis alignment, but the fixture happens to be axis-aligned, so obliquity needs its own check. Rotating the target affine by 20° must leave total volume unchanged — the brain does not shrink because the grid was tilted.

axis-aligned 2699.25 mL   oblique 2682.57 mL
Does the axis-aligned fast path agree with the general path?

mask_occupancy takes a separable shortcut whenever mask -> target is diagonal -- which every fixture above is, so pv_auto already went through it. _force_general_path is a private test hook that reruns the same call through _splat_slab instead, so the two can be compared directly rather than trusted on the strength of an offline prototype.

Looking at it

The point of all of the above is a map you can lay over the anatomy and believe:

The map only means something laid over the anatomy it came from. To do that honestly, the T1 slice has to be the one the MRSI slice actually sits on — found by mapping the target slice index back through the transform rather than guessed:

target slice k=8  ->  T1 slice z=98
<Figure size 1650x510 with 5 Axes>

Occupancy falls off smoothly at the brain’s edge — those are the genuinely partial voxels, and their value is the fraction you would multiply a concentration by. The blanks in the middle panel are the NaNs: MRSI voxels the T1 never reached. Coverage, on the right, is flat at 1 across the interior and drops only where the MRSI grid runs past the T1 — which is exactly the red border from the lattice figure at the top of the page.