Skip to main content

qsm_core/bet/
evolution.rs

1//! BET surface evolution algorithm
2//!
3//! Based on: Smith, S.M. (2002) "Fast robust automated brain extraction"
4//! Human Brain Mapping, 17(3):143-155
5//!
6//! Aligned with FSL-BET2 implementation.
7//!
8//! # Reference pipeline
9//!
10//! - **Defaults.** FSL BET2 (Bostrix/FSL-BET2 d5acd7f, `bet2.cpp`): `-f 0.5` (`:133`), `-g 0`
11//!   (`:136`), smoothness `-w 1` (`:139`), 1000 iterations (`:792`), curvature radii
12//!   3.33–10 mm scaled by the smoothness (`:788-789`), surface from `make_mesh_from_icosa(5, m)`
13//!   (`:772`). [`BetParams::default`] has the same `f`, `g`, smoothness and iterations, and
14//!   `subdivisions: 4`: bet2's `make_mesh_from_icosa(subdivisions + 1)`, 2562 vertices.
15//! - **Input.** BET was developed on structural (T1-, T2-, PD-weighted) head images (Smith
16//!   2002) and works in mm
17//!   (the search distances and curvature limits above), so it assumes human-scale anatomy; see
18//!   [`BetParams::voxel_scale`] for rodents. It needs no orientation: the initial sphere comes
19//!   from the image's intensity centre of gravity. `-g` is a gradient along the voxel z axis.
20//! - **Ported from bet2 step by step** (astewartau/QSM.rs#327):
21//!   - the mesh is bet2's own icosahedron, `make_mesh_from_icosa`: its orientation, the
22//!     triangle order and winding, and `retessellate` ([`super::icosphere::bet2_icosa`]);
23//!   - vertex normals are `Mpoint::local_normal`, the normalised sum of the unnormalised
24//!     triangle normals (area-weighted);
25//!   - intensities are sampled at the nearest voxel, `(int)(p/dim + 0.5)`, not interpolated.
26//!     Inside the search loop this includes newimage's unchecked `value()`, which past an edge
27//!     reads a neighbouring row's voxel;
28//!   - `l` is the mean over vertices of the mean distance to their neighbours, recomputed at the
29//!     start of iterations 0, 50, 100, 200, …;
30//!   - `t2`/`t98` are newimage's `robustmin`/`robustmax` (`find_thresholds`, a histogram method),
31//!     and `tm` is the `⌊n/2⌋`-th smallest value;
32//!   - `-f`, `-w` and `-g` are rounded to single precision, as bet2 reads them;
33//!   - the mask is `make_mask_from_mesh`: the triangles drawn as bet2's `draw_mesh` draws them,
34//!     then a 6-connected spread from the mesh's mean point, with no hole filling;
35//!   - FSL reverses x of a neurologically stored image (positive determinant) before bet2 runs
36//!     ([`FslLeftRightOrder`]).
37//! - **Agreement with bet2** (Bostrix/FSL-BET2 d5acd7f built from source; measured 2026-10-31):
38//!   - **Travelling head:** on `prisma-bridge-run1`'s echo-1 magnitude after ANTs N4, the masks
39//!     are **identical**, at `-f 0.5` and at `-f 0.2` (0 of 6.0 M voxels differ). Before the
40//!     port, Dice was 0.9857 and 0.9916, with QSM.rs's masks 1.25 % and 0.64 % larger.
41//!   - **Synthetic head** of `tests/bet_bet2_parity.rs`: Dice 0.9991 (`-f 0.5`, 5 voxels) and
42//!     1.0000 (`-f 0.2`). That piecewise-constant head is sensitive at this level. Before the
43//!     port: 0.9903 and 0.9922.
44//!   - **Undefined behaviour in bet2:** a sample index outside the whole volume buffer is read
45//!     as 0 here.
46//! - **In QSM pipelines.** MEDI runs BET on `iMag = sqrt(sum(abs(iField).^2,4))`, the RSS over
47//!   echoes, with `f = 0.5` (MEDI_toolbox `README.m:92,111`) or `f = 0.1`
48//!   (`MEDI_full_process.m:109,128`). QSMART runs `bet2 -f 0.2 -m` on the first echo's
49//!   coil-RSS magnitude after N4 (`QSMART_toolbox_v1.0/brainmask.m:7-14`). QSM.jl's README runs
50//!   `bet` on the last echo with `-f 0.5` and erodes the mask by 5 voxels (`README.md:34-38`).
51
52use crate::Grid;
53use super::icosphere::bet2_icosa;
54#[cfg(test)]
55use super::icosphere::create_icosphere;
56use super::mesh::{bet2_vertex_normals, build_neighbor_matrix, self_intersection_heuristic};
57
58/// Brain parameters struct (like FSL's bet_parameters)
59struct BetParameters {
60    t2: f64,       // 2nd percentile (robust min)
61    // `t98` and `cog` are consumed only as intermediates -- `t98` by the `t` threshold and the
62    // `tm` intensity window, `cog` by `cog_mm` -- so the evolution path never reads the stored
63    // fields and the lib target sees them as dead. They are kept because the unit tests below
64    // assert on them directly (that `t98 >= t2`, that `tm` falls between the two, that `cog`
65    // lands on the phantom's centre), which is the only check that `estimate_brain_parameters`
66    // still matches FSL-BET2's `adjust_initial_mesh`. Dropping the fields would delete that.
67    #[allow(dead_code)]
68    t98: f64,      // 98th percentile (robust max)
69    t: f64,        // threshold = t2 + 0.1*(t98-t2)
70    tm: f64,       // median within-brain intensity (critical for proper surface evolution)
71    #[allow(dead_code)]
72    cog: [f64; 3], // center of gravity in voxel coordinates
73    cog_mm: [f64; 3], // center of gravity in mm (for z-gradient)
74    radius: f64,   // estimated brain radius in mm
75}
76
77/// Estimate brain parameters from the image (matches FSL-BET2's adjust_initial_mesh)
78fn estimate_brain_parameters(
79    data: &[f64],
80    nx: usize, ny: usize, nz: usize,
81    voxel_size: &[f64; 3],
82) -> BetParameters {
83    // Collect non-zero values
84    let nonzero: Vec<f64> = data.iter().copied().filter(|&v| v > 0.0).collect();
85
86    if nonzero.is_empty() {
87        let cog = [(nx as f64) / 2.0, (ny as f64) / 2.0, (nz as f64) / 2.0];
88        let cog_mm = [cog[0] * voxel_size[0], cog[1] * voxel_size[1], cog[2] * voxel_size[2]];
89        return BetParameters { t2: 0.0, t98: 1.0, t: 0.1, tm: 0.5, cog, cog_mm, radius: 50.0 };
90    }
91
92    // bet2: newimage's robustmin/robustmax (`find_thresholds`) over the whole volume.
93    let (t2, t98) = find_thresholds(data);
94    let t = t2 + 0.1 * (t98 - t2);
95
96    // Find center of gravity (weighted by intensity, like FSL)
97    let mut sum_x = 0.0;
98    let mut sum_y = 0.0;
99    let mut sum_z = 0.0;
100    let mut sum_weight = 0.0;
101    let mut n_voxels = 0usize;
102
103    // Use Fortran order: index = x + y*nx + z*nx*ny
104    for k in 0..nz {
105        for j in 0..ny {
106            for i in 0..nx {
107                let idx = i + j * nx + k * nx * ny;
108                let val = data[idx];
109                if val > t {
110                    // FSL: weight = min(c, t98 - t2) where c = val - t2
111                    let c = (val - t2).min(t98 - t2);
112                    sum_x += (i as f64) * c;
113                    sum_y += (j as f64) * c;
114                    sum_z += (k as f64) * c;
115                    sum_weight += c;
116                    n_voxels += 1;
117                }
118            }
119        }
120    }
121
122    let cog = if sum_weight > 0.0 {
123        [sum_x / sum_weight, sum_y / sum_weight, sum_z / sum_weight]
124    } else {
125        [(nx as f64) / 2.0, (ny as f64) / 2.0, (nz as f64) / 2.0]
126    };
127
128    // Estimate brain radius
129    let voxel_volume = voxel_size[0] * voxel_size[1] * voxel_size[2];
130    let brain_volume = (n_voxels as f64) * voxel_volume;
131    let radius = (3.0 * brain_volume / (4.0 * std::f64::consts::PI)).powf(1.0 / 3.0);
132
133    // Compute tm: median intensity within a sphere centered at COG with radius = brain radius
134    // This is critical for proper intensity-based surface evolution (FSL bet2.cpp lines 385-403)
135    let cog_mm = [cog[0] * voxel_size[0], cog[1] * voxel_size[1], cog[2] * voxel_size[2]];
136    let radius_sq = radius * radius;
137
138    let mut within_brain_values: Vec<f64> = Vec::new();
139    for k in 0..nz {
140        for j in 0..ny {
141            for i in 0..nx {
142                let idx = i + j * nx + k * nx * ny;
143                let val = data[idx];
144                // Only consider voxels with intensity between t2 and t98
145                if val > t2 && val < t98 {
146                    // Check if within sphere of radius centered at COG
147                    let px = (i as f64) * voxel_size[0];
148                    let py = (j as f64) * voxel_size[1];
149                    let pz = (k as f64) * voxel_size[2];
150                    let dx = px - cog_mm[0];
151                    let dy = py - cog_mm[1];
152                    let dz = pz - cog_mm[2];
153                    let dist_sq = dx * dx + dy * dy + dz * dz;
154                    if dist_sq < radius_sq {
155                        within_brain_values.push(val);
156                    }
157                }
158            }
159        }
160    }
161
162    // Compute median (tm)
163    let tm = if within_brain_values.is_empty() {
164        (t2 + t98) / 2.0 // fallback
165    } else {
166        // bet2: med = floor(n/2); tm = max of the `med` smallest values, i.e. sorted[med - 1].
167        within_brain_values.sort_by(|a, b| a.partial_cmp(b).unwrap());
168        let med = within_brain_values.len() / 2;
169        within_brain_values[med.max(1) - 1]
170    };
171
172    BetParameters { t2, t98, t, tm, cog, cog_mm, radius }
173}
174
175/// newimage's `find_thresholds` (Steve Smith's robust 2 %/98 % limits; FSL-BET2 d5acd7f
176/// `newimage/newimage.cc`), for a `volume<float>`: a 1000-bin histogram between the minimum and
177/// maximum, the bins holding the lowest and highest 2 %, and while those limits span less than a
178/// tenth of the range, the histogram redone between them (up to 10 passes, the last ignoring the
179/// end bins). Returns (`robustmin`, `robustmax`).
180fn find_thresholds(data: &[f64]) -> (f64, f64) {
181    const BINS: i64 = 1000;
182    const MAX_PASSES: i32 = 10;
183    let vol: Vec<f32> = data.iter().map(|&v| v as f32).collect();
184    let vmin = vol.iter().copied().fold(f32::INFINITY, f32::min);
185    let vmax = vol.iter().copied().fold(f32::NEG_INFINITY, f32::max);
186    let (mut min, mut max) = (vmin, vmax);
187    let (mut top_bin, mut bottom_bin, mut pass) = (0i64, 0i64, 1);
188    let (mut lowest_bin, mut highest_bin) = (0i64, BINS - 1);
189    let (mut thresh2, mut thresh98) = (0.0f32, 0.0f32);
190    let mut hist = vec![0i64; BINS as usize];
191    while pass == 1 || ((thresh98 - thresh2) as f64) < ((max - min) as f64) / 10.0 {
192        if pass > 1 {
193            bottom_bin = (bottom_bin - 1).max(0);
194            top_bin = (top_bin + 1).min(BINS - 1);
195            let tmpmin = (min as f64 + (bottom_bin as f64 / BINS as f64) * (max - min) as f64) as f32;
196            max = (min as f64 + ((top_bin + 1) as f64 / BINS as f64) * (max - min) as f64) as f32;
197            min = tmpmin;
198        }
199        if pass == MAX_PASSES || min == max {
200            min = vmin;
201            max = vmax;
202        }
203        // find_histogram
204        hist.iter_mut().for_each(|h| *h = 0);
205        let mut validsize: i64 = if min == max { -1 } else { 0 };
206        if min != max {
207            let fa = BINS as f64 / (max - min) as f64;
208            let fb = (BINS as f64 * (-(min as f64))) / (max - min) as f64;
209            for &v in &vol {
210                let b = ((fa * v as f64 + fb) as i64).clamp(0, BINS - 1);
211                hist[b as usize] += 1;
212                validsize += 1;
213            }
214        }
215        if validsize < 1 {
216            return (min as f64, max as f64);
217        }
218        if pass == MAX_PASSES {
219            validsize -= hist[lowest_bin as usize] + hist[highest_bin as usize];
220            lowest_bin += 1;
221            highest_bin -= 1;
222        }
223        if validsize < 0 {
224            thresh2 = min;
225            thresh98 = min;
226            break;
227        }
228        let fa = (max - min) as f64 / BINS as f64;
229        let mut count = 0i64;
230        bottom_bin = lowest_bin;
231        while count < validsize / 50 {
232            count += hist[bottom_bin as usize];
233            bottom_bin += 1;
234        }
235        bottom_bin -= 1;
236        thresh2 = min + (bottom_bin as f64 * fa) as f32;
237        count = 0;
238        top_bin = highest_bin;
239        while count < validsize / 50 {
240            count += hist[top_bin as usize];
241            top_bin -= 1;
242        }
243        top_bin += 1;
244        thresh98 = min + ((top_bin + 1) as f64 * fa) as f32;
245        if pass == MAX_PASSES {
246            break;
247        }
248        pass += 1;
249    }
250    (thresh2 as f64, thresh98 as f64)
251}
252
253/// Sample min/max intensities along inward normal (matches FSL-BET2's step_of_computation)
254///
255/// FSL samples from 1mm to d1 (7mm) for Imin, and up to d2 (3mm) for Imax.
256/// Initial values: Imin = tm, Imax = t
257/// Final clamps: Imin >= t2, Imax <= tm
258///
259/// point_mm: vertex position in mm coordinates
260/// normal: unit normal vector (outward pointing)
261///
262/// Returns (i_min, i_max, success) where success=false means sampling failed
263/// and the caller should use f3=0 (like FSL does).
264fn sample_intensities_fsl(
265    data: &[f64],
266    nx: usize, ny: usize, nz: usize,
267    point_mm: &[f64; 3],
268    normal: &[f64; 3],
269    voxel_size: &[f64; 3],
270    t2: f64,
271    t: f64,
272    tm: f64,
273) -> (f64, f64, bool) {
274    // bet2.cpp `step_of_computation`: nearest-voxel samples, `(int)(p/dim + 0.5)` (truncated
275    // toward zero), along the inward normal from 1 mm; d1 = 7, d2 = 3.
276    const D1: f64 = 7.0;
277    const D2: f64 = 3.0;
278    let (xdim, ydim, zdim) = (voxel_size[0], voxel_size[1], voxel_size[2]);
279    let dscale = xdim.min(ydim).min(zdim).min(1.0);
280    let in_bounds = |i: i64, j: i64, k: i64| i >= 0 && j >= 0 && k >= 0 && (i as usize) < nx && (j as usize) < ny && (k as usize) < nz;
281    // newimage's `value(x, y, z)` does no bounds check: it reads the voxel at the linear index
282    // `(z·ny + y)·nx + x`, which for an index past an edge is a voxel of a neighbouring row or
283    // slice. That is reproduced; an index outside the whole buffer (undefined behaviour in bet2)
284    // reads 0.
285    let n_total = (nx * ny * nz) as i64;
286    let value = |i: i64, j: i64, k: i64| {
287        let lin = (k * ny as i64 + j) * nx as i64 + i;
288        if (0..n_total).contains(&lin) { data[lin as usize] } else { 0.0 }
289    };
290    let mut i_min = tm;
291    let mut i_max = t;
292    let p = [point_mm[0] - normal[0], point_mm[1] - normal[1], point_mm[2] - normal[2]]; // coord + (-1)·n
293    let (mut iv, mut jv, mut kv) = (p[0] / xdim + 0.5, p[1] / ydim + 0.5, p[2] / zdim + 0.5);
294    if !in_bounds(iv as i64, jv as i64, kv as i64) {
295        return (i_min, i_max, false);
296    }
297    let im = value(iv as i64, jv as i64, kv as i64);
298    i_min = i_min.min(im);
299    i_max = i_max.max(im);
300    let (mut nxv, mut nyv, mut nzv) = (normal[0] / xdim, normal[1] / ydim, normal[2] / zdim);
301    let (i2, j2, k2) = ((iv - (D1 - 1.0) * nxv) as i64, (jv - (D1 - 1.0) * nyv) as i64, (kv - (D1 - 1.0) * nzv) as i64);
302    nxv *= dscale;
303    nyv *= dscale;
304    nzv *= dscale;
305    if !in_bounds(i2, j2, k2) {
306        return (i_min, i_max, false);
307    }
308    let im = value(i2, j2, k2);
309    i_min = i_min.min(im);
310    let mut gi = 2.0;
311    while gi < D1 {
312        iv -= nxv;
313        jv -= nyv;
314        kv -= nzv;
315        let im = value(iv as i64, jv as i64, kv as i64);
316        i_min = i_min.min(im);
317        if gi < D2 {
318            i_max = i_max.max(im);
319        }
320        gi += dscale;
321    }
322    i_min = i_min.max(t2);
323    i_max = i_max.min(tm);
324    (i_min, i_max, true)
325}
326
327/// Convert surface mesh to binary mask using flood fill
328///
329/// vertices_mm: vertex positions in mm coordinates
330/// voxel_size: voxel dimensions in mm
331fn surface_to_mask(
332    vertices_mm: &[[f64; 3]],
333    faces: &[[usize; 3]],
334    nx: usize, ny: usize, nz: usize,
335    voxel_size: &[f64; 3],
336) -> Vec<u8> {
337    // bet2.cpp `make_mask_from_mesh`: a volume of 1s; every triangle drawn as 0s
338    // (`draw_mesh`: points along the edge v1 -> v0 every half of the smallest voxel size, each
339    // joined to v2 by `draw_segment`, samples `floor(p/dim + 0.5)`); then a 6-connected spread
340    // of 0s from the mesh's mean point (truncated to a voxel) through the 1s. Brain is the 0s.
341    let (xdim, ydim, zdim) = (voxel_size[0], voxel_size[1], voxel_size[2]);
342    let mininc = xdim.min(ydim).min(zdim) * 0.5;
343    let mut grid: Vec<u8> = vec![1; nx * ny * nz];
344    let idx = |i: usize, j: usize, k: usize| i + j * nx + k * nx * ny;
345    let set0 = |p: [f64; 3], grid: &mut Vec<u8>| {
346        let (i, j, k) = ((p[0] / xdim + 0.5).floor(), (p[1] / ydim + 0.5).floor(), (p[2] / zdim + 0.5).floor());
347        if i >= 0.0 && j >= 0.0 && k >= 0.0 && (i as usize) < nx && (j as usize) < ny && (k as usize) < nz {
348            grid[idx(i as usize, j as usize, k as usize)] = 0;
349        }
350    };
351    let unit = |a: [f64; 3], b: [f64; 3]| -> ([f64; 3], f64) {
352        let v = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
353        let d = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
354        if d > 0.0 { ([v[0] / d, v[1] / d, v[2] / d], d) } else { (v, d) }
355    };
356    for &[i0, i1, i2] in faces {
357        let (v0, v1, v2) = (vertices_mm[i0], vertices_mm[i1], vertices_mm[i2]);
358        let (n, d) = unit(v0, v1);
359        let mut j = 0.0;
360        while j <= d {
361            let p = [v1[0] + j * n[0], v1[1] + j * n[1], v1[2] + j * n[2]];
362            // draw_segment(p, v2): from v2 towards p.
363            let (m, dd) = unit(p, v2);
364            let mut t = 0.0;
365            while t <= dd {
366                set0([v2[0] + t * m[0], v2[1] + t * m[1], v2[2] + t * m[2]], &mut grid);
367                t += mininc;
368            }
369            j += mininc;
370        }
371    }
372    let mut c = [0.0f64; 3];
373    for v in vertices_mm {
374        c[0] += v[0];
375        c[1] += v[1];
376        c[2] += v[2];
377    }
378    let inv = 1.0 / vertices_mm.len() as f64;
379    let (cx, cy, cz) = ((c[0] * inv / xdim) as i64, (c[1] * inv / ydim) as i64, (c[2] * inv / zdim) as i64);
380    let mut stack: Vec<(i64, i64, i64)> = vec![(cx, cy, cz)];
381    let inside = |x: i64, y: i64, z: i64| x >= 0 && y >= 0 && z >= 0 && (x as usize) < nx && (y as usize) < ny && (z as usize) < nz;
382    while let Some((x, y, z)) = stack.pop() {
383        if !inside(x, y, z) {
384            continue;
385        }
386        grid[idx(x as usize, y as usize, z as usize)] = 0;
387        for (dx, dy, dz) in [(-1, 0, 0), (0, -1, 0), (0, 0, -1), (1, 0, 0), (0, 1, 0), (0, 0, 1)] {
388            let (a, b, c) = (x + dx, y + dy, z + dz);
389            if inside(a, b, c) && grid[idx(a as usize, b as usize, c as usize)] == 1 {
390                stack.push((a, b, c));
391            }
392        }
393    }
394    grid.iter().map(|&v| (v == 0) as u8).collect()
395}
396
397
398/// Self-intersection threshold (matches FSL-BET2)
399const SELF_INTERSECTION_THRESHOLD: f64 = 4000.0;
400
401/// Maximum number of recovery passes (matches FSL-BET2)
402const MAX_PASSES: usize = 10;
403
404/// Run a single pass of surface evolution
405///
406/// Returns the final vertices after evolution
407fn evolution_pass(
408    data: &[f64],
409    nx: usize, ny: usize, nz: usize,
410    voxel_size: &[f64; 3],
411    bp: &BetParameters,
412    vertices: &mut [[f64; 3]],
413    faces: &[[usize; 3]],
414    neighbor_matrix: &[Vec<usize>],
415    neighbor_counts: &[usize],
416    bt: f64,
417    smoothness_factor: f64,
418    gradient_threshold: f64,
419    iterations: usize,
420    pass: usize,
421    progress_callback: &mut Option<&mut dyn FnMut(usize, usize)>,
422) {
423    let n_vertices = vertices.len();
424
425    // BET parameters (from FSL) - adjusted by smoothness_factor
426    let rmin = 3.33 * smoothness_factor;
427    let rmax = 10.0 * smoothness_factor;
428    let e = (1.0 / rmin + 1.0 / rmax) / 2.0;
429    let f = 6.0 / (1.0 / rmin - 1.0 / rmax);
430    let normal_max_update_fraction = 0.5;
431    let lambda_fit = 0.1;
432
433    // bet2: l, the mean over vertices of the mean distance to their neighbours, recomputed at
434    // the start of iterations 0, 50, 100, 200, ...
435    let mut l = 0.0;
436    let mean_neighbour_distance = |vs: &[[f64; 3]]| -> f64 {
437        let mut total = 0.0;
438        for i in 0..vs.len() {
439            let mut li = 0.0;
440            for j in 0..neighbor_counts[i] {
441                let q = vs[neighbor_matrix[i][j]];
442                li += ((q[0] - vs[i][0]).powi(2) + (q[1] - vs[i][1]).powi(2) + (q[2] - vs[i][2]).powi(2)).sqrt();
443            }
444            total += li / neighbor_counts[i] as f64;
445        }
446        total / vs.len() as f64
447    };
448
449    // Smoothing increase factor for recovery passes (FSL: 10^(pass+1))
450    let base_increase = if pass > 0 { 10.0_f64.powi((pass + 1) as i32) } else { 1.0 };
451
452    // Report progress at start if first pass
453    let progress_interval = (iterations / 20).max(1);
454
455    // Debug counter for sampling failures
456    let mut sample_fail_count: usize = 0;
457
458    for iteration in 0..iterations {
459        // Report progress periodically
460        if let Some(ref mut cb) = progress_callback {
461            if iteration % progress_interval == 0 {
462                cb(iteration, iterations);
463            }
464        }
465
466        // Compute increase factor with tapering in later iterations (FSL: after 75%)
467        let incfactor = if pass > 0 && iteration > (0.75 * iterations as f64) as usize {
468            let t = iteration as f64 / iterations as f64;
469            4.0 * (1.0 - t) * (base_increase - 1.0) + 1.0
470        } else {
471            base_increase
472        };
473
474        if iteration == 50 || iteration % 100 == 0 {
475            l = mean_neighbour_distance(vertices);
476        }
477        // Compute vertex normals
478        let normals = bet2_vertex_normals(vertices, faces);
479
480        // Compute updates for each vertex
481        let mut updates: Vec<[f64; 3]> = vec![[0.0, 0.0, 0.0]; n_vertices];
482
483        for i in 0..n_vertices {
484            let v = vertices[i];
485            let n = normals[i];
486
487            // Compute mean neighbor position
488            let mut mean_neighbor = [0.0, 0.0, 0.0];
489            let count = neighbor_counts[i];
490            for j in 0..count {
491                let ni = neighbor_matrix[i][j];
492                mean_neighbor[0] += vertices[ni][0];
493                mean_neighbor[1] += vertices[ni][1];
494                mean_neighbor[2] += vertices[ni][2];
495            }
496            if count > 0 {
497                mean_neighbor[0] /= count as f64;
498                mean_neighbor[1] /= count as f64;
499                mean_neighbor[2] /= count as f64;
500            }
501
502            // Vector from vertex to mean neighbor
503            let dv = [mean_neighbor[0] - v[0], mean_neighbor[1] - v[1], mean_neighbor[2] - v[2]];
504
505            // Dot product with normal
506            let dv_dot_n = dv[0] * n[0] + dv[1] * n[1] + dv[2] * n[2];
507
508            // Normal component
509            let sn = [dv_dot_n * n[0], dv_dot_n * n[1], dv_dot_n * n[2]];
510
511            // Tangential component
512            let st = [dv[0] - sn[0], dv[1] - sn[1], dv[2] - sn[2]];
513
514            // Force 1: Tangential (vertex spacing)
515            let u1 = [st[0] * 0.5, st[1] * 0.5, st[2] * 0.5];
516
517            // Force 2: Normal (smoothness)
518            let sn_mag = dv_dot_n.abs();
519            let rinv = (2.0 * sn_mag) / (l * l);
520            let mut f2 = (1.0 + (f * (rinv - e)).tanh()) * 0.5;
521
522            // In recovery passes, increase smoothing for outward-pointing updates (FSL behavior)
523            if pass > 0 && dv_dot_n > 0.0 {
524                f2 *= incfactor;
525                f2 = f2.min(1.0);
526            }
527
528            let u2 = [f2 * sn[0], f2 * sn[1], f2 * sn[2]];
529
530            // Force 3: Intensity-based (using FSL-style sampling with tm)
531            let (i_min, i_max, sample_ok) = sample_intensities_fsl(
532                data, nx, ny, nz, &v, &n, voxel_size, bp.t2, bp.t, bp.tm
533            );
534
535            // FSL: if sampling fails (out of bounds), f3 = 0 (no intensity force)
536            let u3 = if sample_ok {
537                // Apply z-gradient to local threshold (FSL's -g option)
538                let local_bt = if gradient_threshold.abs() > 1e-10 {
539                    // Vertex is already in mm coordinates
540                    let z_offset = (v[2] - bp.cog_mm[2]) / bp.radius;
541                    (bt + gradient_threshold * z_offset).clamp(0.0, 1.0)
542                } else {
543                    bt
544                };
545
546                // Compute local threshold and force (matches FSL exactly)
547                let t_l = (i_max - bp.t2) * local_bt + bp.t2;
548                let f3 = if i_max - bp.t2 > 0.0 {
549                    2.0 * (i_min - t_l) / (i_max - bp.t2)
550                } else {
551                    2.0 * (i_min - t_l)
552                };
553                let f3 = f3 * normal_max_update_fraction * lambda_fit * l;
554
555                [f3 * n[0], f3 * n[1], f3 * n[2]]
556            } else {
557                // Sampling failed - use f3 = 0 like FSL does
558                sample_fail_count += 1;
559                [0.0, 0.0, 0.0]
560            };
561
562            // Combined update
563            updates[i] = [u1[0] + u2[0] + u3[0], u1[1] + u2[1] + u3[1], u1[2] + u2[2] + u3[2]];
564        }
565
566        // Apply updates
567        for i in 0..n_vertices {
568            vertices[i][0] += updates[i][0];
569            vertices[i][1] += updates[i][1];
570            vertices[i][2] += updates[i][2];
571        }
572
573    }
574
575    // Debug: report sampling failures
576    if sample_fail_count > 0 {
577        let total_samples = iterations * n_vertices;
578        let fail_pct = 100.0 * sample_fail_count as f64 / total_samples as f64;
579        eprintln!("[BET] Sampling fallback used: {} / {} ({:.1}%)",
580                  sample_fail_count, total_samples, fail_pct);
581    }
582}
583
584/// BET algorithm parameters
585#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
586#[derive(Clone, Debug, PartialEq)]
587pub struct BetParams {
588    /// Fractional intensity threshold (0.0-1.0, smaller = larger brain)
589    pub fractional_intensity: f64,
590    /// Surface smoothness factor
591    pub smoothness: f64,
592    /// Gradient threshold (-1 to 1)
593    pub gradient_threshold: f64,
594    /// Number of iterations
595    pub iterations: usize,
596    /// Icosphere subdivision level
597    pub subdivisions: usize,
598    /// Factor applied to the grid's voxel sizes before the surface is evolved (default 1.0 =
599    /// the acquisition's own geometry).
600    ///
601    /// BET's surface model is fixed in human-scale millimetres — a 7 mm / 3 mm intensity search
602    /// along each vertex normal, and curvature bounded between 3.33 mm and 10 mm radii — so on a
603    /// ~1 cm rodent brain those distances span the whole head and the search runs straight
604    /// through the skull. Inflating the voxel sizes so the brain reaches roughly human
605    /// dimensions is the standard preclinical workaround (`fslchpixdim` x10 before `bet`);
606    /// a scale of 10 suits mouse data.
607    ///
608    /// The mask is returned on the input index grid either way, so nothing needs scaling back.
609    /// Prefer [`super::rs2_net`] where the weights can be fetched: on an in-vivo mouse GRE it
610    /// follows the inner skull, where scaled BET still leaks into the skull base.
611    pub voxel_scale: f64,
612}
613
614impl Default for BetParams {
615    fn default() -> Self {
616        Self {
617            fractional_intensity: 0.5,
618            smoothness: 1.0,
619            gradient_threshold: 0.0,
620            iterations: 1000,
621            subdivisions: 4,
622            voxel_scale: 1.0,
623        }
624    }
625}
626
627/// How FSL stores an image's left-right axis, which decides whether FSL flips it on reading
628/// (`FslGetLeftRightOrder2`, `fslio.c`; `read_volume` calls `makeradiological()`): an image
629/// whose sform (or, without one, qform) has a negative determinant is radiological and read as
630/// it is; one with a positive determinant is neurological and FSL reverses its x axis before bet2
631/// sees it (and back when saving). The mesh is not symmetric, so the flip changes the mask.
632#[derive(Clone, Copy, Debug, PartialEq, Eq)]
633pub enum FslLeftRightOrder {
634    /// Negative determinant (or no orientation): processed as stored.
635    Radiological,
636    /// Positive determinant: x reversed for processing, as FSL does.
637    Neurological,
638}
639
640impl FslLeftRightOrder {
641    /// From a voxel-to-world affine (row-major 4×4, the sform or else the qform the image was
642    /// read with). `None` (no orientation) and a determinant within 1e-12 of 0 are
643    /// radiological, as in FSL.
644    pub fn from_affine(affine: Option<&[f64; 16]>) -> Self {
645        match affine {
646            Some(a) => {
647                let det = a[0] * (a[5] * a[10] - a[6] * a[9]) - a[1] * (a[4] * a[10] - a[6] * a[8])
648                    + a[2] * (a[4] * a[9] - a[5] * a[8]);
649                if det >= 1e-12 { Self::Neurological } else { Self::Radiological }
650            }
651            None => Self::Radiological,
652        }
653    }
654}
655
656/// Run BET brain extraction, as FSL's bet2 (see the module docs).
657///
658/// # Arguments
659/// * `data` - 3D magnitude image data (nx * ny * nz, Fortran order)
660/// * `grid` - Volume grid (dimensions and voxel sizes)
661/// * `params` - BET parameters (intensity threshold, smoothness, gradient, iterations, subdivisions)
662/// * `order` - The image's [`FslLeftRightOrder`], from its header
663///   ([`FslLeftRightOrder::from_affine`]): bet2 runs on neurological images with x reversed.
664/// * `progress` - Progress callback: `(iteration, total_iterations)`
665///
666/// # Returns
667/// Binary mask (1 = brain, 0 = background)
668pub fn run_bet<F>(
669    data: &[f64],
670    grid: &Grid,
671    params: &BetParams,
672    order: FslLeftRightOrder,
673    progress: F,
674) -> Vec<u8>
675where
676    F: FnMut(usize, usize),
677{
678    match order {
679        FslLeftRightOrder::Radiological => run_bet_as_stored(data, grid, params, progress),
680        FslLeftRightOrder::Neurological => {
681            let (nx, ny, nz) = grid.dims;
682            let flip_x = |i: usize| (nx - 1 - i % nx) + nx * (i / nx);
683            let flipped: Vec<f64> = (0..nx * ny * nz).map(|i| data[flip_x(i)]).collect();
684            let mask = run_bet_as_stored(&flipped, grid, params, progress);
685            (0..nx * ny * nz).map(|i| mask[flip_x(i)]).collect()
686        }
687    }
688}
689
690fn run_bet_as_stored<F>(
691    data: &[f64],
692    grid: &Grid,
693    params: &BetParams,
694    mut progress: F,
695) -> Vec<u8>
696where
697    F: FnMut(usize, usize),
698{
699    // bet2 reads -f, -w and -g as floats (`Option<float>`).
700    let fractional_intensity = params.fractional_intensity as f32 as f64;
701    let smoothness_factor = params.smoothness as f32 as f64;
702    let gradient_threshold = params.gradient_threshold as f32 as f64;
703    let iterations = params.iterations;
704    let subdivisions = params.subdivisions;
705    let (nx, ny, nz) = grid.dims;
706    // Every physical distance in BET reaches the algorithm through `voxel_size` — the brain
707    // estimate, the surface evolution, the self-intersection heuristic and the rasteriser all
708    // take it from here — so scaling it here is the whole of `voxel_scale`. A non-positive
709    // scale would invert or collapse the geometry; treat it as "unscaled".
710    let scale = if params.voxel_scale > 0.0 { params.voxel_scale } else { 1.0 };
711    let voxel_size = [grid.vsx() * scale, grid.vsy() * scale, grid.vsz() * scale];
712
713    // Step 1: Estimate brain parameters
714    progress(0, iterations);
715    let bp = estimate_brain_parameters(data, nx, ny, nz, &voxel_size);
716
717    // Step 2: Create icosphere
718    // bet2's own icosahedron, `make_mesh_from_icosa(subdivisions + 1)`.
719    let (unit_vertices, faces) = bet2_icosa(subdivisions + 1);
720    let n_vertices = unit_vertices.len();
721
722    // Scale and position sphere in mm coordinates (start at 50% of estimated radius)
723    // Like FSL, we work entirely in mm - voxel conversion only happens at sampling/masking
724    let initial_radius_mm = bp.radius * 0.5;
725
726    let initial_vertices: Vec<[f64; 3]> = unit_vertices
727        .iter()
728        .map(|v| [
729            v[0] * initial_radius_mm + bp.cog_mm[0],
730            v[1] * initial_radius_mm + bp.cog_mm[1],
731            v[2] * initial_radius_mm + bp.cog_mm[2],
732        ])
733        .collect();
734
735    // Build neighbor structure
736    let (neighbor_matrix, neighbor_counts) = build_neighbor_matrix(n_vertices, &faces, 6);
737
738    // FSL power transform: bt = pow(f, 0.275)
739    // This raises 0.5 -> 0.826, which makes the surface more aggressive in expanding
740    let bt = fractional_intensity.powf(0.275);
741
742    // Multi-pass evolution with self-intersection recovery (like FSL-BET2)
743    let mut vertices = initial_vertices.clone();
744    let mut pass = 0;
745
746    loop {
747        // Run evolution pass
748        let mut cb: Option<&mut dyn FnMut(usize, usize)> = Some(&mut progress);
749        evolution_pass(
750            data, nx, ny, nz, &voxel_size, &bp,
751            &mut vertices, &faces,
752            &neighbor_matrix, &neighbor_counts,
753            bt, smoothness_factor, gradient_threshold,
754            iterations, pass,
755            &mut cb,
756        );
757
758        // Check for self-intersection
759        let si_score = self_intersection_heuristic(&vertices, &initial_vertices, &faces, &voxel_size);
760        let has_self_intersection = si_score > SELF_INTERSECTION_THRESHOLD;
761
762        if has_self_intersection {
763            eprintln!("[BET] Self-intersection detected (score={:.0}, threshold={}), pass {}",
764                      si_score, SELF_INTERSECTION_THRESHOLD, pass + 1);
765        }
766
767        // Exit if no self-intersection or max passes reached
768        if !has_self_intersection || pass >= MAX_PASSES {
769            if pass > 0 {
770                eprintln!("[BET] Completed after {} recovery pass(es)", pass);
771            }
772            break;
773        }
774
775        // Reset to original mesh and try again with higher smoothing
776        vertices = initial_vertices.clone();
777        pass += 1;
778    }
779
780    // Final progress update
781    progress(iterations, iterations);
782
783    // Step 4: Convert surface to binary mask
784    surface_to_mask(&vertices, &faces, nx, ny, nz, &voxel_size)
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790
791    /// Helper: create a 3D sphere volume (Fortran order) centered in the grid.
792    /// Returns (data, nx, ny, nz) where the sphere has intensity `intensity`
793    /// and background is 0. Sphere radius in voxels is `radius`.
794    fn make_sphere_volume(n: usize, radius: f64, intensity: f64) -> (Vec<f64>, usize, usize, usize) {
795        let center = (n as f64) / 2.0;
796        let mut data = vec![0.0; n * n * n];
797        for k in 0..n {
798            for j in 0..n {
799                for i in 0..n {
800                    let di = (i as f64) - center;
801                    let dj = (j as f64) - center;
802                    let dk = (k as f64) - center;
803                    let dist = (di * di + dj * dj + dk * dk).sqrt();
804                    if dist <= radius {
805                        // Smoothly varying intensity: bright at center, dimmer at edge
806                        data[i + j * n + k * n * n] = intensity * (1.0 - 0.5 * dist / radius);
807                    }
808                }
809            }
810        }
811        (data, n, n, n)
812    }
813
814    #[test]
815    fn test_estimate_brain_parameters() {
816        let nx = 10;
817        let ny = 10;
818        let nz = 10;
819        let mut data = vec![0.0; nx * ny * nz];
820
821        // Create a sphere with varying intensity (like a real brain)
822        // Fortran order: index = i + j*nx + k*nx*ny
823        for k in 0..nz {
824            for j in 0..ny {
825                for i in 0..nx {
826                    let di = (i as f64) - 5.0;
827                    let dj = (j as f64) - 5.0;
828                    let dk = (k as f64) - 5.0;
829                    let dist = (di*di + dj*dj + dk*dk).sqrt();
830                    if dist <= 4.0 {
831                        // Intensity varies from 50 to 150 based on distance from center
832                        data[i + j * nx + k * nx * ny] = 150.0 - dist * 25.0;
833                    }
834                }
835            }
836        }
837
838        let bp = estimate_brain_parameters(&data, nx, ny, nz, &[1.0, 1.0, 1.0]);
839
840        assert!(bp.t2 >= 0.0);
841        assert!(bp.t98 >= bp.t2); // Allow equal for edge cases
842        assert!((bp.cog[0] - 5.0).abs() < 1.0);
843        assert!((bp.cog[1] - 5.0).abs() < 1.0);
844        assert!((bp.cog[2] - 5.0).abs() < 1.0);
845        assert!(bp.radius > 0.0);
846        assert!(bp.tm > bp.t2 && bp.tm < bp.t98); // tm should be between t2 and t98
847        // Check cog_mm is correctly computed
848        assert!((bp.cog_mm[0] - bp.cog[0]).abs() < 1e-10);
849    }
850
851    #[test]
852    fn test_estimate_brain_parameters_empty_data() {
853        // All zeros should trigger the nonzero.is_empty() branch
854        let nx = 4;
855        let ny = 4;
856        let nz = 4;
857        let data = vec![0.0; nx * ny * nz];
858        let bp = estimate_brain_parameters(&data, nx, ny, nz, &[1.0, 1.0, 1.0]);
859
860        assert!((bp.t2 - 0.0).abs() < 1e-10);
861        assert!((bp.t98 - 1.0).abs() < 1e-10);
862        assert!((bp.t - 0.1).abs() < 1e-10);
863        assert!((bp.tm - 0.5).abs() < 1e-10);
864        assert!((bp.cog[0] - 2.0).abs() < 1e-10);
865        assert!((bp.cog[1] - 2.0).abs() < 1e-10);
866        assert!((bp.cog[2] - 2.0).abs() < 1e-10);
867        assert!((bp.radius - 50.0).abs() < 1e-10);
868    }
869
870    #[test]
871    fn test_estimate_brain_parameters_anisotropic_voxels() {
872        let (data, nx, ny, nz) = make_sphere_volume(12, 4.0, 200.0);
873        let voxel_size = [2.0, 2.0, 2.0];
874        let bp = estimate_brain_parameters(&data, nx, ny, nz, &voxel_size);
875
876        // cog_mm should be cog * voxel_size
877        assert!((bp.cog_mm[0] - bp.cog[0] * voxel_size[0]).abs() < 1e-10);
878        assert!((bp.cog_mm[1] - bp.cog[1] * voxel_size[1]).abs() < 1e-10);
879        assert!((bp.cog_mm[2] - bp.cog[2] * voxel_size[2]).abs() < 1e-10);
880        assert!(bp.radius > 0.0);
881        assert!(bp.t98 > bp.t2);
882    }
883
884    #[test]
885    fn test_power_transform() {
886        // Verify power transform matches FSL
887        let f = 0.5_f64;
888        let bt = f.powf(0.275);
889        // 0.5^0.275 ~ 0.826
890        assert!((bt - 0.826).abs() < 0.01);
891    }
892
893    #[test]
894    fn test_sample_intensities_fsl_basic() {
895        // Create a 16x16x16 uniform volume
896        let n = 16;
897        let data = vec![100.0; n * n * n];
898        let voxel_size = [1.0, 1.0, 1.0];
899        // Point at the center, normal pointing inward
900        let point_mm = [8.0, 8.0, 8.0];
901        let normal = [0.0, 0.0, 1.0]; // outward along z
902
903        let t2 = 10.0;
904        let t = 20.0;
905        let tm = 100.0;
906
907        let (i_min, i_max, ok) = sample_intensities_fsl(
908            &data, n, n, n, &point_mm, &normal, &voxel_size, t2, t, tm,
909        );
910
911        assert!(ok, "Sampling should succeed for center point");
912        assert!(i_min.is_finite());
913        assert!(i_max.is_finite());
914        // Uniform volume: i_min should be clamped to >= t2
915        assert!(i_min >= t2, "i_min={} should be >= t2={}", i_min, t2);
916        // i_max should be clamped to <= tm
917        assert!(i_max <= tm, "i_max={} should be <= tm={}", i_max, tm);
918    }
919
920    #[test]
921    fn test_sample_intensities_fsl_out_of_bounds() {
922        // Point near edge, normal pointing outward -- should fail bounds check
923        let n = 8;
924        let data = vec![100.0; n * n * n];
925        let voxel_size = [1.0, 1.0, 1.0];
926        // Point at the edge
927        let point_mm = [0.5, 0.5, 0.5];
928        let normal = [-1.0, 0.0, 0.0]; // pointing outward (further out of bounds)
929
930        let (_, _, _ok) = sample_intensities_fsl(
931            &data, n, n, n, &point_mm, &normal, &voxel_size, 10.0, 20.0, 100.0,
932        );
933
934        // Construct a case that goes out of bounds on the first inward step:
935        // point - normal = (0.5 - 1.0, 0.5, 0.5) = (-0.5, 0.5, 0.5) -> voxel (-0.5) is OOB
936        let point_mm2 = [0.5, 0.5, 0.5];
937        let normal2 = [1.0, 0.0, 0.0]; // point - normal = (-0.5, 0.5, 0.5) -> voxel (-0.5) out of bounds
938        let (_, _, ok2) = sample_intensities_fsl(
939            &data, n, n, n, &point_mm2, &normal2, &voxel_size, 10.0, 20.0, 100.0,
940        );
941        assert!(!ok2, "Sampling should fail when initial step goes out of bounds");
942    }
943
944    #[test]
945    fn test_sample_intensities_fsl_varying_intensity() {
946        // Create a volume with intensity gradient along z
947        let n = 16;
948        let mut data = vec![0.0; n * n * n];
949        for k in 0..n {
950            for j in 0..n {
951                for i in 0..n {
952                    data[i + j * n + k * n * n] = 50.0 + (k as f64) * 10.0;
953                }
954            }
955        }
956        let voxel_size = [1.0, 1.0, 1.0];
957        let point_mm = [8.0, 8.0, 8.0];
958        // Normal pointing in -z direction (inward sampling goes into higher z)
959        let normal = [0.0, 0.0, -1.0];
960
961        let t2 = 50.0;
962        let t = 55.0;
963        let tm = 120.0;
964
965        let (i_min, i_max, ok) = sample_intensities_fsl(
966            &data, n, n, n, &point_mm, &normal, &voxel_size, t2, t, tm,
967        );
968
969        assert!(ok, "Sampling should succeed");
970        assert!(i_min.is_finite() && i_max.is_finite());
971        assert!(i_min >= t2);
972        assert!(i_max <= tm);
973    }
974
975    #[test]
976    fn test_surface_to_mask_small_sphere() {
977        // Create a small icosphere mesh centered in a 16x16x16 grid
978        let (unit_verts, faces) = create_icosphere(2);
979        let n = 16;
980        let center = (n as f64) / 2.0;
981        let radius = 5.0; // 5 voxel radius
982        let voxel_size = [1.0, 1.0, 1.0];
983
984        // Place vertices in mm (= voxel coords since voxel_size is 1)
985        let vertices_mm: Vec<[f64; 3]> = unit_verts
986            .iter()
987            .map(|v| [
988                v[0] * radius + center,
989                v[1] * radius + center,
990                v[2] * radius + center,
991            ])
992            .collect();
993
994        let mask = surface_to_mask(&vertices_mm, &faces, n, n, n, &voxel_size);
995
996        assert_eq!(mask.len(), n * n * n);
997
998        // Center should be inside the mask
999        let idx = |i: usize, j: usize, k: usize| i + j * n + k * n * n;
1000        assert_eq!(mask[idx(8, 8, 8)], 1, "Center should be brain");
1001
1002        // Corners should be outside the mask
1003        assert_eq!(mask[idx(0, 0, 0)], 0, "Corner should be background");
1004        assert_eq!(mask[idx(15, 15, 15)], 0, "Corner should be background");
1005
1006        // Count brain voxels -- should be a reasonable fraction of total
1007        let brain_count: usize = mask.iter().map(|&v| v as usize).sum();
1008        assert!(brain_count > 0, "Should have some brain voxels");
1009        // Sphere volume ~ 4/3 * pi * 5^3 = 524 voxels; total = 4096
1010        // Allow generous range
1011        assert!(brain_count > 100 && brain_count < 2000,
1012                "Brain voxels ({}) should be roughly sphere-like", brain_count);
1013    }
1014
1015    #[test]
1016    fn test_surface_to_mask_anisotropic_voxels() {
1017        let (unit_verts, faces) = create_icosphere(1);
1018        let n = 16;
1019        let voxel_size = [2.0, 2.0, 2.0];
1020        let center_mm = [(n as f64) * voxel_size[0] / 2.0,
1021                         (n as f64) * voxel_size[1] / 2.0,
1022                         (n as f64) * voxel_size[2] / 2.0];
1023        let radius_mm = 8.0;
1024
1025        let vertices_mm: Vec<[f64; 3]> = unit_verts
1026            .iter()
1027            .map(|v| [
1028                v[0] * radius_mm + center_mm[0],
1029                v[1] * radius_mm + center_mm[1],
1030                v[2] * radius_mm + center_mm[2],
1031            ])
1032            .collect();
1033
1034        let mask = surface_to_mask(&vertices_mm, &faces, n, n, n, &voxel_size);
1035        assert_eq!(mask.len(), n * n * n);
1036
1037        let brain_count: usize = mask.iter().map(|&v| v as usize).sum();
1038        assert!(brain_count > 0, "Should have brain voxels with anisotropic voxels");
1039    }
1040
1041    #[test]
1042    fn test_run_bet_small_synthetic_volume() {
1043        // Create a 16x16x16 volume with a bright sphere
1044        let (data, nx, ny, nz) = make_sphere_volume(16, 6.0, 200.0);
1045        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1046
1047        let mask = run_bet(
1048            &data,
1049            &grid,
1050            &BetParams { fractional_intensity: 0.5, smoothness: 1.0, gradient_threshold: 0.0, iterations: 50, subdivisions: 1, voxel_scale: 1.0 },
1051            FslLeftRightOrder::Radiological, |_, _| {},
1052        );
1053
1054        assert_eq!(mask.len(), nx * ny * nz);
1055
1056        // Should produce a valid binary mask
1057        assert!(mask.iter().all(|&v| v == 0 || v == 1), "Mask should be binary");
1058
1059        // Should have some brain voxels
1060        let brain_count: usize = mask.iter().map(|&v| v as usize).sum();
1061        assert!(brain_count > 0, "BET should extract some brain voxels");
1062
1063        // Center should be brain
1064        let idx = |i: usize, j: usize, k: usize| i + j * nx + k * nx * ny;
1065        assert_eq!(mask[idx(8, 8, 8)], 1, "Center of sphere should be brain");
1066    }
1067
1068    /// `voxel_scale: s` must mean exactly "run BET as if the voxels were s times larger", so
1069    /// scaling the grid and scaling the parameter have to give the same mask. This is what lets
1070    /// the preclinical `fslchpixdim`-style workaround be a parameter rather than a pre-pass.
1071    #[test]
1072    fn test_voxel_scale_matches_an_equally_scaled_grid() {
1073        let (data, nx, ny, nz) = make_sphere_volume(16, 6.0, 200.0);
1074        let base = BetParams {
1075            fractional_intensity: 0.5, smoothness: 1.0, gradient_threshold: 0.0,
1076            iterations: 50, subdivisions: 1, voxel_scale: 1.0,
1077        };
1078
1079        // Anisotropic voxels, so a scale applied to the wrong axis would show up.
1080        let scaled_grid = Grid::new(nx, ny, nz, 3.0, 4.5, 6.0);
1081        let via_grid = run_bet(&data, &scaled_grid, &base, FslLeftRightOrder::Radiological, |_, _| {});
1082
1083        let native_grid = Grid::new(nx, ny, nz, 0.3, 0.45, 0.6);
1084        let via_param = run_bet(
1085            &data, &native_grid,
1086            &BetParams { voxel_scale: 10.0, ..base.clone() },
1087            FslLeftRightOrder::Radiological, |_, _| {},
1088        );
1089
1090        assert_eq!(via_param, via_grid, "voxel_scale must equal scaling the grid itself");
1091    }
1092
1093    /// A non-positive scale would invert or collapse the geometry; it falls back to unscaled
1094    /// rather than producing a degenerate surface.
1095    #[test]
1096    fn test_non_positive_voxel_scale_is_treated_as_unscaled() {
1097        let (data, nx, ny, nz) = make_sphere_volume(16, 6.0, 200.0);
1098        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1099        let base = BetParams {
1100            fractional_intensity: 0.5, smoothness: 1.0, gradient_threshold: 0.0,
1101            iterations: 50, subdivisions: 1, voxel_scale: 1.0,
1102        };
1103        let unscaled = run_bet(&data, &grid, &base, FslLeftRightOrder::Radiological, |_, _| {});
1104
1105        for bad in [0.0, -1.0] {
1106            let mask = run_bet(&data, &grid, &BetParams { voxel_scale: bad, ..base.clone() }, FslLeftRightOrder::Radiological, |_, _| {});
1107            assert_eq!(mask, unscaled, "voxel_scale {bad} should behave as 1.0");
1108        }
1109    }
1110
1111    #[test]
1112    fn test_run_bet_with_gradient_threshold() {
1113        // Exercise the gradient threshold code path
1114        let (data, nx, ny, nz) = make_sphere_volume(16, 6.0, 200.0);
1115        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1116
1117        let mask = run_bet(
1118            &data,
1119            &grid,
1120            &BetParams { fractional_intensity: 0.5, smoothness: 1.0, gradient_threshold: 0.3, iterations: 50, subdivisions: 1, voxel_scale: 1.0 },
1121            FslLeftRightOrder::Radiological, |_, _| {},
1122        );
1123
1124        assert_eq!(mask.len(), nx * ny * nz);
1125        assert!(mask.iter().all(|&v| v == 0 || v == 1));
1126        let brain_count: usize = mask.iter().map(|&v| v as usize).sum();
1127        assert!(brain_count > 0, "BET with gradient should extract brain voxels");
1128    }
1129
1130    #[test]
1131    fn test_run_bet_with_progress() {
1132        // Test the progress callback variant
1133        let (data, nx, ny, nz) = make_sphere_volume(16, 6.0, 200.0);
1134        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1135
1136        let mut progress_calls = Vec::new();
1137        let mask = run_bet(
1138            &data,
1139            &grid,
1140            &BetParams { fractional_intensity: 0.5, smoothness: 1.0, gradient_threshold: 0.0, iterations: 50, subdivisions: 1, voxel_scale: 1.0 },
1141            FslLeftRightOrder::Radiological, |current, total| {
1142                progress_calls.push((current, total));
1143            },
1144        );
1145
1146        assert_eq!(mask.len(), nx * ny * nz);
1147        assert!(mask.iter().all(|&v| v == 0 || v == 1));
1148
1149        // Progress callback should have been called at least twice (start + end)
1150        assert!(progress_calls.len() >= 2,
1151                "Progress callback should be called at least twice, got {} calls",
1152                progress_calls.len());
1153
1154        // First call should be (0, iterations)
1155        assert_eq!(progress_calls[0].0, 0);
1156
1157        // Last call should be (iterations, iterations)
1158        let last = progress_calls.last().unwrap();
1159        assert_eq!(last.0, last.1, "Final progress should be complete");
1160    }
1161
1162    #[test]
1163    fn test_run_bet_different_fractional_intensities() {
1164        let (data, nx, ny, nz) = make_sphere_volume(16, 6.0, 200.0);
1165        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1166
1167        // Higher fractional_intensity = smaller brain
1168        let mask_small = run_bet(
1169            &data,
1170            &grid,
1171            &BetParams { fractional_intensity: 0.7, smoothness: 1.0, gradient_threshold: 0.0, iterations: 50, subdivisions: 1, voxel_scale: 1.0 },
1172            FslLeftRightOrder::Radiological, |_, _| {},
1173        );
1174        let mask_large = run_bet(
1175            &data,
1176            &grid,
1177            &BetParams { fractional_intensity: 0.3, smoothness: 1.0, gradient_threshold: 0.0, iterations: 50, subdivisions: 1, voxel_scale: 1.0 },
1178            FslLeftRightOrder::Radiological, |_, _| {},
1179        );
1180
1181        let count_small: usize = mask_small.iter().map(|&v| v as usize).sum();
1182        let count_large: usize = mask_large.iter().map(|&v| v as usize).sum();
1183
1184        // Both should produce valid masks
1185        assert!(count_small > 0);
1186        assert!(count_large > 0);
1187    }
1188
1189    #[test]
1190    fn test_run_bet_anisotropic_voxels() {
1191        let (data, nx, ny, nz) = make_sphere_volume(16, 6.0, 200.0);
1192        let grid = Grid::new(nx, ny, nz, 2.0, 2.0, 2.0);
1193
1194        let mask = run_bet(
1195            &data,
1196            &grid,
1197            &BetParams { fractional_intensity: 0.5, smoothness: 1.0, gradient_threshold: 0.0, iterations: 50, subdivisions: 1, voxel_scale: 1.0 },
1198            FslLeftRightOrder::Radiological, |_, _| {},
1199        );
1200
1201        assert_eq!(mask.len(), nx * ny * nz);
1202        assert!(mask.iter().all(|&v| v == 0 || v == 1));
1203    }
1204
1205    #[test]
1206    fn test_evolution_pass_basic() {
1207        // Directly test evolution_pass with a small icosphere
1208        let n = 16;
1209        let (data, nx, ny, nz) = make_sphere_volume(n, 6.0, 200.0);
1210        let voxel_size = [1.0, 1.0, 1.0];
1211        let bp = estimate_brain_parameters(&data, nx, ny, nz, &voxel_size);
1212
1213        let (unit_verts, faces) = create_icosphere(1);
1214        let n_vertices = unit_verts.len();
1215        let initial_radius_mm = bp.radius * 0.5;
1216
1217        let mut vertices: Vec<[f64; 3]> = unit_verts
1218            .iter()
1219            .map(|v| [
1220                v[0] * initial_radius_mm + bp.cog_mm[0],
1221                v[1] * initial_radius_mm + bp.cog_mm[1],
1222                v[2] * initial_radius_mm + bp.cog_mm[2],
1223            ])
1224            .collect();
1225
1226        let (neighbor_matrix, neighbor_counts) = build_neighbor_matrix(n_vertices, &faces, 6);
1227        let bt = 0.5_f64.powf(0.275);
1228
1229        let vertices_before = vertices.clone();
1230
1231        evolution_pass(
1232            &data, nx, ny, nz, &voxel_size, &bp,
1233            &mut vertices, &faces,
1234            &neighbor_matrix, &neighbor_counts,
1235            bt, 1.0, 0.0,
1236            10, // few iterations
1237            0,  // first pass
1238            &mut None,
1239        );
1240
1241        // Vertices should have moved
1242        let mut any_moved = false;
1243        for (before, after) in vertices_before.iter().zip(vertices.iter()) {
1244            let dist = ((after[0] - before[0]).powi(2) +
1245                       (after[1] - before[1]).powi(2) +
1246                       (after[2] - before[2]).powi(2)).sqrt();
1247            if dist > 1e-10 {
1248                any_moved = true;
1249            }
1250            // All coordinates should be finite
1251            assert!(after[0].is_finite() && after[1].is_finite() && after[2].is_finite());
1252        }
1253        assert!(any_moved, "Evolution should move at least some vertices");
1254    }
1255
1256    #[test]
1257    fn test_evolution_pass_recovery_pass() {
1258        // Test with pass > 0 to exercise recovery smoothing code paths
1259        let n = 16;
1260        let (data, nx, ny, nz) = make_sphere_volume(n, 6.0, 200.0);
1261        let voxel_size = [1.0, 1.0, 1.0];
1262        let bp = estimate_brain_parameters(&data, nx, ny, nz, &voxel_size);
1263
1264        let (unit_verts, faces) = create_icosphere(1);
1265        let n_vertices = unit_verts.len();
1266        let initial_radius_mm = bp.radius * 0.5;
1267
1268        let mut vertices: Vec<[f64; 3]> = unit_verts
1269            .iter()
1270            .map(|v| [
1271                v[0] * initial_radius_mm + bp.cog_mm[0],
1272                v[1] * initial_radius_mm + bp.cog_mm[1],
1273                v[2] * initial_radius_mm + bp.cog_mm[2],
1274            ])
1275            .collect();
1276
1277        let (neighbor_matrix, neighbor_counts) = build_neighbor_matrix(n_vertices, &faces, 6);
1278        let bt = 0.5_f64.powf(0.275);
1279
1280        // Run with pass=1 and enough iterations to hit the 75% tapering code
1281        evolution_pass(
1282            &data, nx, ny, nz, &voxel_size, &bp,
1283            &mut vertices, &faces,
1284            &neighbor_matrix, &neighbor_counts,
1285            bt, 1.0, 0.0,
1286            20,  // enough iterations to hit 75% mark
1287            1,   // recovery pass
1288            &mut None,
1289        );
1290
1291        // All coordinates should remain finite
1292        for v in &vertices {
1293            assert!(v[0].is_finite() && v[1].is_finite() && v[2].is_finite());
1294        }
1295    }
1296
1297    #[test]
1298    fn test_evolution_pass_with_gradient() {
1299        // Exercise the z-gradient threshold branch
1300        let n = 16;
1301        let (data, nx, ny, nz) = make_sphere_volume(n, 6.0, 200.0);
1302        let voxel_size = [1.0, 1.0, 1.0];
1303        let bp = estimate_brain_parameters(&data, nx, ny, nz, &voxel_size);
1304
1305        let (unit_verts, faces) = create_icosphere(1);
1306        let n_vertices = unit_verts.len();
1307        let initial_radius_mm = bp.radius * 0.5;
1308
1309        let mut vertices: Vec<[f64; 3]> = unit_verts
1310            .iter()
1311            .map(|v| [
1312                v[0] * initial_radius_mm + bp.cog_mm[0],
1313                v[1] * initial_radius_mm + bp.cog_mm[1],
1314                v[2] * initial_radius_mm + bp.cog_mm[2],
1315            ])
1316            .collect();
1317
1318        let (neighbor_matrix, neighbor_counts) = build_neighbor_matrix(n_vertices, &faces, 6);
1319        let bt = 0.5_f64.powf(0.275);
1320
1321        evolution_pass(
1322            &data, nx, ny, nz, &voxel_size, &bp,
1323            &mut vertices, &faces,
1324            &neighbor_matrix, &neighbor_counts,
1325            bt, 1.0, 0.5, // gradient_threshold = 0.5
1326            10, 0,
1327            &mut None,
1328        );
1329
1330        for v in &vertices {
1331            assert!(v[0].is_finite() && v[1].is_finite() && v[2].is_finite());
1332        }
1333    }
1334
1335    #[test]
1336    fn test_evolution_pass_with_progress_callback() {
1337        let n = 16;
1338        let (data, nx, ny, nz) = make_sphere_volume(n, 6.0, 200.0);
1339        let voxel_size = [1.0, 1.0, 1.0];
1340        let bp = estimate_brain_parameters(&data, nx, ny, nz, &voxel_size);
1341
1342        let (unit_verts, faces) = create_icosphere(1);
1343        let n_vertices = unit_verts.len();
1344        let initial_radius_mm = bp.radius * 0.5;
1345
1346        let mut vertices: Vec<[f64; 3]> = unit_verts
1347            .iter()
1348            .map(|v| [
1349                v[0] * initial_radius_mm + bp.cog_mm[0],
1350                v[1] * initial_radius_mm + bp.cog_mm[1],
1351                v[2] * initial_radius_mm + bp.cog_mm[2],
1352            ])
1353            .collect();
1354
1355        let (neighbor_matrix, neighbor_counts) = build_neighbor_matrix(n_vertices, &faces, 6);
1356        let bt = 0.5_f64.powf(0.275);
1357
1358        let mut calls = 0usize;
1359        let mut callback = |_iter: usize, _total: usize| {
1360            calls += 1;
1361        };
1362        let mut cb: Option<&mut dyn FnMut(usize, usize)> = Some(&mut callback);
1363
1364        evolution_pass(
1365            &data, nx, ny, nz, &voxel_size, &bp,
1366            &mut vertices, &faces,
1367            &neighbor_matrix, &neighbor_counts,
1368            bt, 1.0, 0.0,
1369            20, 0,
1370            &mut cb,
1371        );
1372
1373        assert!(calls > 0, "Progress callback should have been called");
1374    }
1375}