05 — Round-trip

Save a map and read it back — its provenance comes with it, no sidecar files.

The map from the last page knows how it was made. This page saves it to a .nii.gz and reads it back in a fresh process — and that knowledge survives the trip, with no companion JSON to keep alongside the file. It works because the on-disk format is a written, versioned contract, so the round-trip is a guarantee rather than a hope.

flowchart LR
    A["raw array<br/>+ affine"] --> B["typed object<br/>NeuroVec / NeuroVol"]
    B --> C["operation<br/>series_roi · temporal_snr · searchlight_apply"]
    C --> D["typed result<br/>+ Receipt (.provenance)"]
    D --> E["file<br/>.nii.gz + neuroim/receipt/v1: extension"]
    E --> F["typed object again<br/>read_image → .provenance rehydrated"]
    F -. "audit / chain / reduce" .-> C

The neuroim value lifecycle. Spatial context and provenance are intrinsic at every step — never reconstructed, never carried in a sidecar.

The loop is the point: a value that leaves the library as a .nii.gz re-enters it with the same Receipt, so the next operation can chain, audit, or reduce against a frame it can verify — not one it has to trust.

Write

from pathlib import Path
import sys
import tempfile

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

import nibabel as nib
import neuroim as ni

repo = 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")

tsnr = bold.temporal_snr(mask=mask)

tmp = Path(tempfile.mkdtemp()) / "tsnr.nii.gz"
nib.save(tsnr.to_nibabel(), str(tmp))
tmp.exists(), tmp.stat().st_size > 0
(True, True)

to_nibabel() serializes the volume to a nibabel.Nifti1Image and embeds the Receipt as a NIfTI header extension with ecode 6 (the standard comment ecode) and content prefix neuroim/receipt/v1:. The prefix is the version marker; foreign comment extensions from other tools are left alone on read.

Note

ni.write_vol(...) and ni.write_vec(...) route through to_nibabel(), so they embed the same Receipt extension — the provenance travels with the file either way. This tutorial uses the explicit to_nibabel() + nib.save() form to show exactly what is written.

Read

roundtrip = ni.read_image(str(tmp))
type(roundtrip).__name__
roundtrip.provenance.method_name
roundtrip.provenance.input_space_hash[:12], tsnr.provenance.input_space_hash[:12]
roundtrip.provenance.mask_hash[:12], tsnr.provenance.mask_hash[:12]
('649ca2f312df', '649ca2f312df')

The Receipt comes back. Every field that went in comes out — same method_name, same hashes, same n_voxels. A collaborator who only has tsnr.nii.gz can answer what produced this, from what input, with what mask, what method?

The hashes mean more than equality. If the source BOLD and mask are still on disk, you can recompute their input_space_hash and mask_hash and confirm they match the volume’s Receipt. This is why the Receipt belongs on the file, not in a sidecar: the image carries enough context for a later reader to check which source image and mask produced it.

Read it without neuroim

The spec is ten lines of nibabel:

import json
import nibabel as nib

def read_neuroim_receipt(path):
    img = nib.load(str(path))
    for ext in getattr(img.header, "extensions", []) or []:
        if int(ext.get_code()) != 6:
            continue
        text = bytes(ext.get_content()).rstrip(b"\x00").decode("utf-8", "replace")
        if text.startswith("neuroim/receipt/v1:"):
            return json.loads(text[len("neuroim/receipt/v1:"):])
    return None

That snippet does not import neuroim. The Receipt is a public artifact. A pipeline written in a different toolchain can produce Receipt extensions that neuroim.read_image will rehydrate, and a pipeline written in neuroim can be inspected by any tool that reads NIfTI extensions. The full schema and a symmetric reference writer live in the Receipt spec.

What it refuses

Skippable on a first read — the edges, for when you hit them.

  • Drop the Receipt on to_nibabel. The receipt extension is always embedded when .provenance is set. The regression test at tests/test_receipt_spec_external_reader.py re-reads the file with the ten-line snippet above.
  • Confuse a foreign comment extension for a Receipt. Only extensions whose payload starts with the literal neuroim/receipt/v1: are treated as Receipts on read.
  • Silently accept a future v2 file as v1. Readers that know only v1 ignore unknown prefixes without raising; they do not try to coerce.

What this finishes

You have now read every contract layer the rest of the docs build on:

  1. read_image returns a typed object.
  2. NeuroSpace is the spatial frame; compatible_with is the alignment check.
  3. ROI extraction returns a Result with .values, .coords, .space, .provenance.
  4. Derived maps are volumes that also carry .provenance.
  5. The Receipt survives the file boundary, by a public spec.

Everything else in the docs — the cookbook recipes, the searchlight infrastructure, the atlas layer, the alternative voxel-series backends — composes these five pieces. Pick a destination:

See also

Back to top