Skip to main content

qsm_core/inversion/
autoqsm.rs

1//! AutoQSM single-step deep-learning reconstruction (`onnx` feature).
2//!
3//! AutoQSM (Wei 2019) is a patch V-Net that maps a **total** field (ppm) directly
4//! to susceptibility (ppm) — no brain extraction, no separate background removal.
5//! The network takes a fixed 64³ input patch and returns the central 32³. Whole
6//! volumes are reconstructed by overlap-tiled sliding-window inference: 32³ output
7//! patches at stride 24 (8-voxel overlap), each from a 64³ input patch with a
8//! 16-voxel context margin, edge-padded and linearly blended across the overlap.
9//! This mirrors the authors' `util.data_predict` / `patch_process`.
10//!
11//! Upstream weights are Keras; QSM.rs ships a clean PyTorch re-export
12//! (`scripts/onnx-export/export_autoqsm.py`) so `tract` runs it. The network is
13//! all Conv3D+ReLU with no normalization, so no input scaling is applied.
14//!
15//! **Field sign.** AutoQSM was trained on fields of the opposite sign to this
16//! crate's (`field = D * chi` with `D = 1/3 - kz^2/k^2`, so a paramagnetic source
17//! raises the field along B0). On the authors' own `test_data/0.mat` input the
18//! network returns bright deep grey matter, while the same field read in this
19//! crate's convention (SHARP + TKD) anticorrelates with that output (r = -0.42);
20//! fed this crate's field unchanged it returns `-chi`-like maps (r = -0.50 on the
21//! test phantom). The field is therefore negated on the way in: the network sees
22//! its own training convention and its output is already susceptibility in ppm
23//! with the usual sign. The network is non-linear, so negating the output instead
24//! would not be equivalent.
25//!
26//! Weights are not bundled; the caller passes the exported `autoqsm.onnx` bytes.
27//!
28//! # Reference pipeline
29//!
30//! From Wei et al., NeuroImage 2019 (doi:10.1016/j.neuroimage.2019.116064), Methods:
31//!
32//! - **Training data:** 3T GE MR750, 0.86×0.86×1 mm (reconstructed), 256×256 matrix, 8 echoes.
33//! - **Input:** Laplacian-unwrapped total phase of every echo, combined as
34//!   `ψ = Σφᵢ / (γ·B0·ΣTEᵢ)` (a TE-weighted mean of `φ/TE`) — a total field in ppm over the whole
35//!   FOV, no brain mask. Labels were STAR-QSM after SMV background removal, masked with FSL BET.
36//! - **Orientation:** training inputs are axial; sagittal/coronal data "should be resliced to the
37//!   axial orientation".
38//! - **Resampling:** the authors' `code/test.py` and `util.data_predict` do not resample.
39//!
40//! # Agreement with the original
41//!
42//! `tests/models_onnx/field.rs` runs it on a 36×32×28 box of in-vivo data (1 mm, 2.89 T, across the
43//! brain's edge) against its original's verified QSM-CI entry, AutoQSM b59f5e7 (`autoqsm`)
44//! (QSMxT/QSM-CI#316; the fixture is regenerated in Actions by
45//! `tests/data/dl_field_qsmci_small.py`): NRMSE 1.8e-4 %, max |Δ| 2.5e-7 ppm (measured 2026-10-30):
46//! float32 rounding.
47//!
48//! # Pipeline rules
49//!
50//! The facts above that a configuration can depart from are published as rules in
51//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `autoqsm.unwrap-laplacian`, `autoqsm.echo-combination`, `autoqsm.echo-weights`.
52
53use crate::grid::Grid;
54use crate::models::onnx::{OnnxModel, OnnxError, Tensor};
55
56const IN: usize = 64;
57const OUT: usize = 32;
58const MARGIN: usize = (IN - OUT) / 2; // 16
59const SHIFT: usize = 24;
60const OVERLAP: usize = OUT - SHIFT; // 8
61
62#[inline]
63fn ri(x: usize, y: usize, z: usize, dy: usize, dz: usize) -> usize {
64    (x * dy + y) * dz + z // row-major (X,Y,Z), matches numpy C-order
65}
66
67/// The output-grid extent for an axis of `d` voxels: the smallest whole number of output
68/// patches (`OUT`, then one more per `SHIFT`) that covers it. An axis shorter than one patch gets
69/// one patch; the samples past `d` are edge-padded and cropped away.
70fn output_extent(d: usize) -> usize {
71    OUT + d.saturating_sub(OUT).div_ceil(SHIFT) * SHIFT
72}
73
74/// Run AutoQSM on a total field map.
75///
76/// * `field_ppm` — total field (ppm) in this crate's sign convention,
77///   column-major `(nx,ny,nz)`. It is negated internally to match the network's
78///   training convention (see the module docs).
79/// * `mask` — binary brain mask; applied to the output (AutoQSM has no brain
80///   extraction, so the raw output is whole-head).
81/// * `model_onnx` — bytes of the exported `autoqsm.onnx` (fixed 64³→32³).
82///
83/// Returns susceptibility (ppm), masked, in the same layout.
84pub fn autoqsm(
85    field_ppm: &[f64],
86    mask: &[u8],
87    grid: &Grid,
88    model_onnx: &[u8],
89) -> Result<Vec<f64>, OnnxError> {
90    let (nx, ny, nz) = grid.dims;
91    let n = nx * ny * nz;
92    assert_eq!(field_ppm.len(), n, "field length must match grid");
93    assert_eq!(mask.len(), n, "mask length must match grid");
94
95    // Column-major (crate) -> row-major (X,Y,Z) working volume, negated into the
96    // network's training field convention (module docs).
97    let mut vol = vec![0.0f64; n];
98    for z in 0..nz {
99        for y in 0..ny {
100            for x in 0..nx {
101                vol[ri(x, y, z, ny, nz)] = -field_ppm[x + nx * (y + ny * z)];
102            }
103        }
104    }
105
106    let (xp, yp, zp) = (output_extent(nx), output_extent(ny), output_extent(nz)); // output-grid size
107
108    // Edge-pad: MARGIN before, (pad + MARGIN) after, per axis (replicate).
109    let (bx, by, bz) = (MARGIN, MARGIN, MARGIN);
110    let (dx, dy, dz) = (xp + 2 * MARGIN, yp + 2 * MARGIN, zp + 2 * MARGIN);
111    let clamp = |v: isize, lo: usize, hi: usize| (v.clamp(lo as isize, hi as isize - 1)) as usize;
112    let mut padded = vec![0.0f64; dx * dy * dz];
113    for x in 0..dx {
114        let sx = clamp(x as isize - bx as isize, 0, nx);
115        for y in 0..dy {
116            let sy = clamp(y as isize - by as isize, 0, ny);
117            for z in 0..dz {
118                let sz = clamp(z as isize - bz as isize, 0, nz);
119                padded[ri(x, y, z, dy, dz)] = vol[ri(sx, sy, sz, ny, nz)];
120            }
121        }
122    }
123
124    let num_i = (xp - OUT) / SHIFT + 1;
125    let num_j = (yp - OUT) / SHIFT + 1;
126    let num_k = (zp - OUT) / SHIFT + 1;
127
128    let model = OnnxModel::load(model_onnx)?;
129    let mut output = vec![0.0f64; xp * yp * zp];
130    let mut patches: Vec<Vec<f64>> = Vec::with_capacity(num_i * num_j * num_k);
131
132    for k in 0..num_k {
133        for j in 0..num_j {
134            for i in 0..num_i {
135                // Extract the 64³ input patch (already row-major = NCDHW buffer).
136                let mut buf = vec![0.0f32; IN * IN * IN];
137                for px in 0..IN {
138                    for py in 0..IN {
139                        for pz in 0..IN {
140                            let src = ri(SHIFT * i + px, SHIFT * j + py, SHIFT * k + pz, dy, dz);
141                            buf[ri(px, py, pz, IN, IN)] = padded[src] as f32;
142                        }
143                    }
144                }
145                let out = model.run_single(&Tensor::new(vec![1, 1, IN, IN, IN], buf))?;
146                let mut patch: Vec<f64> = out.data.iter().map(|&v| v as f64).collect();
147
148                // Linear-ramp blend against already-placed neighbors.
149                if i != 0 {
150                    blend(&mut patch, &patches[patches.len() - 1], 0);
151                }
152                if j != 0 {
153                    blend(&mut patch, &patches[patches.len() - num_i], 1);
154                }
155                if k != 0 {
156                    blend(&mut patch, &patches[patches.len() - num_i * num_j], 2);
157                }
158
159                for ox in 0..OUT {
160                    for oy in 0..OUT {
161                        for oz in 0..OUT {
162                            output[ri(SHIFT * i + ox, SHIFT * j + oy, SHIFT * k + oz, yp, zp)] =
163                                patch[ri(ox, oy, oz, OUT, OUT)];
164                        }
165                    }
166                }
167                patches.push(patch);
168            }
169        }
170    }
171
172    // Crop to the input grid, mask, and repack row-major -> column-major.
173    let mut chi = vec![0.0f64; n];
174    for z in 0..nz {
175        for y in 0..ny {
176            for x in 0..nx {
177                let i = x + nx * (y + ny * z);
178                if mask[i] != 0 {
179                    chi[i] = output[ri(x, y, z, yp, zp)];
180                }
181            }
182        }
183    }
184    Ok(chi)
185}
186
187/// Blend the leading `OVERLAP` slabs of `patch` with the trailing slabs of a
188/// previously-placed `neighbor` along `dir` (0=x,1=y,2=z), ramping neighbor→patch
189/// (matches `util.patch_process`).
190fn blend(patch: &mut [f64], neighbor: &[f64], dir: usize) {
191    let denom = (OVERLAP - 1) as f64;
192    for t in 0..OVERLAP {
193        let w = 1.0 - t as f64 / denom; // weight on the neighbor
194        for a in 0..OUT {
195            for b in 0..OUT {
196                let (pi, ni) = match dir {
197                    0 => (ri(t, a, b, OUT, OUT), ri(OUT - OVERLAP + t, a, b, OUT, OUT)),
198                    1 => (ri(a, t, b, OUT, OUT), ri(a, OUT - OVERLAP + t, b, OUT, OUT)),
199                    _ => (ri(a, b, t, OUT, OUT), ri(a, b, OUT - OVERLAP + t, OUT, OUT)),
200                };
201                patch[pi] = w * neighbor[ni] + (1.0 - w) * patch[pi];
202            }
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    /// An axis shorter than one output patch used to underflow `d - OUT` (a panic in debug
212    /// builds, a wrapped size and a "capacity overflow" in release ones).
213    #[test]
214    fn output_extent_covers_every_axis_in_whole_patches() {
215        for (d, want) in [(1, 32), (16, 32), (32, 32), (33, 56), (56, 56), (57, 80), (184, 200)] {
216            assert_eq!(output_extent(d), want, "d = {d}");
217        }
218        for d in 1..300 {
219            let e = output_extent(d);
220            assert!(e >= d.max(OUT) && (e - OUT).is_multiple_of(SHIFT), "d = {d}: {e}");
221            assert!(e < d.max(OUT) + SHIFT, "d = {d}: {e} is a patch too many");
222        }
223    }
224}