qsm_core/bgremove/bfrnet.rs
1//! BFRnet deep-learning background field removal (`onnx` feature).
2//!
3//! BFRnet is a 3D dual-frequency octave-convolution U-Net that predicts the
4//! **background** field from a masked total field; the local tissue field is
5//! `total − background`, re-masked. Everything is in ppm — the network never
6//! sees TE/B0/B0-direction. This mirrors the QSM-CI ONNX port (faithful to the
7//! authors' MATLAB `predict` to |Δ| ≈ 1e-7).
8//!
9//! Weights are not bundled; the caller passes the exported `bfrnet.onnx` bytes
10//! (see [`crate::models`]).
11
12use crate::grid::Grid;
13use crate::models::onnx::{OnnxModel, OnnxError, Tensor};
14
15/// Run BFRnet on a total field map.
16///
17/// * `field_ppm` — total field (ppm), column-major `(nx,ny,nz)`.
18/// * `mask` — binary brain mask (same layout).
19/// * `model_onnx` — bytes of the exported `bfrnet.onnx`.
20///
21/// Returns the local tissue field (ppm), masked, in the same layout.
22///
23/// The whole volume is run in one pass. BFRnet is fully convolutional with three
24/// pooling levels, so each spatial dimension is zero-padded up to a multiple of
25/// 8 and cropped back. (Memory-bounded Hann-blended tiling — as the Python port
26/// uses for very large volumes — is a future refinement.)
27pub fn bfrnet(
28 field_ppm: &[f64],
29 mask: &[u8],
30 grid: &Grid,
31 model_onnx: &[u8],
32) -> Result<Vec<f64>, OnnxError> {
33 let (nx, ny, nz) = grid.dims;
34 let n = nx * ny * nz;
35 assert_eq!(field_ppm.len(), n, "field length must match grid");
36 assert_eq!(mask.len(), n, "mask length must match grid");
37
38 // The net was trained on masked total field.
39 let masked: Vec<f64> = field_ppm
40 .iter()
41 .zip(mask)
42 .map(|(&f, &m)| if m != 0 { f } else { 0.0 })
43 .collect();
44
45 // Padded spatial dims (multiple of 8).
46 let (px, py, pz) = (
47 nx.div_ceil(8) * 8,
48 ny.div_ceil(8) * 8,
49 nz.div_ceil(8) * 8,
50 );
51
52 // Repack column-major (nx,ny,nz) f64 → row-major (px,py,pz) f32, zero-padded.
53 // ONNX/tract tensors are row-major (last axis fastest), so the axis order
54 // fed to the net matches the Python port's (X,Y,Z).
55 let mut input = vec![0.0f32; px * py * pz];
56 for z in 0..nz {
57 for y in 0..ny {
58 for x in 0..nx {
59 let src = x + nx * (y + ny * z);
60 let dst = z + pz * (y + py * x);
61 input[dst] = masked[src] as f32;
62 }
63 }
64 }
65
66 let model = OnnxModel::load(model_onnx)?;
67 let out = model.run_single(&Tensor::new(vec![1, 1, px, py, pz], input))?;
68 if out.shape != [1, 1, px, py, pz] {
69 return Err(OnnxError::Run(format!(
70 "unexpected output shape {:?}, expected [1,1,{px},{py},{pz}]",
71 out.shape
72 )));
73 }
74
75 // Crop back to (nx,ny,nz), form local = masked − background, re-mask,
76 // and unpack row-major → column-major.
77 let mut local = vec![0.0f64; n];
78 for z in 0..nz {
79 for y in 0..ny {
80 for x in 0..nx {
81 let dst = x + nx * (y + ny * z);
82 if mask[dst] != 0 {
83 let src = z + pz * (y + py * x);
84 local[dst] = masked[dst] - out.data[src] as f64;
85 }
86 }
87 }
88 }
89 Ok(local)
90}