Skip to main content

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}