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
31use crate::grid::Grid;
32use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
33
34/// U-net pool/deconv depth requirement: spatial dims are padded to a multiple of this.
35const SIZE_DIVISOR: usize = 8;
36
37/// Run IR2QSM on a background-removed local field (ppm), column-major `(nx,ny,nz)`.
38/// Returns χ (ppm), masked, on the original grid.
39pub fn ir2qsm(
40    local_field_ppm: &[f64],
41    mask: &[u8],
42    grid: &Grid,
43    onnx: &[u8],
44) -> Result<Vec<f64>, OnnxError> {
45    let (nx, ny, nz) = grid.dims;
46    let n = nx * ny * nz;
47    assert_eq!(local_field_ppm.len(), n, "field length must match grid");
48    assert_eq!(mask.len(), n, "mask length must match grid");
49
50    let model = OnnxModel::load(onnx)?;
51
52    // Center zero-pad each dim up to a multiple of 8 (low offset = ceil(pad/2)).
53    let pad = |d: usize| -> (usize, usize) {
54        let target = d.div_ceil(SIZE_DIVISOR) * SIZE_DIVISOR;
55        let total = target - d;
56        let lo = total.div_ceil(2); // ceil((target - d)/2), matching the reference
57        (lo, total - lo)
58    };
59    let (px0, _px1) = pad(nx);
60    let (py0, _py1) = pad(ny);
61    let (pz0, _pz1) = pad(nz);
62    let (pnx, pny, pnz) =
63        (nx.div_ceil(SIZE_DIVISOR) * SIZE_DIVISOR,
64         ny.div_ceil(SIZE_DIVISOR) * SIZE_DIVISOR,
65         nz.div_ceil(SIZE_DIVISOR) * SIZE_DIVISOR);
66
67    // Build the padded NCDHW (row-major) input from the column-major volume.
68    let mut input = vec![0.0f32; pnx * pny * pnz];
69    for z in 0..nz {
70        for y in 0..ny {
71            for x in 0..nx {
72                let src = x + nx * (y + ny * z);
73                let (px, py, pz) = (x + px0, y + py0, z + pz0);
74                input[((px * pny) + py) * pnz + pz] = local_field_ppm[src] as f32;
75            }
76        }
77    }
78
79    let out = model.run_single(&Tensor::new(vec![1, 1, pnx, pny, pnz], input))?;
80
81    // Crop back to the original grid, repack to column-major, and mask.
82    let mut chi = vec![0.0f64; n];
83    for z in 0..nz {
84        for y in 0..ny {
85            for x in 0..nx {
86                let dst = x + nx * (y + ny * z);
87                let (px, py, pz) = (x + px0, y + py0, z + pz0);
88                let v = out.data[((px * pny) + py) * pnz + pz] as f64;
89                chi[dst] = if mask[dst] != 0 { v } else { 0.0 };
90            }
91        }
92    }
93    Ok(chi)
94}
95
96/// Memory-bounded IR2QSM via overlap-tiling — the IR2U-net run patch-by-patch (for 32-bit WASM,
97/// where whole-volume [`ir2qsm`] overflows the heap on clinical data). No normalization; the
98/// net's 3 pool levels require a `/8` patch. Approximates whole-volume up to tile-boundary
99/// error; see [`crate::inversion::tiled`].
100pub fn ir2qsm_tiled(
101    local_field_ppm: &[f64],
102    mask: &[u8],
103    grid: &Grid,
104    onnx: &[u8],
105    cfg: &super::tiled::TileConfig,
106    progress: impl FnMut(usize, usize),
107) -> Result<Vec<f64>, OnnxError> {
108    let model = OnnxModel::load(onnx)?;
109    super::tiled::tiled_field_inversion(
110        local_field_ppm, mask, grid, &model, SIZE_DIVISOR, cfg, |v| v as f32, |o| o as f64, progress,
111    )
112}