Simulation phantom file format specification and reference implementation, based on NIfTI + JSON
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗Bloch Informatics Technology Initiative phantom format - a playful riff on NIfTI, but specific to MRI simulation data.
A universal, implementation-agnostic format for storing MRI simulation phantoms. A phantom is one JSON file defining tissues and their MR properties, referencing NIfTI files for per-voxel data.
[!IMPORTANT] Goals
- easy to use: Human readable configs, existing viewers for 3D tissue data
- easy to share: Strict spec ensures consistent phantom structuring
- easy to extend: General approach supports future extensions
- easy to implement: JSON and NIfTI are widely supported
[!INFO] Status phantom spec: v1 (see SPEC.md)
registry format: v1 (see REGISTRY.md)
A minimal phantom is a JSON sidecar next to its NIfTI(s) — one tissue,
density from a NIfTI file, everything else a plain number:
{
"$schema": "https://raw.githubusercontent.com/mrx-org/bifti-phantoms/refs/heads/main/bifti-phantom-v1.schema.json",
"units": { "gyro": "MHz/T", "B0": "T", "T1": "s", "T2": "s", "T2'": "s", "ADC": "10^-3 mm^2/s", "dB0": "Hz", "B1+": "rel", "B1-": "rel" },
"system": { "gyro": 42.5764, "B0": 3.0 },
"tissues": {
"gm": { "density": "subj42.nii.gz[0]", "T1": 1.56, "T2": 0.083 }
}
}
Loading phantoms from...
from bifti import NumpyPhantom
phantom = NumpyPhantom.load("subj42.json")
tissue = phantom.tissues["gm"] # a NumpyTissue: density, T1, T2, ... as np.ndarray
print(tissue.shape, tissue.T1.mean())
using BiftiPhantoms
phantom = load_bifti("subj42.json")
tissue = phantom.tissues["gm"] # a VoxelTissue: density, T1, T2, ... as arrays
println(size(tissue), " ", sum(tissue.T1) / length(tissue.T1))
phantom = bifti.loadPhantom('subj42.json');
gm = phantom.tissues(strcmp({phantom.tissues.name}, 'gm')); % density, T1, T2, ... as arrays
disp(mean(gm.T1(:)))
use bifti::{DEFAULT_MAX_SUBSTEPS, Phantom, VolumeData};
let phantom = Phantom::load("subj42.json", DEFAULT_MAX_SUBSTEPS)?;
let tissue = &phantom.tissues["gm"];
if let VolumeData::Float64(t1) = &tissue.t1.data {
println!("{:?} {}", tissue.density.shape, t1.iter().sum::<f64>() / t1.len() as f64);
}
[!TIP] Sharing a phantom in three simple steps:
- Zenodo: upload your NIfTI + JSON files and publish it.
- registry.json: add a new entry with the Zenodo DOI (permanent archive).
- catalog.json: add a line with a display label to make it discoverable.
Step-by-step guide with copy-paste snippets: ADD_PHANTOMS.md. When opening the PR, use the collection template (checklist included): pick your branch (or fork) in the compare view, then create the PR. Already have a PR open? Append
?template=add_phantom_collection.mdto the PR creation URL.
| Path | Purpose |
|---|---|
| SPEC.md | Overview and folder layout. |
| JSON.md | The phantom JSON: structure, units, system, tissues. |
| NIFTI.md | The NIfTI files: format, coordinate conventions and patient position. |
| ADD_PHANTOMS.md | Quick guide: add your phantoms to the registry. |
| REGISTRY.md | The registry: how phantoms are hosted and shared. |
| bifti-phantom-v1.schema.json / bifti-registry-v1.schema.json / bifti-catalog.schema.json | JSON Schemas validating a phantom JSON / registry.json / catalog.json. |
| registry.json | Immutable archive of every published collection — see Registry. |
| catalog.json |
[!IMPORTANT] The example implementations for Python, Rust, Julia and MATLAB were built with the help of LLMs and not yet reviewed thouroughly. They might contain bugs and currently not live up to the targeted quality standard. This will change in the future.
Example phantoms are available in the public registry. Every published collection has a permanent entry in registry.json (the immutable archive); catalog.json is the curated, freely-editable list of which of those collections tools surface, each mapped to its immutable registry name. Browse the catalog here: https://mrx-org.github.io/bifti-phantoms/
This exists to make sharing easy and experiments reproducible. Anyone is welcome to add new phantoms. Phantom files themselves can be hosted for free on Zenodo, under any appropriate license and attribution. Add one with a pull request that adds an entry to registry.json and a label pointing at it in catalog.json. See ADD_PHANTOMS.md for the quick guide and REGISTRY.md for the full contribution workflow.
# Load bifti phantoms from rust
cargo add bifti
# Load bifti phantoms from Python
pip install bifti
# Using the uv package manager:
uv add bifti
# Load bifti phantoms from Julia
julia -e 'using Pkg; Pkg.add("BiftiPhantoms")'
# Load bifti phantoms from MATLAB or Octave: clone the repo, then in MATLAB
# addpath('bifti-phantoms/matlab')
The packages are published on PyPI, crates.io and in Julia's General registry. To use unreleased changes, install from git instead (shown in each package's README). Maintainers: see RELEASING.md.
For more information, including runnable examples and each package's full
API, read the README of the Python bifti package,
Rust bifti crate, Julia BiftiPhantoms package
or MATLAB bifti package.
The implementations currently have some discrepancies:
Python (python/bifti) | Rust (rust/bifti) | Julia (julia/BiftiPhantoms) | MATLAB (matlab/+bifti) | |
|---|---|---|---|---|
| Loaded representation | NumpyPhantom.tissues: dict[str, NumpyTissue] — NumPy arrays | Phantom.tissues: HashMap<String, Tissue> - Volumes (affine + shape + VolumeData) | VoxelPhantom.tissues: OrderedDict{String, VoxelTissue} — Arrays | phantom.tissues struct array of 3-D arrays (bifti.loadPhantom) |
Grid without reslice_to | Every map is brought onto the density map's grid | Every map keeps its own native grid | Every map is brought onto the density map's grid | Every map is brought onto the density map's grid |
Complex-valued NIfTI data (e.g. complex B1+/B1-) | B1+/B1- loaded and resliced as complex128; an error for other properties | Loaded and resliced as complex | B1+/B1- loaded and resliced as ComplexF64; an error for other properties | B1+/B1- loaded and resliced as complex; an error for other properties |
Reslicing (reslice_to) | Shared approach: density-weighted footprint averaging (see below). Uses torch when installed, NumPy otherwise | Same approach, own implementation; all NIfTI data types including complex | Same approach, own implementation; matches Python to floating-point precision | Same approach, own implementation; matches Python to floating-point precision |
func evaluation | Parsed with the spec grammar | Parsed with the spec grammar | Parsed with the spec grammar | Parsed with the spec grammar |
| Catalog / registry access | load_catalog(), load_registry(), load_registry_phantom(collection, name) | Catalog::load(), Registry::load(), registry.load_registry_phantom(collection, name, cache_dir) | load_catalog(), load_registry(), (cached in a scratch space) |
All four implementations resample the same way. Each output voxel is averaged over the whole source region it covers, rather than interpolated from the few voxels nearest its centre — so resampling onto a coarser grid actually averages instead of throwing most of the data away.
The averaging is weighted by the tissue's density. Outside the source FOV, and
in the background between tissues, every map reads 0, and T1/T2/T2'/ADC/
dB0/B1± are intensive quantities: averaging them against those zeros would pull
them towards zero at every edge. Weighting by density excludes the empty voxels
instead. density itself is extensive, so it keeps a plain footprint average and
correctly falls off where an output voxel is only partly filled.
For axis-aligned grids the average is computed exactly, as a true box average.
Oblique transforms fall back to quadrature over the output voxel's parallelepiped,
and axes that are not being downsampled keep plain linear interpolation. The
quadrature is approximate, so an oblique reslice_to always warns, and says so
when the output voxels span more source voxels than the quadrature samples and the
result may alias. Every load function takes a max_substeps option (default 8 per
axis; MaxSubsteps in MATLAB) to raise that cap. Aligning
reslice_to with the source axes gives the exact result.
Every implementation can write a loaded phantom back to a folder: load it (which
reslices it when the phantom has a reslice_to), then save it. This is how a
phantom is resliced on disk, and how a phantom you built or edited in memory is
exported.
The saved phantom is self-contained and never has a reslice_to or a func:
mappings are applied to the resampled data before it is written and become plain
references. It has one NIfTI per property, prefixed with the name of the JSON file
(phantom_density.nii.gz, phantom_T1.nii.gz, phantom_B1plus_0.nii.gz, ... for
phantom.json) so several phantoms can be saved into one folder, with the tissues
stacked along the 4th dimension in order of their sorted names, as
NIFTI.md describes. Constants stay constants in the JSON, and tissues whose
maps are bit-identical share one volume. Complex maps (B1+/B1-) are saved as complex
NIfTIs.
Every implementation's save takes an optional constant threshold (constant_threshold;
ConstantThreshold in MATLAB), off by default. With it, a map that is (nearly) constant
is stored as a number in the JSON instead of a NIfTI: when its RMS deviation from the
real part of its mean (its standard deviation, for a real map) is at most the threshold,
it is replaced by that mean. 0 collapses only exactly constant maps; a negative value
disables it as well. density always stays a map.
The output is deterministic: the NIfTI-1 headers are fixed (sform only, little-endian, no extensions), gzip headers carry no timestamp or file name, and the JSON does not depend on the order the tissues were inserted in. Saving the same phantom twice gives the same bytes, so the folder can be hashed. (The NIfTI headers are byte-identical across the implementations; the stored element types and the compressed bytes are not, so only compare hashes within one implementation and zlib build.)
[!WARNING] The saved phantom can be much larger than the original. A
funcis baked in per tissue, and tissues that shared a map each get a copy of it. When reslicing, intensive properties are weighted by each tissue's own density, so those copies usually differ and cannot be shared.
| Living discovery list: which collections tools show, mapped to registry names. |
| python/bifti/ | Installable Python package + examples. |
| rust/bifti/ | Installable Rust crate + examples. |
| julia/BiftiPhantoms/ | Installable Julia package. |
| matlab/ | MATLAB / Octave package (bifti namespace), no toolboxes needed. |
| docs/ | Source of the registry browser at https://mrx-org.github.io/bifti-phantoms/. |
| tools/ | CI scripts: phantom/registry schema validation, immutability checks. |
| RELEASING.md | How the packages are published to PyPI, crates.io and the Julia registry. |
load_registry_phantom(collection, name)bifti.loadCatalog(), bifti.loadRegistry(), bifti.loadRegistryPhantom(collection, name) (cached in ~/.cache/bifti) |
| Unknown fields | Warned about at every level (phantom, system, patient, reslice_to, tissue, transformed reference), then dropped | Warned about for the phantom and its tissues; kept in unknown so save round-trips them | Warned about at every level; kept for the phantom and its tissues so write_bifti round-trips them | Warned about at every level; kept for the phantom and its tissues so bifti.writePhantom round-trips them |
Saving (load → save) | NumpyPhantom.save(dir) — float64 / complex128 NIfTIs | Phantom::save(dir, json_name) — NIfTIs keep their element type (mixed types in one file are promoted) | save_bifti(dir, phantom) — float64 / complex NIfTIs | bifti.savePhantom(dir, phantom) — double / complex NIfTIs |
| Examples | 4 runnable scripts: plotting, KomaMRI export, MR-zero simulation, legacy-phantom conversion (see python/bifti/README.md) | 1 runnable example: random registry download with tracing instrumentation (see rust/bifti/README.md) | Usage snippets in julia/BiftiPhantoms/README.md | Usage snippets in matlab/README.md |
| Optional instrumentation | - | tracing feature (spans for downloading, NIfTI loading, func evaluation) | - | - |