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.

A mask voxel is a box, not a point

Last edited: 2026-08-24

A segmentation arrives at 512 × 512 × 100, half a millimetre in plane. The spectrum it has to annotate arrives at 16 × 16 × 16, ten to fifteen millimetres a side, on a different field of view and usually oblique to the anatomy. In between sits the number you want: what fraction of this spectroscopic voxel is tumour? downsample_to_coverage claimed to answer it — but pointed at an analytically sharp edge, it placed the boundary half a voxel from where the affines say it is, on all three axes, z opposite to x and y. At these voxel sizes half a voxel is seven and a half millimetres.

Which way to integrate

Both directions are defensible; the resolution ratio picks between them, and it is worth knowing the rule rather than the answer:

Forward binning is the n → ∞ limit of supersampling at a fraction of the cost, because the mask grid is the sample grid. That buys an exact property rather than a tolerance: the operation only moves mass between bins, so mask volume in equals occupied volume out — for the part of the mask that lands on the target grid at all. On the synthetic brain that identity holds to float32 precision for a sphere well inside both fields of view, while the brain mask itself loses 0.16 % off the rim, where the MRSI grid stops before the T1 does.

Two ways to be off by half a voxel

The first was the one the old routine had, and it has a one-line cause worth stating because it is easy to reintroduce: a NIfTI index names a voxel centre, so voxel i spans [i − 0.5, i + 0.5) and mapped coordinates must be binned with floor(idx + 0.5), never floor(idx).

The second only appeared once the first was fixed, and it is the reason this entry is not called “every mask voxel gets exactly one vote” any more. Dropping each mask voxel whole into its nearest bin is the obvious implementation, and it fails for a reason that has nothing to do with registration: the ratios here are 11.75, 16.0 and 11.625 mask voxels per target voxel, so a target voxel catches either 11 or 12 of them per axis depending on where the lattice falls. The count quantises, and the fraction built from it wobbles by roughly ±8 %.

Treating each mask voxel as the box it actually is — half-extent 0.5 * Σ_b |T[a, b]| along target axis a, a row sum rather than a diagonal so obliquity is included — and splitting it between bins in proportion to overlap removes this entirely. The weights of one mask voxel sum to 1 by construction, so the repartition property survives, and coverage on those same 3375 voxels becomes exactly 1.0.

pv = mask_occupancy(seg_nii, mrsi.RAW_exp, min_coverage=0.9)

# (i, j, k), NaN where coverage fell short
pv.occupancy.sel(label="tumour")

# (i, j, k), never masked
pv.coverage

Two variables, because two different questions hide inside one number:

VariableAnswersMasked by min_coverage
occupancy“How much of this voxel is the label?”yes — NaN below the threshold
coverage“How much of this voxel did the anatomy even reach?”no — always readable

Collapsing them loses the distinction between no tumour here and no data here, which at the edge of an MRSI slab is most of the interesting voxels.

Three things that only showed up in code

Labels are detected by value, not dtype. nibabel’s get_fdata() returns float64 whatever the file stores, so a dtype test would classify every segmentation read from disk as a probability map. The rule is instead: one distinct non-zero value is a binary mask, fractional values are a probability map, several distinct whole numbers are labels.

Affines in .attrs are flattened. netCDF attributes must be one-dimensional, and a nested 4 × 4 made the whole Dataset impossible to save — the round trip is now a test on the docs page.

Labels sum to coverage only if the background counts as a label. Left to auto-detect, mask_occupancy finds the non-zero labels and their sum falls short of coverage by exactly the background fraction. The clean partition identity needs labels={0: "background", ...}.

Why not vectorise the supersampler we already had?

That is the plan issue #30 records, and it hinges on reproducing the current output bit-exactly — which would preserve the mis-centring. Its s = ceil(diag(tgt2src)) also reads only the diagonal, understating the scale precisely when the grids are oblique.

Why not weight by the MRSI point-spread function?

A 16³ Hamming-apodised grid has no box-shaped voxels; signal genuinely bleeds across boundaries. But PSF-weighted occupancy is a different quantity, needs the acquisition’s apodisation, and goes negative in the sidelobes. Box overlap is geometric and checkable against an overlay. The kernel stays swappable.

What changed from the plan