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
13use crate::grid::Grid;
14use crate::models::onnx::{OnnxModel, OnnxError, Tensor};
15
16/// Run xQSM dipole inversion.
17///
18/// * `local_field_ppm` — local tissue field (ppm), column-major `(nx,ny,nz)`.
19/// * `mask` — binary brain mask (same layout); applied to the output.
20/// * `model_onnx` — bytes of the exported `xqsm.onnx`.
21///
22/// Returns susceptibility (ppm), masked, in the same layout.
23pub fn xqsm(
24    local_field_ppm: &[f64],
25    mask: &[u8],
26    grid: &Grid,
27    model_onnx: &[u8],
28) -> Result<Vec<f64>, OnnxError> {
29    let (nx, ny, nz) = grid.dims;
30    let n = nx * ny * nz;
31    assert_eq!(local_field_ppm.len(), n, "field length must match grid");
32    assert_eq!(mask.len(), n, "mask length must match grid");
33
34    // Centered padding offsets so each dim is a multiple of 8 (matches the
35    // Python reference's `_zero_pad`).
36    let pad = |s: usize| -> (usize, usize) {
37        let total = (8 - s % 8) % 8;
38        let before = total / 2;
39        (before, s + total) // (offset, padded size)
40    };
41    let (bx, px) = pad(nx);
42    let (by, py) = pad(ny);
43    let (bz, pz) = pad(nz);
44
45    // Repack column-major (nx,ny,nz) f64 → row-major (px,py,pz) f32, centered.
46    // The field is NOT masked before inference (unlike BFRnet).
47    let mut input = vec![0.0f32; px * py * pz];
48    for z in 0..nz {
49        for y in 0..ny {
50            for x in 0..nx {
51                let src = x + nx * (y + ny * z);
52                let dst = (z + bz) + pz * ((y + by) + py * (x + bx));
53                input[dst] = local_field_ppm[src] as f32;
54            }
55        }
56    }
57
58    let model = OnnxModel::load(model_onnx)?;
59    let out = model.run_single(&Tensor::new(vec![1, 1, px, py, pz], input))?;
60    if out.shape != [1, 1, px, py, pz] {
61        return Err(OnnxError::Run(format!(
62            "unexpected output shape {:?}, expected [1,1,{px},{py},{pz}]",
63            out.shape
64        )));
65    }
66
67    // Crop back (centered), mask, and unpack row-major → column-major.
68    let mut chi = vec![0.0f64; n];
69    for z in 0..nz {
70        for y in 0..ny {
71            for x in 0..nx {
72                let dst = x + nx * (y + ny * z);
73                if mask[dst] != 0 {
74                    let src = (z + bz) + pz * ((y + by) + py * (x + bx));
75                    chi[dst] = out.data[src] as f64;
76                }
77            }
78        }
79    }
80    Ok(chi)
81}
82
83/// Memory-bounded xQSM via overlap-tiling — the same fully-convolutional net run patch-by-
84/// patch so peak memory is bounded by one patch (for 32-bit WASM, where the whole-volume
85/// [`xqsm`] overflows the 4 GB heap on clinical-size data). Results approximate whole-volume
86/// xQSM up to tile-boundary error; see [`crate::inversion::tiled`].
87pub fn xqsm_tiled(
88    local_field_ppm: &[f64],
89    mask: &[u8],
90    grid: &Grid,
91    model_onnx: &[u8],
92    cfg: &super::tiled::TileConfig,
93    progress: impl FnMut(usize, usize),
94) -> Result<Vec<f64>, OnnxError> {
95    let model = OnnxModel::load(model_onnx)?;
96    // xQSM feeds the raw field (no normalization) and reads χ directly; pad each patch to /8.
97    super::tiled::tiled_field_inversion(
98        local_field_ppm, mask, grid, &model, 8, cfg, |v| v as f32, |o| o as f64, progress,
99    )
100}