Skip to main content

qsm_core/separation/
chisepnet.rs

1//! χ-sepnet deep-learning χ-separation (`onnx` feature).
2//!
3//! χ-sepnet (SNU-LIST) is a 3D U-Net that maps a 3-channel patch — [QSM (χ_total,
4//! ppm), local field (ppm), R2′/Dr] each z-scored by training statistics — to
5//! paramagnetic (χ+) and diamagnetic (χ−) source magnitudes (z-scored). The network
6//! is a fixed **192×192×128** patch; we run it as an overlapping sliding window over
7//! the whole volume and average the overlaps, then de-normalize.
8//!
9//! Recipe (mirrors the SNU-LIST / QSM-CI `recon.py`): z-score each channel, zero
10//! outside the mask, end-pad each dim up to the patch size, tile with a 0.75 stride
11//! plus a final flush patch, average overlaps, de-normalize, crop, and mask. `Dr`
12//! (114 Hz/ppm, the network's COSMOS-referenced relaxivity) scales R2′ into the
13//! network's ppm-equivalent input channel. We return χ− as a signed (≤ 0) value to
14//! match the crate's separation convention `(chi_pos ≥ 0, chi_neg ≤ 0, chi_total)`.
15//!
16//! Weights are not bundled; the caller passes the exported `chi-sepnet.onnx` bytes.
17
18use crate::grid::Grid;
19use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
20
21const PD: usize = 192; // patch D (=x)
22const PH: usize = 192; // patch H (=y)
23const PW: usize = 128; // patch W (=z)
24
25/// Training z-score constants for χ-sepnet (`xsepnet_train_patch_norm_factor…mat`):
26/// each field is `(mean, std)`. Inputs are normalized `(x-mean)/std`; outputs
27/// de-normalized `y*std + mean`. `dr` scales R2′ (Hz) into the ppm-equivalent
28/// channel `(r2prime/dr - mean)/std`.
29#[derive(Clone, Copy, Debug)]
30pub struct ChiSepNetNorm {
31    pub qsm: (f64, f64),
32    pub field: (f64, f64),
33    pub r2prime: (f64, f64),
34    pub chi_pos: (f64, f64),
35    pub chi_neg: (f64, f64),
36    pub dr: f64,
37}
38
39impl Default for ChiSepNetNorm {
40    /// Constants from `xsepnet_train_patch_norm_factor_inplane_largedegree_romeo_arlo.mat`.
41    fn default() -> Self {
42        Self {
43            qsm: (-0.0013402086915448308, 0.031504031270742416),
44            field: (-0.0026045702397823334, 0.010849659331142902),
45            r2prime: (0.05141879618167877, 0.06977531313896179),
46            chi_pos: (0.025912819430232048, 0.03526417911052704),
47            chi_neg: (0.026700690388679504, 0.027936099097132683),
48            dr: 114.0,
49        }
50    }
51}
52
53/// Run χ-sepnet χ-separation.
54///
55/// * `local_field_ppm`, `qsm` (χ_total, ppm), `r2prime` (Hz) — column-major `(nx,ny,nz)`.
56/// * `mask` — binary brain mask (same layout).
57/// * `model_onnx` — bytes of the exported `chi-sepnet.onnx` (192×192×128, 3→2 chan).
58/// * `norm` — training normalization constants.
59///
60/// Returns `(chi_pos ≥ 0, chi_neg ≤ 0, chi_total = chi_pos + chi_neg)` in ppm,
61/// masked, in the same layout.
62#[allow(clippy::too_many_arguments)]
63pub fn chisepnet(
64    local_field_ppm: &[f64],
65    qsm: &[f64],
66    r2prime: &[f64],
67    mask: &[u8],
68    grid: &Grid,
69    model_onnx: &[u8],
70    norm: &ChiSepNetNorm,
71) -> Result<(Vec<f64>, Vec<f64>, Vec<f64>), OnnxError> {
72    let (nx, ny, nz) = grid.dims;
73    let n = nx * ny * nz;
74    for (name, v) in [("field", local_field_ppm), ("qsm", qsm), ("r2prime", r2prime)] {
75        assert_eq!(v.len(), n, "{name} length must match grid");
76    }
77    assert_eq!(mask.len(), n, "mask length must match grid");
78
79    // End-pad each dim up to at least the patch size (col-major padded volume).
80    let (px, py, pz) = (nx.max(PD), ny.max(PH), nz.max(PW));
81    let mut ch = [vec![0.0f32; px * py * pz], vec![0.0f32; px * py * pz], vec![0.0f32; px * py * pz]];
82    for z in 0..nz {
83        for y in 0..ny {
84            for x in 0..nx {
85                let i = x + nx * (y + ny * z);
86                if mask[i] == 0 {
87                    continue;
88                }
89                let p = x + px * (y + py * z);
90                ch[0][p] = ((qsm[i] - norm.qsm.0) / norm.qsm.1) as f32;
91                ch[1][p] = ((local_field_ppm[i] - norm.field.0) / norm.field.1) as f32;
92                ch[2][p] = ((r2prime[i] / norm.dr - norm.r2prime.0) / norm.r2prime.1) as f32;
93            }
94        }
95    }
96
97    // Sliding-window start positions: 0, 0.75·patch, … then a final flush at size-patch.
98    let starts = |size: usize, patch: usize| -> Vec<usize> {
99        if size <= patch {
100            return vec![0];
101        }
102        let step = (patch as f64 * 0.75) as usize;
103        let mut s: Vec<usize> = (0..=size - patch).step_by(step.max(1)).collect();
104        if *s.last().unwrap() != size - patch {
105            s.push(size - patch);
106        }
107        s
108    };
109
110    let model = OnnxModel::load(model_onnx)?;
111    let plane = PD * PH * PW;
112    let mut acc0 = vec![0.0f64; px * py * pz];
113    let mut acc1 = vec![0.0f64; px * py * pz];
114    let mut wsum = vec![0.0f64; px * py * pz];
115    for &x0 in &starts(px, PD) {
116        for &y0 in &starts(py, PH) {
117            for &z0 in &starts(pz, PW) {
118                // Build the [1,3,192,192,128] NCDHW patch (D=x, H=y, W=z).
119                let mut buf = vec![0.0f32; 3 * plane];
120                for i in 0..PD {
121                    for j in 0..PH {
122                        for k in 0..PW {
123                            let p = (x0 + i) + px * ((y0 + j) + py * (z0 + k));
124                            let o = (i * PH + j) * PW + k;
125                            buf[o] = ch[0][p];
126                            buf[plane + o] = ch[1][p];
127                            buf[2 * plane + o] = ch[2][p];
128                        }
129                    }
130                }
131                let out = model.run_single(&Tensor::new(vec![1, 3, PD, PH, PW], buf))?;
132                if out.data.len() < 2 * plane {
133                    return Err(OnnxError::Run("expected 2-channel output".into()));
134                }
135                for i in 0..PD {
136                    for j in 0..PH {
137                        for k in 0..PW {
138                            let p = (x0 + i) + px * ((y0 + j) + py * (z0 + k));
139                            let o = (i * PH + j) * PW + k;
140                            acc0[p] += out.data[o] as f64;
141                            acc1[p] += out.data[plane + o] as f64;
142                            wsum[p] += 1.0;
143                        }
144                    }
145                }
146            }
147        }
148    }
149
150    // Average overlaps, de-normalize, crop to (nx,ny,nz), mask. χ− → signed (≤ 0).
151    let mut chi_pos = vec![0.0f64; n];
152    let mut chi_neg = vec![0.0f64; n];
153    let mut chi_total = vec![0.0f64; n];
154    for z in 0..nz {
155        for y in 0..ny {
156            for x in 0..nx {
157                let i = x + nx * (y + ny * z);
158                if mask[i] == 0 {
159                    continue;
160                }
161                let p = x + px * (y + py * z);
162                let w = wsum[p].max(1.0);
163                let pos = (acc0[p] / w) * norm.chi_pos.1 + norm.chi_pos.0;
164                let neg = (acc1[p] / w) * norm.chi_neg.1 + norm.chi_neg.0;
165                chi_pos[i] = pos;
166                chi_neg[i] = -neg;
167                chi_total[i] = pos - neg;
168            }
169        }
170    }
171    Ok((chi_pos, chi_neg, chi_total))
172}