Skip to main content

qsm_core/inversion/
xqsm.rs

1//! xQSM deep-learning dipole inversion (`onnx` feature).
2//!
3//! xQSM is an octave-convolution U-Net (with a global residual) that maps a
4//! local tissue field (ppm) to susceptibility (ppm). It is orientation-agnostic
5//! and takes no dipole kernel — the field goes in, χ comes out. This mirrors the
6//! authors' pure-Python inference (`sunhongfu/xQSM`): centered zero-pad each
7//! dimension to a multiple of 8, run the net, crop back, and multiply the output
8//! by the mask. No normalization.
9//!
10//! Weights are not bundled; the caller passes the exported `xqsm.onnx` bytes
11//! (see [`crate::models`]).
12//!
13//! # Reference pipeline
14//!
15//! - **Training data:** local fields simulated from 1 mm isotropic χ maps (`xQSM_invivo`: 90
16//!   LN-QSM subjects, 48³ patches) by the forward dipole model; no field strength or TE enters
17//!   (Gao et al., NMR Biomed 2021, "Data Preparation").
18//! - **Inputs fed in the paper's in-vivo tests:** best-path unwrapping, then RESHARP background
19//!   removal (paper, in-vivo experiments paragraph); tested at 1 mm (3T) and 0.6 mm (7T) isotropic.
20//!   The paper's resolution study found all networks degrade on anisotropic voxels (NRMSE 22–47 %)
21//!   and on voxels finer than the training data ("Generalization Evaluation").
22//! - **Resampling:** the authors' MATLAB wrapper (`sunhongfu/iQSM_Plus`
23//!   `legacy/matlab/xQSM.m:104-127,170-176`) interpolates anisotropic data to an isotropic grid at
24//!   the finest voxel size (`imresize3`) before the net and back afterwards. Their Python
25//!   `sunhongfu/xQSM` `inference.py` does not resample. This crate does not resample either; the
26//!   caller must supply an isotropic grid to match the MATLAB wrapper.
27//! - **Orientation:** axial (B0 along the third axis; `matlab/eval/run_demo.m:8`).
28//!
29//! # Agreement with the original
30//!
31//! `tests/models_onnx/dipole.rs` runs it on a 36×32×28 box of in-vivo data (1 mm, 2.89 T, across
32//! the brain's edge) against its original's verified QSM-CI entry, xQSM 5d13b36 (`xqsm`)
33//! (QSMxT/QSM-CI#316; the fixture is regenerated in Actions by
34//! `tests/data/dl_dipole_qsmci_small.py`): NRMSE 1.5e-3 %, max |Δ| 1.0e-5 ppm (measured
35//! 2026-10-30): float32 rounding. On QSM-CI's 128×160×96 case: 4.2e-4 %.
36//!
37//! # Pipeline rules
38//!
39//! The facts above that a configuration can depart from are published as rules in
40//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `xqsm.bg-resharp`.
41
42use crate::grid::Grid;
43use crate::models::onnx::{OnnxModel, OnnxError, Tensor};
44
45/// Run xQSM dipole inversion.
46///
47/// * `local_field_ppm` — local tissue field (ppm), column-major `(nx,ny,nz)`.
48/// * `mask` — binary brain mask (same layout); applied to the output.
49/// * `model_onnx` — bytes of the exported `xqsm.onnx`.
50///
51/// Returns susceptibility (ppm), masked, in the same layout.
52pub fn xqsm(
53    local_field_ppm: &[f64],
54    mask: &[u8],
55    grid: &Grid,
56    model_onnx: &[u8],
57) -> Result<Vec<f64>, OnnxError> {
58    let (nx, ny, nz) = grid.dims;
59    let n = nx * ny * nz;
60    assert_eq!(local_field_ppm.len(), n, "field length must match grid");
61    assert_eq!(mask.len(), n, "mask length must match grid");
62
63    // Centered padding offsets so each dim is a multiple of 8 (matches the
64    // Python reference's `_zero_pad`).
65    let pad = |s: usize| -> (usize, usize) {
66        let total = (8 - s % 8) % 8;
67        let before = total / 2;
68        (before, s + total) // (offset, padded size)
69    };
70    let (bx, px) = pad(nx);
71    let (by, py) = pad(ny);
72    let (bz, pz) = pad(nz);
73
74    // Repack column-major (nx,ny,nz) f64 → row-major (px,py,pz) f32, centered.
75    // The field is NOT masked before inference (unlike BFRnet).
76    let mut input = vec![0.0f32; px * py * pz];
77    for z in 0..nz {
78        for y in 0..ny {
79            for x in 0..nx {
80                let src = x + nx * (y + ny * z);
81                let dst = (z + bz) + pz * ((y + by) + py * (x + bx));
82                input[dst] = local_field_ppm[src] as f32;
83            }
84        }
85    }
86
87    let model = OnnxModel::load(model_onnx)?;
88    let out = model.run_single(&Tensor::new(vec![1, 1, px, py, pz], input))?;
89    if out.shape != [1, 1, px, py, pz] {
90        return Err(OnnxError::Run(format!(
91            "unexpected output shape {:?}, expected [1,1,{px},{py},{pz}]",
92            out.shape
93        )));
94    }
95
96    // Crop back (centered), mask, and unpack row-major → column-major.
97    let mut chi = vec![0.0f64; n];
98    for z in 0..nz {
99        for y in 0..ny {
100            for x in 0..nx {
101                let dst = x + nx * (y + ny * z);
102                if mask[dst] != 0 {
103                    let src = (z + bz) + pz * ((y + by) + py * (x + bx));
104                    chi[dst] = out.data[src] as f64;
105                }
106            }
107        }
108    }
109    Ok(chi)
110}
111
112/// Memory-bounded xQSM via overlap-tiling — the same fully-convolutional net run patch-by-
113/// patch so peak memory is bounded by one patch (for 32-bit WASM, where the whole-volume
114/// [`xqsm`] overflows the 4 GB heap on clinical-size data). Results approximate whole-volume
115/// xQSM up to tile-boundary error; see [`crate::inversion::tiled`].
116pub fn xqsm_tiled(
117    local_field_ppm: &[f64],
118    mask: &[u8],
119    grid: &Grid,
120    model_onnx: &[u8],
121    cfg: &super::tiled::TileConfig,
122    progress: impl FnMut(usize, usize),
123) -> Result<Vec<f64>, OnnxError> {
124    let model = OnnxModel::load(model_onnx)?;
125    // xQSM feeds the raw field (no normalization) and reads χ directly; pad each patch to /8.
126    super::tiled::tiled_field_inversion(
127        local_field_ppm, mask, grid, &model, &super::tiled::tile_cost_of("xqsm"), cfg, |v| v as f32, |o| o as f64, progress,
128    )
129}