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}