Skip to main content

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}