Cross-subject concat with contracts

Stack subjects with frame validation at the API surface.

Goal

Concatenate per-subject 4-D BOLD runs into one series for a group reduction, with the shared spatial frame validated before the stack happens — not discovered after a wrong-frame subject has polluted the mean.

Code

from pathlib import Path
import sys

sys.path.insert(0, str(Path.cwd().parents[1] / "src"))

import numpy as np
import neuroim as ni

repo = Path.cwd().parents[1] if Path.cwd().name == "cookbook" else Path.cwd()

# Two "subjects" in the same frame (the fixture stands in for two runs).
subj_a = ni.read_image(str(repo / "golden_tests/fixtures/tiny_bold.nii.gz"))
subj_b = 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")

merged = subj_a.concat(subj_b)               # also: ni.concat(subj_a, subj_b)
roi = ni.ROICoords(np.argwhere(np.asarray(mask.data)), space=mask.space)
group_mean_ts = merged.series_roi(roi).values.mean(axis=1)

merged.shape, group_mean_ts.shape
((8, 8, 4, 20), (20,))

concat is available as the instance method a.concat(*rest) and the module function ni.concat(*vecs).

What’s guaranteed

  • Every pair’s spatial frame is checked before concatenation. A subject in a different frame never reaches the stack.
  • series_roi then re-validates the mask frame against the merged series — the contract holds end to end.

What survives the file

merged.series_roi(roi) returns an ROIExtractionResult with a Receipt; the input_space_hash reflects the subjects’ shared frame. If you build a group map and to_nibabel() it, that hash rides the file.

Trapdoors

from neuroim.neuro_space import NeuroSpace

wrong_frame = ni.DenseNeuroVec(
    np.asarray(subj_b.data),
    NeuroSpace(tuple(subj_b.space.dim), spacing=(9, 9, 9, 1)),
)
subj_a.concat(wrong_frame)
---------------------------------------------------------------------------
SpaceMismatchError                        Traceback (most recent call last)
Cell In[2], line 7
      1 from neuroim.neuro_space import NeuroSpace
      3 wrong_frame = ni.DenseNeuroVec(
      4     np.asarray(subj_b.data),
      5     NeuroSpace(tuple(subj_b.space.dim), spacing=(9, 9, 9, 1)),
      6 )
----> 7 subj_a.concat(wrong_frame)

File ~/code/pycode/neuroim-python/src/neuroim/neuro_vec.py:1046, in DenseNeuroVec.concat(self, *others)
   1044 # Check spatial compatibility
   1045 for vec in all_vecs[1:]:
-> 1046     assert_same_space(self.space, vec.space)
   1048 # Concatenate data
   1049 all_data = [vec.data for vec in all_vecs]

File ~/code/pycode/neuroim-python/src/neuroim/verify.py:104, in assert_same_space(a, b)
    102         sa.compatible_with(sb)
    103     except ValueError as exc:
--> 104         raise SpaceMismatchError(
    105             f"assert_same_space: spatial contract mismatch — {exc}"
    106         ) from None
    107     return
    109 ha = _space_hash_of(a)

SpaceMismatchError: assert_same_space: spatial contract mismatch — NeuroSpace mismatch in affine: left=[[2.0, 0.0, 0.0, -8.0], [0.0, 2.0, 0.0, -8.0], [0.0, 0.0, 2.5, -5.0], [0.0, 0.0, 0.0, 1.0]], right=[[9.0, 0.0, 0.0, 0.0], [0.0, 9.0, 0.0, 0.0], [0.0, 0.0, 9.0, 0.0], [0.0, 0.0, 0.0, 1.0]]

A frame-mismatched subject raises ValueError: spatial contract mismatch: NeuroVecs must have same spatial dimensions and affine; …. The failure is at the API surface, before any array is stacked.

See also

Back to top