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}