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}