Check that two images line up before you combine them.
Before you combine two images — a BOLD and its mask, a subject and a template — they need to describe the same grid. Every image you load already carries that information as its .space, so the check is one call you don’t have to set up.
What it is
from pathlib import Pathimport syssys.path.insert(0, str(Path.cwd().parents[1] /"src"))import neuroim as nirepo = Path.cwd().parents[1] if Path.cwd().name =="start"else Path.cwd()bold = ni.read_image(str(repo /"golden_tests/fixtures/tiny_bold.nii.gz"))mask = ni.read_image(str(repo /"golden_tests/fixtures/tiny_mask.nii.gz"), type="vol")bold.space.dim, bold.space.spacing.tolist(), bold.space.origin.tolist()
NeuroSpace is frozen. Its dim, spacing, origin, and 4×4 trans (affine) are set at construction and never mutate. Two NeuroSpaces with the same dim, spacing, origin, and affine compare equal and produce the same hash_neurospace digest.
That immutability is what makes content-addressable provenance work: a hashed space pinned in a Receipt continues to identify the same frame for the life of the process and the file.
What it guarantees
bold.space.compatible_with(mask.space)
True
compatible_with is the alignment check. It returns True when two spaces describe the same grid (first three dims) with the same affine within atol=1e-6. It does not return False on a mismatch — it raises ValueError naming the offending field:
from neuroim.neuro_space import NeuroSpaceshifted = NeuroSpace(tuple(mask.space.dim), spacing=(9, 9, 9))mask.space.compatible_with(shifted) # raises ValueError: "NeuroSpace mismatch in affine: ..."
A true/raise predicate (rather than true/false) is deliberate: the failure carries the mismatched field and both values, so a mask/space mistake is diagnosable from the traceback alone instead of from a silent False the caller forgot to check. You rarely call compatible_with yourself — but every public operation that takes a mask= argument routes through verify.assert_same_space, which calls it, before it touches the array.
Where the affine comes from
bold.space.trans is the 4×4 voxel-to-world affine. It is the same matrix nib.load(...).affine returns for the same file. The grid-to-world transform is one of the four fields hashed into Receipt.input_space_hash, and the only one hashed alone into Receipt.source_affine_hash. Two spaces with the same dim/spacing/origin but a different affine produce different hashes; the contract layer treats them as distinct frames.
What it doesn’t do
No units of time.NeuroSpace carries the time axis as a dimension but does not record the repetition time. TR is data, not contract.
No coordinate conversion overload. Use apply_affine(space.trans, [x, y, z]) for grid→world and the inverse for world→grid. There is no implicit conversion when comparing spaces.
No mutation.dim, spacing, origin, trans are read-only arrays. Code that needs a modified frame constructs a new NeuroSpace; the original keeps its identity for the receipts that reference it.
What it refuses
Skippable on a first read — the edges, for when you hit them.
compatible_with on differently-shaped grids or affines. Raises ValueError: "NeuroSpace mismatch in spatial dim: left=…, right=…" or "NeuroSpace mismatch in affine: left=…, right=…". Public operations wrap this as "assert_same_space: spatial contract mismatch — …".
Construction with non-positive dims or spacing.NeuroSpace((-1, 4, 4)) raises ValueError: "All dimensions must be positive"; bad spacing raises "Spacing values must be positive".
Mutation. Assigning to dim, spacing, origin, or trans raises AttributeError: "NeuroSpace is immutable".
Wrongly-shaped trans=. A 3-D space with a non-4×4 affine raises "trans must be 4x4 matrix for 3D space, got …"; a non-invertible affine raises "Transformation matrix must be invertible".
Next
03 — ROI extraction pulls a region’s time-by-voxel matrix — and is the first page where this frame check runs for you automatically, inside the call.