Skip to main content

qsm_core/inversion/
qsmnet.rs

1//! QSMnet deep-learning dipole inversion (`onnx` feature).
2//!
3//! QSMnet is a 3D U-Net (SNU-LIST) that maps a local field (ppm) to
4//! susceptibility (ppm). The upstream weights are TensorFlow, but we ship a
5//! *clean* PyTorch re-export (`scripts/onnx-export/export_qsmnet.py`): the plain
6//! U-Net rebuilt in PyTorch with the TF weights ported in, giving a tidy NCDHW
7//! ONNX (`[1, 1, X, Y, Z]`) that the pure-Rust `tract` engine runs — unlike the
8//! `tf2onnx` graph, whose NHWC↔NCHW Reshape/Transpose ops tract can't analyse.
9//! This mirrors the authors' inference (`Code/inference.py`): normalize by the
10//! dataset mean/std shipped with the checkpoint, centered zero-pad each dim to a
11//! multiple of 16 (four pool/deconv levels), run, crop, de-normalize, and mask.
12//!
13//! Weights are not bundled; the caller passes the exported `qsmnet.onnx` bytes
14//! (see [`crate::models`]).
15//!
16//! # Reference pipeline
17//!
18//! From the authors' README (`SNU-LIST/QSMnet` `README.md:34-72`) unless noted:
19//!
20//! - **Training data:** 3T (Siemens), single-echo 3D GRE, 1 mm isotropic, TE = 25 ms; COSMOS
21//!   labels.
22//! - **Phase processing:** FSL BET brain extraction, STI Suite Laplacian unwrapping, STI Suite
23//!   3D V-SHARP background removal.
24//! - **Resolution:** data at another resolution "need to" be interpolated to 1 mm isotropic
25//!   (e.g. Fourier zero-padding/truncation) *before* phase processing. This crate does not
26//!   resample; the caller must.
27//! - **Units:** the V-SHARP tissue phase is converted to ppm as `phase / (ΣTE · B0 · γ)`
28//!   (`save_input_data_for_QSMnet.m:22`), i.e. the field in ppm this module takes.
29//! - **Output:** `Code/inference.py` crops but does not multiply by the mask; this crate does.
30//! - **Orientation:** inputs must match the orientation/polarity of the shipped `val_input.mat`
31//!   (`README.md:86`): axial, B0 along the third axis.
32//!
33//! # Activation and parity
34//!
35//! The two checkpoints differ in more than their weights: each one's `network_info_<name>.npy`
36//! names its activation, and the authors' inference builds the U-Net with it. `QSMnet_64` uses
37//! plain ReLU, `QSMnet+_64` LeakyReLU (α 0.1). The first `qsmnet.onnx` export used LeakyReLU for
38//! both, and was checked against a TF graph built the same way, so it went unnoticed: χ came out
39//! 1.34× too large, 81 % NRMSE from the original (QSM.rs#330). `export_qsmnet.py` now takes the
40//! activation from `network_info`.
41//!
42//! Measured against QSM-CI's `qsmnet` and `qsmnet-plus` entries (TensorFlow 1.14, SNU-LIST/QSMnet
43//! f8782ed, the authors' checkpoints) on a 36×32×28 in-vivo box of `prisma-bridge-run1`:
44//! [`qsmnet`] is 0.0002 % NRMSE from the original (max |Δ| 2.9e-7 ppm; before the re-export,
45//! 81 %). Run through ONNX Runtime with the same normalisation and padding, the exported graph is
46//! 0.00006 % from it and QSMnet+'s is 0.0001 %. The exported graph matches the TF graph to 4.4e-6
47//! on the export's own check.
48//!
49//! `tests/models_onnx/dipole.rs` checks both against those entries on that box (QSMxT/QSM-CI#316;
50//! the fixture is regenerated in Actions by `tests/data/dl_dipole_qsmci_small.py`): QSMnet+ NRMSE
51//! 3.7e-4 %, max |Δ| 4.3e-7 ppm (float32 rounding).
52//!
53//! # Pipeline rules
54//!
55//! The facts above that a configuration can depart from are published as rules in
56//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `qsmnet.mask-bet`, `qsmnet.unwrap-laplacian`, `qsmnet.echo-combination`, `qsmnet.echo-weights`, `qsmnet.bg-vsharp`. All are the authors' setting (`Kind::Reference`): the README instructs BET, Laplacian unwrapping and V-SHARP as its process flow without saying they are needed or better (reclassified from recommendations 2026-11-04).
57
58use crate::grid::Grid;
59use crate::models::onnx::{OnnxModel, OnnxError, Tensor};
60
61/// Dataset normalization constants stored beside a QSMnet checkpoint
62/// (`norm_factor_<name>.mat`): `field_n = (field - in_mean)/in_std`, and
63/// `chi = out_std*pred + out_mean`.
64#[derive(Clone, Copy, Debug)]
65pub struct QsmnetNorm {
66    pub in_mean: f64,
67    pub in_std: f64,
68    pub out_mean: f64,
69    pub out_std: f64,
70}
71
72impl QsmnetNorm {
73    /// Constants for the `QSMnet_64` checkpoint.
74    pub fn qsmnet() -> Self {
75        Self { in_mean: 0.0, in_std: 0.01, out_mean: 0.0, out_std: 0.0317 }
76    }
77
78    /// Constants for the `QSMnet+_64` checkpoint.
79    pub fn qsmnet_plus() -> Self {
80        Self { in_mean: 0.0, in_std: 0.0205, out_mean: 0.0, out_std: 0.0734 }
81    }
82}
83
84impl Default for QsmnetNorm {
85    /// Constants for the `QSMnet_64` checkpoint.
86    fn default() -> Self {
87        Self::qsmnet()
88    }
89}
90
91/// Run QSMnet dipole inversion.
92///
93/// * `local_field_ppm` — local tissue field (ppm), column-major `(nx,ny,nz)`.
94/// * `mask` — binary brain mask (same layout); applied to the output.
95/// * `model_onnx` — bytes of the exported `qsmnet.onnx` (NDHWC input/output).
96/// * `norm` — dataset normalization constants for this checkpoint.
97///
98/// Returns susceptibility (ppm), masked, in the same layout.
99pub fn qsmnet(
100    local_field_ppm: &[f64],
101    mask: &[u8],
102    grid: &Grid,
103    model_onnx: &[u8],
104    norm: &QsmnetNorm,
105) -> Result<Vec<f64>, OnnxError> {
106    let (nx, ny, nz) = grid.dims;
107    let n = nx * ny * nz;
108    assert_eq!(local_field_ppm.len(), n, "field length must match grid");
109    assert_eq!(mask.len(), n, "mask length must match grid");
110
111    // Centered padding to a multiple of 16 (matches the Python `pad_to_multiple`).
112    let pad = |s: usize| -> (usize, usize) {
113        let total = (16 - s % 16) % 16;
114        (total / 2, s + total)
115    };
116    let (bx, px) = pad(nx);
117    let (by, py) = pad(ny);
118    let (bz, pz) = pad(nz);
119
120    // Normalize + repack column-major (nx,ny,nz) f64 → row-major NCDHW
121    // [1,1,px,py,pz] f32, centered.
122    let inv_std = 1.0 / norm.in_std;
123    let mut input = vec![0.0f32; px * py * pz];
124    for z in 0..nz {
125        for y in 0..ny {
126            for x in 0..nx {
127                let src = x + nx * (y + ny * z);
128                let dst = ((x + bx) * py + (y + by)) * pz + (z + bz);
129                input[dst] = ((local_field_ppm[src] - norm.in_mean) * inv_std) as f32;
130            }
131        }
132    }
133
134    let model = OnnxModel::load(model_onnx)?;
135    let out = model.run_single(&Tensor::new(vec![1, 1, px, py, pz], input))?;
136    let expected = [1, 1, px, py, pz];
137    if out.shape != expected {
138        return Err(OnnxError::Run(format!(
139            "unexpected output shape {:?}, expected {expected:?}",
140            out.shape
141        )));
142    }
143
144    // De-normalize, crop (centered), mask, unpack → column-major.
145    let mut chi = vec![0.0f64; n];
146    for z in 0..nz {
147        for y in 0..ny {
148            for x in 0..nx {
149                let dst = x + nx * (y + ny * z);
150                if mask[dst] != 0 {
151                    let src = ((x + bx) * py + (y + by)) * pz + (z + bz);
152                    chi[dst] = norm.out_std * out.data[src] as f64 + norm.out_mean;
153                }
154            }
155        }
156    }
157    Ok(chi)
158}
159
160/// Memory-bounded QSMnet via overlap-tiling — the fully-convolutional U-Net run patch-by-patch
161/// (for 32-bit WASM, where whole-volume [`qsmnet`] overflows the heap on clinical data). The
162/// dataset normalization is applied per value inside the tile loop and the net's pool depth
163/// requires a `/16` patch. Approximates whole-volume up to tile-boundary error; see
164/// [`crate::inversion::tiled`].
165pub fn qsmnet_tiled(
166    local_field_ppm: &[f64],
167    mask: &[u8],
168    grid: &Grid,
169    model_onnx: &[u8],
170    norm: &QsmnetNorm,
171    cfg: &super::tiled::TileConfig,
172    progress: impl FnMut(usize, usize),
173) -> Result<Vec<f64>, OnnxError> {
174    let model = OnnxModel::load(model_onnx)?;
175    let (in_mean, inv_std, out_std, out_mean) =
176        (norm.in_mean, 1.0 / norm.in_std, norm.out_std, norm.out_mean);
177    super::tiled::tiled_field_inversion(
178        // QSMnet and QSMnet+ are one architecture, so one tile costs the same (a registry test
179        // checks the two figures agree).
180        local_field_ppm, mask, grid, &model, &super::tiled::tile_cost_of("qsmnet"), cfg,
181        move |v| ((v - in_mean) * inv_std) as f32,
182        move |o| out_std * o as f64 + out_mean,
183        progress,
184    )
185}