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}