Skip to main content

qsm_core/inversion/
iqsm.rs

1//! iQSM single-step deep-learning reconstruction (`onnx` feature).
2//!
3//! iQSM (Gao 2022) maps raw wrapped MRI **phase** (radians) directly to
4//! susceptibility (ppm) — unwrapping, background removal and dipole inversion in
5//! one network (a learnable-Laplacian "LoT" front-end + a U-Net). The exported
6//! graph takes four inputs: `phase`, `mask`, `te` (s, scalar), `b0` (T, scalar).
7//!
8//! This mirrors the authors' `inference.run_iqsm`: flip the phase sign, erode the
9//! mask by a radius-3 sphere, centre-pad each dim to a multiple of 16, run the
10//! net, multiply by the (padded) mask, and crop back. For multi-echo data each
11//! echo is reconstructed and combined with (magnitude·TE)² weighting by default
12//! ([`iqsm_multi_echo`], [`IqsmEchoWeighting`]).
13//!
14//! The authors run the whole volume in one pass, and so does [`iqsm`]. Its activations scale with
15//! the volume; the multi-echo entry point can instead run each echo patch by patch (a
16//! [`TileConfig`]), with memory bounded by the patch: an approximation, since the network's
17//! unwrapping, background removal and inversion are not local, as for the other tiled networks
18//! (see [`super::tiled`]).
19//!
20//! Weights are not bundled; the caller passes the exported `iqsm.onnx` bytes.
21//!
22//! # Reference pipeline
23//!
24//! From the authors' code (`sunhongfu/iQSM`):
25//!
26//! - **Normalisation:** the LoT layer scales its output by `3 · 20e-3 / (B0 · TE)`
27//!   (`models/unet_blocks.py:21-22`), i.e. to the 3 T, TE = 20 ms reference, so any field strength
28//!   and TE are handled through the `te`/`b0` inputs.
29//! - **Masking:** with no mask supplied, `run.py:499-506` runs FSL `bet2 -m -f 0.5` on the first
30//!   magnitude volume (`bet2_utils.py:47,64`).
31//! - **Resolution:** "for data with resolution finer than 0.7 mm isotropic, interpolate to 1 mm
32//!   before reconstruction" (`README.md:9`; also `legacy/matlab/demo_single_echo.m:21-23`). The
33//!   MATLAB demo interpolates anisotropic data to an isotropic grid at the finest voxel size in the
34//!   complex domain (`demo_single_echo.m:52-60`). The Python `inference.py` accepts `voxel_size`
35//!   but does not resample. This crate does not resample.
36//! - **Phase masking:** the MATLAB `Save_Input.m:25` multiplies the phase by the *eroded* mask; the
37//!   Python `inference.py` does not, and neither does this crate. The 3×3×3 LoT stencil output is
38//!   multiplied by the eroded mask, so only the outermost shell of the eroded mask can differ.
39//!
40//! # Agreement with the original
41//!
42//! `tests/models_onnx/field.rs` runs it on a 36×32×28 box of in-vivo data (1 mm, 2.89 T, across the
43//! brain's edge) against its original's verified QSM-CI entry, iQSM bb163ef (`iqsm`)
44//! (QSMxT/QSM-CI#316; the fixture is regenerated in Actions by
45//! `tests/data/dl_field_qsmci_small.py`): per echo, NRMSE 9.8e-4 to 2.0e-3 %, max |Δ| up to 3.9e-6
46//! ppm: float32 rounding.
47//!
48//! The original runs one echo at a time and combines the echoes afterwards. The authors' two
49//! implementations combine them differently (see [`IqsmEchoWeighting`]): their MATLAB echo fit,
50//! published with the paper, weights by mag·TE², and their Python driver by (mag·TE)². This crate
51//! defaults to (mag·TE)², the inverse-variance weight (each echo's estimate has noise variance
52//! ∝ 1/(mag·TE)²), and offers the MATLAB's mag·TE² as an option, for parity with it. On the box's first and third echoes the driver's combination
53//! is (mag·TE)² of its per-echo outputs to 6e-6 %, and the two weightings differ by 3.0 % NRMSE;
54//! [`iqsm_multi_echo`] matches the MATLAB fit of the original's per-echo outputs to 9.4e-4 %
55//! (measured 2026-10-30), and with the Python weighting their (mag·TE)² combination to 9.5e-4 %
56//! (measured 2026-10-31).
57//!
58//! # Pipeline rules
59//!
60//! The facts above that a configuration can depart from are published as rules in
61//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `iqsm.mask-bet`.
62
63pub use super::iqsm_echo_weighting::IqsmEchoWeighting;
64pub(crate) use super::iqsm_echo_weighting::combine_echoes;
65use super::tiled::{tile_cost_of, tile_patch_size, tiled_volume_algorithm, TileConfig};
66use crate::models::TileCost;
67use crate::grid::Grid;
68use crate::models::onnx::{OnnxModel, OnnxError, Tensor};
69
70/// Runs the network on its input tensors: a whole-volume model (re-planned per shape) or a plan
71/// built once for a tile's shape.
72pub(crate) type Net<'a> = &'a (dyn Fn(&[Tensor]) -> Result<Vec<Tensor>, OnnxError> + Sync);
73
74/// Run iQSM on a single echo of wrapped phase.
75///
76/// * `phase_rad` — wrapped phase (radians), column-major `(nx,ny,nz)`.
77/// * `mask` — binary brain mask (same layout).
78/// * `te` — echo time in seconds; `b0` — field strength in Tesla.
79/// * `phase_sign` — sign convention (`-1` matches the authors' default).
80/// * `eroded_rad` — mask erosion radius in voxels (`3` matches the default; `0` disables).
81/// * `model_onnx` — bytes of the exported `iqsm.onnx`.
82///
83/// Returns susceptibility (ppm), masked, in the same layout.
84#[allow(clippy::too_many_arguments)]
85pub fn iqsm(
86    phase_rad: &[f64],
87    mask: &[u8],
88    grid: &Grid,
89    te: f64,
90    b0: f64,
91    phase_sign: f64,
92    eroded_rad: i32,
93    model_onnx: &[u8],
94) -> Result<Vec<f64>, OnnxError> {
95    let model = OnnxModel::load(model_onnx)?;
96    iqsm_with(&|i| model.run(i), phase_rad, mask, grid, te, b0, phase_sign, eroded_rad)
97}
98
99/// Multi-echo iQSM: reconstruct each echo and combine them with `weighting` (see
100/// [`IqsmEchoWeighting`]; the default is (magnitude·TE)², the inverse-variance weight).
101/// `phases`/`magnitudes` are per-echo volumes; `tes` the echo times (s). Without
102/// `magnitudes` the weights are TE² alone (uniform magnitude).
103///
104/// `tile: None` runs each echo on the whole volume; `Some` runs it patch by patch (see the
105/// module docs), the mask eroded once on the whole volume first.
106#[allow(clippy::too_many_arguments)]
107pub fn iqsm_multi_echo(
108    phases: &[&[f64]],
109    magnitudes: &[&[f64]],
110    mask: &[u8],
111    grid: &Grid,
112    tes: &[f64],
113    b0: f64,
114    phase_sign: f64,
115    eroded_rad: i32,
116    model_onnx: &[u8],
117    tile: Option<&TileConfig>,
118    weighting: IqsmEchoWeighting,
119) -> Result<Vec<f64>, OnnxError> {
120    lot_multi_echo(
121        "iqsm", phases, magnitudes, mask, grid, tes, b0, phase_sign, eroded_rad, model_onnx, tile, weighting,
122    )
123}
124
125/// [`iqsm_multi_echo`] for either head of the network: `model_id` (`"iqsm"` or `"iqfm"`) names
126/// the registry entry whose tile cost the tiled path budgets with.
127#[allow(clippy::too_many_arguments)]
128pub(crate) fn lot_multi_echo(
129    model_id: &str,
130    phases: &[&[f64]],
131    magnitudes: &[&[f64]],
132    mask: &[u8],
133    grid: &Grid,
134    tes: &[f64],
135    b0: f64,
136    phase_sign: f64,
137    eroded_rad: i32,
138    model_onnx: &[u8],
139    tile: Option<&TileConfig>,
140    weighting: IqsmEchoWeighting,
141) -> Result<Vec<f64>, OnnxError> {
142    assert_eq!(phases.len(), tes.len(), "one TE per echo");
143    let model = OnnxModel::load(model_onnx)?;
144    let whole = |i: &[Tensor]| model.run(i);
145    let cost = tile_cost_of(model_id);
146    let tiled = match tile {
147        Some(cfg) => {
148            let p = tile_patch_size(cfg, cost.divisor);
149            let v = [1, 1, p, p, p];
150            Some((cfg, model.plan_for(&[&v, &v, &[1], &[1], &v])?))
151        }
152        None => None,
153    };
154    combine_echoes(phases, magnitudes, tes, grid.n_total(), weighting, |phase, te| match &tiled {
155        None => iqsm_with(&whole, phase, mask, grid, te, b0, phase_sign, eroded_rad),
156        Some((cfg, plan)) => tiled_echo(phase, mask, grid, eroded_rad, cfg, &cost, |ph, m, g| {
157            iqsm_with(&|i| plan.run(i), ph, m, g, te, b0, phase_sign, 0)
158        }),
159    })
160}
161
162/// One echo patch by patch: erode the mask on the whole volume, then run `run_patch` (the
163/// network with no erosion of its own) on each tile. `cost` is the network's tile cost, whose
164/// divisor (16: the LoT-Unet has four pooling levels) pads each patch.
165pub(crate) fn tiled_echo(
166    phase: &[f64],
167    mask: &[u8],
168    grid: &Grid,
169    eroded_rad: i32,
170    cfg: &TileConfig,
171    cost: &TileCost,
172    run_patch: impl Fn(&[f64], &[u8], &Grid) -> Result<Vec<f64>, OnnxError> + Sync,
173) -> Result<Vec<f64>, OnnxError> {
174    let eroded = if eroded_rad > 0 { sphere_erode(mask, grid, eroded_rad) } else { mask.to_vec() };
175    tiled_volume_algorithm(phase, &eroded, grid, cost, cfg, run_patch, |_, _| {})
176}
177
178#[allow(clippy::too_many_arguments)]
179fn iqsm_with(
180    net: Net,
181    phase_rad: &[f64],
182    mask: &[u8],
183    grid: &Grid,
184    te: f64,
185    b0: f64,
186    phase_sign: f64,
187    eroded_rad: i32,
188) -> Result<Vec<f64>, OnnxError> {
189    let (nx, ny, nz) = grid.dims;
190    let n = nx * ny * nz;
191    assert_eq!(phase_rad.len(), n, "phase length must match grid");
192    assert_eq!(mask.len(), n, "mask length must match grid");
193
194    let eroded = if eroded_rad > 0 {
195        sphere_erode(mask, grid, eroded_rad)
196    } else {
197        mask.to_vec()
198    };
199
200    // Centre-pad to a multiple of 16 (four pooling levels).
201    let pad = |s: usize| -> (usize, usize) {
202        let total = (16 - s % 16) % 16;
203        (total / 2, s + total)
204    };
205    let (bx, px) = pad(nx);
206    let (by, py) = pad(ny);
207    let (bz, pz) = pad(nz);
208
209    // Repack column-major (nx,ny,nz) → row-major NCDHW [1,1,px,py,pz] f32, centered.
210    let mut phase_t = vec![0.0f32; px * py * pz];
211    let mut mask_t = vec![0.0f32; px * py * pz];
212    for z in 0..nz {
213        for y in 0..ny {
214            for x in 0..nx {
215                let src = x + nx * (y + ny * z);
216                let dst = (z + bz) + pz * ((y + by) + py * (x + bx));
217                phase_t[dst] = (phase_sign * phase_rad[src]) as f32;
218                mask_t[dst] = eroded[src] as f32;
219            }
220        }
221    }
222
223    // Border mask: 0 on the padded volume's outer 1-voxel shell, 1 inside. The
224    // LoT layer's boundary zeroing is applied inside the graph as `conv * border`
225    // (the only tract-friendly encoding of that op).
226    let mut border = vec![1.0f32; px * py * pz];
227    for a in 0..px {
228        for b in 0..py {
229            for c in 0..pz {
230                if a == 0 || a == px - 1 || b == 0 || b == py - 1 || c == 0 || c == pz - 1 {
231                    border[(c) + pz * ((b) + py * a)] = 0.0;
232                }
233            }
234        }
235    }
236
237    let shape = vec![1, 1, px, py, pz];
238    let inputs = [
239        Tensor::new(shape.clone(), phase_t),
240        Tensor::new(shape.clone(), mask_t),
241        Tensor::new(vec![1], vec![te as f32]),
242        Tensor::new(vec![1], vec![b0 as f32]),
243        Tensor::new(shape, border),
244    ];
245    let out = net(&inputs)?;
246    let chi_pad = &out[0].data;
247
248    // Multiply by the (padded) mask, crop, unpack → column-major.
249    let mut chi = vec![0.0f64; n];
250    for z in 0..nz {
251        for y in 0..ny {
252            for x in 0..nx {
253                let dst = x + nx * (y + ny * z);
254                if eroded[dst] != 0 {
255                    let src = (z + bz) + pz * ((y + by) + py * (x + bx));
256                    chi[dst] = chi_pad[src] as f64; // mask already applied (eroded[dst]==1 here)
257                }
258            }
259        }
260    }
261    Ok(chi)
262}
263
264/// Binary erosion by a solid sphere of the given radius (voxels), matching
265/// `scipy.ndimage.binary_erosion` with `border_value=0` (out-of-bounds = false).
266pub(crate) fn sphere_erode(mask: &[u8], grid: &Grid, radius: i32) -> Vec<u8> {
267    let (nx, ny, nz) = grid.dims;
268    // Precompute sphere offsets (dx²+dy²+dz² ≤ r²).
269    let r2 = radius * radius;
270    let mut offs: Vec<(i32, i32, i32)> = Vec::new();
271    for dz in -radius..=radius {
272        for dy in -radius..=radius {
273            for dx in -radius..=radius {
274                if dx * dx + dy * dy + dz * dz <= r2 {
275                    offs.push((dx, dy, dz));
276                }
277            }
278        }
279    }
280    let (nxi, nyi, nzi) = (nx as i32, ny as i32, nz as i32);
281    let mut out = vec![0u8; nx * ny * nz];
282    for z in 0..nzi {
283        for y in 0..nyi {
284            for x in 0..nxi {
285                let mut keep = true;
286                for &(dx, dy, dz) in &offs {
287                    let (xx, yy, zz) = (x + dx, y + dy, z + dz);
288                    let inside = xx >= 0 && xx < nxi && yy >= 0 && yy < nyi && zz >= 0 && zz < nzi;
289                    if !inside
290                        || mask[xx as usize + nx * (yy as usize + ny * zz as usize)] == 0
291                    {
292                        keep = false;
293                        break;
294                    }
295                }
296                if keep {
297                    out[x as usize + nx * (y as usize + ny * z as usize)] = 1;
298                }
299            }
300        }
301    }
302    out
303}