Skip to main content

qsm_core/inversion/
iqsm_plus.rs

1//! iQSM+ single-step deep-learning reconstruction (`onnx` feature).
2//!
3//! iQSM+ (Gao 2024) extends [iQSM](crate::inversion::iqsm) with orientation-adaptive
4//! latent feature editing (OA-LFE): the B0 direction (`z_prjs`) is a network input,
5//! so oblique/sagittal/coronal acquisitions reconstruct correctly. The exported
6//! graph takes six inputs: `phase`, `mask`, `te` (s), `b0` (T), `z_prjs` (B0 dir,
7//! `[1,1,3]`), and `border` (the LoT boundary mask, as for iQSM).
8//!
9//! Pipeline (mirrors the authors' `inference.run_iqsm_plus`): flip the phase sign,
10//! erode the mask by a radius-3 sphere, **crop to the brain bounding box + 16-voxel
11//! margin**, centre-pad to a multiple of 16, run, ×mask, then paste the result back
12//! into the full grid. Multi-echo data is combined with (magnitude·TE)² weighting.
13//!
14//! Only axial-ish acquisitions are handled directly here; the authors' extra
15//! axis-permutation for strongly oblique fields (`|dir_y| > |dir_z|`) is not applied.
16//!
17//! Weights are not bundled; the caller passes the exported `iqsm-plus.onnx` bytes.
18//!
19//! # Reference pipeline
20//!
21//! As for [iQSM](mod@crate::inversion::iqsm) (normalisation, `bet2 -f 0.5` default mask), with two
22//! differences in the authors' `sunhongfu/iQSM_Plus` code:
23//!
24//! - **Resampling:** `inference.py:443-471` (and `legacy/matlab/iQSM_plus.m:175-210`) interpolates
25//!   anisotropic phase to an isotropic grid at the finest voxel size in the complex domain whenever
26//!   the rounded grid would change. This crate does not resample.
27//! - **Phase masking:** `inference.py:425` and `legacy/matlab/fcns/Save_Input_iQSMplus.m` multiply
28//!   the phase by the *unmodified* mask before erosion. This crate does not; with an erosion radius
29//!   of at least 1 that cannot change the result, since the 3×3×3 LoT stencil only reaches voxels
30//!   inside the unmodified mask wherever its output survives the eroded mask.
31//!
32//! # Agreement with the original
33//!
34//! `tests/models_onnx/field.rs` runs it on a 36×32×28 box of in-vivo data (1 mm, 2.89 T, across the
35//! brain's edge) against its original's verified QSM-CI entry, iQSM+ a534d94 (`iqsm-plus`)
36//! (QSMxT/QSM-CI#316; the fixture is regenerated in Actions by
37//! `tests/data/dl_field_qsmci_small.py`): per echo, NRMSE 2.7e-4 to 3.3e-4 %, max |Δ| up to 6.8e-7
38//! ppm: float32 rounding.
39//!
40//! The original runs one echo at a time and combines the echoes afterwards with weights
41//! (mag·TE)², in both of the authors' implementations (see [`iqsm_plus_multi_echo`]), and so does
42//! this crate. On the box's first and third echoes the driver's combination is (mag·TE)² of its
43//! per-echo outputs to 1.6e-3 %, and mag·TE² weighting (iQSM's MATLAB echo fit, which this crate
44//! used for iQSM+ until 2026-10-31) differs from it by 3.1 % NRMSE (measured 2026-10-30);
45//! [`iqsm_plus_multi_echo`] matches the (mag·TE)² combination of the original's per-echo outputs
46//! to 2.5e-4 % (measured 2026-10-31).
47//!
48//! # Pipeline rules
49//!
50//! The facts above that a configuration can depart from are published as rules in
51//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `iqsm.mask-bet`.
52
53use crate::grid::Grid;
54use crate::inversion::iqsm::{combine_echoes, sphere_erode, tiled_echo, IqsmEchoWeighting, Net};
55use crate::inversion::tiled::{tile_cost_of, tile_patch_size, TileConfig};
56use crate::models::onnx::{OnnxModel, OnnxError, Tensor};
57
58/// Run iQSM+ on a single echo of wrapped phase.
59///
60/// * `phase_rad`, `mask` — column-major `(nx,ny,nz)`.
61/// * `te` (s), `b0` (T), `b0_dir` — acquisition parameters (`b0_dir` normalized internally).
62/// * `phase_sign` (`-1` default), `eroded_rad` (`3` default).
63///
64/// Returns susceptibility (ppm), masked, in the same layout.
65#[allow(clippy::too_many_arguments)]
66pub fn iqsm_plus(
67    phase_rad: &[f64],
68    mask: &[u8],
69    grid: &Grid,
70    te: f64,
71    b0: f64,
72    b0_dir: (f64, f64, f64),
73    phase_sign: f64,
74    eroded_rad: i32,
75    model_onnx: &[u8],
76) -> Result<Vec<f64>, OnnxError> {
77    let model = OnnxModel::load(model_onnx)?;
78    iqsm_plus_with(&|i| model.run(i), phase_rad, mask, grid, te, b0, b0_dir, phase_sign, eroded_rad, true)
79}
80
81/// Multi-echo iQSM+: reconstruct each echo and combine them with weights (magnitude·TE)², i.e.
82/// [`IqsmEchoWeighting::MagnitudeSquaredTeSquared`]; without `magnitudes`, TE² alone.
83///
84/// Both of the authors' implementations weight this way (checked at `sunhongfu/iQSM_Plus`
85/// 3ba25f5): the MATLAB `legacy/matlab/iQSM_plus.m:273-275`
86/// (`weights = (mag.*TE).^2; sum(weights.*chi,4)./sum(weights,4)`) and the Python `run.py:199` and
87/// `app.py:415` (`weights = (mag_data * te_bc) ** 2`), and it is the inverse-variance weight. Unlike
88/// iQSM and iQFM, whose MATLAB uses mag·TE² (an option there; see [`IqsmEchoWeighting`]), there is
89/// no other weighting to offer.
90///
91/// `tile: None` runs each echo on the brain's bounding box in one pass; `Some` runs it patch by
92/// patch, with memory bounded by the patch (an approximation; see [`super::iqsm`]).
93#[allow(clippy::too_many_arguments)]
94pub fn iqsm_plus_multi_echo(
95    phases: &[&[f64]],
96    magnitudes: &[&[f64]],
97    mask: &[u8],
98    grid: &Grid,
99    tes: &[f64],
100    b0: f64,
101    b0_dir: (f64, f64, f64),
102    phase_sign: f64,
103    eroded_rad: i32,
104    model_onnx: &[u8],
105    tile: Option<&TileConfig>,
106) -> Result<Vec<f64>, OnnxError> {
107    assert_eq!(phases.len(), tes.len(), "one TE per echo");
108    let model = OnnxModel::load(model_onnx)?;
109    let whole = |i: &[Tensor]| model.run(i);
110    let cost = tile_cost_of("iqsm-plus");
111    let tiled = match tile {
112        Some(cfg) => {
113            let p = tile_patch_size(cfg, cost.divisor);
114            let v = [1, 1, p, p, p];
115            Some((cfg, model.plan_for(&[&v, &v, &[1], &[1], &[1, 1, 3], &v])?))
116        }
117        None => None,
118    };
119    let weighting = IqsmEchoWeighting::MagnitudeSquaredTeSquared;
120    combine_echoes(phases, magnitudes, tes, grid.n_total(), weighting, |phase, te| match &tiled {
121        None => iqsm_plus_with(&whole, phase, mask, grid, te, b0, b0_dir, phase_sign, eroded_rad, true),
122        // A tile is already a multiple of 16 and shaped as the plan is: no crop to the brain.
123        Some((cfg, plan)) => tiled_echo(phase, mask, grid, eroded_rad, cfg, &cost, |ph, m, g| {
124            iqsm_plus_with(&|i| plan.run(i), ph, m, g, te, b0, b0_dir, phase_sign, 0, false)
125        }),
126    })
127}
128
129/// One pass of the network. With `crop`, on the eroded mask's bounding box plus 16 voxels,
130/// centre-padded to a multiple of 16; without, on the whole input, which must already be one.
131#[allow(clippy::too_many_arguments)]
132fn iqsm_plus_with(
133    net: Net,
134    phase_rad: &[f64],
135    mask: &[u8],
136    grid: &Grid,
137    te: f64,
138    b0: f64,
139    b0_dir: (f64, f64, f64),
140    phase_sign: f64,
141    eroded_rad: i32,
142    crop: bool,
143) -> Result<Vec<f64>, OnnxError> {
144    let (nx, ny, nz) = grid.dims;
145    let n = nx * ny * nz;
146    assert_eq!(phase_rad.len(), n, "phase length must match grid");
147    assert_eq!(mask.len(), n, "mask length must match grid");
148    let idx = |x: usize, y: usize, z: usize| x + nx * (y + ny * z);
149
150    let eroded = if eroded_rad > 0 {
151        sphere_erode(mask, grid, eroded_rad)
152    } else {
153        mask.to_vec()
154    };
155
156    // Brain bounding box of the eroded mask, expanded by 16 and clamped.
157    let (mut x0, mut y0, mut z0) = (nx, ny, nz);
158    let (mut x1, mut y1, mut z1) = (0usize, 0usize, 0usize);
159    let mut any = false;
160    for z in 0..nz {
161        for y in 0..ny {
162            for x in 0..nx {
163                if eroded[idx(x, y, z)] != 0 {
164                    any = true;
165                    x0 = x0.min(x); y0 = y0.min(y); z0 = z0.min(z);
166                    x1 = x1.max(x); y1 = y1.max(y); z1 = z1.max(z);
167                }
168            }
169        }
170    }
171    if !any {
172        return Ok(vec![0.0; n]);
173    }
174    let pad = 16usize;
175    let (bx0, by0, bz0, bx1, by1, bz1) = if crop {
176        (
177            x0.saturating_sub(pad),
178            y0.saturating_sub(pad),
179            z0.saturating_sub(pad),
180            (x1 + 1 + pad).min(nx),
181            (y1 + 1 + pad).min(ny),
182            (z1 + 1 + pad).min(nz),
183        )
184    } else {
185        (0, 0, 0, nx, ny, nz)
186    };
187    let (cx, cy, cz) = (bx1 - bx0, by1 - by0, bz1 - bz0);
188
189    // Centre-pad the cropped box to a multiple of 16.
190    let mpad = |s: usize| -> (usize, usize) {
191        let total = (16 - s % 16) % 16;
192        (total / 2, s + total)
193    };
194    let (ox, px) = mpad(cx);
195    let (oy, py) = mpad(cy);
196    let (oz, pz) = mpad(cz);
197
198    // Fill NCDHW row-major [1,1,px,py,pz] phase/mask/border from the cropped box.
199    let mut phase_t = vec![0.0f32; px * py * pz];
200    let mut mask_t = vec![0.0f32; px * py * pz];
201    let mut border = vec![1.0f32; px * py * pz];
202    let rdst = |i: usize, j: usize, k: usize| (k + oz) + pz * ((j + oy) + py * (i + ox));
203    for k in 0..cz {
204        for j in 0..cy {
205            for i in 0..cx {
206                let src = idx(bx0 + i, by0 + j, bz0 + k);
207                let dst = rdst(i, j, k);
208                phase_t[dst] = (phase_sign * phase_rad[src]) as f32;
209                mask_t[dst] = eroded[src] as f32;
210            }
211        }
212    }
213    for a in 0..px {
214        for b in 0..py {
215            for c in 0..pz {
216                if a == 0 || a == px - 1 || b == 0 || b == py - 1 || c == 0 || c == pz - 1 {
217                    border[c + pz * (b + py * a)] = 0.0;
218                }
219            }
220        }
221    }
222
223    let norm = (b0_dir.0.powi(2) + b0_dir.1.powi(2) + b0_dir.2.powi(2)).sqrt();
224    let zdir = if norm > 0.0 { norm } else { 1.0 };
225    let z_prjs = vec![
226        (b0_dir.0 / zdir) as f32,
227        (b0_dir.1 / zdir) as f32,
228        (b0_dir.2 / zdir) as f32,
229    ];
230
231    let shape = vec![1, 1, px, py, pz];
232    let inputs = [
233        Tensor::new(shape.clone(), phase_t),
234        Tensor::new(shape.clone(), mask_t),
235        Tensor::new(vec![1], vec![te as f32]),
236        Tensor::new(vec![1], vec![b0 as f32]),
237        Tensor::new(vec![1, 1, 3], z_prjs),
238        Tensor::new(shape, border),
239    ];
240    let out = net(&inputs)?;
241    let chi_pad = &out[0].data;
242
243    // ×mask, crop pad, paste back into the full grid at the bounding box.
244    let mut chi = vec![0.0f64; n];
245    for k in 0..cz {
246        for j in 0..cy {
247            for i in 0..cx {
248                let src = idx(bx0 + i, by0 + j, bz0 + k);
249                if eroded[src] != 0 {
250                    chi[src] = chi_pad[rdst(i, j, k)] as f64;
251                }
252            }
253        }
254    }
255    Ok(chi)
256}