Skip to main content

qsm_core/inversion/
lpcnn.rs

1//! LPCNN dipole inversion (`onnx` feature).
2//!
3//! LPCNN (Lai 2020) solves the dipole deconvolution by unrolling **proximal
4//! gradient descent** (`iter_num = 3`): each iteration takes a k-space
5//! data-consistency step with the dipole kernel `D`, then applies a learned 3D-CNN
6//! proximal operator `gen`. Only `gen` is a network (exported ONNX, a plain
7//! conv/BN/ReLU residual stack); the FFT data-consistency, the unroll, the learned
8//! step size `alpha`, and the mean/std normalization live here in Rust.
9//!
10//! Input is the background-removed **local field in ppm** (single orientation). The
11//! authors' Hz↔ppm round-trip (`×tesla·γ` then `÷tesla·γ`) cancels, so the field is
12//! consumed directly in ppm. Weights are not bundled; the caller passes `lpcnn.onnx`.
13
14use num_complex::Complex64;
15
16use crate::fft::{fft3d_real, ifft3d_real};
17use crate::grid::Grid;
18use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
19
20/// Learned data-consistency step size (checkpoint `alpha`, Bmodel).
21pub const LPCNN_ALPHA: f64 = 3.718_820_571_899_414;
22/// Ground-truth normalization (ppm), baked into the training pipeline.
23pub const LPCNN_GT_MEAN: f64 = -0.000_247_246_032_403_825_13;
24pub const LPCNN_GT_STD: f64 = 0.028_365_555_003_933_052;
25const ITER_NUM: usize = 3;
26
27/// Run LPCNN on a background-removed local field (ppm), column-major `(nx,ny,nz)`.
28/// `bdir` is the B0 direction (for the dipole kernel). Returns χ (ppm), masked.
29pub fn lpcnn(
30    local_field_ppm: &[f64],
31    mask: &[u8],
32    grid: &Grid,
33    bdir: (f64, f64, f64),
34    gen_onnx: &[u8],
35) -> Result<Vec<f64>, OnnxError> {
36    let (nx, ny, nz) = grid.dims;
37    let n = nx * ny * nz;
38    assert_eq!(local_field_ppm.len(), n, "field length must match grid");
39    assert_eq!(mask.len(), n, "mask length must match grid");
40
41    let model = OnnxModel::load(gen_onnx)?;
42    let maskf: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
43    let dk = lpcnn_dipole_kernel(grid, bdir);
44    let dipole = |v: &[f64]| dipole_apply(v, &dk, grid);
45
46    // x_est = α·D(y)
47    let x_est: Vec<f64> = dipole(local_field_ppm).iter().map(|&v| LPCNN_ALPHA * v).collect();
48
49    let mut den = vec![0.0f64; n];
50    let mut x_pred = vec![0.0f64; n];
51    for i in 0..ITER_NUM {
52        // Data-consistency step: pn = α·D(y) at i=0, else den + α·D(y − D(den)).
53        let pn: Vec<f64> = if i == 0 {
54            x_est.clone()
55        } else {
56            let dd = dipole(&dipole(&den)); // D²(den)
57            (0..n).map(|k| den[k] + x_est[k] - LPCNN_ALPHA * dd[k]).collect()
58        };
59        // Proximal CNN on the normalized, masked estimate.
60        let x_input: Vec<f64> =
61            (0..n).map(|k| ((pn[k] - LPCNN_GT_MEAN) / LPCNN_GT_STD) * maskf[k]).collect();
62        x_pred = run_gen(&model, &x_input, grid)?;
63        den = (0..n)
64            .map(|k| (x_pred[k] * LPCNN_GT_STD + LPCNN_GT_MEAN) * maskf[k])
65            .collect();
66    }
67    // Denormalize the final proximal output, masked.
68    Ok((0..n)
69        .map(|k| (x_pred[k] * LPCNN_GT_STD + LPCNN_GT_MEAN) * maskf[k])
70        .collect())
71}
72
73/// LPCNN dipole kernel `D = 1/3 − (k·B̂)²/|k|²` with `k` from `fftfreq` (DC at the
74/// array corner, `D[0,0,0]=0`) — the convention the model's ortho-FFT expects.
75pub(crate) fn lpcnn_dipole_kernel(grid: &Grid, bdir: (f64, f64, f64)) -> Vec<f64> {
76    let (nx, ny, nz) = grid.dims;
77    let (vx, vy, vz) = grid.voxel_size;
78    let (bx, by, bz) = bdir;
79    let bn = (bx * bx + by * by + bz * bz).sqrt();
80    let (bx, by, bz) = (bx / bn, by / bn, bz / bn);
81    let freq = |i: usize, ntot: usize, d: f64| -> f64 {
82        let ii = if i < ntot.div_ceil(2) { i as f64 } else { i as f64 - ntot as f64 };
83        ii / (ntot as f64 * d)
84    };
85    let mut k = vec![0.0f64; nx * ny * nz];
86    for z in 0..nz {
87        let kz = freq(z, nz, vz);
88        for y in 0..ny {
89            let ky = freq(y, ny, vy);
90            for x in 0..nx {
91                let kx = freq(x, nx, vx);
92                let k2 = kx * kx + ky * ky + kz * kz;
93                let kb = kx * bx + ky * by + kz * bz;
94                k[x + nx * (y + ny * z)] = if k2 > 0.0 { 1.0 / 3.0 - kb * kb / k2 } else { 0.0 };
95            }
96        }
97    }
98    k
99}
100
101/// `D(v) = real(ifft3(fft3(v)·dk))` with `dk` in fftfreq (unshifted) layout.
102fn dipole_apply(v: &[f64], dk: &[f64], grid: &Grid) -> Vec<f64> {
103    let (nx, ny, nz) = grid.dims;
104    let mut spec = fft3d_real(v, nx, ny, nz);
105    for (s, &d) in spec.iter_mut().zip(dk) {
106        *s *= Complex64::new(d, 0.0);
107    }
108    ifft3d_real(&spec, nx, ny, nz)
109}
110
111/// Run the single-in/single-out proximal CNN on a column-major volume.
112fn run_gen(model: &OnnxModel, vol: &[f64], grid: &Grid) -> Result<Vec<f64>, OnnxError> {
113    let (nx, ny, nz) = grid.dims;
114    let mut input = vec![0.0f32; nx * ny * nz];
115    for z in 0..nz {
116        for y in 0..ny {
117            for x in 0..nx {
118                input[(x * ny + y) * nz + z] = vol[x + nx * (y + ny * z)] as f32;
119            }
120        }
121    }
122    let out = model.run_single(&Tensor::new(vec![1, 1, nx, ny, nz], input))?;
123    let mut v = vec![0.0f64; nx * ny * nz];
124    for z in 0..nz {
125        for y in 0..ny {
126            for x in 0..nx {
127                v[x + nx * (y + ny * z)] = out.data[(x * ny + y) * nz + z] as f64;
128            }
129        }
130    }
131    Ok(v)
132}
133
134/// Memory-bounded LPCNN via whole-algorithm overlap-tiling — the entire unrolled net is run on
135/// each patch sub-volume so peak memory is bounded (for the 32-bit WASM heap). LPCNN's k-space
136/// data-consistency step is global, so tiling is **strongly off-design** and approximate; for
137/// real work run whole-volume (e.g. in QSMxT). See [`crate::inversion::tiled::tiled_volume_algorithm`].
138pub fn lpcnn_tiled(
139    local_field_ppm: &[f64],
140    mask: &[u8],
141    grid: &Grid,
142    bdir: (f64, f64, f64),
143    gen_onnx: &[u8],
144    cfg: &super::tiled::TileConfig,
145    progress: impl FnMut(usize, usize),
146) -> Result<Vec<f64>, OnnxError> {
147    super::tiled::tiled_volume_algorithm(
148        local_field_ppm, mask, grid, 8, cfg,
149        |f, m, g| lpcnn(f, m, g, bdir, gen_onnx),
150        progress,
151    )
152}