Skip to main content

qsm_core/inversion/
qsmgan.rs

1//! QSMGAN dipole inversion (`onnx` feature).
2//!
3//! QSMGAN (Chen 2020) inverts the dipole with a 3D U-Net **generator** refined by a
4//! Wasserstein GAN. Inference is patch-based with an increased receptive field: a
5//! 64³ local-field patch maps to a 48³ susceptibility patch (a centre crop baked
6//! into the exported graph — the "i64o48" scheme). The network itself is the only
7//! part in ONNX; this glue reproduces the authors' `recon.py` pipeline:
8//!
9//! * sign flip — the QSM-CI runner negates the local field before the net;
10//! * tile by non-overlapping 48³ **output** patches, each fed a 64³ **input** patch
11//!   (same centre, 8-voxel context margin, zero-padded at the volume edges);
12//! * `input_scale = 100`; output head `tanh`, so `χ = atanh(clip(out)) / 10`;
13//! * mask the result.
14//!
15//! The input is the SEPIA-style **local field in ppm** (already background-removed).
16//! Weights are not bundled; the caller passes the exported `qsmgan.onnx` bytes.
17//!
18//! # Reference pipeline
19//!
20//! From Chen et al., NeuroImage 2020 (doi:10.1016/j.neuroimage.2019.116389), Methods, and the
21//! authors' code (`mmorri10/QSMGAN-LupoLab` `code_pytorch_v3.zip`):
22//!
23//! - **Training data:** 7T GE, 4-echo GRE (TE 6/9.5/13/16.5 ms), 0.8 mm isotropic; COSMOS labels
24//!   (checkpoint `WGAN_i64o48/config.json`: `data_path: 7T_cosmos`, `input_algo: vsharp`).
25//! - **Phase processing:** Laplacian unwrapping, FSL BET mask from magnitude, V-SHARP.
26//! - **Resampling:** patient data acquired at 0.8×0.8×1 mm were resampled to 0.8 mm isotropic
27//!   (paper); the authors' `utils/data.py:201,221` (`QsmPatchDataHD`) zooms those scans to 0.8 mm
28//!   isotropic before the net and `predict.py:112` zooms the result back. This crate does not
29//!   resample.
30//! - **Input unit:** the README feeds SEPIA's local field; the paper and code scale it by 100
31//!   without stating its unit. This crate passes ppm (as the QSM-CI runner does).
32//!
33//! # Agreement with the original
34//!
35//! `tests/models_onnx/dipole.rs` runs it on a 36×32×28 box of in-vivo data (1 mm, 2.89 T, across
36//! the brain's edge) against its original's verified QSM-CI entry, QSMGAN 521d202 (`qsmgan`)
37//! (QSMxT/QSM-CI#316; the fixture is regenerated in Actions by
38//! `tests/data/dl_dipole_qsmci_small.py`): NRMSE 1.7e-4 %, max |Δ| 8.4e-8 ppm (measured
39//! 2026-10-30): float32 rounding.
40//!
41//! # Pipeline rules
42//!
43//! The facts above that a configuration can depart from are published as rules in
44//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `qsmgan.mask-bet`, `qsmgan.unwrap-laplacian`, `qsmgan.bg-vsharp`.
45
46use crate::grid::Grid;
47use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
48
49const IPS: usize = 64; // input patch size
50const OPS: usize = 48; // output patch size
51const INPUT_SCALE: f64 = 100.0;
52const OUTPUT_SCALE: f64 = 10.0;
53
54/// Run QSMGAN on a background-removed local field (ppm), column-major `(nx,ny,nz)`.
55/// Returns susceptibility (ppm), masked, in the same layout.
56pub fn qsmgan(
57    local_field_ppm: &[f64],
58    mask: &[u8],
59    grid: &Grid,
60    model_onnx: &[u8],
61) -> Result<Vec<f64>, OnnxError> {
62    let (nx, ny, nz) = grid.dims;
63    let n = nx * ny * nz;
64    assert_eq!(local_field_ppm.len(), n, "field length must match grid");
65    assert_eq!(mask.len(), n, "mask length must match grid");
66
67    let model = OnnxModel::load(model_onnx)?;
68    let (half_i, half_o) = (IPS as i64 / 2, OPS as i64 / 2); // 32, 24
69    let (nxi, nyi, nzi) = (nx as i64, ny as i64, nz as i64);
70    // Negated local field (the runner's sign flip), zero outside the volume.
71    let field = |x: i64, y: i64, z: i64| -> f32 {
72        if x >= 0 && x < nxi && y >= 0 && y < nyi && z >= 0 && z < nzi {
73            (-INPUT_SCALE * local_field_ppm[x as usize + nx * (y as usize + ny * z as usize)]) as f32
74        } else {
75            0.0
76        }
77    };
78
79    let mut predict = vec![0.0f64; n];
80    // Output-patch centres: range(half_o, dim + half_o + 1, OPS) per axis.
81    let mut cx = half_o;
82    while cx <= nxi + half_o {
83        let mut cy = half_o;
84        while cy <= nyi + half_o {
85            let mut cz = half_o;
86            while cz <= nzi + half_o {
87                // In-volume extent of this 48³ output patch.
88                let (x0, y0, z0) = (cx - half_o, cy - half_o, cz - half_o); // ≥ 0
89                let xe = (x0 + OPS as i64).min(nxi);
90                let ye = (y0 + OPS as i64).min(nyi);
91                let ze = (z0 + OPS as i64).min(nzi);
92                let (px, py, pz) = (xe - x0, ye - y0, ze - z0);
93                if px <= 0 || py <= 0 || pz <= 0 {
94                    cz += OPS as i64;
95                    continue;
96                }
97
98                // 64³ input patch centred at (cx,cy,cz), row-major NCDHW.
99                let mut inp = vec![0.0f32; IPS * IPS * IPS];
100                for i in 0..IPS {
101                    for j in 0..IPS {
102                        for k in 0..IPS {
103                            let v = field(
104                                cx - half_i + i as i64,
105                                cy - half_i + j as i64,
106                                cz - half_i + k as i64,
107                            );
108                            inp[(i * IPS + j) * IPS + k] = v;
109                        }
110                    }
111                }
112                let out = model.run_single(&Tensor::new(vec![1, 1, IPS, IPS, IPS], inp))?;
113
114                // Place the in-bounds part of the 48³ output patch: χ = atanh(clip)/10.
115                for oi in 0..px as usize {
116                    for oj in 0..py as usize {
117                        for ok in 0..pz as usize {
118                            let o = (out.data[(oi * OPS + oj) * OPS + ok] as f64)
119                                .clamp(-0.999999, 0.999999);
120                            let dst = (x0 as usize + oi)
121                                + nx * ((y0 as usize + oj) + ny * (z0 as usize + ok));
122                            predict[dst] = o.atanh() / OUTPUT_SCALE;
123                        }
124                    }
125                }
126                cz += OPS as i64;
127            }
128            cy += OPS as i64;
129        }
130        cx += OPS as i64;
131    }
132
133    for i in 0..n {
134        if mask[i] == 0 {
135            predict[i] = 0.0;
136        }
137    }
138    Ok(predict)
139}