qsm_core/separation/susep_net.rs
1//! SUSEP-Net deep-learning χ-separation (`onnx` feature).
2//!
3//! SUSEP-Net (Li/Gao/Sun 2025) is a dual-branch 3D U-Net that maps three guidance
4//! maps — QSM (χ_total, ppm), R2′ (Hz), local field (ppm) — to paramagnetic (χ+)
5//! and diamagnetic (χ−) source magnitudes. Clean NCDHW ONNX export (three inputs
6//! `qsm`,`r2prime`,`lfs`; two outputs `chi_pos`,`chi_neg`).
7//!
8//! Recipe (mirrors the authors' `recon.py`): z-score each input by the training
9//! stats, zero outside the mask, post-pad each dim to a multiple of 8, run,
10//! de-normalize the outputs, crop, and mask. We return χ− as a signed (≤ 0) value to match
11//! the crate's separation convention `(chi_pos ≥ 0, chi_neg ≤ 0, chi_total)`.
12//!
13//! **Deviation:** both decoders end in a 1×1 convolution with no activation (`SQNet.py`), so a
14//! de-normalised magnitude can be negative, and `test_demo.py` saves it as it is. Here negative
15//! χ+ and |χ−| are set to 0, since a negative source magnitude has no physical meaning, and
16//! χ_total is χ+ + χ− after that. Every non-negative value is the network's unchanged.
17//!
18//! **Array orientation.** SUSEP-Net is a different group's network from the χ-sepnet family and
19//! its training orientation has not been established here; the authors' `recon.py` does not
20//! reorient, and neither does this function (it is handed a [`Grid`], not an affine). Treat it as
21//! assuming the usual axial convention — slice axis along B0 — and see
22//! [`chisepnet`](crate::separation::chisepnet) for how little that turned out to be worth on the
23//! χ-sepnet side; no equivalent measurement has been made for this network.
24//!
25//! **Whole volume vs patches.** The authors run the whole volume in one forward pass, and
26//! [`SusepNetParams::patch`] defaults to that (`None`). Activations then scale with the volume,
27//! which a 32-bit host cannot afford — at 1 mm whole-brain the first conv block alone runs to
28//! several GB against a 4 GB WASM heap — so such a host passes a patch and gets an overlapping
29//! sliding window (0.75 stride, overlaps averaged) with memory bounded by the patch. Tiling a
30//! net trained on whole volumes is an **approximation**: each patch is blind to structure
31//! outside it. Patch dimensions must be multiples of 8 (three pooling levels).
32//!
33//! Weights are not bundled; the caller passes the exported `susep-net.onnx` bytes
34//! (see [`crate::models`]).
35//!
36//! # Reference pipeline
37//!
38//! Li M., …, Sun H., Gao Y. 2025 (arXiv:2506.13293):
39//!
40//! - **Training (§2.3):** 3 T (Philips Ingenia Elition X), 0.9 × 0.9 × 1 mm, 8 echoes,
41//! TE1/ΔTE = 4.1/4.0 ms, with 64³ patches. The training inputs are *simulated* by the forward
42//! model (Eq. 1) from APART-QSM-reconstructed χ+/χ− and decay-kernel maps, so the network was
43//! never fit to one particular field-mapping chain.
44//! - **In-vivo inputs (§3.2):** the local field from iQFM and the QSM from iQSM+, both computed
45//! directly from the multi-echo GRE phase. R2* and R2 come from mono-exponential fits in
46//! STI Suite (multi-echo GRE and multi-echo SE), and R2' = R2* − R2.
47//! - R2' enters in Hz with no D_r scaling (the input normalisation in [`SusepNetNorm`]).
48//!
49//! Reference:
50//! Li, M., Chen, C., Li, Z., …, Sun, H., Gao, Y. (2025). "SUSEP-Net: Simulation-Supervised and
51//! Contrastive Learning-based Deep Neural Networks for Susceptibility Source Separation."
52//! arXiv:2506.13293.
53//! Code: <https://github.com/YangGaoUQ/SUSEP-Net>
54//!
55//! # Agreement with the original
56//!
57//! `tests/models_onnx/separation.rs` runs it on a 36×32×28 box of in-vivo data (1 mm, 2.89 T,
58//! across the brain's edge) against its original's verified QSM-CI entry, SUSEP-Net (`susep-net`)
59//! (QSMxT/QSM-CI#316; the fixture is regenerated in Actions by
60//! `tests/data/dl_separation_qsmci_small.py`): χ+ and χ− NRMSE 2.4e-5 %, max |Δ| 1.7e-7 ppm
61//! (measured 2026-10-30): float32 rounding. The original moves by about 1e-7 relative from run to
62//! run, since its wrapper uses every CPU.
63//!
64//! # Pipeline rules
65//!
66//! The facts above that a configuration can depart from are published as rules in
67//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `susep-net.qsm-iqsm-plus`.
68
69// The parameters are plain Rust, so configs can carry them; only inference needs `onnx`.
70#![cfg_attr(not(feature = "onnx"), allow(dead_code))]
71
72#[cfg(feature = "onnx")]
73use crate::grid::Grid;
74#[cfg(feature = "onnx")]
75use crate::models::onnx::{OnnxModel, OnnxError, Tensor};
76#[cfg(feature = "onnx")]
77use crate::utils::sliding::starts;
78
79/// Three 2× pooling levels, so every patch dimension must be a multiple of this. Public (and
80/// built without `onnx`) so a host can check a patch, or state the rule, without restating the
81/// number.
82pub const POOL_MULTIPLE: usize = 8;
83
84/// A patch that fits a 32-bit (WASM) heap alongside a browser pipeline's other volumes. Peak
85/// memory, measured natively (single-threaded): about 1.8 KB per patch voxel plus ~0.9 GB for the
86/// parsed model and buffers, so 2.0 GB at this patch, 2.8 GB at 128×128×64 and 12.5 GB for a
87/// whole 184×256×128 volume. On the QSM-CI χ-separation phantom it agrees with the whole volume
88/// to r ≥ 0.998 on both maps (`tests/chisep_xsepnet.rs`).
89pub const WASM_PATCH: (usize, usize, usize) = (96, 96, 64);
90
91/// Inference parameters for [`susep_net`].
92#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
94pub struct SusepNetParams {
95 /// `None` (the default) runs the whole volume in one pass, as the authors do. `Some(patch)`
96 /// runs an overlapping sliding window instead — bounded memory, at the cost of each patch
97 /// seeing less context. Each dimension must be a multiple of 8.
98 pub patch: Option<(usize, usize, usize)>,
99}
100
101impl SusepNetParams {
102 /// Settings for a 32-bit (WASM) heap: a [`WASM_PATCH`] sliding window.
103 pub fn low_memory() -> Self {
104 Self { patch: Some(WASM_PATCH) }
105 }
106
107 /// Check the patch, if any: every dimension a positive multiple of 8 (the network's three
108 /// pooling levels). [`susep_net`] panics on what this refuses.
109 pub fn validate(&self) -> Result<(), String> {
110 let Some((d, h, w)) = self.patch else { return Ok(()) };
111 for (axis, v) in [("D", d), ("H", h), ("W", w)] {
112 if v == 0 || v % POOL_MULTIPLE != 0 {
113 return Err(format!(
114 "SUSEP-Net patch {axis} = {v} must be a positive multiple of {POOL_MULTIPLE} \
115 (three pooling levels)"
116 ));
117 }
118 }
119 Ok(())
120 }
121}
122
123/// Training z-score constants for SUSEP-Net (`all_mean_std.mat`): each field is
124/// `(mean, std)`. Inputs are normalized `(x-mean)/std`; outputs de-normalized
125/// `y*std + mean`.
126#[derive(Clone, Copy, Debug)]
127pub struct SusepNetNorm {
128 pub qsm: (f64, f64),
129 pub lfs: (f64, f64),
130 pub r2prime: (f64, f64),
131 pub chi_pos: (f64, f64),
132 pub chi_neg: (f64, f64),
133}
134
135impl Default for SusepNetNorm {
136 /// Constants shipped with the released `SUSEPNet.pth`.
137 fn default() -> Self {
138 Self {
139 qsm: (-6.0663105e-05, 0.023533047),
140 lfs: (-4.8702253e-05, 0.012554166),
141 r2prime: (4.7629275, 10.889079),
142 chi_pos: (0.0089528897, 0.025519046),
143 chi_neg: (0.0090135528, 0.019603666),
144 }
145 }
146}
147
148/// Run SUSEP-Net χ-separation.
149///
150/// * `local_field_ppm`, `qsm` (χ_total, ppm), `r2prime` (Hz) — column-major `(nx,ny,nz)`.
151/// * `mask` — binary brain mask (same layout).
152/// * `model_onnx` — bytes of the exported `susep-net.onnx`.
153/// * `norm` — training normalization constants.
154/// * `params` — whole volume or sliding-window patch; see [`SusepNetParams`].
155/// * `progress` — progress callback `(patches_done, patches_total)`; a whole-volume run
156/// reports a single patch.
157///
158/// Returns `(chi_pos ≥ 0, chi_neg ≤ 0, chi_total = chi_pos + chi_neg)` in ppm,
159/// masked, in the same layout.
160#[cfg(feature = "onnx")]
161#[allow(clippy::too_many_arguments)]
162pub fn susep_net(
163 local_field_ppm: &[f64],
164 qsm: &[f64],
165 r2prime: &[f64],
166 mask: &[u8],
167 grid: &Grid,
168 model_onnx: &[u8],
169 norm: &SusepNetNorm,
170 params: &SusepNetParams,
171 mut progress: impl FnMut(usize, usize),
172) -> Result<super::ChiSepMaps, OnnxError> {
173 let (nx, ny, nz) = grid.dims;
174 let n = nx * ny * nz;
175 for (name, v) in [("field", local_field_ppm), ("qsm", qsm), ("r2prime", r2prime)] {
176 assert_eq!(v.len(), n, "{name} length must match grid");
177 }
178 assert_eq!(mask.len(), n, "mask length must match grid");
179
180 // Whole-volume (the authors' default) is the degenerate case of one patch covering the
181 // padded volume, so both paths share the code below. Either way every extent is a
182 // multiple of 8, the network's three pooling levels.
183 let round_up = |v: usize| v.div_ceil(POOL_MULTIPLE) * POOL_MULTIPLE;
184 if let Err(e) = params.validate() {
185 panic!("{e}");
186 }
187 let (pd, ph, pw) = params.patch.unwrap_or((round_up(nx), round_up(ny), round_up(nz)));
188 let (px, py, pz) = (nx.max(pd), ny.max(ph), nz.max(pw));
189
190 // z-score + mask, in the padded volume's row-major NCDHW layout.
191 let plane = px * py * pz;
192 let zscore = |src: &[f64], (mean, std): (f64, f64)| -> Vec<f32> {
193 let inv = 1.0 / std;
194 let mut buf = vec![0.0f32; plane];
195 for z in 0..nz {
196 for y in 0..ny {
197 for x in 0..nx {
198 let i = x + nx * (y + ny * z);
199 if mask[i] != 0 {
200 buf[(x * py + y) * pz + z] = ((src[i] - mean) * inv) as f32;
201 }
202 }
203 }
204 }
205 buf
206 };
207 // Channel order must match the exported graph: qsm, r2prime, lfs.
208 let channels = [
209 zscore(qsm, norm.qsm),
210 zscore(r2prime, norm.r2prime),
211 zscore(local_field_ppm, norm.lfs),
212 ];
213
214 let model = OnnxModel::load(model_onnx)?;
215 let patch_voxels = pd * ph * pw;
216 let mut acc_pos = vec![0.0f64; plane];
217 let mut acc_neg = vec![0.0f64; plane];
218 let mut wsum = vec![0.0f64; plane];
219
220 let (sx, sy, sz) = (starts(px, pd), starts(py, ph), starts(pz, pw));
221 let total = sx.len() * sy.len() * sz.len();
222 let mut done = 0usize;
223 progress(0, total);
224 for &x0 in &sx {
225 for &y0 in &sy {
226 for &z0 in &sz {
227 let tensors: Vec<Tensor> = channels
228 .iter()
229 .map(|ch| {
230 let mut buf = vec![0.0f32; patch_voxels];
231 for i in 0..pd {
232 for j in 0..ph {
233 let row = ((x0 + i) * py + (y0 + j)) * pz + z0;
234 let dst = (i * ph + j) * pw;
235 buf[dst..dst + pw].copy_from_slice(&ch[row..row + pw]);
236 }
237 }
238 Tensor::new(vec![1, 1, pd, ph, pw], buf)
239 })
240 .collect();
241 let outs = model.run(&tensors)?;
242 if outs.len() < 2 {
243 return Err(OnnxError::Run(format!("expected 2 outputs, got {}", outs.len())));
244 }
245 for i in 0..pd {
246 for j in 0..ph {
247 for k in 0..pw {
248 let dst = ((x0 + i) * py + (y0 + j)) * pz + (z0 + k);
249 let src = (i * ph + j) * pw + k;
250 acc_pos[dst] += outs[0].data[src] as f64;
251 acc_neg[dst] += outs[1].data[src] as f64;
252 wsum[dst] += 1.0;
253 }
254 }
255 }
256 done += 1;
257 progress(done, total);
258 }
259 }
260 }
261
262 // Average overlaps, de-normalize, crop, mask, unpack. χ− is a magnitude → return signed.
263 let mut chi_pos = vec![0.0f64; n];
264 let mut chi_neg = vec![0.0f64; n];
265 let mut chi_total = vec![0.0f64; n];
266 let (pm, ps) = norm.chi_pos;
267 let (nm, ns) = norm.chi_neg;
268 for z in 0..nz {
269 for y in 0..ny {
270 for x in 0..nx {
271 let i = x + nx * (y + ny * z);
272 if mask[i] == 0 {
273 continue;
274 }
275 let src = (x * py + y) * pz + z;
276 let w = wsum[src].max(1.0);
277 let pos = (acc_pos[src] / w) * ps + pm;
278 let neg_mag = (acc_neg[src] / w) * ns + nm;
279 // deviation: no negative source magnitudes (see the module docs)
280 (chi_pos[i], chi_neg[i], chi_total[i]) = super::sources_from_magnitudes(pos, neg_mag);
281 }
282 }
283 }
284 Ok((chi_pos, chi_neg, chi_total))
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 /// Whole volume needs no patch; a given patch must sit on the 8-grid.
292 #[test]
293 fn patch_validation() {
294 assert_eq!(SusepNetParams::default().patch, None);
295 assert_eq!(SusepNetParams::low_memory().patch, Some(WASM_PATCH));
296 assert!(SusepNetParams::default().validate().is_ok());
297 assert!(SusepNetParams::low_memory().validate().is_ok());
298 for bad in [(0, 128, 64), (128, 124, 64), (128, 128, 4)] {
299 let e = SusepNetParams { patch: Some(bad) }.validate().unwrap_err();
300 assert!(e.contains("multiple of 8"), "{e}");
301 }
302 }
303}