Skip to main content

qsm_core/segment/
synthseg.rs

1//! SynthSeg whole-brain segmentation (`onnx` feature).
2//!
3//! SynthSeg is a 3D U-Net trained purely on synthetic images sampled from anatomical label maps
4//! with fully randomised contrast and resolution. That domain randomisation is what makes it
5//! contrast-agnostic: one set of weights segments any MR contrast without retraining, which is
6//! why it is the whole-brain parcellation that can be run directly on a GRE magnitude image
7//! rather than on a separately acquired T1w scan.
8//!
9//! This module is a port of the non-robust path of `SynthSeg/predict_synthseg.py` around the
10//! exported network (`synthseg.onnx`, see [`crate::models`]):
11//!
12//! 1. resample to 1 mm isotropic (anti-aliased, linear) if the input is not already there;
13//! 2. permute and flip the axes into RAS orientation using the NIfTI affine;
14//! 3. optionally centre-crop, then rescale the 0.5–99.5 percentile range to `[0, 1]`;
15//! 4. pad to a multiple of 32 (at least 128) and run the network, giving one posterior map per
16//!    label;
17//! 5. blur the posteriors, and average them with a left-right flipped second pass;
18//! 6. keep the largest connected component of the foreground and of each topological class,
19//!    renormalise, take the argmax, and undo the crop and the orientation change.
20//!
21//! **Feed it magnitude, not susceptibility.** On a χ map the white/grey boundary is faint and
22//! the brainstem is badly under-segmented (Dice ≈0.70 against the same subject's magnitude
23//! segmentation); on the echo-combined GRE magnitude the structure volumes come out in the
24//! normal physiological range. For multi-echo data the root-sum-of-squares over echoes works.
25//!
26//! The default whole-volume run reproduces FreeSurfer's `mri_synthseg` (SynthSeg 2.0,
27//! non-robust): on three 1 mm scans (a 184×256×128 GRE magnitude, a 180×241×131 GRE magnitude
28//! and a 176×240×256 MP2RAGE), every label's Dice against it is at least 0.9999 and the soft
29//! volumes agree to 0.03 % on average.
30//!
31//! Memory is the practical constraint: the network takes about 0.95 KB per voxel of its input at
32//! once (2.2 GB at 128³, 6.0 GB on a 184×256×128 scan padded to 192×256×128, measured natively),
33//! more than a 32-bit WASM heap (4 GB) holds. The whole volume needed 6–12 GB on those scans,
34//! with or without the head crop and half-precision posteriors, because the network's
35//! activations dominate; no whole-volume setting fits the browser. Three settings bound it:
36//!
37//! - [`SynthSegInference::SlidingWindow`] runs the network on overlapping cubic windows whose
38//!   posteriors are blended with a Gaussian weight, as nnU-Net does, so the network's memory
39//!   follows the window instead of the volume. This is what makes the browser possible, at an
40//!   accuracy cost set by the window's size (see [`SynthSegInference::LOW_MEMORY`]).
41//! - [`SynthSegParams::head_crop`] runs everything (network, posteriors, post-processing) on the
42//!   head's bounding box plus a margin instead of the padded field of view.
43//! - [`PosteriorPrecision::F16`] keeps the posteriors (`n_labels` values per voxel, the one
44//!   whole-volume buffer the windows cannot bound) at half precision.
45//!
46//! [`SynthSegParams::low_memory`] combines them for the browser.
47//!
48//! # Reference pipeline
49//!
50//! From the SynthSeg repository (BBillot/SynthSeg 2a2aa3b):
51//!
52//! - **Contrast and resolution.** Any contrast, "any resolution up to 10mm slice spacing"
53//!   (`README.md:5-7`); inputs are resampled to 1 mm (step 1). The reference returns its
54//!   segmentation at 1 mm isotropic (`README.md:105-106,122`); this port maps it back onto the
55//!   input grid (nearest neighbour), and on 1 mm scans matches `mri_synthseg` as above.
56//! - **Orientation.** Needs the image's affine to reorient to RAS (step 2); `synthseg` takes it
57//!   as an argument (the NIfTI sform/qform, as carried by `ScanMetadata::affine`). It does not
58//!   use the B0 direction.
59//! - **Robust model not ported.** `--robust` (SynthSeg-robust) is the authors' variant for scans
60//!   with low SNR or tissue contrast, or clinical data with large slice spacing
61//!   (`README.md:67-70,96-97`; `scripts/commands/SynthSeg_predict.py:38`). Only the non-robust
62//!   network is ported, so thick-slice or low-SNR GRE magnitudes get the model the authors
63//!   suggest replacing for them.
64//! - **Defaults.** Non-robust, flip averaging and topology cleanup on (`--fast` turns both off),
65//!   posterior smoothing 0.5, no crop: [`SynthSegParams::default`] matches.
66//! - **WASM** (4 GiB heap). No whole-volume setting fits (see above); [`SynthSegParams::low_memory`]
67//!   (96³ windows) does. 128³ windows fit only network grids up to about 192×256×128
68//!   ([`SynthSegInference::LOW_MEMORY`]); [`synthseg_network_grid`] gives the grid for a scan.
69//!
70//! Reference:
71//! Billot, B., Greve, D.N., Puonti, O., et al. (2023). "SynthSeg: Segmentation of brain MRI
72//! scans of any contrast and resolution without retraining." Medical Image Analysis, 86:102789.
73//! <https://doi.org/10.1016/j.media.2023.102789>
74//!
75//! Reference implementation: <https://github.com/BBillot/SynthSeg> (Apache-2.0).
76//!
77//! # Pipeline rules
78//!
79//! The facts above that a configuration can depart from are published as rules in
80//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `synthseg.wasm-windows`, `synthseg.wasm-128-window`.
81
82// The pre/post-processing is plain Rust; only inference needs `onnx`.
83#![cfg_attr(not(feature = "onnx"), allow(dead_code))]
84
85use crate::geometry::AxisCode;
86use crate::grid::Grid;
87#[cfg(feature = "onnx")]
88use crate::models::onnx::{OnnxError, OnnxModel, Tensor};
89use crate::segment::labels::SynthSegVersion;
90use crate::utils::connected::{largest_component, Connectivity};
91use crate::utils::resample::resize;
92use crate::utils::morphology::signal_erosion::gaussian_filter_anisotropic;
93#[cfg(feature = "parallel")]
94use rayon::prelude::*;
95
96/// Network down-sampling factor: the padded input must be a multiple of this on every axis.
97const DIVISOR: usize = 32;
98/// The U-Net's max-pooling lattice: four 2× poolings, so a window must start on a multiple of 16
99/// voxels from the whole-volume input's origin to pool the same voxels together, and any input
100/// to the network must be a multiple of 16 on every axis.
101const LATTICE: usize = 16;
102/// Minimum padded size per axis, as in the SynthSeg CLI when `--crop` is not given.
103const MIN_PAD: usize = 128;
104/// Target resolution (mm).
105const TARGET_RES: f64 = 1.0;
106/// Normalised intensity (the 0.5–99.5 percentile range mapped to `[0, 1]`) above which a voxel
107/// counts as head when [`SynthSegParams::head_crop`] looks for the head's bounding box.
108const HEAD_THRESHOLD: f32 = 0.1;
109
110/// How the network is applied to the preprocessed (1 mm, RAS, padded) volume.
111#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
112#[derive(Clone, Copy, Debug, PartialEq)]
113pub enum SynthSegInference {
114    /// One network pass over the whole padded volume, as the SynthSeg CLI runs it; the output
115    /// matches FreeSurfer's `mri_synthseg` (every label Dice ≥ 0.9999). Peak memory grows with the
116    /// volume (about 0.95 KB per padded voxel): 6–12 GB on 1 mm head scans, so it does not fit a
117    /// 4 GB WASM heap even with the head crop and half-precision posteriors.
118    FullVolume,
119    /// Gaussian-weighted sliding window: the network runs on cubes of `patch` voxels (at least
120    /// 64, rounded up to a multiple of 16) that overlap their neighbours by at least `overlap`
121    /// voxels, and each voxel's posteriors are the Gaussian-weighted (σ = patch/8) mean over the
122    /// windows that cover it. Peak memory for the network follows the patch. An axis the volume
123    /// does not exceed is covered by one window; a volume one window covers runs as `FullVolume`
124    /// does.
125    ///
126    /// Window origins are on the network's max-pooling lattice (multiples of 16 voxels from the
127    /// whole-volume input's origin), so every window pools the same voxels together that the
128    /// whole-volume pass does; the step between windows is a multiple of 16 no larger than
129    /// `patch − overlap`, spread evenly over the axis. Against windows at a step of half a patch
130    /// this gave no measurable gain on three 1 mm scans: at 128³ those steps were already on the
131    /// lattice, and at 96³ the lowest label rose on one scan (Dice 0.948 → 0.963) and fell on
132    /// another (0.937 → 0.928). The error at 96³ comes from the window's limited context, not
133    /// from pooling phase. The lattice is kept because it costs nothing and is the placement
134    /// under which a window sees exactly what the whole-volume pass sees.
135    ///
136    /// The overlap is a minimum in voxels, so the window count is explicit. At 96³, an overlap
137    /// of 64 instead of 48 raised mean Dice by up to 0.003 on two of the three scans and cut the
138    /// worst label's volume error (9.8 → 6.1 %), but needs 1.5–2× the windows and about 1.6× the
139    /// time. At 128³ the two give the same windows on 192- and 256-voxel axes.
140    SlidingWindow {
141        /// Edge of the cubic patch, voxels at 1 mm.
142        patch: usize,
143        /// Minimum overlap between neighbouring windows, voxels (less than `patch`).
144        overlap: usize,
145    },
146}
147
148impl SynthSegInference {
149    /// The memory-bounded window, for a 32-bit WASM heap (see [`SynthSegParams::low_memory`]):
150    /// 96³ patches overlapping by at least 48. Against FreeSurfer's `mri_synthseg` (which the
151    /// whole volume matches) on three 1 mm scans: mean Dice over the labels 0.984–0.988, lowest
152    /// label 0.93–0.96, single labels' volumes off by up to 6–10 % (the inferior lateral
153    /// ventricles and the cerebellum). 128³ windows reach 0.993–0.996 (lowest 0.98, volumes
154    /// within 3 %) but, even with half-precision posteriors and the head crop, took a 184×256×128
155    /// scan (padded to 192×256×128) to 3.83 GB of the 4 GB heap; a larger grid does not fit, so
156    /// 128³ is an option only for grids up to about 192×256×128. Overlap 48 rather than 64: on
157    /// 192- and 256-voxel axes it places as many windows as a step of half a patch, where 64
158    /// needs 1.5–2× as many (see [`SynthSegInference::SlidingWindow`]).
159    pub const LOW_MEMORY: Self = Self::SlidingWindow { patch: 96, overlap: 48 };
160}
161
162/// Storage for the posteriors, `n_labels` values per voxel of the network's grid.
163#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
164#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
165pub enum PosteriorPrecision {
166    /// Single precision, as the SynthSeg CLI keeps them.
167    #[default]
168    F32,
169    /// Half precision (IEEE binary16, about 3 significant digits): half the memory. The
170    /// arithmetic stays in single precision; only the stored values are rounded. With
171    /// `FullVolume` and flip averaging, the first pass's posteriors are rounded to half
172    /// precision before the flipped pass runs, so the two passes are never held in full
173    /// precision at once.
174    ///
175    /// No accuracy cost on three 1 mm scans: with windows, Dice and soft volumes match single
176    /// precision to 4 digits; on the whole volume the lowest label's Dice against single
177    /// precision is 0.9993. It saved 0.4 GB natively on a 192×256×128 grid (0.8 GB on
178    /// 192×256×256) and 0.39 GB of WASM heap with 96³ windows (2418 → 2025 MB). Conversions go
179    /// through slices (see `Posterior::load`), so it runs as fast as single precision.
180    /// 16-bit fixed point on `[0, 1]` was tried as an alternative and rejected: it rounds the
181    /// many tiny posteriors to zero, shifting small labels' soft volumes by up to 0.4 %.
182    F16,
183}
184
185/// SynthSeg inference parameters.
186#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
187#[derive(Clone, Debug, PartialEq)]
188pub struct SynthSegParams {
189    /// Which generation of weights is being run — this selects the label table, so it must match
190    /// the `.onnx` being passed in.
191    pub version: SynthSegVersion,
192    /// Optional cubic centre-crop, in voxels at 1 mm, applied before inference (rounded up to a
193    /// multiple of 32). `None` — the default, and the SynthSeg CLI's — processes the whole
194    /// volume. Note that the CLI's `--crop` help text claims a default of 192, but its argparse
195    /// default is in fact `None`; matching the CLI means cropping nothing.
196    pub crop: Option<usize>,
197    /// Average the posteriors with a left-right flipped second pass (SynthSeg's default).
198    /// Doubles inference time; `--fast` turns it off.
199    pub flip_averaging: bool,
200    /// Reset each topological class to its largest connected component (SynthSeg's default).
201    /// `--fast` turns it off.
202    pub topology_cleanup: bool,
203    /// Gaussian blur applied to the posteriors, in voxels (SynthSeg's default is 0.5). Zero
204    /// disables it.
205    pub sigma_smoothing: f64,
206    /// Whole-volume (default, the CLI's) or sliding-window inference.
207    pub inference: SynthSegInference,
208    /// Crop the padded volume to the head before inference: the bounding box of the largest
209    /// connected region brighter than 10 % of the normalised intensity range, grown by this
210    /// many voxels on every side and rounded out to the pooling lattice. Everything outside it
211    /// is background. `None` (the default, and the CLI's) runs on the whole padded volume.
212    ///
213    /// It saves memory and time only where there is empty space around the head (wide-FOV
214    /// acquisitions, e.g. a 256³ field of view). When the head fills the field of view, as on
215    /// typical GRE and MP2RAGE head scans (and their neck), the box plus margin is the whole
216    /// padded volume and the run is identical to `None`, at no extra cost.
217    pub head_crop: Option<usize>,
218    /// How the posteriors are stored (the default, single precision, is the CLI's).
219    pub posteriors: PosteriorPrecision,
220}
221
222impl Default for SynthSegParams {
223    fn default() -> Self {
224        Self {
225            version: SynthSegVersion::default(),
226            crop: None,
227            flip_averaging: true,
228            topology_cleanup: true,
229            sigma_smoothing: 0.5,
230            inference: SynthSegInference::FullVolume,
231            head_crop: None,
232            posteriors: PosteriorPrecision::F32,
233        }
234    }
235}
236
237impl SynthSegParams {
238    /// SynthSeg's `--fast`: no flip averaging and no topological cleanup. Roughly halves the
239    /// runtime for a small accuracy cost.
240    pub fn fast() -> Self {
241        Self { flip_averaging: false, topology_cleanup: false, ..Self::default() }
242    }
243
244    /// Memory-bounded setting for constrained hosts (e.g. 32-bit WASM): the defaults with
245    /// [`SynthSegInference::LOW_MEMORY`] windows, a 16-voxel head crop and half-precision
246    /// posteriors.
247    ///
248    /// - The windows are what bring the network under a 4 GB heap; no whole-volume setting
249    ///   fits. 96³ rather than 128³ because 128³ fits only grids up to about 192×256×128.
250    /// - The head crop saves memory and time on wide-FOV scans and changes nothing (at no cost)
251    ///   when the head fills the field of view.
252    /// - Half-precision posteriors save 0.4 GB with no accuracy loss: peak WASM heap 2025 MB
253    ///   against 2418 MB at single precision on a 184×256×128 head, 2138 against 2634 MB on a
254    ///   180×241×131 one.
255    ///
256    /// It runs no slower than 96³ windows at a step of half a patch (the earlier browser
257    /// setting): both place the same number of windows on 192×256×128 and 192×256×160 grids,
258    /// and in back-to-back runs on those two grids it took 0.96–1.00× that setting's time
259    /// natively (median of 3 pairs each, 1 and 8 threads) and 0.98–1.00× in a single-threaded
260    /// WASM build under Node (about 17 and 26 minutes). Converting half-precision values one
261    /// at a time had made it 1–6 % slower natively; see `Posterior::load`.
262    pub fn low_memory() -> Self {
263        Self {
264            inference: SynthSegInference::LOW_MEMORY,
265            head_crop: Some(16),
266            posteriors: PosteriorPrecision::F16,
267            ..Self::default()
268        }
269    }
270}
271
272/// The outcome of a SynthSeg run.
273pub struct SynthSegResult {
274    /// FreeSurfer label id per voxel on the input grid, column-major `(nx, ny, nz)`.
275    pub labels: Vec<i32>,
276    /// Volume in mm³ per label, parallel to [`SynthSegVersion::labels`]`().ids`. These are
277    /// partial-volume aware — the sum of the soft posteriors, as SynthSeg reports them, not a
278    /// count of `labels`. The background entry is always zero.
279    pub volumes: Vec<f64>,
280}
281
282/// Whole-brain segmentation via SynthSeg.
283///
284/// * `magnitude` — magnitude image, column-major `(nx, ny, nz)`. Not a susceptibility map; see
285///   the module docs.
286/// * `affine` — the image's NIfTI affine (row-major 4×4), used to find the RAS orientation.
287/// * `model_onnx` — bytes of the exported `synthseg.onnx` (see [`crate::models`]).
288/// * `progress(done, total)` — called after each network evaluation.
289#[cfg(feature = "onnx")]
290pub fn synthseg(
291    magnitude: &[f64],
292    grid: &Grid,
293    affine: &[f64; 16],
294    model_onnx: &[u8],
295    params: &SynthSegParams,
296    mut progress: impl FnMut(usize, usize),
297) -> Result<SynthSegResult, OnnxError> {
298    let (nx, ny, nz) = grid.dims;
299    assert_eq!(magnitude.len(), nx * ny * nz, "magnitude length must match grid");
300    if params.sigma_smoothing < 0.0 {
301        return Err(OnnxError::Shape("sigma_smoothing must not be negative".into()));
302    }
303    let n_labels = params.version.n_labels();
304
305    let window = match params.inference {
306        SynthSegInference::FullVolume => None,
307        SynthSegInference::SlidingWindow { patch, overlap } => {
308            if patch < 64 {
309                return Err(OnnxError::Shape(format!("the SynthSeg patch must be at least 64 voxels, not {patch}")));
310            }
311            let patch = round_up(patch, LATTICE);
312            if overlap >= patch {
313                return Err(OnnxError::Shape(format!(
314                    "the SynthSeg window overlap must be less than the patch ({patch}), not {overlap}"
315                )));
316            }
317            Some((patch, overlap))
318        }
319    };
320
321    let pre = preprocess(magnitude, grid, affine, params);
322    let model = OnnxModel::load(model_onnx)?;
323    let flip = params.flip_averaging.then(|| params.version.labels().flip);
324    match params.posteriors {
325        PosteriorPrecision::F32 => infer::<f32>(&model, &pre, n_labels, window, flip, params, &mut progress),
326        PosteriorPrecision::F16 => infer::<half::f16>(&model, &pre, n_labels, window, flip, params, &mut progress),
327    }
328}
329
330/// Network, flip averaging and post-processing, with the posteriors stored as `T`.
331#[cfg(feature = "onnx")]
332fn infer<T: Posterior>(
333    model: &OnnxModel,
334    pre: &Preprocessed,
335    n_labels: usize,
336    window: Option<(usize, usize)>,
337    flip: Option<&[usize]>,
338    params: &SynthSegParams,
339    progress: &mut impl FnMut(usize, usize),
340) -> Result<SynthSegResult, OnnxError> {
341    // A volume one window covers runs as a whole volume does.
342    if let Some((patch, overlap)) = window.filter(|&(patch, _)| pre.dims.iter().any(|&d| d > patch)) {
343        let mut post: Vec<T> = run_windows(model, &pre.data, pre.dims, n_labels, patch, overlap, flip, progress)?;
344        if params.sigma_smoothing > 0.0 {
345            blur_posteriors(&mut post, pre.dims, n_labels, params.sigma_smoothing);
346        }
347        return Ok(postprocess(post, pre, params));
348    }
349
350    let passes = 1 + flip.is_some() as usize;
351    let post = run_pass(model, &pre.data, pre.dims, n_labels, params)?;
352    progress(1, passes);
353    // At half precision the first pass is rounded (and its single-precision copy freed) before
354    // the second runs.
355    let mut post = T::store(post);
356    if let Some(table) = flip {
357        let flipped = flip_axis0(&pre.data, pre.dims, 1);
358        let post_f = run_pass(model, &flipped, pre.dims, n_labels, params)?;
359        drop(flipped);
360        progress(2, passes);
361        merge_flipped(&mut post, &post_f, pre.dims, n_labels, table);
362    }
363    Ok(postprocess(post, pre, params))
364}
365
366/// A stored posterior value: `f32`, or `half::f16` for [`PosteriorPrecision::F16`]. Arithmetic
367/// is in `f32` either way.
368trait Posterior: Copy + Default + Send + Sync + 'static {
369    fn get(self) -> f32;
370    fn put(v: f32) -> Self;
371    /// Convert a single-precision buffer (no copy when `Self` is `f32`).
372    fn store(v: Vec<f32>) -> Vec<Self>;
373    /// `dst[i] = src[i].get()`, in bulk.
374    fn load(src: &[Self], dst: &mut [f32]) {
375        dst.iter_mut().zip(src).for_each(|(d, s)| *d = s.get());
376    }
377    /// `dst[i] = put(src[i])`, in bulk.
378    fn save(src: &[f32], dst: &mut [Self]) {
379        dst.iter_mut().zip(src).for_each(|(d, &s)| *d = Self::put(s));
380    }
381}
382
383impl Posterior for f32 {
384    #[inline]
385    fn get(self) -> f32 {
386        self
387    }
388    #[inline]
389    fn put(v: f32) -> Self {
390        v
391    }
392    fn store(v: Vec<f32>) -> Vec<Self> {
393        v
394    }
395    fn load(src: &[Self], dst: &mut [f32]) {
396        dst.copy_from_slice(src);
397    }
398    fn save(src: &[f32], dst: &mut [Self]) {
399        dst.copy_from_slice(src);
400    }
401}
402
403#[cfg(feature = "onnx")]
404impl Posterior for half::f16 {
405    #[inline]
406    fn get(self) -> f32 {
407        self.to_f32()
408    }
409    #[inline]
410    fn put(v: f32) -> Self {
411        half::f16::from_f32(v)
412    }
413    fn store(v: Vec<f32>) -> Vec<Self> {
414        let mut out = vec![half::f16::ZERO; v.len()];
415        Self::save(&v, &mut out);
416        out
417    }
418    // The slice conversions use the CPU's half-precision instructions (F16C on x86-64) several
419    // values at a time; converting one value per call costs a feature check and a function call
420    // each, which made half-precision posteriors 2–7 % slower than single precision natively.
421    fn load(src: &[Self], dst: &mut [f32]) {
422        use half::slice::HalfFloatSliceExt;
423        src.convert_to_f32_slice(dst);
424    }
425    fn save(src: &[f32], dst: &mut [Self]) {
426        use half::slice::HalfFloatSliceExt;
427        dst.convert_from_f32_slice(src);
428    }
429}
430
431/// Average the posteriors of the left-right flipped pass `flipped` (still mirrored along axis 0)
432/// into `post`, mapping each channel to its left-right partner `table[c]`. In place: the
433/// mirrored copy is never materialised.
434#[cfg(feature = "onnx")]
435fn merge_flipped<T: Posterior>(post: &mut [T], flipped: &[f32], dims: [usize; 3], n_labels: usize, table: &[usize]) {
436    let plane = dims[1] * dims[2] * n_labels;
437    crate::maybe_par_chunks_mut!(post, plane).enumerate().for_each(|(o0, out)| {
438        let src = &flipped[(dims[0] - 1 - o0) * plane..(dims[0] - o0) * plane];
439        for (o, f) in out.chunks_exact_mut(n_labels).zip(src.chunks_exact(n_labels)) {
440            for c in 0..n_labels {
441                o[c] = T::put(0.5 * (o[c].get() + f[table[c]]));
442            }
443        }
444    });
445}
446
447/// The network input plus everything needed to map predictions back onto the caller's grid.
448struct Preprocessed {
449    /// Normalised network input in RAS orientation, C-order `[d0, d1, d2]`: the padded volume,
450    /// or the head's box in it.
451    data: Vec<f32>,
452    dims: [usize; 3],
453    /// Shape of the RAS-aligned volume before cropping and padding.
454    aligned_dims: [usize; 3],
455    /// Where the network grid's first voxel sits in the RAS-aligned volume, per axis (negative
456    /// where the grid starts in padding).
457    origin: [isize; 3],
458    /// The network-grid range that holds aligned data, `[start, end)` per axis: inside the
459    /// padding, and inside any crop.
460    valid: [(usize, usize); 3],
461    /// `perm[i]` is the pre-alignment axis that became RAS axis `i`.
462    perm: [usize; 3],
463    /// Whether RAS axis `i` was flipped during alignment.
464    flip: [bool; 3],
465    /// Shape of the volume the alignment consumed (post-resampling, pre-alignment).
466    source_dims: [usize; 3],
467    /// Resolution the network saw, mm.
468    res: f64,
469    /// The caller's grid, for mapping the result back.
470    out_dims: (usize, usize, usize),
471    /// Whether a resample to 1 mm happened (the label map is returned on the caller's grid
472    /// either way).
473    resampled: bool,
474}
475
476// ----------------------------------------------------------------------- orientation
477
478/// The axis permutation and per-axis flips that take a volume into RAS orientation.
479///
480/// Returns `(perm, flip)` where RAS axis `i` is the source volume's axis `perm[i]`, reversed
481/// when `flip[i]`. Equivalent to lab2im's `align_volume_to_ref(..., aff_ref=eye(4))`.
482fn ras_alignment(affine: &[f64; 16]) -> ([usize; 3], [bool; 3]) {
483    crate::geometry::alignment_to(affine, [AxisCode::R, AxisCode::A, AxisCode::S])
484}
485
486// ----------------------------------------------------------------------- preprocessing
487
488fn round_up(n: usize, m: usize) -> usize {
489    if n.is_multiple_of(m) { n } else { (n / m + 1) * m }
490}
491
492/// The padded 1 mm grid SynthSeg's network runs on for a scan on `grid`, in the scan's voxel axis
493/// order: resampled to 1 mm (unless within 0.05 mm already), centre-cropped by
494/// [`SynthSegParams::crop`], and padded to a multiple of 32 and at least 128 per axis, as
495/// `preprocess` does. [`SynthSegParams::head_crop`] can only shrink it, so with a head crop this
496/// is an upper bound. This is the grid the memory figures in [`SynthSegInference::LOW_MEMORY`]
497/// refer to (e.g. 192×256×128 for a 184×256×128 1 mm scan).
498pub fn synthseg_network_grid(grid: &Grid, params: &SynthSegParams) -> [usize; 3] {
499    let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
500    let dims = [grid.nx(), grid.ny(), grid.nz()];
501    let resample = vs.iter().any(|&v| (v - TARGET_RES).abs() > 0.05);
502    let min_pad = params.crop.map(|c| round_up(c, DIVISOR)).unwrap_or(MIN_PAD);
503    std::array::from_fn(|i| {
504        let n = if resample { ((dims[i] as f64) * vs[i] / TARGET_RES).ceil().max(1.0) as usize } else { dims[i] };
505        let n = params.crop.map_or(n, |c| round_up(c, DIVISOR).min(n));
506        round_up(n, DIVISOR).max(round_up(min_pad, DIVISOR))
507    })
508}
509
510/// `np.percentile(sorted, q)` with linear interpolation between order statistics.
511fn percentile(sorted_scratch: &mut [f32], q: f64) -> f64 {
512    let n = sorted_scratch.len();
513    if n == 0 {
514        return 0.0;
515    }
516    let pos = q / 100.0 * (n - 1) as f64;
517    let lo = pos.floor() as usize;
518    let frac = pos - lo as f64;
519    let a = *sorted_scratch.select_nth_unstable_by(lo, |x, y| x.total_cmp(y)).1 as f64;
520    if frac == 0.0 || lo + 1 >= n {
521        return a;
522    }
523    // Everything above index `lo` is already partitioned to the right; the next order statistic
524    // is the minimum of that tail.
525    let b = sorted_scratch[lo + 1..].iter().copied().fold(f32::INFINITY, f32::min) as f64;
526    a + frac * (b - a)
527}
528
529fn preprocess(
530    magnitude: &[f64],
531    grid: &Grid,
532    affine: &[f64; 16],
533    params: &SynthSegParams,
534) -> Preprocessed {
535    let (perm, flip) = ras_alignment(affine);
536    let out_dims = grid.dims;
537
538    // 1. resample to 1 mm isotropic if needed (SynthSeg's 0.05 mm tolerance).
539    let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
540    let needs_resample = vs.iter().any(|&v| (v - TARGET_RES).abs() > 0.05);
541    let (source, source_dims_cm) = if needs_resample {
542        resample_to_iso(magnitude, grid)
543    } else {
544        (magnitude.to_vec(), [grid.nx(), grid.ny(), grid.nz()])
545    };
546
547    // 2. align to RAS, writing straight into the C-order layout the network wants.
548    let aligned_dims = [source_dims_cm[perm[0]], source_dims_cm[perm[1]], source_dims_cm[perm[2]]];
549    let mut data = align_to_ras(&source, source_dims_cm, aligned_dims, perm, flip);
550
551    // 3. centre-crop.
552    let mut dims = aligned_dims;
553    let crop = match params.crop {
554        Some(c) => {
555            let want = round_up(c, DIVISOR);
556            let mut bounds = [(0, 0); 3];
557            for i in 0..3 {
558                let keep = want.min(dims[i]);
559                let start = (dims[i] - keep) / 2;
560                bounds[i] = (start, start + keep);
561            }
562            data = crop_c_order(&data, dims, bounds, 1);
563            dims = [bounds[0].1 - bounds[0].0, bounds[1].1 - bounds[1].0, bounds[2].1 - bounds[2].0];
564            bounds
565        }
566        None => [(0, dims[0]), (0, dims[1]), (0, dims[2])],
567    };
568
569    // 4. rescale the robust intensity range to [0, 1].
570    let mut scratch = data.clone();
571    let lo = percentile(&mut scratch, 0.5);
572    let mut scratch = data.clone();
573    let hi = percentile(&mut scratch, 99.5);
574    if hi > lo {
575        let scale = 1.0 / (hi - lo);
576        for v in data.iter_mut() {
577            *v = (((*v as f64).clamp(lo, hi) - lo) * scale) as f32;
578        }
579    } else {
580        data.iter_mut().for_each(|v| *v = 0.0);
581    }
582
583    // 5. pad to a multiple of 32, at least 128 per axis.
584    let min_pad = params.crop.map(|c| round_up(c, DIVISOR)).unwrap_or(MIN_PAD);
585    let padded: [usize; 3] =
586        std::array::from_fn(|i| round_up(dims[i], DIVISOR).max(round_up(min_pad, DIVISOR)));
587    let mut pad = [(0, 0); 3];
588    for i in 0..3 {
589        let start = (padded[i] - dims[i]) / 2;
590        pad[i] = (start, start + dims[i]);
591    }
592    let mut data = pad_c_order(&data, dims, padded, pad, 1);
593    let mut net_dims = padded;
594    let mut origin: [isize; 3] = std::array::from_fn(|i| crop[i].0 as isize - pad[i].0 as isize);
595    let mut valid = pad;
596
597    // 6. optionally crop to the head. The box's corners are on the pooling lattice of the
598    //    padded volume, so windows placed from the box's origin stay in phase with it.
599    if let Some(bounds) = params.head_crop.and_then(|margin| head_box(&data, padded, margin)) {
600        data = crop_c_order(&data, padded, bounds, 1);
601        net_dims = std::array::from_fn(|i| bounds[i].1 - bounds[i].0);
602        for i in 0..3 {
603            origin[i] += bounds[i].0 as isize;
604            let start = pad[i].0.max(bounds[i].0);
605            let end = pad[i].1.min(bounds[i].1).max(start);
606            valid[i] = (start - bounds[i].0, end - bounds[i].0);
607        }
608    }
609
610    Preprocessed {
611        data,
612        dims: net_dims,
613        aligned_dims,
614        origin,
615        valid,
616        perm,
617        flip,
618        source_dims: source_dims_cm,
619        res: TARGET_RES,
620        out_dims,
621        resampled: needs_resample,
622    }
623}
624
625/// The head's bounding box in the normalised, padded C-order volume `data`: the largest
626/// face-connected region above [`HEAD_THRESHOLD`], grown by `margin` voxels on every side and
627/// rounded out to multiples of [`LATTICE`] (`dims` are multiples of it already). `None` when
628/// nothing is above the threshold or the box is the whole volume.
629fn head_box(data: &[f32], dims: [usize; 3], margin: usize) -> Option<[(usize, usize); 3]> {
630    let above: Vec<u8> = data.iter().map(|&v| (v > HEAD_THRESHOLD) as u8).collect();
631    // 6-connectivity is symmetric under axis permutation: the C-order volume is column-major
632    // with the axes reversed.
633    let head = largest_component(&above, (dims[2], dims[1], dims[0]), Connectivity::Face);
634    drop(above);
635    let mut lo = dims;
636    let mut hi = [0usize; 3];
637    for (l, _) in head.iter().enumerate().filter(|(_, &h)| h != 0) {
638        let at = [l / (dims[1] * dims[2]), (l / dims[2]) % dims[1], l % dims[2]];
639        for i in 0..3 {
640            lo[i] = lo[i].min(at[i]);
641            hi[i] = hi[i].max(at[i] + 1);
642        }
643    }
644    if hi.contains(&0) {
645        return None;
646    }
647    let bounds: [(usize, usize); 3] = std::array::from_fn(|i| {
648        let start = lo[i].saturating_sub(margin) / LATTICE * LATTICE;
649        let end = round_up((hi[i] + margin).min(dims[i]), LATTICE).min(dims[i]);
650        (start, end)
651    });
652    (bounds != [(0, dims[0]), (0, dims[1]), (0, dims[2])]).then_some(bounds)
653}
654
655/// Resample a column-major volume to 1 mm isotropic, matching lab2im's `resample_volume`:
656/// an anti-aliasing Gaussian when down-sampling, then linear interpolation on a half-pixel grid.
657fn resample_to_iso(magnitude: &[f64], grid: &Grid) -> (Vec<f64>, [usize; 3]) {
658    let vs = [grid.vsx(), grid.vsy(), grid.vsz()];
659    let dims = [grid.nx(), grid.ny(), grid.nz()];
660    let factor: [f64; 3] = std::array::from_fn(|i| vs[i] / TARGET_RES);
661    let sigma: [f64; 3] =
662        std::array::from_fn(|i| if factor[i] > 1.0 { 0.0 } else { 0.25 / factor[i] });
663    let blurred = gaussian_filter_anisotropic(magnitude, grid.dims, sigma);
664    let new_dims: [usize; 3] =
665        std::array::from_fn(|i| ((dims[i] as f64) * factor[i]).ceil().max(1.0) as usize);
666    // `resize` works in C-order; a column-major (nx, ny, nz) volume is C-order [nz, ny, nx].
667    let out = resize(
668        &blurred,
669        [dims[2], dims[1], dims[0]],
670        [new_dims[2], new_dims[1], new_dims[0]],
671        1,
672    );
673    (out, new_dims)
674}
675
676/// Gather a column-major `(nx, ny, nz)` volume into a C-order RAS-oriented `f32` buffer.
677fn align_to_ras(
678    source: &[f64],
679    source_dims: [usize; 3],
680    dims: [usize; 3],
681    perm: [usize; 3],
682    flip: [bool; 3],
683) -> Vec<f32> {
684    let (nx, ny) = (source_dims[0], source_dims[1]);
685    let nxy = nx * ny;
686    let strides = [1usize, nx, nxy];
687    let mut out = vec![0f32; dims[0] * dims[1] * dims[2]];
688    // Source offset contributed by each output axis, and its step.
689    let mut base = 0usize;
690    let mut step = [0isize; 3];
691    for i in 0..3 {
692        let s = strides[perm[i]] as isize;
693        if flip[i] {
694            base += (dims[i] - 1) * strides[perm[i]];
695            step[i] = -s;
696        } else {
697            step[i] = s;
698        }
699    }
700    crate::maybe_par_chunks_mut!(out, dims[1] * dims[2]).enumerate().for_each(|(o0, plane)| {
701        let row0 = base as isize + step[0] * o0 as isize;
702        for o1 in 0..dims[1] {
703            let row = row0 + step[1] * o1 as isize;
704            let dst = &mut plane[o1 * dims[2]..(o1 + 1) * dims[2]];
705            for (o2, d) in dst.iter_mut().enumerate() {
706                *d = source[(row + step[2] * o2 as isize) as usize] as f32;
707            }
708        }
709    });
710    out
711}
712
713/// Scatter a C-order RAS-oriented volume back to column-major `(nx, ny, nz)`.
714fn unalign_from_ras<T: Copy>(
715    aligned: &[T],
716    dims: [usize; 3],
717    out_dims: [usize; 3],
718    perm: [usize; 3],
719    flip: [bool; 3],
720    out: &mut [T],
721) {
722    let (nx, ny) = (out_dims[0], out_dims[1]);
723    let nxy = nx * ny;
724    let strides = [1usize, nx, nxy];
725    let mut base = 0usize;
726    let mut step = [0isize; 3];
727    for i in 0..3 {
728        let s = strides[perm[i]] as isize;
729        if flip[i] {
730            base += (dims[i] - 1) * strides[perm[i]];
731            step[i] = -s;
732        } else {
733            step[i] = s;
734        }
735    }
736    for o0 in 0..dims[0] {
737        let row0 = base as isize + step[0] * o0 as isize;
738        for o1 in 0..dims[1] {
739            let row = row0 + step[1] * o1 as isize;
740            let src = &aligned[(o0 * dims[1] + o1) * dims[2]..(o0 * dims[1] + o1 + 1) * dims[2]];
741            for (o2, &v) in src.iter().enumerate() {
742                out[(row + step[2] * o2 as isize) as usize] = v;
743            }
744        }
745    }
746}
747
748/// Crop a C-order `[d0, d1, d2]` volume of `n_ch`-element voxels to `bounds`.
749fn crop_c_order<T: Copy + Default>(
750    data: &[T],
751    dims: [usize; 3],
752    bounds: [(usize, usize); 3],
753    n_ch: usize,
754) -> Vec<T> {
755    let out_dims: [usize; 3] = std::array::from_fn(|i| bounds[i].1 - bounds[i].0);
756    let mut out = vec![T::default(); out_dims.iter().product::<usize>() * n_ch];
757    for o0 in 0..out_dims[0] {
758        for o1 in 0..out_dims[1] {
759            let src = ((o0 + bounds[0].0) * dims[1] + o1 + bounds[1].0) * dims[2] + bounds[2].0;
760            let dst = (o0 * out_dims[1] + o1) * out_dims[2];
761            out[dst * n_ch..(dst + out_dims[2]) * n_ch]
762                .copy_from_slice(&data[src * n_ch..(src + out_dims[2]) * n_ch]);
763        }
764    }
765    out
766}
767
768/// Zero-pad a C-order `[d0, d1, d2]` volume of `n_ch`-element voxels so the data lands at `at`.
769fn pad_c_order<T: Copy + Default>(
770    data: &[T],
771    dims: [usize; 3],
772    out_dims: [usize; 3],
773    at: [(usize, usize); 3],
774    n_ch: usize,
775) -> Vec<T> {
776    let mut out = vec![T::default(); out_dims.iter().product::<usize>() * n_ch];
777    for o0 in 0..dims[0] {
778        for o1 in 0..dims[1] {
779            let src = (o0 * dims[1] + o1) * dims[2];
780            let dst = ((o0 + at[0].0) * out_dims[1] + o1 + at[1].0) * out_dims[2] + at[2].0;
781            out[dst * n_ch..(dst + dims[2]) * n_ch]
782                .copy_from_slice(&data[src * n_ch..(src + dims[2]) * n_ch]);
783        }
784    }
785    out
786}
787
788/// Reverse axis 0 of a C-order `[d0, d1, d2]` volume of `n_ch`-element voxels.
789fn flip_axis0(data: &[f32], dims: [usize; 3], n_ch: usize) -> Vec<f32> {
790    let plane = dims[1] * dims[2] * n_ch;
791    let mut out = vec![0f32; data.len()];
792    for o0 in 0..dims[0] {
793        let src = (dims[0] - 1 - o0) * plane;
794        out[o0 * plane..(o0 + 1) * plane].copy_from_slice(&data[src..src + plane]);
795    }
796    out
797}
798
799// ----------------------------------------------------------------------- inference
800
801#[cfg(feature = "onnx")]
802fn run_pass(
803    model: &OnnxModel,
804    data: &[f32],
805    dims: [usize; 3],
806    n_labels: usize,
807    params: &SynthSegParams,
808) -> Result<Vec<f32>, OnnxError> {
809    let input = Tensor::new(vec![1, dims[0], dims[1], dims[2], 1], data.to_vec());
810    let out = model.run_single(&input)?;
811    let expect = [1, dims[0], dims[1], dims[2], n_labels];
812    if out.shape != expect {
813        return Err(OnnxError::Shape(format!(
814            "SynthSeg returned {:?}, expected {:?} — do the weights match params.version?",
815            out.shape, expect
816        )));
817    }
818    let mut post = out.data;
819    if params.sigma_smoothing > 0.0 {
820        blur_posteriors(&mut post, dims, n_labels, params.sigma_smoothing);
821    }
822    Ok(post)
823}
824
825/// Window origins along an axis of `size` voxels for windows of `p` (both multiples of
826/// [`LATTICE`]): the first at 0, the last at `size − p`, and the fewest windows whose steps — each a
827/// multiple of the lattice, as even as the lattice allows — leave at least `overlap` voxels shared
828/// between neighbours.
829fn lattice_windows(size: usize, p: usize, overlap: usize) -> Vec<usize> {
830    debug_assert!(size.is_multiple_of(LATTICE) && p.is_multiple_of(LATTICE), "{size} {p}");
831    if size <= p {
832        return vec![0];
833    }
834    let span = (size - p) / LATTICE;
835    let max_step = (p.saturating_sub(overlap) / LATTICE).max(1);
836    let steps = span.div_ceil(max_step);
837    (0..=steps).map(|i| i * span / steps * LATTICE).collect()
838}
839
840/// Sliding-window posteriors (channels-last, `[d0, d1, d2, n_labels]`) of the network input
841/// `data` (dims multiples of [`LATTICE`]): the network on each `patch`-cube window (clamped to
842/// the volume on an axis it does not exceed), origins on the pooling lattice
843/// ([`lattice_windows`]), blended with nnU-Net's Gaussian importance. With `flip` (the label
844/// table's left-right partners), each window is also run mirrored along axis 0 and its
845/// posteriors, mirrored back with each channel mapped to its partner, averaged in, as the
846/// whole-volume flip averaging does. The blur is the caller's, once, on the blended posteriors.
847///
848/// The blending weights are summed before any window runs, so each window's posteriors go
849/// straight into the normalised mean: the only whole-volume buffers are the posteriors (as `T`)
850/// and one weight per voxel.
851#[cfg(feature = "onnx")]
852#[allow(clippy::too_many_arguments)]
853fn run_windows<T: Posterior>(
854    model: &OnnxModel,
855    data: &[f32],
856    dims: [usize; 3],
857    n_labels: usize,
858    patch: usize,
859    overlap: usize,
860    flip: Option<&[usize]>,
861    progress: &mut impl FnMut(usize, usize),
862) -> Result<Vec<T>, OnnxError> {
863    use crate::bet::hdbet::gaussian_importance;
864    let p: [usize; 3] = std::array::from_fn(|a| patch.min(dims[a]));
865    let starts: [Vec<usize>; 3] = std::array::from_fn(|a| lattice_windows(dims[a], p[a], overlap));
866    let mut origins = Vec::new();
867    for &s0 in &starts[0] {
868        for &s1 in &starts[1] {
869            for &s2 in &starts[2] {
870                origins.push([s0, s1, s2]);
871            }
872        }
873    }
874    let gauss = gaussian_importance(p);
875    let shape = [1, p[0], p[1], p[2], 1];
876    let plan = model.plan_for(&[&shape])?;
877    let passes = 1 + flip.is_some() as usize;
878    let total = origins.len() * passes;
879    let mut done = 0;
880
881    // Calls `f(volume offset, window offset)` for the start of each row of the window at `o`.
882    let rows = |o: [usize; 3], f: &mut dyn FnMut(usize, usize)| {
883        for z in 0..p[0] {
884            for y in 0..p[1] {
885                f(((o[0] + z) * dims[1] + o[1] + y) * dims[2] + o[2], (z * p[1] + y) * p[2]);
886            }
887        }
888    };
889
890    // 1 / (the summed Gaussian weight of the windows covering each voxel).
891    let n: usize = dims.iter().product();
892    let mut inv_weight = vec![0f32; n];
893    for &o in &origins {
894        rows(o, &mut |v, w| {
895            for k in 0..p[2] {
896                inv_weight[v + k] += gauss[w + k];
897            }
898        });
899    }
900    inv_weight.iter_mut().for_each(|w| *w = if *w > 0.0 { 1.0 / *w } else { 0.0 });
901
902    let mut post = vec![T::default(); n * n_labels];
903    let mut input = vec![0f32; p.iter().product()];
904    let mut row = vec![0f32; p[2] * n_labels];
905    let run = |x: Vec<f32>| -> Result<Vec<f32>, OnnxError> {
906        let out = plan.run_single(&Tensor::new(shape.to_vec(), x))?;
907        let expect = [1, p[0], p[1], p[2], n_labels];
908        if out.shape != expect {
909            return Err(OnnxError::Shape(format!(
910                "SynthSeg returned {:?}, expected {:?} — do the weights match params.version?",
911                out.shape, expect
912            )));
913        }
914        Ok(out.data)
915    };
916    for &o in &origins {
917        rows(o, &mut |v, w| input[w..w + p[2]].copy_from_slice(&data[v..v + p[2]]));
918        let mut pred = run(input.clone())?;
919        done += 1;
920        progress(done, total);
921        if let Some(table) = flip {
922            let back = flip_axis0(&run(flip_axis0(&input, p, 1))?, p, n_labels);
923            done += 1;
924            progress(done, total);
925            for (out, chunk) in pred.chunks_exact_mut(n_labels).zip(back.chunks_exact(n_labels)) {
926                for c in 0..n_labels {
927                    out[c] = 0.5 * (out[c] + chunk[table[c]]);
928                }
929            }
930        }
931        // Row by row through a single-precision copy, so half-precision rows convert in bulk.
932        rows(o, &mut |v, w| {
933            let dst = &mut post[v * n_labels..(v + p[2]) * n_labels];
934            T::load(dst, &mut row);
935            for k in 0..p[2] {
936                let g = gauss[w + k] * inv_weight[v + k];
937                let (a, b) = (k * n_labels, (w + k) * n_labels);
938                for c in 0..n_labels {
939                    row[a + c] += g * pred[b + c];
940                }
941            }
942            T::save(&row, dst);
943        });
944    }
945    Ok(post)
946}
947
948/// Per-channel separable Gaussian blur of a `[d0, d1, d2, n_ch]` posterior map.
949///
950/// Matches lab2im's `GaussianBlur`: kernel width `2·ceil(2.5σ)/2 + 1`, normalised, applied with
951/// zero padding at the borders (TensorFlow's `SAME`) — note that is zero padding, not the
952/// reflection [`gaussian_filter_anisotropic`] uses.
953fn blur_posteriors<T: Posterior>(post: &mut [T], dims: [usize; 3], n_ch: usize, sigma: f64) {
954    let width = ((2.5 * sigma).ceil() as usize / 2) * 2 + 1;
955    if width <= 1 {
956        return;
957    }
958    let radius = width / 2;
959    let mut w: Vec<f32> = (0..width)
960        .map(|i| {
961            let x = i as f64 - (width - 1) as f64 / 2.0;
962            (-(x * x) / (2.0 * sigma * sigma)).exp() as f32
963        })
964        .collect();
965    let s: f32 = w.iter().sum();
966    w.iter_mut().for_each(|v| *v /= s);
967
968    // [d0, d1, d2, n_ch] seen as [outer, n, inner] for each axis in turn.
969    blur_along(post, 1, dims[0], dims[1] * dims[2] * n_ch, &w, radius);
970    blur_along(post, dims[0], dims[1], dims[2] * n_ch, &w, radius);
971    blur_along(post, dims[0] * dims[1], dims[2], n_ch, &w, radius);
972}
973
974/// Convolve `data`, viewed as `[outer, n, inner]`, along the middle axis with zero padding.
975fn blur_along<T: Posterior>(data: &mut [T], outer: usize, n: usize, inner: usize, w: &[f32], radius: usize) {
976    if n <= 1 {
977        return;
978    }
979    let stride = n * inner;
980    let taps = 2 * radius + 1;
981    crate::maybe_par_chunks_mut!(data, stride).take(outer).for_each(|line| {
982        // Single-precision copies of the last `taps` input rows (a ring), so a row is read
983        // before it is overwritten and each row converts once, in bulk.
984        let mut history = vec![0f32; taps * inner];
985        let mut acc = vec![0f32; inner];
986        for i in 0..n + radius {
987            if i < n {
988                let slot = (i % taps) * inner;
989                T::load(&line[i * inner..(i + 1) * inner], &mut history[slot..slot + inner]);
990            }
991            if i < radius {
992                continue;
993            }
994            // Output row `o` needs input rows `o − radius ..= o + radius = i`, all in the ring.
995            let o = i - radius;
996            acc.fill(0.0);
997            for (t, &wt) in w.iter().enumerate() {
998                let src = o as isize + t as isize - radius as isize;
999                if src < 0 || src as usize >= n {
1000                    continue; // zero padding
1001                }
1002                let slot = (src as usize % taps) * inner;
1003                for (a, &h) in acc.iter_mut().zip(&history[slot..slot + inner]) {
1004                    *a += wt * h;
1005                }
1006            }
1007            T::save(&acc, &mut line[o * inner..(o + 1) * inner]);
1008        }
1009    });
1010}
1011
1012// ----------------------------------------------------------------------- postprocessing
1013
1014#[cfg(feature = "onnx")]
1015fn postprocess<T: Posterior>(mut post: Vec<T>, pre: &Preprocessed, params: &SynthSegParams) -> SynthSegResult {
1016    let labels = params.version.labels();
1017    let n_labels = labels.ids.len();
1018    let dims = pre.dims;
1019    let n_vox = dims.iter().product::<usize>();
1020    // Connected-component work happens on the network's grid; 6-connectivity is symmetric under
1021    // axis permutation, so the C-order volume can be handed over as column-major reversed.
1022    let cc_dims = (dims[2], dims[1], dims[0]);
1023
1024    // Foreground: everything but the background channel, thresholded and reduced to one blob.
1025    let mut fg = vec![0u8; n_vox];
1026    for (v, chunk) in fg.iter_mut().zip(post.chunks_exact(n_labels)) {
1027        *v = (chunk[1..].iter().map(|p| p.get()).sum::<f32>() > 0.25) as u8;
1028    }
1029    let fg = largest_component(&fg, cc_dims, Connectivity::Face);
1030    for (keep, chunk) in fg.iter().zip(post.chunks_exact_mut(n_labels)) {
1031        if *keep == 0 {
1032            chunk[1..].fill(T::default());
1033        }
1034    }
1035    drop(fg);
1036
1037    if params.topology_cleanup {
1038        let max_class = labels.topology.iter().copied().max().unwrap_or(0);
1039        for class in 1..=max_class {
1040            let members: Vec<usize> =
1041                (0..n_labels).filter(|&c| labels.topology[c] == class).collect();
1042            if members.is_empty() {
1043                continue;
1044            }
1045            let mut mask = vec![0u8; n_vox];
1046            for (v, chunk) in mask.iter_mut().zip(post.chunks_exact(n_labels)) {
1047                *v = members.iter().any(|&c| chunk[c].get() > 0.25) as u8;
1048            }
1049            let mask = largest_component(&mask, cc_dims, Connectivity::Face);
1050            for (keep, chunk) in mask.iter().zip(post.chunks_exact_mut(n_labels)) {
1051                if *keep == 0 {
1052                    for &c in &members {
1053                        chunk[c] = T::default();
1054                    }
1055                }
1056            }
1057        }
1058    } else {
1059        // `--fast` keeps only the posteriors that clear a slightly lower bar.
1060        for chunk in post.chunks_exact_mut(n_labels) {
1061            for c in chunk.iter_mut().skip(1) {
1062                if c.get() <= 0.2 {
1063                    *c = T::default();
1064                }
1065            }
1066        }
1067    }
1068
1069    // Over the voxels that hold data (the padding dropped): renormalise, take the argmax, and
1070    // sum the soft volumes.
1071    let valid = pre.valid;
1072    let valid_dims: [usize; 3] = std::array::from_fn(|i| valid[i].1 - valid[i].0);
1073    let mut volumes = vec![0f64; n_labels];
1074    let mut hard = Vec::with_capacity(valid_dims.iter().product::<usize>());
1075    let mut p = vec![0f32; n_labels];
1076    for o0 in valid[0].0..valid[0].1 {
1077        for o1 in valid[1].0..valid[1].1 {
1078            let row = ((o0 * dims[1] + o1) * dims[2] + valid[2].0) * n_labels;
1079            for chunk in post[row..row + valid_dims[2] * n_labels].chunks_exact(n_labels) {
1080                p.iter_mut().zip(chunk).for_each(|(p, c)| *p = c.get());
1081                let total: f32 = p.iter().sum();
1082                if total > 0.0 {
1083                    p.iter_mut().for_each(|v| *v /= total);
1084                }
1085                let mut best = 0usize;
1086                for c in 1..n_labels {
1087                    if p[c] > p[best] {
1088                        best = c;
1089                    }
1090                }
1091                hard.push(labels.ids[best]);
1092                // Accumulate in f64: the reference sums ~10^6 f32 posteriors per structure and
1093                // loses about 0.4 % to rounding doing so.
1094                for c in 1..n_labels {
1095                    volumes[c] += p[c] as f64;
1096                }
1097            }
1098        }
1099    }
1100    drop(post);
1101    let voxel_volume = pre.res * pre.res * pre.res;
1102    volumes.iter_mut().for_each(|v| *v *= voxel_volume);
1103
1104    // Place the valid block in the RAS-aligned volume (background around it: the crop, and
1105    // anything outside a head crop), then undo the alignment.
1106    let at: [(usize, usize); 3] = std::array::from_fn(|i| {
1107        let start = (valid[i].0 as isize + pre.origin[i]) as usize;
1108        (start, start + valid_dims[i])
1109    });
1110    let aligned = if valid_dims == pre.aligned_dims {
1111        hard
1112    } else {
1113        let mut out = vec![labels.ids[0]; pre.aligned_dims.iter().product()];
1114        for o0 in 0..valid_dims[0] {
1115            for o1 in 0..valid_dims[1] {
1116                let src = (o0 * valid_dims[1] + o1) * valid_dims[2];
1117                let dst = ((o0 + at[0].0) * pre.aligned_dims[1] + o1 + at[1].0) * pre.aligned_dims[2] + at[2].0;
1118                out[dst..dst + valid_dims[2]].copy_from_slice(&hard[src..src + valid_dims[2]]);
1119            }
1120        }
1121        out
1122    };
1123
1124    let mut source = vec![labels.ids[0]; pre.source_dims.iter().product::<usize>()];
1125    unalign_from_ras(&aligned, pre.aligned_dims, pre.source_dims, pre.perm, pre.flip, &mut source);
1126
1127    let out_dims = [pre.out_dims.0, pre.out_dims.1, pre.out_dims.2];
1128    let labels_out = if pre.resampled {
1129        resample_labels_nearest(&source, pre.source_dims, out_dims)
1130    } else {
1131        source
1132    };
1133
1134    SynthSegResult { labels: labels_out, volumes }
1135}
1136
1137/// Nearest-neighbour resample of a column-major label volume back onto the caller's grid.
1138fn resample_labels_nearest(src: &[i32], dims: [usize; 3], out_dims: [usize; 3]) -> Vec<i32> {
1139    let mut out = vec![0i32; out_dims.iter().product()];
1140    let scale: [f64; 3] = std::array::from_fn(|i| dims[i] as f64 / out_dims[i] as f64);
1141    for z in 0..out_dims[2] {
1142        let sz = (((z as f64 + 0.5) * scale[2] - 0.5).round().max(0.0) as usize).min(dims[2] - 1);
1143        for y in 0..out_dims[1] {
1144            let sy =
1145                (((y as f64 + 0.5) * scale[1] - 0.5).round().max(0.0) as usize).min(dims[1] - 1);
1146            for x in 0..out_dims[0] {
1147                let sx = (((x as f64 + 0.5) * scale[0] - 0.5).round().max(0.0) as usize)
1148                    .min(dims[0] - 1);
1149                out[x + out_dims[0] * (y + out_dims[1] * z)] =
1150                    src[sx + dims[0] * (sy + dims[1] * sz)];
1151            }
1152        }
1153    }
1154    out
1155}
1156
1157#[cfg(test)]
1158mod tests {
1159    use super::*;
1160
1161    /// A radiological-ish affine: +i is left, +j anterior, +k superior (the qsmxt example's).
1162    const LAS: [f64; 16] = [
1163        -1.0, 0.0, 0.0, 91.0, 0.0, 1.0, 0.0, -113.5, 0.0, 0.0, 1.0, -39.7, 0.0, 0.0, 0.0, 1.0,
1164    ];
1165
1166    #[test]
1167    fn las_affine_flips_only_the_left_right_axis() {
1168        let (perm, flip) = ras_alignment(&LAS);
1169        assert_eq!(perm, [0, 1, 2]);
1170        assert_eq!(flip, [true, false, false]);
1171    }
1172
1173    #[test]
1174    fn identity_affine_is_already_ras() {
1175        let mut aff = [0.0; 16];
1176        aff[0] = 1.0;
1177        aff[5] = 1.0;
1178        aff[10] = 1.0;
1179        aff[15] = 1.0;
1180        assert_eq!(ras_alignment(&aff), ([0, 1, 2], [false; 3]));
1181    }
1182
1183    #[test]
1184    fn permuted_affine_is_detected() {
1185        // +i superior, +j left, +k posterior.
1186        let mut aff = [0.0; 16];
1187        aff[8] = 1.0; // world S from axis 0
1188        aff[1] = -1.0; // world R from axis 1, negated
1189        aff[6] = -1.0; // world A from axis 2, negated
1190        aff[15] = 1.0;
1191        let (perm, flip) = ras_alignment(&aff);
1192        assert_eq!(perm, [1, 2, 0], "R, A, S should come from axes 1, 2, 0");
1193        assert_eq!(flip, [true, true, false]);
1194    }
1195
1196    #[test]
1197    fn alignment_round_trips() {
1198        let dims = [3usize, 4, 5];
1199        let n = dims.iter().product::<usize>();
1200        let src: Vec<f64> = (0..n).map(|i| i as f64).collect();
1201        let (perm, flip) = ras_alignment(&LAS);
1202        let aligned_dims = [dims[perm[0]], dims[perm[1]], dims[perm[2]]];
1203        let aligned = align_to_ras(&src, dims, aligned_dims, perm, flip);
1204        let mut back = vec![0f32; n];
1205        unalign_from_ras(&aligned, aligned_dims, dims, perm, flip, &mut back);
1206        let expected: Vec<f32> = src.iter().map(|&v| v as f32).collect();
1207        assert_eq!(back, expected);
1208    }
1209
1210    #[test]
1211    fn alignment_round_trips_under_permutation() {
1212        let dims = [3usize, 4, 5];
1213        let n = dims.iter().product::<usize>();
1214        let src: Vec<f64> = (0..n).map(|i| i as f64).collect();
1215        let (perm, flip) = ([2usize, 0, 1], [true, false, true]);
1216        let aligned_dims = [dims[perm[0]], dims[perm[1]], dims[perm[2]]];
1217        let aligned = align_to_ras(&src, dims, aligned_dims, perm, flip);
1218        assert_eq!(aligned.len(), n);
1219        let mut back = vec![0f32; n];
1220        unalign_from_ras(&aligned, aligned_dims, dims, perm, flip, &mut back);
1221        let expected: Vec<f32> = src.iter().map(|&v| v as f32).collect();
1222        assert_eq!(back, expected);
1223    }
1224
1225    #[test]
1226    fn percentile_matches_linear_interpolation() {
1227        let make = || (0..101).map(|i| i as f32).collect::<Vec<_>>();
1228        assert!((percentile(&mut make(), 0.0) - 0.0).abs() < 1e-6);
1229        assert!((percentile(&mut make(), 100.0) - 100.0).abs() < 1e-6);
1230        assert!((percentile(&mut make(), 50.0) - 50.0).abs() < 1e-6);
1231        // np.percentile(np.arange(101), 0.5) == 0.5
1232        assert!((percentile(&mut make(), 0.5) - 0.5).abs() < 1e-6);
1233        assert!((percentile(&mut make(), 99.5) - 99.5).abs() < 1e-6);
1234    }
1235
1236    #[test]
1237    fn round_up_hits_multiples_of_the_divisor() {
1238        assert_eq!(round_up(128, DIVISOR), 128);
1239        assert_eq!(round_up(129, DIVISOR), 160);
1240        assert_eq!(round_up(184, DIVISOR), 192);
1241        assert_eq!(round_up(256, DIVISOR), 256);
1242    }
1243
1244    #[test]
1245    fn crop_and_pad_are_inverses() {
1246        let dims = [4usize, 5, 6];
1247        let n = dims.iter().product::<usize>();
1248        let src: Vec<f32> = (0..n).map(|i| i as f32).collect();
1249        let bounds = [(1, 3), (1, 4), (2, 5)];
1250        let cropped = crop_c_order(&src, dims, bounds, 1);
1251        let inner: [usize; 3] = std::array::from_fn(|i| bounds[i].1 - bounds[i].0);
1252        assert_eq!(cropped.len(), inner.iter().product::<usize>());
1253        let back = pad_c_order(&cropped, inner, dims, bounds, 1);
1254        for o0 in bounds[0].0..bounds[0].1 {
1255            for o1 in bounds[1].0..bounds[1].1 {
1256                for o2 in bounds[2].0..bounds[2].1 {
1257                    let i = (o0 * dims[1] + o1) * dims[2] + o2;
1258                    assert_eq!(back[i], src[i]);
1259                }
1260            }
1261        }
1262    }
1263
1264    #[test]
1265    fn blur_preserves_a_constant_interior() {
1266        // A 3-tap normalised kernel on a constant field reproduces it away from the borders.
1267        let dims = [8usize, 8, 8];
1268        let n_ch = 2;
1269        let mut post = vec![1f32; dims.iter().product::<usize>() * n_ch];
1270        blur_posteriors(&mut post, dims, n_ch, 0.5);
1271        let at = |a: usize, b: usize, c: usize, ch: usize| ((a * dims[1] + b) * dims[2] + c) * n_ch + ch;
1272        assert!((post[at(4, 4, 4, 0)] - 1.0).abs() < 1e-5);
1273        assert!((post[at(4, 4, 4, 1)] - 1.0).abs() < 1e-5);
1274        // Zero padding means the corner loses weight.
1275        assert!(post[at(0, 0, 0, 0)] < 0.95);
1276    }
1277
1278    #[test]
1279    fn blur_keeps_channels_independent() {
1280        let dims = [6usize, 6, 6];
1281        let n_ch = 3;
1282        let mut post = vec![0f32; dims.iter().product::<usize>() * n_ch];
1283        let at = |a: usize, b: usize, c: usize, ch: usize| ((a * dims[1] + b) * dims[2] + c) * n_ch + ch;
1284        post[at(3, 3, 3, 1)] = 1.0;
1285        blur_posteriors(&mut post, dims, n_ch, 0.5);
1286        assert!(post[at(3, 3, 3, 1)] > 0.0, "channel 1 should carry the blurred impulse");
1287        for a in 0..dims[0] {
1288            for b in 0..dims[1] {
1289                for c in 0..dims[2] {
1290                    assert_eq!(post[at(a, b, c, 0)], 0.0, "channel 0 leaked");
1291                    assert_eq!(post[at(a, b, c, 2)], 0.0, "channel 2 leaked");
1292                }
1293            }
1294        }
1295    }
1296
1297    #[cfg(feature = "onnx")]
1298    #[test]
1299    fn a_window_under_64_or_an_overlap_of_the_whole_patch_is_refused() {
1300        let grid = Grid::new(4, 4, 4, 1.0, 1.0, 1.0);
1301        let mag = vec![1.0; 64];
1302        let aff = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
1303        for (patch, overlap) in [(63, 0), (32, 0), (128, 128), (100, 112)] {
1304            let params = SynthSegParams { inference: SynthSegInference::SlidingWindow { patch, overlap }, ..SynthSegParams::default() };
1305            let err = synthseg(&mag, &grid, &aff, &[], &params, |_, _| {}).err().expect("refused");
1306            assert!(matches!(err, OnnxError::Shape(_)), "{patch} {overlap}: {err:?}");
1307        }
1308    }
1309
1310    #[test]
1311    fn low_memory_is_the_defaults_with_windows_a_head_crop_and_half_precision() {
1312        let p = SynthSegParams::low_memory();
1313        assert_eq!(p.inference, SynthSegInference::LOW_MEMORY);
1314        assert_eq!(p.posteriors, PosteriorPrecision::F16);
1315        assert!(p.head_crop.is_some());
1316        let whole = SynthSegParams { inference: SynthSegInference::FullVolume, head_crop: None, posteriors: PosteriorPrecision::F32, ..p };
1317        assert_eq!(whole, SynthSegParams::default());
1318    }
1319
1320    #[test]
1321    fn windows_sit_on_the_pooling_lattice_and_keep_the_overlap() {
1322        for (size, p, overlap) in [(256, 96, 48), (256, 96, 64), (192, 96, 48), (256, 128, 48), (256, 128, 64), (176, 128, 32), (240, 112, 0)] {
1323            let w = lattice_windows(size, p, overlap);
1324            assert_eq!(w[0], 0);
1325            assert_eq!(*w.last().unwrap(), size - p, "{size} {p} {overlap}: {w:?}");
1326            assert!(w.iter().all(|o| o % LATTICE == 0), "{w:?}");
1327            for pair in w.windows(2) {
1328                assert!(pair[1] > pair[0] && p - (pair[1] - pair[0]) >= overlap, "{size} {p} {overlap}: {w:?}");
1329            }
1330            // No window could be dropped: one fewer step would have to exceed the largest allowed.
1331            let max_step = (p - overlap) / LATTICE * LATTICE;
1332            assert!(w.len() == 2 || (size - p) > (w.len() - 2) * max_step, "{size} {p} {overlap}: {w:?}");
1333        }
1334        assert_eq!(lattice_windows(256, 96, 48), vec![0, 32, 80, 112, 160]);
1335        assert_eq!(lattice_windows(192, 96, 48), vec![0, 48, 96]);
1336        assert_eq!(lattice_windows(256, 128, 48), vec![0, 64, 128]);
1337        assert_eq!(lattice_windows(96, 96, 48), vec![0]);
1338        assert_eq!(lattice_windows(64, 96, 48), vec![0]);
1339    }
1340
1341    #[test]
1342    fn the_head_box_is_the_largest_bright_region_grown_and_on_the_lattice() {
1343        let dims = [64usize, 96, 128];
1344        let mut data = vec![0f32; dims.iter().product()];
1345        let at = |a: usize, b: usize, c: usize| (a * dims[1] + b) * dims[2] + c;
1346        // The head, and a brighter speck elsewhere that is not connected to it.
1347        for a in 20..40 {
1348            for b in 30..61 {
1349                for c in 40..90 {
1350                    data[at(a, b, c)] = 0.5;
1351                }
1352            }
1353        }
1354        data[at(2, 2, 2)] = 1.0;
1355        let bounds = head_box(&data, dims, 4).expect("a box");
1356        assert_eq!(bounds, [(16, 48), (16, 80), (32, 96)]);
1357        // A margin that reaches the edges is clamped; a box of the whole volume is no crop.
1358        assert_eq!(head_box(&data, dims, 40), None);
1359        assert_eq!(head_box(&vec![0f32; data.len()], dims, 4), None);
1360    }
1361
1362    #[test]
1363    fn blur_at_half_precision_follows_single_precision() {
1364        let dims = [6usize, 7, 8];
1365        let n_ch = 3;
1366        let n = dims.iter().product::<usize>() * n_ch;
1367        let src: Vec<f32> = (0..n).map(|i| ((i * 7919) % 101) as f32 / 101.0).collect();
1368        let mut single = src.clone();
1369        blur_posteriors(&mut single, dims, n_ch, 0.5);
1370        #[cfg(feature = "onnx")]
1371        {
1372            let mut half: Vec<half::f16> = <half::f16 as Posterior>::store(src);
1373            blur_posteriors(&mut half, dims, n_ch, 0.5);
1374            for (a, b) in single.iter().zip(&half) {
1375                assert!((a - b.to_f32()).abs() < 2e-3, "{a} vs {b}");
1376            }
1377        }
1378    }
1379
1380    #[cfg(feature = "onnx")]
1381    #[test]
1382    fn merging_the_flipped_pass_in_place_matches_mirroring_it_first() {
1383        let dims = [5usize, 3, 2];
1384        let n_ch = 3;
1385        let table = [0usize, 2, 1];
1386        let n = dims.iter().product::<usize>() * n_ch;
1387        let post: Vec<f32> = (0..n).map(|i| i as f32 * 0.25).collect();
1388        let flipped: Vec<f32> = (0..n).map(|i| (n - i) as f32 * 0.5).collect();
1389        let mut expected = post.clone();
1390        let mirrored = flip_axis0(&flipped, dims, n_ch);
1391        for (out, chunk) in expected.chunks_exact_mut(n_ch).zip(mirrored.chunks_exact(n_ch)) {
1392            for c in 0..n_ch {
1393                out[c] = 0.5 * (out[c] + chunk[table[c]]);
1394            }
1395        }
1396        let mut got = post;
1397        merge_flipped(&mut got, &flipped, dims, n_ch, &table);
1398        assert_eq!(got, expected);
1399    }
1400
1401    #[test]
1402    fn flip_axis0_is_an_involution() {
1403        let dims = [3usize, 2, 2];
1404        let n_ch = 2;
1405        let n = dims.iter().product::<usize>() * n_ch;
1406        let src: Vec<f32> = (0..n).map(|i| i as f32).collect();
1407        assert_eq!(flip_axis0(&flip_axis0(&src, dims, n_ch), dims, n_ch), src);
1408    }
1409}