Skip to main content

qsm_core/inversion/
qsmnetme.rs

1//! QSMnetME deep-learning dipole inversion (`onnx` feature).
2//!
3//! QSMnetME is the QSM network of the SNU-LIST χ-separation toolbox v1.1.3
4//! (`models/240904_QSMnet.onnx`): a QSMnet-style 3D U-Net (Conv/ReLU/MaxPool/ConvTranspose/Concat,
5//! 1 channel in and out) trained on multi-echo data (Kim et al. 2025, χ-sepnet, §2.2.2), which the
6//! toolbox runs to make χ-sepnet's QSM channel. It is not the QSMnet of Yoon et al. 2018
7//! ([`super::qsmnet`]): its own weights, normalisation and input route. The hosted
8//! `qsmnetme.onnx` is the toolbox's graph with its fixed 192×192×128 input re-declared dynamic
9//! (`scripts/onnx-export/redeclare_dynamic_axes.py`; weights and nodes untouched, identical output
10//! at the declared shape).
11//!
12//! # The authors' route
13//!
14//! The toolbox never runs QSMnetME on its own. It runs it inside `chi_sepnet_general`
15//! (`Chisep_script.m`:194), which was probed as a black box in licensed MATLAB R2023b (QSM.rs#377;
16//! method and the resampling in [`crate::utils::chisep_frame`]). The QSM channel there is:
17//!
18//! 1. the local field (Hz) resampled into the training frame (B0 along the third axis, 1 mm,
19//!    padded to multiples of 16) with `interp3 'spline'`, the mask with `'linear'` > 0.5;
20//! 2. converted to ppm with the central frequency (`field_hz / (CF·1e-6)`), z-scored with the
21//!    *field* statistics of the χ-sepnet normalisation file
22//!    (`xsepnet_train_patch_norm_factor_inplane_largedegree_romeo_arlo.mat`: `field_mean`,
23//!    `field_std`), and set to 0 outside the frame mask;
24//! 3. one pass of the network over the whole frame volume (no tiling);
25//! 4. de-normalised with the COSMOS susceptibility statistics of the same file
26//!    (`cosmos_sus_std·y + cosmos_sus_mean`), and set to 0 outside the frame mask.
27//!
28//! Every constant and step was read off the toolbox's workspaces: the network input matched
29//! `(field/(CF·1e-6) − field_mean)/field_std` inside the mask to 7.7e-7 (float32 rounding) and
30//! 0 outside, and the QSM matched `cosmos_sus_std·y + cosmos_sus_mean` exactly.
31//!
32//! [`qsmnetme`] takes the local field in ppm, as every inversion in this crate does, so step 2's
33//! conversion is the identity. It then does what the toolbox does to the maps it returns:
34//! resample the frame QSM back onto the acquired grid (`interp3 'spline'`, see
35//! [`crate::utils::chisep_frame`]) and mask it. That last step is the toolbox's treatment of
36//! χ-sepnet's χ+ and χ−, applied here to the QSM, which the toolbox keeps internal; the QSM is
37//! signed, so nothing is clipped.
38//!
39//! # Agreement with the original
40//!
41//! The toolbox's QSM channel (MATLAB R2023b, `chi_sepnet_general` with the network computed by
42//! ONNX Runtime) against the Python reproduction QSM-CI's `qsmnetme` entry runs
43//! (`algorithms/qsmnetme/recon.py`): 1.7e-5 % NRMSE on 64×60×48 at 0.8×0.9×1.1 mm with B0 along
44//! (0.1, 0.2, 0.97), 1.9e-5 % at 1 mm with B0 along z (float32 rounding, ONNX Runtime's thread
45//! count). `tests/models_onnx/dipole.rs` checks [`qsmnetme`] against that entry on the in-vivo box
46//! (`tests/data/dl_snu_route_small.py`).
47//!
48//! # Memory
49//!
50//! The authors run the whole frame in one pass. For a 1 mm head that is ~192×224×176 voxels with
51//! 64 channels at full resolution: about 1.1 GB per activation. [`QsmnetmeParams::patch`] runs an
52//! overlapping sliding window instead (a smaller result near patch seams: not the authors'), for
53//! hosts that cannot hold that.
54//!
55//! Reference: Kim, M., …, Shin, H.-G., Lee, J. (2025). "χ-sepnet: Deep neural network for magnetic
56//! susceptibility source separation." Human Brain Mapping, 46(4):e70136. doi:10.1002/hbm.70136.
57
58#![cfg_attr(not(feature = "onnx"), allow(dead_code))]
59
60#[cfg(feature = "onnx")]
61use crate::grid::Grid;
62#[cfg(feature = "onnx")]
63use crate::models::onnx::{OnnxError, OnnxModel};
64
65/// QSMnetME's normalisation: `(mean, std)` of the field (ppm) input and the χ (ppm) output, from
66/// `xsepnet_train_patch_norm_factor_inplane_largedegree_romeo_arlo.mat` (`field_*`,
67/// `cosmos_sus_*`; the same values χ-sepnet's [`ChiSepNetNorm`](crate::separation::ChiSepNetNorm)
68/// uses for its field and QSM channels).
69#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
70#[derive(Clone, Copy, Debug, PartialEq)]
71pub struct QsmnetmeNorm {
72    pub field: (f64, f64),
73    pub chi: (f64, f64),
74}
75
76impl Default for QsmnetmeNorm {
77    fn default() -> Self {
78        Self {
79            field: (-0.0026045702397823334, 0.010849659331142902),
80            chi: (-0.0013402086915448308, 0.031504031270742416),
81        }
82    }
83}
84
85/// Inference parameters for [`qsmnetme`].
86#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
87#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
88pub struct QsmnetmeParams {
89    /// `None` (default): the whole frame in one pass, as the toolbox runs it. `Some(patch)`: an
90    /// overlapping sliding window of that patch (each dimension a multiple of 16), for hosts that
91    /// cannot hold the whole volume's activations.
92    pub patch: Option<(usize, usize, usize)>,
93}
94
95impl QsmnetmeParams {
96    /// Check the patch: every dimension a positive multiple of 16.
97    pub fn validate(&self) -> Result<(), String> {
98        if let Some((d, h, w)) = self.patch {
99            for (axis, v) in [("D", d), ("H", h), ("W", w)] {
100                if v == 0 || v % 16 != 0 {
101                    return Err(format!("QSMnetME patch {axis} = {v} must be a positive multiple of 16"));
102                }
103            }
104        }
105        Ok(())
106    }
107}
108
109/// QSMnetME in the training frame (steps 2-4 of the module docs, ppm in): the frame QSM (ppm,
110/// masked) and the z-scored field channel, which χ-sepnet takes too.
111#[cfg(feature = "onnx")]
112pub(crate) fn qsmnetme_in_frame(
113    field_ppm: &[f64],
114    mask: &[u8],
115    dims: (usize, usize, usize),
116    model: &OnnxModel,
117    norm: &QsmnetmeNorm,
118    patch: Option<(usize, usize, usize)>,
119) -> Result<(Vec<f64>, Vec<f32>), OnnxError> {
120    let x: Vec<f32> = field_ppm
121        .iter()
122        .zip(mask)
123        .map(|(&f, &m)| if m != 0 { ((f - norm.field.0) / norm.field.1) as f32 } else { 0.0 })
124        .collect();
125    let y = crate::utils::chisep_frame::run_net(model, std::slice::from_ref(&x), dims, 1, patch)?;
126    let qsm = y[0]
127        .iter()
128        .zip(mask)
129        .map(|(&v, &m)| if m != 0 { v as f64 * norm.chi.1 + norm.chi.0 } else { 0.0 })
130        .collect();
131    Ok((qsm, x))
132}
133
134/// Run QSMnetME dipole inversion by the authors' route (module docs).
135///
136/// * `local_field_ppm` — local field (ppm), column-major `(nx, ny, nz)`.
137/// * `mask` — binary brain mask (same layout); the output is 0 outside it.
138/// * `grid` — dimensions and voxel sizes (mm).
139/// * `b0_dir` — B0 direction in the array's axes.
140/// * `model_onnx` — bytes of the hosted `qsmnetme.onnx`.
141///
142/// Returns χ (ppm) on the acquired grid.
143#[cfg(feature = "onnx")]
144pub fn qsmnetme(
145    local_field_ppm: &[f64],
146    mask: &[u8],
147    grid: &Grid,
148    b0_dir: (f64, f64, f64),
149    model_onnx: &[u8],
150    norm: &QsmnetmeNorm,
151    params: &QsmnetmeParams,
152) -> Result<Vec<f64>, OnnxError> {
153    let n = grid.n_total();
154    assert_eq!(local_field_ppm.len(), n, "field length must match grid");
155    assert_eq!(mask.len(), n, "mask length must match grid");
156    params.validate().map_err(OnnxError::Domain)?;
157    let frame = crate::utils::chisep_frame::TrainingFrame::new(grid.dims, grid.voxel_size, b0_dir);
158    let field = frame.to_frame(local_field_ppm);
159    let fmask = frame.mask_to_frame(mask);
160    let model = OnnxModel::load(model_onnx)?;
161    let (qsm, _) = qsmnetme_in_frame(&field, &fmask, frame.dims(), &model, norm, params.patch)?;
162    let back = frame.to_native(&qsm);
163    Ok(back.into_iter().zip(mask).map(|(v, &m)| if m != 0 { v } else { 0.0 }).collect())
164}