qsm_core/inversion/modl_qsm.rs
1//! MoDL-QSM dipole inversion (`onnx` feature).
2//!
3//! MoDL-QSM (Feng 2021) solves the dipole deconvolution by unrolling a
4//! **model-based gradient descent** (`num_iter = 3`): each iteration takes a
5//! k-space data-consistency step with the dipole kernel `D` (the `A`/`A^H`
6//! operators), then applies a learned 3D-CNN prior. Only the CNN prior is a
7//! network (exported ONNX — a conv/BN/ReLU residual stack, 2-channel in/out); the
8//! FFT data-consistency, the unroll, the learned step size `alpha`, and the
9//! per-channel mean/std normalization live here in Rust.
10//!
11//! # Field & susceptibility layout
12//!
13//! Input is the background-removed **local field in ppm** (single orientation).
14//! MoDL-QSM's `phi` input is the tissue field normalized to ppm (the repo's example
15//! `test_data.mat` fields span ~±0.1–0.2 ppm), so the field is consumed directly.
16//!
17//! The network is STI-flavored: it works on a **2-channel** susceptibility estimate
18//! — channel 0 is the STI tensor component χ33 (comparable to scalar QSM) and
19//! channel 1 is the field induced by the χ13/χ23 terms. The `A^H` operator maps a
20//! 1-channel field to 2 channels `[ifft(D·fft(φ)), φ]`; the `A` operator maps the
21//! 2-channel susceptibility back to a 1-channel field `ifft(D·fft(χ33) + fft(χ13/23))`.
22//! We return **channel 0 (χ33)** as the QSM susceptibility, masked (matching the
23//! authors' `recon.py`, which keeps `Y[...,0]`).
24//!
25//! # FFT convention
26//!
27//! MoDL-QSM's `A`/`A^H` use an **ortho** FFT (`fft/√N`, `ifft·√N`); the `√N` factors
28//! cancel through the linear (i)FFT for both operators, so we use the crate's
29//! standard normalized `fft3d_real`/`ifft3d_real` pair directly. The dipole kernel
30//! `D = 1/3 − (k·B̂)²/|k|²` uses the `fftfreq` convention (DC at the array corner,
31//! `D[0,0,0]=0`) — bit-identical to `test_tools.dipole_kernel` (which builds a
32//! centered `D` then `fftshift`s it). This is the same kernel LPCNN uses.
33//!
34//! # Grid requirements
35//!
36//! `model_test` requires an isotropic 1 mm grid with even dimensions (odd dims are
37//! cropped, non-unit voxels are k-space interpolated to 1 mm before inference). The
38//! Rust glue therefore assumes 1 mm even-dimension input (the QSM-CI/dev grid);
39//! callers needing resampling should do so upstream. Weights are not bundled; the
40//! caller passes `modl-qsm.onnx`.
41//!
42//! # Parity
43//!
44//! Measured against the authors' Keras graph (`define_generator` + `model_test`, QSM-CI's
45//! `modl-qsm` image, MoDL-QSM 111a493, `logs/last.h5`) on a 36×32×28 in-vivo box of
46//! `prisma-bridge-run1` (QSM.rs#330):
47//!
48//! - Every intermediate of the unroll (`A^H φ`, the step, the normalisation, each CNN-prior pass,
49//! `-α A^H A x`) matches the Keras layer outputs to 1e-7–3e-6 relative.
50//! - End to end: 0.0002 % NRMSE (r 1.0000000, max |Δ| 3.2e-7 ppm).
51//! - QSM-CI's `recon.py` wrote χ through the mask's uint8 NIfTI header, so nibabel stored it in 8
52//! bits; that quantisation was the whole 1.49 % gap first measured against that entry. The port
53//! does not reproduce it.
54//!
55//! # Reference pipeline
56//!
57//! The authors' `test/test_tools.py:210-249` (`model_test`) is what the grid requirements above
58//! paraphrase: `inter_data` zero-pads/truncates k-space to 1 mm before the net and back to the
59//! input voxel size afterwards, then masks. The README describes the input as the "tissue phase";
60//! `test/test_demo.py` feeds the shipped `phi` with voxel size `[1,1,1]` and B0 `[0,0,1]`.
61//!
62//! # Agreement with the original
63//!
64//! `tests/models_onnx/dipole.rs` runs it on a 36×32×28 box of in-vivo data (1 mm, 2.89 T, across
65//! the brain's edge) against its original's verified QSM-CI entry, MoDL-QSM 111a493 (`modl-qsm`)
66//! (QSMxT/QSM-CI#316; the fixture is regenerated in Actions by
67//! `tests/data/dl_dipole_qsmci_small.py`, from the entry that writes χ as float32,
68//! QSMxT/QSM-CI#331): **NRMSE 0.0002 %**, r 1.0000000, max |Δ| 3.2e-7 ppm (measured 2026-11-02),
69//! float32 rounding. The 1.5 % first measured was the entry's uint8 output (above;
70//! astewartau/QSM.rs#330).
71
72use num_complex::Complex64;
73
74use crate::fft::{fft3d_real, ifft3d_real};
75use crate::grid::Grid;
76use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
77
78/// Learned data-consistency step size (`Alpha`, checkpoint `logs/last.h5` MyLayer;
79/// initializer was 4.0, this is the trained value).
80pub const MODL_ALPHA: f64 = 1.101_906_418_800_354;
81/// Train-set per-channel mean (`NormFactor.mat` `CosTrnMean`): [χ33, χ13/23 field].
82pub const MODL_MEAN: [f64; 2] = [-0.002_256_532_665_342_092_5, -0.000_485_291_442_601_010_2];
83/// Train-set per-channel std (`NormFactor.mat` `CosTrnStd`): [χ33, χ13/23 field].
84pub const MODL_STD: [f64; 2] = [0.026_074_999_943_375_587, 0.004_082_275_088_876_486];
85const NUM_ITER: usize = 3;
86
87/// Run MoDL-QSM on a background-removed local field (ppm), column-major `(nx,ny,nz)`.
88/// `bdir` is the B0 direction (for the dipole kernel). Returns χ33 (ppm), masked.
89pub fn modl_qsm(
90 local_field_ppm: &[f64],
91 mask: &[u8],
92 grid: &Grid,
93 bdir: (f64, f64, f64),
94 prior_onnx: &[u8],
95) -> Result<Vec<f64>, OnnxError> {
96 let (nx, ny, nz) = grid.dims;
97 let n = nx * ny * nz;
98 assert_eq!(local_field_ppm.len(), n, "field length must match grid");
99 assert_eq!(mask.len(), n, "mask length must match grid");
100
101 let model = OnnxModel::load(prior_onnx)?;
102 let maskf: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
103 let dk = modl_dipole_kernel(grid, bdir);
104
105 // y_input = alpha · A^H(phi) (2-channel, interleaved [ch0, ch1] per voxel)
106 let y_input = ah_op(local_field_ppm, &dk, grid);
107 let y_input: Vec<[f64; 2]> =
108 y_input.iter().map(|&[a, b]| [MODL_ALPHA * a, MODL_ALPHA * b]).collect();
109
110 let mut x_output: Vec<[f64; 2]> = vec![[0.0; 2]; n];
111 for i in 0..NUM_ITER {
112 // Data-consistency: layer_input.
113 // i == 0: layer_input = y_input
114 // i > 0: layer_input = x_output − alpha·A^H(A(x_output)) + y_input
115 let layer_input: Vec<[f64; 2]> = if i == 0 {
116 y_input.clone()
117 } else {
118 let a_x = a_op(&x_output, &dk, grid); // 1-channel field
119 let ah_a_x = ah_op(&a_x, &dk, grid); // 2-channel
120 (0..n)
121 .map(|k| {
122 [
123 x_output[k][0] - MODL_ALPHA * ah_a_x[k][0] + y_input[k][0],
124 x_output[k][1] - MODL_ALPHA * ah_a_x[k][1] + y_input[k][1],
125 ]
126 })
127 .collect()
128 };
129
130 // Normalize per channel, mask, run CNN prior, mask, denormalize per channel.
131 let mut norm = vec![0.0f64; 2 * n];
132 for k in 0..n {
133 norm[2 * k] = ((layer_input[k][0] - MODL_MEAN[0]) / MODL_STD[0]) * maskf[k];
134 norm[2 * k + 1] = ((layer_input[k][1] - MODL_MEAN[1]) / MODL_STD[1]) * maskf[k];
135 }
136 let fx = run_prior(&model, &norm, grid)?; // 2·n interleaved
137 for k in 0..n {
138 let c0 = fx[2 * k] * maskf[k];
139 let c1 = fx[2 * k + 1] * maskf[k];
140 x_output[k] = [c0 * MODL_STD[0] + MODL_MEAN[0], c1 * MODL_STD[1] + MODL_MEAN[1]];
141 }
142 }
143
144 // Keep channel 0 (χ33), masked (recon.py: Y[...,0] · mask).
145 Ok((0..n).map(|k| x_output[k][0] * maskf[k]).collect())
146}
147
148/// MoDL-QSM dipole kernel `D = 1/3 − (k·B̂)²/|k|²` with `k` from `fftfreq` (DC at the
149/// array corner, `D[0,0,0]=0`). Bit-identical to `test_tools.dipole_kernel`.
150pub(crate) fn modl_dipole_kernel(grid: &Grid, bdir: (f64, f64, f64)) -> Vec<f64> {
151 let (nx, ny, nz) = grid.dims;
152 let (vx, vy, vz) = grid.voxel_size;
153 let (bx, by, bz) = bdir;
154 let bn = (bx * bx + by * by + bz * bz).sqrt();
155 let (bx, by, bz) = (bx / bn, by / bn, bz / bn);
156 let freq = |i: usize, ntot: usize, d: f64| -> f64 {
157 let ii = if i < ntot.div_ceil(2) { i as f64 } else { i as f64 - ntot as f64 };
158 ii / (ntot as f64 * d)
159 };
160 let mut k = vec![0.0f64; nx * ny * nz];
161 for z in 0..nz {
162 let kz = freq(z, nz, vz);
163 for y in 0..ny {
164 let ky = freq(y, ny, vy);
165 for x in 0..nx {
166 let kx = freq(x, nx, vx);
167 let k2 = kx * kx + ky * ky + kz * kz;
168 let kb = kx * bx + ky * by + kz * bz;
169 k[x + nx * (y + ny * z)] = if k2 > 0.0 { 1.0 / 3.0 - kb * kb / k2 } else { 0.0 };
170 }
171 }
172 }
173 k
174}
175
176/// `A^H` operator: 1-channel field φ → 2-channel `[real(ifft(D·fft(φ))), φ]`.
177/// Interleaved output `[ch0, ch1]` per voxel.
178fn ah_op(phi: &[f64], dk: &[f64], grid: &Grid) -> Vec<[f64; 2]> {
179 let ch0 = dipole_apply(phi, dk, grid);
180 ch0.iter().zip(phi).map(|(&a, &p)| [a, p]).collect()
181}
182
183/// `A` operator: 2-channel susceptibility → 1-channel field
184/// `real(ifft(D·fft(χ33) + fft(χ13/23)))`. Input is interleaved `[ch0, ch1]`.
185fn a_op(sus: &[[f64; 2]], dk: &[f64], grid: &Grid) -> Vec<f64> {
186 let (nx, ny, nz) = grid.dims;
187 let n = nx * ny * nz;
188 let ch0: Vec<f64> = (0..n).map(|k| sus[k][0]).collect();
189 let ch1: Vec<f64> = (0..n).map(|k| sus[k][1]).collect();
190 let mut spec0 = fft3d_real(&ch0, nx, ny, nz);
191 let spec1 = fft3d_real(&ch1, nx, ny, nz);
192 for k in 0..n {
193 spec0[k] = spec0[k] * Complex64::new(dk[k], 0.0) + spec1[k];
194 }
195 ifft3d_real(&spec0, nx, ny, nz)
196}
197
198/// `D(v) = real(ifft3(fft3(v)·dk))` with `dk` in fftfreq (unshifted) layout.
199fn dipole_apply(v: &[f64], dk: &[f64], grid: &Grid) -> Vec<f64> {
200 let (nx, ny, nz) = grid.dims;
201 let mut spec = fft3d_real(v, nx, ny, nz);
202 for (s, &d) in spec.iter_mut().zip(dk) {
203 *s *= Complex64::new(d, 0.0);
204 }
205 ifft3d_real(&spec, nx, ny, nz)
206}
207
208/// Run the 2-in / 2-out CNN prior on a column-major volume. `vol` is interleaved
209/// `[ch0, ch1]` per voxel (length `2·n`); output is the same interleaved layout.
210fn run_prior(model: &OnnxModel, vol: &[f64], grid: &Grid) -> Result<Vec<f64>, OnnxError> {
211 let (nx, ny, nz) = grid.dims;
212 let n = nx * ny * nz;
213 // NCDHW row-major, C=2: [batch, chan, x, y, z].
214 let mut input = vec![0.0f32; 2 * n];
215 for z in 0..nz {
216 for y in 0..ny {
217 for x in 0..nx {
218 let col = x + nx * (y + ny * z);
219 let row = (x * ny + y) * nz + z;
220 input[row] = vol[2 * col] as f32; // channel 0 plane
221 input[n + row] = vol[2 * col + 1] as f32; // channel 1 plane
222 }
223 }
224 }
225 let out = model.run_single(&Tensor::new(vec![1, 2, nx, ny, nz], input))?;
226 let mut v = vec![0.0f64; 2 * n];
227 for z in 0..nz {
228 for y in 0..ny {
229 for x in 0..nx {
230 let col = x + nx * (y + ny * z);
231 let row = (x * ny + y) * nz + z;
232 v[2 * col] = out.data[row] as f64;
233 v[2 * col + 1] = out.data[n + row] as f64;
234 }
235 }
236 }
237 Ok(v)
238}
239
240/// Memory-bounded MoDL-QSM via whole-algorithm overlap-tiling — the full unrolled net runs on
241/// each patch sub-volume. MoDL's data-consistency step is a global k-space operation, so tiling
242/// is **strongly off-design** and approximate; prefer a whole-volume run (e.g. QSMxT). See
243/// [`crate::inversion::tiled::tiled_volume_algorithm`].
244pub fn modl_qsm_tiled(
245 local_field_ppm: &[f64],
246 mask: &[u8],
247 grid: &Grid,
248 bdir: (f64, f64, f64),
249 prior_onnx: &[u8],
250 cfg: &super::tiled::TileConfig,
251 progress: impl FnMut(usize, usize),
252) -> Result<Vec<f64>, OnnxError> {
253 super::tiled::tiled_volume_algorithm(
254 local_field_ppm, mask, grid, &super::tiled::tile_cost_of("modl-qsm"), cfg,
255 |f, m, g| modl_qsm(f, m, g, bdir, prior_onnx),
256 progress,
257 )
258}