Skip to main content

qsm_core/inversion/
ir2qsm.rs

1//! IR2QSM dipole inversion (`onnx` feature).
2//!
3//! IR2QSM (Li et al., Med. Phys. 2025; arXiv:2406.12300) maps the **local (tissue)
4//! field in ppm** directly to **susceptibility in ppm** with a single "IR2U-net": a
5//! 3D U-net (`depth=4`) run for `iterations=4` unrolled passes with reverse
6//! concatenations and a recurrent SRU middle module, then a learned integration of
7//! the four per-iteration residual estimates (`latest_out`, the network's final
8//! product). Unlike LPCNN there is no separate physics/unroll here — the **entire**
9//! network is the exported ONNX graph, and this glue only does the surrounding I/O
10//! pipeline:
11//!
12//! 1. **No normalization.** IR2QSM consumes the ppm local field directly and emits
13//!    ppm susceptibility — no dataset mean/std, no `norm_factor` (there are no baked
14//!    scalar constants). Matches `IR2QSM/Evaluate/test_util.py`.
15//! 2. **Zero-pad to a multiple of 8.** The U-net has 3 pool/deconv levels, so each
16//!    spatial dim must be divisible by `2³ = 8`; we center-pad exactly as the
17//!    reference `zero_padding(image, 8)` (low offset `= ceil((target − shape)/2)`),
18//!    run the net, then crop back.
19//! 3. **Mask.** The result is multiplied by the supplied brain mask on the original
20//!    grid.
21//!
22//! **Determinism.** `IR2Unet.forward` has an ungated inference-time `AddNoise` in the
23//! decoder (`torch.rand(1) > 0.3` per iteration) that makes plain PyTorch inference
24//! mildly stochastic. The ONNX was exported with that call pinned to its noise-free
25//! branch (identity), so this net is deterministic. See `export_ir2qsm.py`.
26//!
27//! Input is the background-removed local field in ppm (single orientation; the net is
28//! orientation-agnostic — no dipole kernel, no `b_vec`). Weights are not bundled; the
29//! caller passes `ir2qsm.onnx`.
30//!
31//! # Reference pipeline
32//!
33//! From Li et al., arXiv:2406.12300, "Experimental Setup":
34//!
35//! - **Training data:** 14,400 64³ patches of 1 mm isotropic 3T χ maps (best-path unwrapping,
36//!   RESHARP, iLSQR), with local fields simulated by the forward model, as for xQSM.
37//! - **Testing:** only 1 mm isotropic 3T data; the authors state other resolutions are untested
38//!   (Discussion). The authors' `Evaluate/test_util.py` does not resample.
39//!
40//! # Agreement with the original
41//!
42//! `tests/models_onnx/dipole.rs` runs it on a 36×32×28 box of in-vivo data (1 mm, 2.89 T, across
43//! the brain's edge) against its original's verified QSM-CI entry, IR2QSM 1ddb147 (`ir2qsm`)
44//! (QSMxT/QSM-CI#316; the fixture is regenerated in Actions by
45//! `tests/data/dl_dipole_qsmci_small.py`): NRMSE 5.4e-4 %, max |Δ| 2.7e-6 ppm (measured
46//! 2026-10-30): float32 rounding. The original runs with `add_noise=false`, the noise-free branch
47//! this export pins (above); with the authors' default, noise on, it differs from itself by 12.6 %
48//! NRMSE run to run.
49
50use crate::grid::Grid;
51use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
52
53/// U-net pool/deconv depth requirement: spatial dims are padded to a multiple of this.
54const SIZE_DIVISOR: usize = 8;
55
56/// Run IR2QSM on a background-removed local field (ppm), column-major `(nx,ny,nz)`.
57/// Returns χ (ppm), masked, on the original grid.
58pub fn ir2qsm(
59    local_field_ppm: &[f64],
60    mask: &[u8],
61    grid: &Grid,
62    onnx: &[u8],
63) -> Result<Vec<f64>, OnnxError> {
64    let (nx, ny, nz) = grid.dims;
65    let n = nx * ny * nz;
66    assert_eq!(local_field_ppm.len(), n, "field length must match grid");
67    assert_eq!(mask.len(), n, "mask length must match grid");
68
69    let model = OnnxModel::load(onnx)?;
70
71    // Center zero-pad each dim up to a multiple of 8 (low offset = ceil(pad/2)).
72    let pad = |d: usize| -> (usize, usize) {
73        let target = d.div_ceil(SIZE_DIVISOR) * SIZE_DIVISOR;
74        let total = target - d;
75        let lo = total.div_ceil(2); // ceil((target - d)/2), matching the reference
76        (lo, total - lo)
77    };
78    let (px0, _px1) = pad(nx);
79    let (py0, _py1) = pad(ny);
80    let (pz0, _pz1) = pad(nz);
81    let (pnx, pny, pnz) =
82        (nx.div_ceil(SIZE_DIVISOR) * SIZE_DIVISOR,
83         ny.div_ceil(SIZE_DIVISOR) * SIZE_DIVISOR,
84         nz.div_ceil(SIZE_DIVISOR) * SIZE_DIVISOR);
85
86    // Build the padded NCDHW (row-major) input from the column-major volume.
87    let mut input = vec![0.0f32; pnx * pny * pnz];
88    for z in 0..nz {
89        for y in 0..ny {
90            for x in 0..nx {
91                let src = x + nx * (y + ny * z);
92                let (px, py, pz) = (x + px0, y + py0, z + pz0);
93                input[((px * pny) + py) * pnz + pz] = local_field_ppm[src] as f32;
94            }
95        }
96    }
97
98    let out = model.run_single(&Tensor::new(vec![1, 1, pnx, pny, pnz], input))?;
99
100    // Crop back to the original grid, repack to column-major, and mask.
101    let mut chi = vec![0.0f64; n];
102    for z in 0..nz {
103        for y in 0..ny {
104            for x in 0..nx {
105                let dst = x + nx * (y + ny * z);
106                let (px, py, pz) = (x + px0, y + py0, z + pz0);
107                let v = out.data[((px * pny) + py) * pnz + pz] as f64;
108                chi[dst] = if mask[dst] != 0 { v } else { 0.0 };
109            }
110        }
111    }
112    Ok(chi)
113}
114
115/// Memory-bounded IR2QSM via overlap-tiling — the IR2U-net run patch-by-patch (for 32-bit WASM,
116/// where whole-volume [`ir2qsm`] overflows the heap on clinical data). No normalization; the
117/// net's 3 pool levels require a `/8` patch. Approximates whole-volume up to tile-boundary
118/// error; see [`crate::inversion::tiled`].
119pub fn ir2qsm_tiled(
120    local_field_ppm: &[f64],
121    mask: &[u8],
122    grid: &Grid,
123    onnx: &[u8],
124    cfg: &super::tiled::TileConfig,
125    progress: impl FnMut(usize, usize),
126) -> Result<Vec<f64>, OnnxError> {
127    let model = OnnxModel::load(onnx)?;
128    super::tiled::tiled_field_inversion(
129        local_field_ppm, mask, grid, &model, &super::tiled::tile_cost_of("ir2qsm"), cfg, |v| v as f32, |o| o as f64, progress,
130    )
131}