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}