Architecture¶
UniMRI is organised into seven layers. Each depends only on the layers below it.
The design goal is that a reconstruction algorithm never contains a
if vendor == ... or if trajectory == ... branch — those decisions are
resolved once, when the data is loaded and when operators are assembled.
┌─────────────────────────────────────────────────────────┐
│ 7 User API unimri.read() / unimri.reconstruct() │
│ Python API · CLI · notebooks │
├─────────────────────────────────────────────────────────┤
│ 6 Pipeline ordered, reproducible Stage chains │
├─────────────────────────────────────────────────────────┤
│ 5 Reconstruction fft · sense · grappa · cg · cs · … │
├─────────────────────────────────────────────────────────┤
│ 4 Operators LinearOperator algebra: A, Aᴴ, AᴴA │
│ Fourier · NUFFT · Sampling · Coil │
├─────────────────────────────────────────────────────────┤
│ 3 Calibration & sensitivity maps · GRAPPA kernels · │
│ preprocessing DCF · coil compression · noise │
├─────────────────────────────────────────────────────────┤
│ 2 Data model MRIData: kspace · trajectory · │
│ encoding · coils · metadata · provenance│
├─────────────────────────────────────────────────────────┤
│ 1 I/O Reader registry: Siemens · GE · │
│ Philips · ISMRMRD · HDF5 │
└─────────────────────────────────────────────────────────┘
Layer 1 — I/O¶
A Reader maps one on-disk file to one MRIData. Readers register themselves
and are selected by sniffing (Reader.can_read), so unimri.read("scan.dat")
just works. Vendor parsing is delegated to existing libraries
(twixtools, ismrmrd, h5py); UniMRI only owns the mapping onto the data
model. See I/O and formats.
Implemented today: ISMRMRDReader for a single Cartesian encoding space
(wraps the ismrmrd package), validated end-to-end by writing a real
ISMRMRD file and reconstructing it. HDF5Reader and TwixReader are still
interfaces only.
Layer 2 — Data model¶
MRIData is the pivot of the framework and the part most worth getting right.
It is a plain dataclass; there is no hidden lazy-loading or vendor state. Its
full schema and — importantly — its axis and unit conventions are in
data-model.md.
Reproducibility is built in: MRIData.provenance is an ordered log, and every
reader and transform appends a step to it.
Layer 3 — Calibration & preprocessing¶
Vendor-neutral operations on MRIData: oversampling removal, noise
pre-whitening, ramp-sampling regridding, gradient-delay / trajectory correction,
coil compression, and the estimation problems — coil sensitivities (ESPIRiT,
adaptive combine), GRAPPA kernels, density-compensation functions. These wrap
sigpy.mri / BART where a good implementation already exists.
Layer 4 — Operators¶
Reconstruction is treated as the inverse problem
$$ y = A x, \qquad A = P\,F\,S $$
| symbol | meaning | UniMRI operator |
|---|---|---|
| $x$ | the image we want | — |
| $S$ | coil sensitivities | SensitivityOperator |
| $F$ | Fourier encoding (uniform or non-uniform) | FourierOperator / NUFFTOperator |
| $P$ | k-space sampling / masking | SamplingOperator |
| $y$ | measured k-space | MRIData.kspace |
Every block is a LinearOperator exposing forward ($A$), adjoint ($A^H$),
and normal ($A^H A$). Operators compose with @ and scale with *:
A = SamplingOperator(...) @ FourierOperator(...) @ SensitivityOperator(...)
so a whole encoding model is one expression, and a solver only needs
forward / adjoint / normal. It is oblivious to whether $F$ was an FFT or a
NUFFT — which is exactly how unimri.reconstruct(data, method="cg") runs the
same CG-SENSE code on Cartesian and radial data (docs/reference/operators.md,
docs/reference/optimization.md).
Implemented today: the algebraic core (LinearOperator, IdentityOperator,
ScaledOperator, CompositeOperator, AdjointOperator, UncheckedOperator,
and an adjoint dot-test), FourierOperator (Cartesian), NUFFTOperator
(non-Cartesian, FINUFFT-backed), and SensitivityOperator (coils) — composable
via @ into a full y = F S x encoding, as used by reconstruct(method="cg").
SamplingOperator (explicit Cartesian undersampling masks) is still planned —
see the roadmap.
The dot-test¶
Every operator must pass op.dot_test(), which checks
$\langle A x, y\rangle = \langle x, A^H y\rangle$ on random complex inputs. A
wrong adjoint silently corrupts every iterative reconstruction, so this is a
required part of the operator contract, enforced in the test suite.
Layer 5 — Reconstruction¶
Named methods dispatched from unimri.reconstruct(data, method=...). Each is a
thin function that assembles operators (layer 4) from calibration outputs
(layer 3) and, if iterative, hands them to a solver from unimri.optimization
(layer 6). Implemented: "adjoint" (single-pass gridding / iFFT) and "cg"
(CG-SENSE). Planned: "sense" (direct), "grappa", "cs" (compressed sensing).
Layer 6 — Pipeline¶
Pipeline is an ordered list of Stage objects threaded over one MRIData.
Each stage records provenance, so a pipeline run is fully auditable and
reproducible. Interface defined today; concrete stages arrive with layers 3–5.
Layer 7 — User API¶
unimri.read(path)→MRIDataunimri.reconstruct(data, method="fft", device="cuda")→ image- the operator / optimization API for algorithm developers
- later: a small CLI and notebook helpers
Backend abstraction¶
Core code targets the Python array API:
operator and reconstruction code asks an array for its namespace
(unimri.backend.get_namespace) rather than importing NumPy. The same operator
then runs on numpy, cupy, or torch arrays. NumPy is the only required
runtime dependency; GPU and PyTorch are optional extras. UniMRI does not aim to
be a full array-compat shim — if that is needed, it will depend on
array-api-compat rather than grow one.
Non-goals¶
- Not a DICOM / image-domain toolkit — UniMRI starts at k-space.
- Not a new raw-data file format — it interoperates with ISMRMRD.
- Not a from-scratch NUFFT or optimisation library — those are wrapped.
- Not (initially) a deep-learning reconstruction framework, though the operator layer is designed to make unrolled networks straightforward later.