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
42use num_complex::Complex64;
43
44use crate::fft::{fft3d_real, ifft3d_real};
45use crate::grid::Grid;
46use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
47
48/// Learned data-consistency step size (`Alpha`, checkpoint `logs/last.h5` MyLayer;
49/// initializer was 4.0, this is the trained value).
50pub const MODL_ALPHA: f64 = 1.101_906_418_800_354;
51/// Train-set per-channel mean (`NormFactor.mat` `CosTrnMean`): [χ33, χ13/23 field].
52pub const MODL_MEAN: [f64; 2] = [-0.002_256_532_665_342_092_5, -0.000_485_291_442_601_010_2];
53/// Train-set per-channel std (`NormFactor.mat` `CosTrnStd`): [χ33, χ13/23 field].
54pub const MODL_STD: [f64; 2] = [0.026_074_999_943_375_587, 0.004_082_275_088_876_486];
55const NUM_ITER: usize = 3;
56
57/// Run MoDL-QSM on a background-removed local field (ppm), column-major `(nx,ny,nz)`.
58/// `bdir` is the B0 direction (for the dipole kernel). Returns χ33 (ppm), masked.
59pub fn modl_qsm(
60 local_field_ppm: &[f64],
61 mask: &[u8],
62 grid: &Grid,
63 bdir: (f64, f64, f64),
64 prior_onnx: &[u8],
65) -> Result<Vec<f64>, OnnxError> {
66 let (nx, ny, nz) = grid.dims;
67 let n = nx * ny * nz;
68 assert_eq!(local_field_ppm.len(), n, "field length must match grid");
69 assert_eq!(mask.len(), n, "mask length must match grid");
70
71 let model = OnnxModel::load(prior_onnx)?;
72 let maskf: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
73 let dk = modl_dipole_kernel(grid, bdir);
74
75 // y_input = alpha · A^H(phi) (2-channel, interleaved [ch0, ch1] per voxel)
76 let y_input = ah_op(local_field_ppm, &dk, grid);
77 let y_input: Vec<[f64; 2]> =
78 y_input.iter().map(|&[a, b]| [MODL_ALPHA * a, MODL_ALPHA * b]).collect();
79
80 let mut x_output: Vec<[f64; 2]> = vec![[0.0; 2]; n];
81 for i in 0..NUM_ITER {
82 // Data-consistency: layer_input.
83 // i == 0: layer_input = y_input
84 // i > 0: layer_input = x_output − alpha·A^H(A(x_output)) + y_input
85 let layer_input: Vec<[f64; 2]> = if i == 0 {
86 y_input.clone()
87 } else {
88 let a_x = a_op(&x_output, &dk, grid); // 1-channel field
89 let ah_a_x = ah_op(&a_x, &dk, grid); // 2-channel
90 (0..n)
91 .map(|k| {
92 [
93 x_output[k][0] - MODL_ALPHA * ah_a_x[k][0] + y_input[k][0],
94 x_output[k][1] - MODL_ALPHA * ah_a_x[k][1] + y_input[k][1],
95 ]
96 })
97 .collect()
98 };
99
100 // Normalize per channel, mask, run CNN prior, mask, denormalize per channel.
101 let mut norm = vec![0.0f64; 2 * n];
102 for k in 0..n {
103 norm[2 * k] = ((layer_input[k][0] - MODL_MEAN[0]) / MODL_STD[0]) * maskf[k];
104 norm[2 * k + 1] = ((layer_input[k][1] - MODL_MEAN[1]) / MODL_STD[1]) * maskf[k];
105 }
106 let fx = run_prior(&model, &norm, grid)?; // 2·n interleaved
107 for k in 0..n {
108 let c0 = fx[2 * k] * maskf[k];
109 let c1 = fx[2 * k + 1] * maskf[k];
110 x_output[k] = [c0 * MODL_STD[0] + MODL_MEAN[0], c1 * MODL_STD[1] + MODL_MEAN[1]];
111 }
112 }
113
114 // Keep channel 0 (χ33), masked (recon.py: Y[...,0] · mask).
115 Ok((0..n).map(|k| x_output[k][0] * maskf[k]).collect())
116}
117
118/// MoDL-QSM dipole kernel `D = 1/3 − (k·B̂)²/|k|²` with `k` from `fftfreq` (DC at the
119/// array corner, `D[0,0,0]=0`). Bit-identical to `test_tools.dipole_kernel`.
120pub(crate) fn modl_dipole_kernel(grid: &Grid, bdir: (f64, f64, f64)) -> Vec<f64> {
121 let (nx, ny, nz) = grid.dims;
122 let (vx, vy, vz) = grid.voxel_size;
123 let (bx, by, bz) = bdir;
124 let bn = (bx * bx + by * by + bz * bz).sqrt();
125 let (bx, by, bz) = (bx / bn, by / bn, bz / bn);
126 let freq = |i: usize, ntot: usize, d: f64| -> f64 {
127 let ii = if i < ntot.div_ceil(2) { i as f64 } else { i as f64 - ntot as f64 };
128 ii / (ntot as f64 * d)
129 };
130 let mut k = vec![0.0f64; nx * ny * nz];
131 for z in 0..nz {
132 let kz = freq(z, nz, vz);
133 for y in 0..ny {
134 let ky = freq(y, ny, vy);
135 for x in 0..nx {
136 let kx = freq(x, nx, vx);
137 let k2 = kx * kx + ky * ky + kz * kz;
138 let kb = kx * bx + ky * by + kz * bz;
139 k[x + nx * (y + ny * z)] = if k2 > 0.0 { 1.0 / 3.0 - kb * kb / k2 } else { 0.0 };
140 }
141 }
142 }
143 k
144}
145
146/// `A^H` operator: 1-channel field φ → 2-channel `[real(ifft(D·fft(φ))), φ]`.
147/// Interleaved output `[ch0, ch1]` per voxel.
148fn ah_op(phi: &[f64], dk: &[f64], grid: &Grid) -> Vec<[f64; 2]> {
149 let ch0 = dipole_apply(phi, dk, grid);
150 ch0.iter().zip(phi).map(|(&a, &p)| [a, p]).collect()
151}
152
153/// `A` operator: 2-channel susceptibility → 1-channel field
154/// `real(ifft(D·fft(χ33) + fft(χ13/23)))`. Input is interleaved `[ch0, ch1]`.
155fn a_op(sus: &[[f64; 2]], dk: &[f64], grid: &Grid) -> Vec<f64> {
156 let (nx, ny, nz) = grid.dims;
157 let n = nx * ny * nz;
158 let ch0: Vec<f64> = (0..n).map(|k| sus[k][0]).collect();
159 let ch1: Vec<f64> = (0..n).map(|k| sus[k][1]).collect();
160 let mut spec0 = fft3d_real(&ch0, nx, ny, nz);
161 let spec1 = fft3d_real(&ch1, nx, ny, nz);
162 for k in 0..n {
163 spec0[k] = spec0[k] * Complex64::new(dk[k], 0.0) + spec1[k];
164 }
165 ifft3d_real(&spec0, nx, ny, nz)
166}
167
168/// `D(v) = real(ifft3(fft3(v)·dk))` with `dk` in fftfreq (unshifted) layout.
169fn dipole_apply(v: &[f64], dk: &[f64], grid: &Grid) -> Vec<f64> {
170 let (nx, ny, nz) = grid.dims;
171 let mut spec = fft3d_real(v, nx, ny, nz);
172 for (s, &d) in spec.iter_mut().zip(dk) {
173 *s *= Complex64::new(d, 0.0);
174 }
175 ifft3d_real(&spec, nx, ny, nz)
176}
177
178/// Run the 2-in / 2-out CNN prior on a column-major volume. `vol` is interleaved
179/// `[ch0, ch1]` per voxel (length `2·n`); output is the same interleaved layout.
180fn run_prior(model: &OnnxModel, vol: &[f64], grid: &Grid) -> Result<Vec<f64>, OnnxError> {
181 let (nx, ny, nz) = grid.dims;
182 let n = nx * ny * nz;
183 // NCDHW row-major, C=2: [batch, chan, x, y, z].
184 let mut input = vec![0.0f32; 2 * n];
185 for z in 0..nz {
186 for y in 0..ny {
187 for x in 0..nx {
188 let col = x + nx * (y + ny * z);
189 let row = (x * ny + y) * nz + z;
190 input[row] = vol[2 * col] as f32; // channel 0 plane
191 input[n + row] = vol[2 * col + 1] as f32; // channel 1 plane
192 }
193 }
194 }
195 let out = model.run_single(&Tensor::new(vec![1, 2, nx, ny, nz], input))?;
196 let mut v = vec![0.0f64; 2 * n];
197 for z in 0..nz {
198 for y in 0..ny {
199 for x in 0..nx {
200 let col = x + nx * (y + ny * z);
201 let row = (x * ny + y) * nz + z;
202 v[2 * col] = out.data[row] as f64;
203 v[2 * col + 1] = out.data[n + row] as f64;
204 }
205 }
206 }
207 Ok(v)
208}
209
210/// Memory-bounded MoDL-QSM via whole-algorithm overlap-tiling — the full unrolled net runs on
211/// each patch sub-volume. MoDL's data-consistency step is a global k-space operation, so tiling
212/// is **strongly off-design** and approximate; prefer a whole-volume run (e.g. QSMxT). See
213/// [`crate::inversion::tiled::tiled_volume_algorithm`].
214pub fn modl_qsm_tiled(
215 local_field_ppm: &[f64],
216 mask: &[u8],
217 grid: &Grid,
218 bdir: (f64, f64, f64),
219 prior_onnx: &[u8],
220 cfg: &super::tiled::TileConfig,
221 progress: impl FnMut(usize, usize),
222) -> Result<Vec<f64>, OnnxError> {
223 super::tiled::tiled_volume_algorithm(
224 local_field_ppm, mask, grid, 1, cfg,
225 |f, m, g| modl_qsm(f, m, g, bdir, prior_onnx),
226 progress,
227 )
228}