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//!
14//! # Reference pipeline
15//!
16//! From Lai et al., MICCAI 2020 (arXiv:2008.05024), "Dataset Acquisition and Preprocessing", and
17//! `Sulam-Group/LPCNN`:
18//!
19//! - **Training data:** 7T Philips, 1×1×1 mm, multi-echo GRE (TE1/ΔTE 5/5 or 2/2 ms), 4–5
20//!   orientations per subject, COSMOS labels; 64³ patches with a zero-padded full-size dipole.
21//! - **Phase processing:** best-path unwrapping, FSL BET, V-SHARP, then averaging of the echoes
22//!   with TE between 10 and 30 ms.
23//! - **Units:** `LPCNN/inference.py:50` divides the local phase (Hz) by `tesla · γ` (γ =
24//!   42.57747892 MHz/T), i.e. the network sees ppm; `--tesla` takes only 3 or 7.
25//! - **Resampling:** none in the authors' code; the dipole kernel is built for the input's voxel
26//!   size.
27//!
28//! # Agreement with the original
29//!
30//! `tests/models_onnx/dipole.rs` runs it on a 36×32×28 box of in-vivo data (1 mm, 2.89 T, across
31//! the brain's edge) against its original's verified QSM-CI entry, LPCNN dd1ef2b (`lpcnn`)
32//! (QSMxT/QSM-CI#316; the fixture is regenerated in Actions by
33//! `tests/data/dl_dipole_qsmci_small.py`): NRMSE 3.7e-5 %, max |Δ| 8.5e-8 ppm (measured
34//! 2026-10-30): float32 rounding.
35//!
36//! # Pipeline rules
37//!
38//! The facts above that a configuration can depart from are published as rules in
39//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `lpcnn.mask-bet`, `lpcnn.bg-vsharp`.
40
41use num_complex::Complex64;
42
43use crate::fft::{fft3d_real, ifft3d_real};
44use crate::grid::Grid;
45use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
46
47/// Learned data-consistency step size (checkpoint `alpha`, Bmodel).
48pub const LPCNN_ALPHA: f64 = 3.718_820_571_899_414;
49/// Ground-truth normalization (ppm), baked into the training pipeline.
50pub const LPCNN_GT_MEAN: f64 = -0.000_247_246_032_403_825_13;
51pub const LPCNN_GT_STD: f64 = 0.028_365_555_003_933_052;
52const ITER_NUM: usize = 3;
53
54/// Run LPCNN on a background-removed local field (ppm), column-major `(nx,ny,nz)`.
55/// `bdir` is the B0 direction (for the dipole kernel). Returns χ (ppm), masked.
56pub fn lpcnn(
57    local_field_ppm: &[f64],
58    mask: &[u8],
59    grid: &Grid,
60    bdir: (f64, f64, f64),
61    gen_onnx: &[u8],
62) -> Result<Vec<f64>, OnnxError> {
63    let (nx, ny, nz) = grid.dims;
64    let n = nx * ny * nz;
65    assert_eq!(local_field_ppm.len(), n, "field length must match grid");
66    assert_eq!(mask.len(), n, "mask length must match grid");
67
68    let model = OnnxModel::load(gen_onnx)?;
69    let maskf: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
70    let dk = lpcnn_dipole_kernel(grid, bdir);
71    let dipole = |v: &[f64]| dipole_apply(v, &dk, grid);
72
73    // x_est = α·D(y)
74    let x_est: Vec<f64> = dipole(local_field_ppm).iter().map(|&v| LPCNN_ALPHA * v).collect();
75
76    let mut den = vec![0.0f64; n];
77    let mut x_pred = vec![0.0f64; n];
78    for i in 0..ITER_NUM {
79        // Data-consistency step: pn = α·D(y) at i=0, else den + α·D(y − D(den)).
80        let pn: Vec<f64> = if i == 0 {
81            x_est.clone()
82        } else {
83            let dd = dipole(&dipole(&den)); // D²(den)
84            (0..n).map(|k| den[k] + x_est[k] - LPCNN_ALPHA * dd[k]).collect()
85        };
86        // Proximal CNN on the normalized, masked estimate.
87        let x_input: Vec<f64> =
88            (0..n).map(|k| ((pn[k] - LPCNN_GT_MEAN) / LPCNN_GT_STD) * maskf[k]).collect();
89        x_pred = run_gen(&model, &x_input, grid)?;
90        den = (0..n)
91            .map(|k| (x_pred[k] * LPCNN_GT_STD + LPCNN_GT_MEAN) * maskf[k])
92            .collect();
93    }
94    // Denormalize the final proximal output, masked.
95    Ok((0..n)
96        .map(|k| (x_pred[k] * LPCNN_GT_STD + LPCNN_GT_MEAN) * maskf[k])
97        .collect())
98}
99
100/// LPCNN dipole kernel `D = 1/3 − (k·B̂)²/|k|²` with `k` from `fftfreq` (DC at the
101/// array corner, `D[0,0,0]=0`) — the convention the model's ortho-FFT expects.
102pub(crate) fn lpcnn_dipole_kernel(grid: &Grid, bdir: (f64, f64, f64)) -> Vec<f64> {
103    let (nx, ny, nz) = grid.dims;
104    let (vx, vy, vz) = grid.voxel_size;
105    let (bx, by, bz) = bdir;
106    let bn = (bx * bx + by * by + bz * bz).sqrt();
107    let (bx, by, bz) = (bx / bn, by / bn, bz / bn);
108    let freq = |i: usize, ntot: usize, d: f64| -> f64 {
109        let ii = if i < ntot.div_ceil(2) { i as f64 } else { i as f64 - ntot as f64 };
110        ii / (ntot as f64 * d)
111    };
112    let mut k = vec![0.0f64; nx * ny * nz];
113    for z in 0..nz {
114        let kz = freq(z, nz, vz);
115        for y in 0..ny {
116            let ky = freq(y, ny, vy);
117            for x in 0..nx {
118                let kx = freq(x, nx, vx);
119                let k2 = kx * kx + ky * ky + kz * kz;
120                let kb = kx * bx + ky * by + kz * bz;
121                k[x + nx * (y + ny * z)] = if k2 > 0.0 { 1.0 / 3.0 - kb * kb / k2 } else { 0.0 };
122            }
123        }
124    }
125    k
126}
127
128/// `D(v) = real(ifft3(fft3(v)·dk))` with `dk` in fftfreq (unshifted) layout.
129fn dipole_apply(v: &[f64], dk: &[f64], grid: &Grid) -> Vec<f64> {
130    let (nx, ny, nz) = grid.dims;
131    let mut spec = fft3d_real(v, nx, ny, nz);
132    for (s, &d) in spec.iter_mut().zip(dk) {
133        *s *= Complex64::new(d, 0.0);
134    }
135    ifft3d_real(&spec, nx, ny, nz)
136}
137
138/// Run the single-in/single-out proximal CNN on a column-major volume.
139fn run_gen(model: &OnnxModel, vol: &[f64], grid: &Grid) -> Result<Vec<f64>, OnnxError> {
140    let (nx, ny, nz) = grid.dims;
141    let mut input = vec![0.0f32; nx * ny * nz];
142    for z in 0..nz {
143        for y in 0..ny {
144            for x in 0..nx {
145                input[(x * ny + y) * nz + z] = vol[x + nx * (y + ny * z)] as f32;
146            }
147        }
148    }
149    let out = model.run_single(&Tensor::new(vec![1, 1, nx, ny, nz], input))?;
150    let mut v = vec![0.0f64; nx * ny * nz];
151    for z in 0..nz {
152        for y in 0..ny {
153            for x in 0..nx {
154                v[x + nx * (y + ny * z)] = out.data[(x * ny + y) * nz + z] as f64;
155            }
156        }
157    }
158    Ok(v)
159}
160
161/// Memory-bounded LPCNN via whole-algorithm overlap-tiling — the entire unrolled net is run on
162/// each patch sub-volume so peak memory is bounded (for the 32-bit WASM heap). LPCNN's k-space
163/// data-consistency step is global, so tiling is **strongly off-design** and approximate; for
164/// real work run whole-volume (e.g. in QSMxT). See [`crate::inversion::tiled::tiled_volume_algorithm`].
165pub fn lpcnn_tiled(
166    local_field_ppm: &[f64],
167    mask: &[u8],
168    grid: &Grid,
169    bdir: (f64, f64, f64),
170    gen_onnx: &[u8],
171    cfg: &super::tiled::TileConfig,
172    progress: impl FnMut(usize, usize),
173) -> Result<Vec<f64>, OnnxError> {
174    super::tiled::tiled_volume_algorithm(
175        local_field_ppm, mask, grid, &super::tiled::tile_cost_of("lpcnn"), cfg,
176        |f, m, g| lpcnn(f, m, g, bdir, gen_onnx),
177        progress,
178    )
179}