Skip to main content

qsm_core/inversion/
tiled.rs

1//! Overlap-tiled inference for fully-convolutional deep-learning inversions (`onnx`).
2//!
3//! Whole-volume nets (e.g. xQSM, QSMnet) allocate activations proportional to the entire
4//! volume, which overflows a 32-bit WASM heap (4 GB ceiling) on clinical-size data. Because
5//! these nets are fully convolutional, they can instead be run **patch-by-patch**: each
6//! output "core" is produced from a patch that includes a `halo` of surrounding context,
7//! then written back — the classic U-Net overlap-tile strategy. Peak memory is bounded by a
8//! single patch regardless of volume size.
9//!
10//! NOTE: tiling a net trained on whole volumes is an **approximation**. Dipole inversion is a
11//! global operation, so a patch is blind to distant susceptibility sources; a larger `halo`
12//! reduces the resulting low-frequency / boundary error but does not eliminate it.
13
14use crate::grid::Grid;
15use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
16
17/// Tiling parameters for [`tiled_field_inversion`].
18#[derive(Clone, Copy, Debug)]
19pub struct TileConfig {
20    /// Output core size per axis — the region each patch contributes to the result.
21    pub core: usize,
22    /// Context margin (voxels) included on every side of each patch. Larger halos reduce
23    /// tile-boundary artifacts at the cost of more work per patch.
24    pub halo: usize,
25}
26
27impl Default for TileConfig {
28    /// 128³ cores with an 8-voxel halo → 144³ patches (~0.4 GB of f32 activations for a typical
29    /// net, comfortably within a conservative ~2 GB WASM budget). Empirically the halo barely
30    /// affects accuracy here — the tiling error is dominated by *global* low-frequency drift
31    /// (a patch can't see distant susceptibility sources), not tile-boundary seams — so this
32    /// favours the large core (few patches, ~4× less overlap compute than 64³/32) over a big
33    /// halo. On real 3 T data this matched whole-volume xQSM at r≈0.94 in ~1/4 the time.
34    fn default() -> Self {
35        Self { core: 128, halo: 8 }
36    }
37}
38
39/// A core-aligned tile: `(x0, y0, z0, cx, cy, cz)` — origin + core extent (clamped at edges).
40pub type Tile = (usize, usize, usize, usize, usize, usize);
41
42/// Padded input-patch size per axis for a config and the net's `size_divisor`: `core + 2·halo`
43/// rounded up to a multiple of `divisor`. Isotropic, so one value serves all axes. Callers use
44/// this to build a reusable [`OnnxModel::plan_for`] plan of shape `[1, 1, p, p, p]`.
45pub fn tile_patch_size(cfg: &TileConfig, divisor: usize) -> usize {
46    (cfg.core.max(1) + 2 * cfg.halo).div_ceil(divisor.max(1)) * divisor.max(1)
47}
48
49/// Shared overlap-tiling driver. Enumerates the core-aligned tiles that actually touch `mask`
50/// (all-background tiles are skipped → work is restricted to the mask bounding box for free),
51/// runs each through `run_tile`, and scatters the results back into a full-volume buffer
52/// (masked). Handles the empty-tile skip, parallel batching, and progress reporting so each
53/// model only supplies its own per-patch logic.
54///
55/// `run_tile(&tile)` must return the tile's **post-processed core block** — row-major
56/// `oi,oj,ok`, length `cx·cy·cz` — and be pure + `Sync` (it may run on many threads at once,
57/// each holding one patch's activations in the shared wasm heap). `progress(done, total)` is
58/// called from the driver thread only (the JS callback isn't `Sync`).
59pub fn tiled_scatter(
60    grid: &Grid,
61    mask: &[u8],
62    cfg: &TileConfig,
63    run_tile: impl Fn(&Tile) -> Result<Vec<f64>, OnnxError> + Sync,
64    mut progress: impl FnMut(usize, usize),
65) -> Result<Vec<f64>, OnnxError> {
66    let (nx, ny, nz) = grid.dims;
67    let n = nx * ny * nz;
68    assert_eq!(mask.len(), n, "mask length must match grid");
69    let core = cfg.core.max(1);
70
71    // Does the core block at (x0,y0,z0) cover any mask voxel?
72    let core_has_mask = |x0: usize, y0: usize, z0: usize, cx: usize, cy: usize, cz: usize| {
73        for oj in 0..cy {
74            for ok in 0..cz {
75                let row = x0 + nx * ((y0 + oj) + ny * (z0 + ok));
76                if mask[row..row + cx].iter().any(|&m| m != 0) {
77                    return true;
78                }
79            }
80        }
81        false
82    };
83
84    // Enumerate the core-aligned tiles that actually touch the mask.
85    let mut tiles: Vec<Tile> = Vec::new();
86    let mut x0 = 0usize;
87    while x0 < nx {
88        let cx = core.min(nx - x0);
89        let mut y0 = 0usize;
90        while y0 < ny {
91            let cy = core.min(ny - y0);
92            let mut z0 = 0usize;
93            while z0 < nz {
94                let cz = core.min(nz - z0);
95                if core_has_mask(x0, y0, z0, cx, cy, cz) {
96                    tiles.push((x0, y0, z0, cx, cy, cz));
97                }
98                z0 += core;
99            }
100            y0 += core;
101        }
102        x0 += core;
103    }
104    let total_tiles = tiles.len();
105    progress(0, total_tiles);
106
107    // Scatter a computed core block (row-major oi,oj,ok) into the full volume (masked).
108    let write_tile = |chi: &mut [f64], &(x0, y0, z0, cx, cy, cz): &Tile, block: &[f64]| {
109        for oi in 0..cx {
110            for oj in 0..cy {
111                for ok in 0..cz {
112                    let dst = (x0 + oi) + nx * ((y0 + oj) + ny * (z0 + ok));
113                    if mask[dst] != 0 {
114                        chi[dst] = block[(oi * cy + oj) * cz + ok];
115                    }
116                }
117            }
118        }
119    };
120
121    let mut chi = vec![0.0f64; n];
122
123    // Parallel path: run tiles in batches sized to the rayon pool, then write + report progress
124    // from this (driver) thread. Falls back to sequential without the `parallel` feature.
125    #[cfg(feature = "parallel")]
126    {
127        use rayon::prelude::*;
128        let batch = rayon::current_num_threads().max(1);
129        let mut done = 0usize;
130        for chunk in tiles.chunks(batch) {
131            let blocks: Vec<Vec<f64>> = chunk.par_iter().map(&run_tile).collect::<Result<_, _>>()?;
132            for (tile, block) in chunk.iter().zip(&blocks) {
133                write_tile(&mut chi, tile, block);
134                done += 1;
135                progress(done, total_tiles);
136            }
137        }
138    }
139    #[cfg(not(feature = "parallel"))]
140    {
141        for (t, tile) in tiles.iter().enumerate() {
142            let block = run_tile(tile)?;
143            write_tile(&mut chi, tile, &block);
144            progress(t + 1, total_tiles);
145        }
146    }
147    Ok(chi)
148}
149
150/// Overlap-tile an **entire volume→volume algorithm** (not just one forward pass). For each
151/// tile, a padded `p³` sub-volume of `field`/`mask` is cut out (with `halo` context, zero
152/// outside the volume), `run_patch(field_patch, mask_patch, patch_grid)` is run on it, and the
153/// central core is written back. Use this for the FFT-unrolled nets (lpcnn/modl-qsm/nextqsm)
154/// whose Rust-side physics loop wraps a whole-volume CNN — running the whole algorithm per patch
155/// bounds memory. Strongly off-design (the dipole/k-space step then sees only a patch), so results
156/// are approximate; callers should warn and steer users to a full-volume run for real work.
157#[allow(clippy::too_many_arguments)]
158pub fn tiled_volume_algorithm(
159    field: &[f64],
160    mask: &[u8],
161    grid: &Grid,
162    divisor: usize,
163    cfg: &TileConfig,
164    run_patch: impl Fn(&[f64], &[u8], &Grid) -> Result<Vec<f64>, OnnxError> + Sync,
165    progress: impl FnMut(usize, usize),
166) -> Result<Vec<f64>, OnnxError> {
167    let (nx, ny, nz) = grid.dims;
168    assert_eq!(field.len(), nx * ny * nz, "field length must match grid");
169    let halo = cfg.halo;
170    let (nxi, nyi, nzi) = (nx as i64, ny as i64, nz as i64);
171    let p = tile_patch_size(cfg, divisor);
172    let (vsx, vsy, vsz) = grid.voxel_size;
173    let inside = move |x: i64, y: i64, z: i64| x >= 0 && x < nxi && y >= 0 && y < nyi && z >= 0 && z < nzi;
174
175    let run_tile = move |&(x0, y0, z0, cx, cy, cz): &Tile| -> Result<Vec<f64>, OnnxError> {
176        // Cut a column-major p³ sub-volume of field + mask (zero/empty outside the volume).
177        let mut fpatch = vec![0.0f64; p * p * p];
178        let mut mpatch = vec![0u8; p * p * p];
179        for k in 0..p {
180            let vz = z0 as i64 - halo as i64 + k as i64;
181            for j in 0..p {
182                let vy = y0 as i64 - halo as i64 + j as i64;
183                for i in 0..p {
184                    let vx = x0 as i64 - halo as i64 + i as i64;
185                    if inside(vx, vy, vz) {
186                        let s = vx as usize + nx * (vy as usize + ny * vz as usize);
187                        let d = i + p * (j + p * k);
188                        fpatch[d] = field[s];
189                        mpatch[d] = mask[s];
190                    }
191                }
192            }
193        }
194        let pgrid = Grid::new(p, p, p, vsx, vsy, vsz);
195        let chi = run_patch(&fpatch, &mpatch, &pgrid)?;
196        if chi.len() != p * p * p {
197            return Err(OnnxError::Run(format!(
198                "tiled algorithm returned {} voxels, expected {}", chi.len(), p * p * p
199            )));
200        }
201        // Extract the central core (column-major) at offset `halo`.
202        let mut core = vec![0.0f64; cx * cy * cz];
203        for oi in 0..cx {
204            for oj in 0..cy {
205                for ok in 0..cz {
206                    let src = (halo + oi) + p * ((halo + oj) + p * (halo + ok));
207                    core[(oi * cy + oj) * cz + ok] = chi[src];
208                }
209            }
210        }
211        Ok(core)
212    };
213
214    tiled_scatter(grid, mask, cfg, run_tile, progress)
215}
216
217/// Run a fully-convolutional field→χ ONNX net patch-by-patch with a context halo, bounding
218/// peak memory to a single patch.
219///
220/// Values are fed/read as f32 in the x-outer `NCDHW` layout the nets use; `pre` maps each
221/// input field value, `post` maps each raw net output value. Each patch is zero-padded to a
222/// multiple of `divisor` (the net's `size_divisor`) and to include the halo. The result is
223/// masked, matching the whole-volume wrappers.
224#[allow(clippy::too_many_arguments)]
225pub fn tiled_field_inversion(
226    field: &[f64],
227    mask: &[u8],
228    grid: &Grid,
229    model: &OnnxModel,
230    divisor: usize,
231    cfg: &TileConfig,
232    pre: impl Fn(f64) -> f32 + Sync,
233    post: impl Fn(f32) -> f64 + Sync,
234    progress: impl FnMut(usize, usize),
235) -> Result<Vec<f64>, OnnxError> {
236    let (nx, ny, nz) = grid.dims;
237    assert_eq!(field.len(), nx * ny * nz, "field length must match grid");
238    let halo = cfg.halo;
239    let (nxi, nyi, nzi) = (nx as i64, ny as i64, nz as i64);
240
241    // Field sampler: zero outside the volume (edge context / padding).
242    let at = |x: i64, y: i64, z: i64| -> f64 {
243        if x >= 0 && x < nxi && y >= 0 && y < nyi && z >= 0 && z < nzi {
244            field[x as usize + nx * (y as usize + ny * z as usize)]
245        } else {
246            0.0
247        }
248    };
249
250    // One fixed patch shape for the whole run → the graph is optimized once and reused.
251    let p = tile_patch_size(cfg, divisor);
252    let plan = model.plan_for(&[&[1, 1, p, p, p]])?;
253
254    // Per-patch: build the p³ input (NCDHW, x outer, z inner) sampling at (x0-halo+i, …), run the
255    // net, and return the central core (offset `halo`) post-processed.
256    let run_tile = move |&(x0, y0, z0, cx, cy, cz): &Tile| -> Result<Vec<f64>, OnnxError> {
257        let mut inp = vec![0.0f32; p * p * p];
258        for i in 0..p {
259            let vx = x0 as i64 - halo as i64 + i as i64;
260            for j in 0..p {
261                let vy = y0 as i64 - halo as i64 + j as i64;
262                let base = (i * p + j) * p;
263                for k in 0..p {
264                    let vz = z0 as i64 - halo as i64 + k as i64;
265                    inp[base + k] = pre(at(vx, vy, vz));
266                }
267            }
268        }
269        let out = plan.run_single(&Tensor::new(vec![1, 1, p, p, p], inp))?;
270        if out.shape != [1, 1, p, p, p] {
271            return Err(OnnxError::Run(format!(
272                "unexpected patch output shape {:?}, expected [1,1,{p},{p},{p}]",
273                out.shape
274            )));
275        }
276        let mut core_block = vec![0.0f64; cx * cy * cz];
277        for oi in 0..cx {
278            for oj in 0..cy {
279                for ok in 0..cz {
280                    let src = ((halo + oi) * p + (halo + oj)) * p + (halo + ok);
281                    core_block[(oi * cy + oj) * cz + ok] = post(out.data[src]);
282                }
283            }
284        }
285        Ok(core_block)
286    };
287
288    tiled_scatter(grid, mask, cfg, run_tile, progress)
289}