Skip to main content

qsm_core/inversion/
iqsm.rs

1//! iQSM single-step deep-learning reconstruction (`onnx` feature).
2//!
3//! iQSM (Gao 2022) maps raw wrapped MRI **phase** (radians) directly to
4//! susceptibility (ppm) — unwrapping, background removal and dipole inversion in
5//! one network (a learnable-Laplacian "LoT" front-end + a U-Net). The exported
6//! graph takes four inputs: `phase`, `mask`, `te` (s, scalar), `b0` (T, scalar).
7//!
8//! This mirrors the authors' `inference.run_iqsm`: flip the phase sign, erode the
9//! mask by a radius-3 sphere, centre-pad each dim to a multiple of 16, run the
10//! net, multiply by the (padded) mask, and crop back. For multi-echo data each
11//! echo is reconstructed and combined with magnitude·TE² weighting
12//! ([`iqsm_multi_echo`]).
13//!
14//! Weights are not bundled; the caller passes the exported `iqsm.onnx` bytes.
15
16use crate::grid::Grid;
17use crate::models::onnx::{OnnxModel, OnnxError, Tensor};
18
19/// Run iQSM on a single echo of wrapped phase.
20///
21/// * `phase_rad` — wrapped phase (radians), column-major `(nx,ny,nz)`.
22/// * `mask` — binary brain mask (same layout).
23/// * `te` — echo time in seconds; `b0` — field strength in Tesla.
24/// * `phase_sign` — sign convention (`-1` matches the authors' default).
25/// * `eroded_rad` — mask erosion radius in voxels (`3` matches the default; `0` disables).
26/// * `model_onnx` — bytes of the exported `iqsm.onnx`.
27///
28/// Returns susceptibility (ppm), masked, in the same layout.
29#[allow(clippy::too_many_arguments)]
30pub fn iqsm(
31    phase_rad: &[f64],
32    mask: &[u8],
33    grid: &Grid,
34    te: f64,
35    b0: f64,
36    phase_sign: f64,
37    eroded_rad: i32,
38    model_onnx: &[u8],
39) -> Result<Vec<f64>, OnnxError> {
40    let model = OnnxModel::load(model_onnx)?;
41    iqsm_with(&model, phase_rad, mask, grid, te, b0, phase_sign, eroded_rad)
42}
43
44/// Multi-echo iQSM: reconstruct each echo and combine with magnitude·TE² weights
45/// (the authors' `--echo_4d` path). `phases`/`magnitudes` are per-echo volumes;
46/// `tes` the echo times (s). Falls back to uniform weights if `magnitudes` is empty.
47#[allow(clippy::too_many_arguments)]
48pub fn iqsm_multi_echo(
49    phases: &[&[f64]],
50    magnitudes: &[&[f64]],
51    mask: &[u8],
52    grid: &Grid,
53    tes: &[f64],
54    b0: f64,
55    phase_sign: f64,
56    eroded_rad: i32,
57    model_onnx: &[u8],
58) -> Result<Vec<f64>, OnnxError> {
59    assert_eq!(phases.len(), tes.len(), "one TE per echo");
60    let n = grid.n_total();
61    let model = OnnxModel::load(model_onnx)?;
62
63    let mut acc = vec![0.0f64; n];
64    let mut wsum = vec![0.0f64; n];
65    for (e, &phase) in phases.iter().enumerate() {
66        let chi = iqsm_with(&model, phase, mask, grid, tes[e], b0, phase_sign, eroded_rad)?;
67        let te2 = tes[e] * tes[e];
68        for i in 0..n {
69            let w = magnitudes.get(e).map(|m| m[i]).unwrap_or(1.0) * te2;
70            acc[i] += w * chi[i];
71            wsum[i] += w;
72        }
73    }
74    for i in 0..n {
75        acc[i] = if wsum[i] > 0.0 { acc[i] / wsum[i] } else { 0.0 };
76    }
77    Ok(acc)
78}
79
80fn iqsm_with(
81    model: &OnnxModel,
82    phase_rad: &[f64],
83    mask: &[u8],
84    grid: &Grid,
85    te: f64,
86    b0: f64,
87    phase_sign: f64,
88    eroded_rad: i32,
89) -> Result<Vec<f64>, OnnxError> {
90    let (nx, ny, nz) = grid.dims;
91    let n = nx * ny * nz;
92    assert_eq!(phase_rad.len(), n, "phase length must match grid");
93    assert_eq!(mask.len(), n, "mask length must match grid");
94
95    let eroded = if eroded_rad > 0 {
96        sphere_erode(mask, grid, eroded_rad)
97    } else {
98        mask.to_vec()
99    };
100
101    // Centre-pad to a multiple of 16 (four pooling levels).
102    let pad = |s: usize| -> (usize, usize) {
103        let total = (16 - s % 16) % 16;
104        (total / 2, s + total)
105    };
106    let (bx, px) = pad(nx);
107    let (by, py) = pad(ny);
108    let (bz, pz) = pad(nz);
109
110    // Repack column-major (nx,ny,nz) → row-major NCDHW [1,1,px,py,pz] f32, centered.
111    let mut phase_t = vec![0.0f32; px * py * pz];
112    let mut mask_t = vec![0.0f32; px * py * pz];
113    for z in 0..nz {
114        for y in 0..ny {
115            for x in 0..nx {
116                let src = x + nx * (y + ny * z);
117                let dst = (z + bz) + pz * ((y + by) + py * (x + bx));
118                phase_t[dst] = (phase_sign * phase_rad[src]) as f32;
119                mask_t[dst] = eroded[src] as f32;
120            }
121        }
122    }
123
124    // Border mask: 0 on the padded volume's outer 1-voxel shell, 1 inside. The
125    // LoT layer's boundary zeroing is applied inside the graph as `conv * border`
126    // (the only tract-friendly encoding of that op).
127    let mut border = vec![1.0f32; px * py * pz];
128    for a in 0..px {
129        for b in 0..py {
130            for c in 0..pz {
131                if a == 0 || a == px - 1 || b == 0 || b == py - 1 || c == 0 || c == pz - 1 {
132                    border[(c) + pz * ((b) + py * a)] = 0.0;
133                }
134            }
135        }
136    }
137
138    let shape = vec![1, 1, px, py, pz];
139    let inputs = [
140        Tensor::new(shape.clone(), phase_t),
141        Tensor::new(shape.clone(), mask_t),
142        Tensor::new(vec![1], vec![te as f32]),
143        Tensor::new(vec![1], vec![b0 as f32]),
144        Tensor::new(shape, border),
145    ];
146    let out = model.run(&inputs)?;
147    let chi_pad = &out[0].data;
148
149    // Multiply by the (padded) mask, crop, unpack → column-major.
150    let mut chi = vec![0.0f64; n];
151    for z in 0..nz {
152        for y in 0..ny {
153            for x in 0..nx {
154                let dst = x + nx * (y + ny * z);
155                if eroded[dst] != 0 {
156                    let src = (z + bz) + pz * ((y + by) + py * (x + bx));
157                    chi[dst] = chi_pad[src] as f64; // mask already applied (eroded[dst]==1 here)
158                }
159            }
160        }
161    }
162    Ok(chi)
163}
164
165/// Binary erosion by a solid sphere of the given radius (voxels), matching
166/// `scipy.ndimage.binary_erosion` with `border_value=0` (out-of-bounds = false).
167pub(crate) fn sphere_erode(mask: &[u8], grid: &Grid, radius: i32) -> Vec<u8> {
168    let (nx, ny, nz) = grid.dims;
169    // Precompute sphere offsets (dx²+dy²+dz² ≤ r²).
170    let r2 = radius * radius;
171    let mut offs: Vec<(i32, i32, i32)> = Vec::new();
172    for dz in -radius..=radius {
173        for dy in -radius..=radius {
174            for dx in -radius..=radius {
175                if dx * dx + dy * dy + dz * dz <= r2 {
176                    offs.push((dx, dy, dz));
177                }
178            }
179        }
180    }
181    let (nxi, nyi, nzi) = (nx as i32, ny as i32, nz as i32);
182    let mut out = vec![0u8; nx * ny * nz];
183    for z in 0..nzi {
184        for y in 0..nyi {
185            for x in 0..nxi {
186                let mut keep = true;
187                for &(dx, dy, dz) in &offs {
188                    let (xx, yy, zz) = (x + dx, y + dy, z + dz);
189                    let inside = xx >= 0 && xx < nxi && yy >= 0 && yy < nyi && zz >= 0 && zz < nzi;
190                    if !inside
191                        || mask[xx as usize + nx * (yy as usize + ny * zz as usize)] == 0
192                    {
193                        keep = false;
194                        break;
195                    }
196                }
197                if keep {
198                    out[x as usize + nx * (y as usize + ny * z as usize)] = 1;
199                }
200            }
201        }
202    }
203    out
204}