Skip to main content

qsm_core/utils/
bias_correction.rs

1//! Bias field correction (homogeneity correction)
2//!
3//! Implements the makehomogeneous algorithm for correcting RF receive field inhomogeneities.
4//! This uses the "boxsegment" approach with box filter Gaussian approximation.
5//!
6//! Reference:
7//! Eckstein, K., Trattnig, S., Robinson, S.D. (2019).
8//! "A Simple Homogeneity Correction for Neuroimaging at 7T."
9//! Proc. ISMRM 27th Annual Meeting.
10//!
11//! Reference implementation: https://github.com/korbinian90/MriResearchTools.jl
12//!
13//! # Reference pipeline
14//!
15//! MriResearchTools.jl (commit 771eadf, `src/intensitycorrection.jl`):
16//!
17//! - `makehomogeneous(mag::NIVolume; sigma_mm=7, nbox=15)` (`:1-14`): sigma 7 mm converted to
18//!   voxels with the NIfTI voxel size, and `nbox` = 15 boxes per dimension for the `boxsegment`
19//!   tissue segmentation. The masked smoothing uses 8 box-filter passes (`nbox_smoother=8`,
20//!   `:85-99`), the sigma is split 0.7 / √(1−0.7²) between the final and the masked smoothing
21//!   (`getsigma`), and the bias field is estimated from the first echo inside `robustmask`
22//!   (`:92-96`). [`get_sensitivity`] / [`makehomogeneous`] follow this; the pipeline config's
23//!   `masking.homogeneity_sigma_mm` 7.0 and `masking.homogeneity_nbox` 15 (applied by the host,
24//!   not by qsm-core's masking stage) are these defaults.
25//! - `getsensitivity` without a `sigma_mm` uses `get_default_sigma_mm`, commented "7mm, but a
26//!   maximum of 10% FoV" but computing `min(median(voxel size × matrix size), 7)` — the whole
27//!   field of view, not 10 % of it (`:104-113`). Neither cap is applied here; `makehomogeneous`
28//!   with an explicit `sigma_mm` (as above) applies none either.
29//! - Published with CLEAR-SWI (Eckstein et al. 2021, *NeuroImage* 237:118175,
30//!   <https://doi.org/10.1016/j.neuroimage.2021.118175>; `intensitycorrection.jl:78`), which uses
31//!   it as the magnitude sensitivity correction. QSMxT's Python releases called
32//!   `makehomogeneous(mag; sigma=[20, 20, 10])` (voxels; `qsmxt/scripts/makehomogeneous.jl`).
33//! - It assumes one dominant tissue present across the object ("main tissue", `:75`); it uses
34//!   the voxel size but no field strength or orientation.
35//!
36//! # Agreement with MriResearchTools
37//!
38//! `tests/mritools_parity.rs` against MriResearchTools 771eadf in the verified QSM-CI `julia-qsm`
39//! image (QSMxT/QSM-CI#299), measured 2026-10-30, on a 20×18×14 phantom at 1×1×1.5 mm:
40//! [`robust_mask`] is `robustmask` to the voxel, and [`makehomogeneous`] (σ 7 mm, `nbox` 15) is
41//! `makehomogeneous` bit for bit. It was 3.3 % NRMSE in the mask (the ratio of the two between
42//! 0.85 and 1.05) before three port errors were fixed (astewartau/QSM.rs#319); see
43//! [`get_sensitivity`].
44
45/// Parameters for inhomogeneity correction (bias field removal).
46///
47/// Not used by [`makehomogeneous`], which takes `sigma_mm` and `nbox` directly; the pipeline
48/// config carries them as `masking.homogeneity_sigma_mm` / `masking.homogeneity_nbox` (defaults
49/// 7.0 and 15, MriResearchTools' defaults) for the host to apply.
50#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
51#[derive(Clone, Debug)]
52pub struct HomogeneityParams {
53    /// Gaussian smoothing sigma in mm (default: 7.0)
54    pub sigma_mm: f64,
55    /// Boxes per dimension for the `boxsegment` tissue segmentation, `makehomogeneous`'s `nbox`
56    /// (default: 15, MriResearchTools'). Not the number of box-filter passes of the smoothing.
57    ///
58    /// Measured on the EMTP travelling heads (bridge run 1; #314): the earlier default of 3 left
59    /// echo 1 visibly uneven, with a large dark centre on a GE MR750 (in-brain coefficient of
60    /// variation 0.429, against 0.114 with 15 and 0.116 with N4; Philips Achieva 0.152 / 0.098 /
61    /// 0.103). With 15 the image matches N4's; BET masks moved by at most 1.4 % in volume, and
62    /// on GE agreed better with HD-BET.
63    pub nbox: usize,
64}
65
66impl Default for HomogeneityParams {
67    fn default() -> Self {
68        Self {
69            sigma_mm: 7.0,
70            nbox: 15,
71        }
72    }
73}
74
75use std::collections::VecDeque;
76use crate::error::InputError;
77use crate::Grid;
78use crate::utils::morphology::fill_holes;
79
80/// Index into 3D array (Fortran/column-major order)
81#[inline(always)]
82pub(crate) fn idx3d(i: usize, j: usize, k: usize, nx: usize, ny: usize) -> usize {
83    i + j * nx + k * nx * ny
84}
85
86/// Check a caller-supplied volume against the grid it is about to be indexed by.
87///
88/// Every filter here walks `0..nx*ny*nz` and indexes straight into the caller's
89/// buffer, so a volume that does not match the grid reads past its end. Refusing
90/// at the entry point names both sizes; the index panic 300 lines down does not
91/// say which input was wrong, or that the grid was the problem at all.
92#[inline]
93pub(crate) fn check_volume(name: &str, len: usize, grid: &Grid) -> usize {
94    let (nx, ny, nz) = grid.dims;
95    let n_total = nx * ny * nz;
96    assert_eq!(
97        len, n_total,
98        "{name} has {len} voxels but the grid is {nx}x{ny}x{nz} ({n_total} voxels)"
99    );
100    n_total
101}
102
103//=============================================================================
104// Box Filter Gaussian Approximation (matching MriResearchTools.jl)
105//=============================================================================
106
107/// Calculate box sizes to approximate Gaussian with given sigma using n box filters
108///
109/// This implements the algorithm from MriResearchTools.jl:
110/// Multiple box filter passes approximate a Gaussian convolution.
111fn get_box_sizes(sigma: f64, n: usize) -> Vec<usize> {
112    if sigma <= 0.0 || n == 0 {
113        return vec![0; n];
114    }
115
116    // wideal = sqrt((12*sigma^2 / n) + 1)
117    let wideal = ((12.0 * sigma * sigma / n as f64) + 1.0).sqrt();
118
119    // wl = next lower odd integer
120    let wl_float = wideal - (wideal + 1.0) % 2.0;
121    // Julia's `round` (and `round(Int, …)`) rounds ties to even
122    let wl = wl_float.round_ties_even() as usize;
123    let wl = if wl.is_multiple_of(2) { wl + 1 } else { wl }; // ensure odd
124    let wu = wl + 2;
125
126    // mideal = (12*sigma^2 - n*wl^2 - 4*n*wl - 3*n) / (-4*wl - 4)
127    let wl_f = wl as f64;
128    let n_f = n as f64;
129    let mideal = (12.0 * sigma * sigma - n_f * wl_f * wl_f - 4.0 * n_f * wl_f - 3.0 * n_f)
130                 / (-4.0 * wl_f - 4.0);
131    let m = mideal.round_ties_even() as usize;
132
133    (0..n).map(|i| if i < m { wl } else { wu }).collect()
134}
135
136/// Check and adjust box sizes to fit image dimensions
137fn check_box_sizes(boxsizes: &mut [Vec<usize>], dims: &[usize]) {
138    for (dim, bs) in boxsizes.iter_mut().enumerate() {
139        if dim >= dims.len() {
140            continue;
141        }
142        for b in bs.iter_mut() {
143            // Ensure odd
144            if *b % 2 == 0 {
145                *b += 1;
146            }
147            // Limit to half image size
148            let max_size = dims[dim] / 2;
149            if *b > max_size {
150                *b = if max_size.is_multiple_of(2) { max_size + 1 } else { max_size };
151            }
152        }
153    }
154}
155
156/// 1D box filter on a line (in-place), matching Julia's boxfilterline!
157///
158/// Uses running sum with edge handling.
159fn box_filter_line(line: &mut [f64], boxsize: usize) {
160    if boxsize < 3 || line.len() < boxsize {
161        return;
162    }
163
164    let n = line.len();
165    let r = boxsize / 2;
166
167    // Use a circular buffer approach
168    let mut queue: VecDeque<f64> = VecDeque::with_capacity(boxsize);
169
170    // Initialize with first r values
171    let mut lsum: f64 = line[..r].iter().sum();
172    for i in 0..r {
173        queue.push_back(line[i]);
174    }
175
176    // Start with edge effect (growing window)
177    for i in 0..=r {
178        lsum += line[i + r];
179        queue.push_back(line[i + r]);
180        line[i] = lsum / (r + i + 1) as f64;
181    }
182
183    // Middle part (full window)
184    for i in (r + 1)..(n - r) {
185        let old = queue.pop_front().unwrap();
186        lsum += line[i + r] - old;
187        queue.push_back(line[i + r]);
188        line[i] = lsum / boxsize as f64;
189    }
190
191    // End with edge effect (shrinking window)
192    for i in (n - r)..n {
193        let old = queue.pop_front().unwrap();
194        lsum -= old;
195        line[i] = lsum / (r + n - i) as f64;
196    }
197}
198
199/// 1D weighted box filter on a line (in-place), matching Julia's weighted boxfilterline!
200///
201/// The weights are Float32 there (`gaussiansmooth3d!` converts them), so `w^2` and `w − wold`
202/// are computed in f32 and each updated weight is rounded to f32; the sums are f64.
203fn box_filter_line_weighted(line: &mut [f64], weight: &mut [f32], boxsize: usize) {
204    if boxsize < 3 || line.len() < boxsize {
205        return;
206    }
207
208    let n = line.len();
209    let r = boxsize / 2;
210
211    let mut lq: VecDeque<f64> = VecDeque::with_capacity(boxsize);
212    let mut wq: VecDeque<f32> = VecDeque::with_capacity(boxsize);
213
214    // Initialize with first boxsize values
215    let mut sum = f64::EPSILON; // slightly bigger than 0 to avoid division by 0
216    let mut wsum = f64::EPSILON;
217    let mut wsmooth = f64::EPSILON;
218
219    for i in 0..boxsize {
220        sum += line[i] * weight[i] as f64;
221        wsum += weight[i] as f64;
222        wsmooth += (weight[i] * weight[i]) as f64;
223        lq.push_back(line[i]);
224        wq.push_back(weight[i]);
225    }
226
227    // Middle part
228    for i in (r + 1)..(n - r) {
229        let w = weight[i + r];
230        let l = line[i + r];
231        let wold = wq.pop_front().unwrap();
232        let lold = lq.pop_front().unwrap();
233        wq.push_back(w);
234        lq.push_back(l);
235
236        sum += l * w as f64 - lold * wold as f64;
237        wsum += (w - wold) as f64;
238        line[i] = sum / wsum;
239        wsmooth += (w * w - wold * wold) as f64;
240        weight[i] = (wsmooth / wsum) as f32;
241    }
242}
243
244/// Running-average line filter that treats `NaN` as "no data": the box only starts once it
245/// holds `boxsize` valid samples, and when it runs into `NaN` it extrapolates linearly for up
246/// to `boxsize/2` samples ("fill" mode) before waiting for data again. Positions the filter
247/// never reaches keep their input value. Port of `nanboxfilterline!` (MriResearchTools.jl);
248/// `orig` is scratch space that is resized as needed.
249///
250/// [`makehomogeneous`]'s masked smoothing had its own copy until 2026-10-30, which wrote each
251/// fill-mode extrapolation one voxel further than the original (`i + r < n` with a 0-based `i`,
252/// so the last voxel of a line was overwritten) and ignored the original's stop when the running
253/// sum turns NaN. The first alone put [`makehomogeneous`] 4.0 % NRMSE from MriResearchTools (see
254/// [`get_sensitivity`]). This is the port MCPC-3D-S already used (exact in the ROMEO fixture).
255pub fn nan_box_filter_line(line: &mut [f64], boxsize: usize, orig: &mut Vec<f64>) {
256    #[derive(PartialEq, Clone, Copy)]
257    enum Mode { Nan, Normal, Fill }
258    let n = line.len();
259    let r = boxsize / 2;
260    if n == 0 || boxsize < 3 {
261        return;
262    }
263    let maxfills = r;
264    orig.clear();
265    orig.resize(n + boxsize - 1, f64::NAN);
266    orig[r..r + n].copy_from_slice(line);
267
268    let mut lsum: f64 = orig[r..2 * r].iter().sum();
269    if lsum.is_nan() {
270        lsum = 0.0;
271    }
272    let (mut nfills, mut nvalids) = (0usize, 0usize);
273    let mut mode = Mode::Nan;
274    let bs = boxsize as f64;
275
276    // `i` is the 1-based output index, as in the reference; orig[k] here is orig[k+1] there.
277    for i in 1..=n {
278        if lsum.is_nan() {
279            break;
280        }
281        let lead = orig[i - 1 + 2 * r];
282        match mode {
283            Mode::Normal => {
284                if lead.is_nan() {
285                    mode = Mode::Fill;
286                }
287            }
288            Mode::Nan => {
289                if lead.is_nan() { nvalids = 0; } else { nvalids += 1; }
290                if nvalids == boxsize {
291                    mode = Mode::Normal;
292                    lsum = orig[i - 1..i + 2 * r].iter().sum();
293                    line[i - 1] = lsum / bs;
294                    continue;
295                }
296            }
297            Mode::Fill => {
298                if lead.is_nan() {
299                    nfills += 1;
300                    if nfills > maxfills {
301                        mode = Mode::Nan;
302                        nfills = 0;
303                        lsum = 0.0;
304                        nvalids = 0;
305                    }
306                } else {
307                    mode = Mode::Normal;
308                    nfills = 0;
309                }
310            }
311        }
312        match mode {
313            Mode::Normal => {
314                let trailing = if i >= 2 { orig[i - 2] } else { 0.0 };
315                lsum += orig[i - 1 + 2 * r] - trailing;
316                line[i - 1] = lsum / bs;
317            }
318            Mode::Fill => {
319                let trailing = if i >= 2 { orig[i - 2] } else { 0.0 };
320                lsum -= trailing;
321                line[i - 1] = (lsum - orig[i - 1]) / (bs - 2.0);
322                let prev = if i > r { line[i - 1 - r] } else { line[i - 1] };
323                let extrapolated = 2.0 * line[i - 1] - prev;
324                orig[i - 1 + 2 * r] = extrapolated;
325                if i + r < n {
326                    line[i - 1 + r] = extrapolated;
327                }
328                lsum += extrapolated;
329            }
330            Mode::Nan => {}
331        }
332    }
333}
334
335/// 3D Gaussian smoothing using box filter approximation
336///
337/// This matches MriResearchTools.jl's gaussiansmooth3d function.
338///
339/// Parameters:
340/// - data: input 3D data (will be copied)
341/// - sigma: sigma values for each dimension [sx, sy, sz]
342/// - mask: optional mask (None = no masking)
343/// - weight: optional weights (None = no weighting). As in MriResearchTools, they are copied to
344///   f32 (`Float32.(weight)`), zeros are replaced by the smallest non-zero weight, and the
345///   caller's array is left unchanged.
346/// - nbox: number of box filter passes (default 3, or 4 with mask)
347/// - nx, ny, nz: dimensions
348pub fn gaussian_smooth_3d(
349    data: &[f64],
350    sigma: [f64; 3],
351    mask: Option<&[u8]>,
352    weight: Option<&[f64]>,
353    nbox: usize,
354    grid: &Grid,
355) -> Vec<f64> {
356    let (nx, ny, nz) = grid.dims;
357    let n_total = check_volume("gaussian_smooth_3d: data", data.len(), grid);
358    if let Some(m) = mask {
359        check_volume("gaussian_smooth_3d: mask", m.len(), grid);
360    }
361    if let Some(w) = weight {
362        check_volume("gaussian_smooth_3d: weight", w.len(), grid);
363    }
364    // `weight = Float32.(weight); weight[weight .== 0] .= minimum(weight[weight .!= 0])`
365    let mut weight: Option<Vec<f32>> = weight.map(|w| {
366        let mut w: Vec<f32> = w.iter().map(|&v| v as f32).collect();
367        let min_nz = w.iter().copied().filter(|&v| v != 0.0).fold(f32::INFINITY, f32::min);
368        for v in w.iter_mut().filter(|v| **v == 0.0) {
369            *v = min_nz;
370        }
371        w
372    });
373    let mut result: Vec<f64> = data.to_vec();
374
375    // Calculate box sizes for each dimension
376    let mut boxsizes: Vec<Vec<usize>> = sigma.iter()
377        .map(|&s| get_box_sizes(s, nbox))
378        .collect();
379
380    check_box_sizes(&mut boxsizes, &[nx, ny, nz]);
381
382    // Apply mask: set masked-out voxels to NaN
383    if let Some(m) = mask {
384        for i in 0..n_total {
385            if m[i] == 0 {
386                result[i] = f64::NAN;
387            }
388        }
389    }
390
391    // Apply box filters for each pass and dimension
392    let mut scratch: Vec<f64> = Vec::new();
393    for ibox in 0..nbox {
394        // X direction
395        let bsize_x = boxsizes[0][ibox];
396        if nx > 1 && bsize_x >= 3 {
397            // Alternate direction for masked smoothing on even passes
398            let reverse = mask.is_some() && ibox % 2 == 1;
399
400            for k in 0..nz {
401                for j in 0..ny {
402                    let mut line: Vec<f64> = (0..nx).map(|i| {
403                        let idx = if reverse { nx - 1 - i } else { i };
404                        result[idx3d(idx, j, k, nx, ny)]
405                    }).collect();
406
407                    if mask.is_some() {
408                        nan_box_filter_line(&mut line, bsize_x, &mut scratch);
409                    } else if let Some(ref mut w) = weight.as_deref_mut() {
410                        let mut wline: Vec<f32> = (0..nx).map(|i| {
411                            let idx = if reverse { nx - 1 - i } else { i };
412                            w[idx3d(idx, j, k, nx, ny)]
413                        }).collect();
414                        box_filter_line_weighted(&mut line, &mut wline, bsize_x);
415                        for i in 0..nx {
416                            let idx = if reverse { nx - 1 - i } else { i };
417                            w[idx3d(idx, j, k, nx, ny)] = wline[i];
418                        }
419                    } else {
420                        box_filter_line(&mut line, bsize_x);
421                    }
422
423                    for i in 0..nx {
424                        let idx = if reverse { nx - 1 - i } else { i };
425                        result[idx3d(idx, j, k, nx, ny)] = line[i];
426                    }
427                }
428            }
429        }
430
431        // Y direction
432        let bsize_y = boxsizes[1][ibox];
433        if ny > 1 && bsize_y >= 3 {
434            let reverse = mask.is_some() && ibox % 2 == 1;
435
436            for k in 0..nz {
437                for i in 0..nx {
438                    let mut line: Vec<f64> = (0..ny).map(|j| {
439                        let idx = if reverse { ny - 1 - j } else { j };
440                        result[idx3d(i, idx, k, nx, ny)]
441                    }).collect();
442
443                    if mask.is_some() {
444                        nan_box_filter_line(&mut line, bsize_y, &mut scratch);
445                    } else if let Some(ref mut w) = weight.as_deref_mut() {
446                        let mut wline: Vec<f32> = (0..ny).map(|j| {
447                            let idx = if reverse { ny - 1 - j } else { j };
448                            w[idx3d(i, idx, k, nx, ny)]
449                        }).collect();
450                        box_filter_line_weighted(&mut line, &mut wline, bsize_y);
451                        for j in 0..ny {
452                            let idx = if reverse { ny - 1 - j } else { j };
453                            w[idx3d(i, idx, k, nx, ny)] = wline[j];
454                        }
455                    } else {
456                        box_filter_line(&mut line, bsize_y);
457                    }
458
459                    for j in 0..ny {
460                        let idx = if reverse { ny - 1 - j } else { j };
461                        result[idx3d(i, idx, k, nx, ny)] = line[j];
462                    }
463                }
464            }
465        }
466
467        // Z direction
468        let bsize_z = boxsizes[2][ibox];
469        if nz > 1 && bsize_z >= 3 {
470            let reverse = mask.is_some() && ibox % 2 == 1;
471
472            for j in 0..ny {
473                for i in 0..nx {
474                    let mut line: Vec<f64> = (0..nz).map(|k| {
475                        let idx = if reverse { nz - 1 - k } else { k };
476                        result[idx3d(i, j, idx, nx, ny)]
477                    }).collect();
478
479                    if mask.is_some() {
480                        nan_box_filter_line(&mut line, bsize_z, &mut scratch);
481                    } else if let Some(ref mut w) = weight.as_deref_mut() {
482                        let mut wline: Vec<f32> = (0..nz).map(|k| {
483                            let idx = if reverse { nz - 1 - k } else { k };
484                            w[idx3d(i, j, idx, nx, ny)]
485                        }).collect();
486                        box_filter_line_weighted(&mut line, &mut wline, bsize_z);
487                        for k in 0..nz {
488                            let idx = if reverse { nz - 1 - k } else { k };
489                            w[idx3d(i, j, idx, nx, ny)] = wline[k];
490                        }
491                    } else {
492                        box_filter_line(&mut line, bsize_z);
493                    }
494
495                    for k in 0..nz {
496                        let idx = if reverse { nz - 1 - k } else { k };
497                        result[idx3d(i, j, idx, nx, ny)] = line[k];
498                    }
499                }
500            }
501        }
502    }
503
504    result
505}
506
507/// Simplified smoothing with explicit box sizes (for robustmask post-processing)
508pub fn gaussian_smooth_3d_boxsizes(
509    data: &[f64],
510    boxsizes: &[Vec<usize>],
511    nbox: usize,
512    grid: &Grid,
513) -> Vec<f64> {
514    let (nx, ny, nz) = grid.dims;
515    check_volume("gaussian_smooth_3d_boxsizes: data", data.len(), grid);
516    assert_eq!(
517        boxsizes.len(), 3,
518        "gaussian_smooth_3d_boxsizes: boxsizes needs one entry per axis, got {}",
519        boxsizes.len()
520    );
521    let mut result = data.to_vec();
522
523    // Apply box filters for each pass and dimension
524    for ibox in 0..nbox {
525        // X direction
526        if nx > 1 && ibox < boxsizes[0].len() {
527            let bsize = boxsizes[0][ibox];
528            if bsize >= 3 {
529                for k in 0..nz {
530                    for j in 0..ny {
531                        let mut line: Vec<f64> = (0..nx).map(|i| result[idx3d(i, j, k, nx, ny)]).collect();
532                        box_filter_line(&mut line, bsize);
533                        for i in 0..nx {
534                            result[idx3d(i, j, k, nx, ny)] = line[i];
535                        }
536                    }
537                }
538            }
539        }
540
541        // Y direction
542        if ny > 1 && ibox < boxsizes[1].len() {
543            let bsize = boxsizes[1][ibox];
544            if bsize >= 3 {
545                for k in 0..nz {
546                    for i in 0..nx {
547                        let mut line: Vec<f64> = (0..ny).map(|j| result[idx3d(i, j, k, nx, ny)]).collect();
548                        box_filter_line(&mut line, bsize);
549                        for j in 0..ny {
550                            result[idx3d(i, j, k, nx, ny)] = line[j];
551                        }
552                    }
553                }
554            }
555        }
556
557        // Z direction
558        if nz > 1 && ibox < boxsizes[2].len() {
559            let bsize = boxsizes[2][ibox];
560            if bsize >= 3 {
561                for j in 0..ny {
562                    for i in 0..nx {
563                        let mut line: Vec<f64> = (0..nz).map(|k| result[idx3d(i, j, k, nx, ny)]).collect();
564                        box_filter_line(&mut line, bsize);
565                        for k in 0..nz {
566                            result[idx3d(i, j, k, nx, ny)] = line[k];
567                        }
568                    }
569                }
570            }
571        }
572    }
573
574    result
575}
576
577//=============================================================================
578// Robust Mask (matching MriResearchTools.jl)
579//=============================================================================
580
581/// MriResearchTools' unmasked `gaussiansmooth3d!` with explicit box sizes (`for ibox, dim`), in
582/// f32: `boxfilterline!` (`src/smoothing.jl`, 771eadf), a running sum with shrinking windows at
583/// both ends.
584fn smooth_boxes_f32(image: &mut [f32], boxsizes: &[usize], grid: &Grid) {
585    let (nx, ny, nz) = grid.dims;
586    let dims = [nx, ny, nz];
587    let mut line: Vec<f32> = Vec::new();
588    for &bsize0 in boxsizes {
589        for d in 0..3 {
590            let len = dims[d];
591            // checkboxsizes!: odd, and at most half the axis
592            let mut bsize = bsize0 + usize::from(bsize0 % 2 == 0);
593            if bsize as f64 > len as f64 / 2.0 {
594                bsize = len / 2;
595                if bsize % 2 == 0 {
596                    bsize += 1;
597                }
598            }
599            if len == 1 || bsize < 3 {
600                continue;
601            }
602            let (stride, na, sa, nb, sb) = match d {
603                0 => (1, ny, nx, nz, nx * ny),
604                1 => (nx, nx, 1, nz, nx * ny),
605                _ => (nx * ny, nx, 1, ny, nx),
606            };
607            line.resize(len, 0.0);
608            for b in 0..nb {
609                for a in 0..na {
610                    let base = a * sa + b * sb;
611                    for k in 0..len {
612                        line[k] = image[base + k * stride];
613                    }
614                    box_filter_line_f32(&mut line, bsize);
615                    for k in 0..len {
616                        image[base + k * stride] = line[k];
617                    }
618                }
619            }
620        }
621    }
622}
623
624/// `boxfilterline!` (MriResearchTools `src/smoothing.jl`, 771eadf) in f32.
625fn box_filter_line_f32(line: &mut [f32], boxsize: usize) {
626    let n = line.len();
627    let r = boxsize / 2;
628    if n < boxsize {
629        return;
630    }
631    let mut q: VecDeque<f32> = VecDeque::with_capacity(boxsize);
632    let mut lsum: f32 = line[..r].iter().sum();
633    q.extend(line[..r].iter().copied());
634    for i in 0..=r {
635        lsum += line[i + r];
636        q.push_back(line[i + r]);
637        line[i] = lsum / (r + i + 1) as f32;
638    }
639    for i in (r + 1)..(n - r) {
640        lsum += line[i + r] - q.pop_front().unwrap();
641        q.push_back(line[i + r]);
642        line[i] = lsum / boxsize as f32;
643    }
644    for i in (n - r)..n {
645        lsum -= q.pop_front().unwrap();
646        line[i] = lsum / (r + n - i) as f32;
647    }
648}
649
650/// MriResearchTools' `sample(I; n=1e5)` (`src/utility.jl:23-33`, 771eadf): `len = ceil(√min(n,
651/// length))` blocks of `len` consecutive values, starting at `round.(Int, range(0, length − len;
652/// length = len))` (0-based here), keeping the finite ones; all finite values if none are.
653fn mrt_sample(x: &[f64]) -> Vec<f64> {
654    let total = x.len();
655    if total == 0 {
656        return Vec::new();
657    }
658    let n = (1e5f64).min(total as f64);
659    let len = n.sqrt().ceil() as usize;
660    let stop = (total - len) as f64;
661    let mut out = Vec::with_capacity(len * len);
662    for k in 0..len {
663        // Julia's range(0, stop; length=len) and round (ties to even)
664        let start = if len == 1 { 0.0 } else { stop * k as f64 / (len - 1) as f64 };
665        let s = start.round_ties_even() as usize;
666        out.extend(x[s..s + len].iter().copied().filter(|v| v.is_finite()));
667    }
668    if out.is_empty() {
669        out = x.iter().copied().filter(|v| v.is_finite()).collect();
670    }
671    out
672}
673
674/// `Statistics.quantile(v, p)` (type 7, the default) of an ascending `sorted`, in Julia 1.10's
675/// arithmetic (`Statistics._quantile`, `stdlib/v1.10/Statistics/src/Statistics.jl:1000-1031`):
676/// `aleph = n·p + (1 − p)`, `j = clamp(trunc(aleph), 1, n − 1)` (1-based), `γ = aleph − j`, then
677/// `a + γ(b − a)` when `a ≈ b` (`isapprox`, rtol √eps) and `(1 − γ)a + γb` otherwise.
678fn quantile_sorted(sorted: &[f64], p: f64) -> f64 {
679    let n = sorted.len();
680    if n == 1 {
681        return sorted[0];
682    }
683    let m = 1.0 - p; // alpha + p (1 − alpha − beta) with alpha = beta = 1, exactly
684    let aleph = n as f64 * p + m;
685    let j = (aleph.trunc() as usize).clamp(1, n - 1);
686    let gamma = (aleph - j as f64).clamp(0.0, 1.0);
687    let (a, b) = (sorted[j - 1], sorted[j]);
688    let approx = a == b || (a - b).abs() <= f64::EPSILON.sqrt() * a.abs().max(b.abs());
689    if a.is_finite() && b.is_finite() && approx {
690        a + gamma * (b - a)
691    } else {
692        (1.0 - gamma) * a + gamma * b
693    }
694}
695
696/// Create robust mask from magnitude using quantile-based thresholding: MriResearchTools.jl's
697/// `robustmask` (`src/masking.jl:6-27`, 771eadf), with its threshold from `sample` and
698/// `Statistics.quantile`, and its box smoothing in single precision (see the comments in the
699/// body). On the √|HIP| of the QSM-CI in-vivo travelling-head scan it gives MriResearchTools'
700/// mask exactly (2,488,761 voxels); before it sampled every positive voxel, took nearest-rank
701/// quantiles and smoothed in f64, and 12,619 voxels differed.
702pub fn robust_mask(mag: &[f64], grid: &Grid) -> Vec<u8> {
703    let n_total = check_volume("robust_mask: magnitude", mag.len(), grid);
704
705    // MriResearchTools `robustmask` (`src/masking.jl:6-27`, 771eadf) estimates its threshold
706    // from `sample(weight)` (`src/utility.jl:23-33`): `len = ceil(√min(1e5, n))` blocks of `len`
707    // consecutive voxels (in memory order), starting at `round.(Int, range(0, n − len;
708    // length=len))`, finite values only (zeros included).
709    let samples = mrt_sample(mag);
710    if samples.is_empty() {
711        return vec![0u8; n_total];
712    }
713    let mut sorted = samples.clone();
714    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
715    // Statistics.quantile (type 7) on the sample
716    let q05 = quantile_sorted(&sorted, 0.05);
717    let q15 = quantile_sorted(&sorted, 0.15);
718    let q80 = quantile_sorted(&sorted, 0.80);
719    let q99 = quantile_sorted(&sorted, 0.99);
720    let mean = |v: Vec<f64>| if v.is_empty() { f64::NAN } else { v.iter().sum::<f64>() / v.len() as f64 };
721
722    // high_intensity = mean(w[q8 .<= w .<= q99]); noise = mean(w[w .<= q15])
723    let high_intensity = mean(samples.iter().copied().filter(|&v| q80 <= v && v <= q99).collect());
724    let mut noise = mean(samples.iter().copied().filter(|&v| v <= q15).collect());
725    if noise > high_intensity / 10.0 {
726        noise = mean(samples.iter().copied().filter(|&v| v <= q05).collect());
727        if noise > high_intensity / 10.0 {
728            noise = 0.0; // no noise detected
729        }
730    }
731
732    // Calculate threshold: max(5*noise, high_intensity/5)
733    let threshold = (5.0 * noise).max(high_intensity / 5.0);
734
735    // The mask, then: smoothed with one 5-voxel box per axis, > 0.4; holes filled; smoothed
736    // with two 3-voxel boxes per axis, > 0.6. MriResearchTools smooths the Bool mask as Float32
737    // (`gaussiansmooth3d` starts from `0f0 .+ image`) and compares with the Float64 literals, so
738    // a box average of exactly 2/5 counts as above 0.4 there (0.4f0 > 0.4). The smoothing here
739    // is the same running-sum box filter in f32, compared the same way.
740    let mut mask_f32: Vec<f32> = mag.iter()
741        .map(|&v| if v > threshold { 1.0 } else { 0.0 })
742        .collect();
743    smooth_boxes_f32(&mut mask_f32, &[5], grid);
744    let mut mask: Vec<u8> = mask_f32.iter()
745        .map(|&v| u8::from(v as f64 > 0.4))
746        .collect();
747
748    // fill_holes(mask; max_hole_size = length(mask) / 20)
749    let max_hole_size = n_total / 20;
750    mask = fill_holes(&mask, grid, max_hole_size);
751
752    let mut mask_f32: Vec<f32> = mask.iter().map(|&v| v as f32).collect();
753    smooth_boxes_f32(&mut mask_f32, &[3, 3], grid);
754    mask = mask_f32.iter()
755        .map(|&v| u8::from(v as f64 > 0.6))
756        .collect();
757
758    mask
759}
760
761//=============================================================================
762// Box Segmentation
763//=============================================================================
764
765/// Box segmentation for finding tissue regions
766///
767/// Divides the image into nbox^3 boxes and identifies voxels that
768/// consistently appear in the high-intensity range across multiple boxes.
769///
770/// MriResearchTools' `boxsegment` and `threshold` (`src/intensitycorrection.jl`, 771eadf): box
771/// centres every `boxshift = ceil(N / nbox)` voxels from the first, each box
772/// `max(1, c − boxshift):min(c + boxshift, N)` with both ends included, a voxel counts in a box
773/// when it is strictly within ±10 % of the box's `quantile(image[mask], 0.9)`, and tissue is what
774/// at least two boxes count.
775fn box_segment(
776    image: &[f64],
777    mask: &[u8],
778    nbox: usize,
779    nx: usize, ny: usize, nz: usize,
780) -> Vec<u8> {
781    let n_total = nx * ny * nz;
782    let mut vote_count = vec![0u8; n_total];
783
784    // Calculate box shift (stride between box centers)
785    let box_shift_x = nx.div_ceil(nbox);
786    let box_shift_y = ny.div_ceil(nbox);
787    let box_shift_z = nz.div_ceil(nbox);
788
789    // For each box center
790    let mut cz = 0;
791    while cz < nz {
792        let mut cy = 0;
793        while cy < ny {
794            let mut cx = 0;
795            while cx < nx {
796                // Box bounds: `max(1, c − boxshift):min(c + boxshift, N)`, both ends included
797                // (MriResearchTools `boxsegment`, `src/intensitycorrection.jl`, 771eadf).
798                let x_start = cx.saturating_sub(box_shift_x);
799                let x_end = (cx + box_shift_x + 1).min(nx);
800                let y_start = cy.saturating_sub(box_shift_y);
801                let y_end = (cy + box_shift_y + 1).min(ny);
802                let z_start = cz.saturating_sub(box_shift_z);
803                let z_end = (cz + box_shift_z + 1).min(nz);
804
805                // Collect values in this box
806                let mut box_vals: Vec<f64> = Vec::new();
807                for z in z_start..z_end {
808                    for y in y_start..y_end {
809                        for x in x_start..x_end {
810                            let idx = idx3d(x, y, z, nx, ny);
811                            if mask[idx] > 0 && image[idx].is_finite() {
812                                box_vals.push(image[idx]);
813                            }
814                        }
815                    }
816                }
817
818                if box_vals.is_empty() {
819                    cx += box_shift_x;
820                    continue;
821                }
822
823                // `threshold`: `quantile(image[mask], 0.9)` (Statistics, type 7)
824                box_vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
825                let q90 = quantile_sorted(&box_vals, 0.9);
826
827                // Define tissue range around 90th percentile
828                let width = 0.1;
829                let low = (1.0 - width) * q90;
830                let high = (1.0 + width) * q90;
831
832                // Vote for voxels in tissue range
833                for z in z_start..z_end {
834                    for y in y_start..y_end {
835                        for x in x_start..x_end {
836                            let idx = idx3d(x, y, z, nx, ny);
837                            if mask[idx] > 0 {
838                                let v = image[idx];
839                                if v > low && v < high {
840                                    vote_count[idx] = vote_count[idx].saturating_add(1);
841                                }
842                            }
843                        }
844                    }
845                }
846
847                cx += box_shift_x;
848            }
849            cy += box_shift_y;
850        }
851        cz += box_shift_z;
852    }
853
854    // Threshold: must be identified as tissue in >= 2 boxes
855    let mut segmented = vec![0u8; n_total];
856    for i in 0..n_total {
857        if vote_count[i] >= 2 && mask[i] > 0 {
858            segmented[i] = 1;
859        }
860    }
861
862    segmented
863}
864
865//=============================================================================
866// Fill and Smooth (with weighted smoothing)
867//=============================================================================
868
869/// Fill holes and smooth the lowpass field with weighted smoothing
870///
871/// Matches MriResearchTools.jl's fillandsmooth! function.
872/// Uses weighted smoothing where filled holes get weight 0.2.
873fn fill_and_smooth(
874    lowpass: &mut [f64],
875    stable_mean: f64,
876    sigma2: [f64; 3],
877    grid: &Grid,
878) {
879    let n_total = grid.n_total();
880
881    // Identify holes/outliers and create weight mask
882    // lowpassweight = 1.2 - lowpassmask (so holes get 0.2, normal get 1.2)
883    let mut weight = vec![1.2f64; n_total];
884
885    for i in 0..n_total {
886        if lowpass[i] < stable_mean / 4.0 ||
887           lowpass[i].is_nan() ||
888           lowpass[i] > 10.0 * stable_mean {
889            lowpass[i] = 3.0 * stable_mean;
890            weight[i] = 1.2 - 1.0; // `1.2 .- lowpassmask`: filled holes get less weight
891        }
892    }
893
894    // Apply weighted smoothing
895    let nbox = 3; // default for non-masked smoothing
896    let smoothed = gaussian_smooth_3d(lowpass, sigma2, None, Some(&weight), nbox, grid);
897    lowpass.copy_from_slice(&smoothed);
898}
899
900//=============================================================================
901// Main API
902//=============================================================================
903
904/// Get sensitivity (bias field) from magnitude
905///
906/// This estimates the RF receive field inhomogeneity (sensitivity map)
907/// that can be divided out to correct the image.
908///
909/// MriResearchTools' `getsensitivity` (771eadf). **Agreement:** bit-identical to
910/// MriResearchTools' `makehomogeneous(mag; sigma = 7 mm in voxels, nbox = 15)` on the 20×18×14,
911/// 1×1×1.5 mm phantom of `tests/mritools_parity.rs` (verified QSM-CI `julia-qsm` image,
912/// QSMxT/QSM-CI#299; measured 2026-10-30), where it was 3.3 % NRMSE in the mask before three
913/// port errors were fixed (astewartau/QSM.rs#319). Each measured alone, with the other two fixed:
914/// - the masked smoothing's `nanboxfilterline!` wrote each fill-mode extrapolation onto the last
915///   voxel of the line as well (an off-by-one from Julia's 1-based index): 4.0 %;
916/// - [`box_segment`]'s boxes stopped one voxel short of `c + boxshift`, which the original
917///   includes: 1.05 %;
918/// - its 90th percentile was a nearest-rank index, not `Statistics.quantile` (type 7): 0.35 %.
919///
920/// Also now as the original, at rounding level: Julia's ties-to-even `round` in the box widths,
921/// and the Float32 weights of `fillandsmooth!`'s weighted smoothing (`gaussiansmooth3d!` converts
922/// them). These are port fixes, not tuning: the algorithm and its defaults are unchanged.
923///
924/// # Errors
925/// If `nbox` (boxes per dimension for the tissue segmentation) is 0.
926pub fn get_sensitivity(
927    mag: &[f64],
928    grid: &Grid,
929    sigma_mm: f64,
930    nbox: usize,
931) -> Result<Vec<f64>, InputError> {
932    if nbox == 0 {
933        return Err(InputError::param("homogeneity nbox", nbox, ">= 1"));
934    }
935    let (nx, ny, nz) = grid.dims;
936    let (vx, vy, vz) = grid.voxel_size;
937    let n_total = check_volume("get_sensitivity: magnitude", mag.len(), grid);
938
939    // Convert mm to voxels
940    let sigma = [sigma_mm / vx, sigma_mm / vy, sigma_mm / vz];
941
942    // Create initial mask (with full post-processing)
943    let mask = robust_mask(mag, grid);
944
945    // Box segmentation to find tissue
946    let segmentation = box_segment(mag, &mask, nbox, nx, ny, nz);
947
948    // Split sigma into two parts (matching MriResearchTools.jl)
949    let factor: f64 = 0.7;
950    let sigma1 = [
951        (1.0_f64 - factor * factor).sqrt() * sigma[0],
952        (1.0_f64 - factor * factor).sqrt() * sigma[1],
953        (1.0_f64 - factor * factor).sqrt() * sigma[2],
954    ];
955    let sigma2 = [
956        factor * sigma[0],
957        factor * sigma[1],
958        factor * sigma[2],
959    ];
960
961    // First smoothing with tissue mask (nbox=8 for masked smoothing)
962    let mut lowpass = gaussian_smooth_3d(mag, sigma1, Some(&segmentation), None, 8, grid);
963
964    // Calculate stable mean for filling
965    let mut sum = 0.0;
966    let mut count = 0usize;
967    for i in 0..n_total {
968        if mask[i] > 0 && mag[i].is_finite() {
969            sum += mag[i];
970            count += 1;
971        }
972    }
973    let stable_mean = if count > 0 { sum / count as f64 } else { 1.0 };
974
975    // Fill holes and apply weighted second smoothing
976    fill_and_smooth(&mut lowpass, stable_mean, sigma2, grid);
977
978    Ok(lowpass)
979}
980
981/// Make magnitude homogeneous by dividing by bias field
982///
983/// This is the main entry point for bias field correction.
984///
985/// # Arguments
986/// * `mag` - Input magnitude data (nx * ny * nz)
987/// * `grid` - Dimensions and voxel sizes in mm
988/// * `sigma_mm` - Smoothing sigma in mm, used as given (MriResearchTools' default is 7; no
989///   field-of-view cap is applied, see the module docs)
990/// * `nbox` - Number of boxes per dimension for segmentation (MriResearchTools' default 15)
991///
992/// # Returns
993/// Bias-corrected magnitude
994///
995/// # Errors
996/// If `nbox` is 0 (see [`get_sensitivity`]).
997pub fn makehomogeneous(
998    mag: &[f64],
999    grid: &Grid,
1000    sigma_mm: f64,
1001    nbox: usize,
1002) -> Result<Vec<f64>, InputError> {
1003    let n_total = check_volume("makehomogeneous: magnitude", mag.len(), grid);
1004    let sensitivity = get_sensitivity(mag, grid, sigma_mm, nbox)?;
1005
1006    let mut result = vec![0.0; n_total];
1007    for i in 0..n_total {
1008        if sensitivity[i] > 1e-10 && !sensitivity[i].is_nan() {
1009            result[i] = mag[i] / sensitivity[i];
1010        } else {
1011            result[i] = mag[i];
1012        }
1013    }
1014
1015    Ok(result)
1016}
1017
1018/// RSS (Root Sum of Squares) magnitude combination
1019///
1020/// Combines multi-echo magnitude images using RSS.
1021///
1022/// # Arguments
1023/// * `mags_flat` - Flattened magnitudes [echo0, echo1, ...]
1024/// * `n_echoes` - Number of echoes
1025/// * `n_total` - Voxels per echo (nx * ny * nz)
1026///
1027/// # Returns
1028/// RSS-combined magnitude
1029pub fn rss_combine(
1030    mags_flat: &[f64],
1031    n_echoes: usize,
1032    n_total: usize,
1033) -> Vec<f64> {
1034    assert_eq!(
1035        mags_flat.len(), n_echoes * n_total,
1036        "rss_combine: magnitudes hold {} values but {n_echoes} echoes of {n_total} voxels \
1037         need {}",
1038        mags_flat.len(), n_echoes * n_total
1039    );
1040    let mut result = vec![0.0; n_total];
1041
1042    for e in 0..n_echoes {
1043        let offset = e * n_total;
1044        for i in 0..n_total {
1045            let v = mags_flat[offset + i];
1046            result[i] += v * v;
1047        }
1048    }
1049
1050    for i in 0..n_total {
1051        result[i] = result[i].sqrt();
1052    }
1053
1054    result
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060
1061    fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
1062        Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
1063    }
1064
1065    #[test]
1066    fn test_get_box_sizes() {
1067        // Test box size calculation matches Julia
1068        let sizes = get_box_sizes(5.0, 3);
1069        assert_eq!(sizes.len(), 3);
1070        // For sigma=5, n=3: wideal ≈ 5.77
1071        // All sizes should be odd and reasonable
1072        for &s in &sizes {
1073            assert!(s % 2 == 1, "Box size should be odd, got {}", s);
1074            assert!((3..=11).contains(&s), "Box size should be in reasonable range, got {}", s);
1075        }
1076    }
1077
1078    #[test]
1079    fn test_box_filter_line() {
1080        // Simple test: uniform values should stay uniform
1081        let mut line = vec![1.0; 10];
1082        box_filter_line(&mut line, 3);
1083        for &v in &line {
1084            assert!((v - 1.0).abs() < 1e-10, "Uniform line should stay uniform");
1085        }
1086    }
1087
1088    #[test]
1089    fn test_robust_mask_basic() {
1090        // Simple test with uniform high values
1091        let mag = vec![100.0; 27];
1092        let mask = robust_mask(&mag, &grid(3, 3, 3));
1093        // All values are the same, so all should be masked
1094        let masked_count: usize = mask.iter().map(|&v| v as usize).sum();
1095        assert!(masked_count > 0, "Should have some masked voxels");
1096    }
1097
1098    #[test]
1099    fn test_rss_combine() {
1100        // Two echoes, 4 voxels each
1101        let mags = vec![
1102            3.0, 0.0, 0.0, 5.0,  // echo 0
1103            4.0, 0.0, 0.0, 12.0, // echo 1
1104        ];
1105        let result = rss_combine(&mags, 2, 4);
1106
1107        // sqrt(3^2 + 4^2) = 5
1108        assert!((result[0] - 5.0).abs() < 1e-10);
1109        // sqrt(0 + 0) = 0
1110        assert!((result[1] - 0.0).abs() < 1e-10);
1111        // sqrt(5^2 + 12^2) = 13
1112        assert!((result[3] - 13.0).abs() < 1e-10);
1113    }
1114
1115    // =====================================================================
1116    // Helper: create a 3D sphere magnitude phantom with optional bias field
1117    // =====================================================================
1118
1119    /// Create a 3D sphere phantom with a smooth bias field applied.
1120    /// Returns (magnitude_data, mask) where mask marks inside-sphere voxels.
1121    fn make_sphere_phantom(n: usize, bias: bool) -> (Vec<f64>, Vec<u8>) {
1122        let center = n as f64 / 2.0;
1123        let radius = n as f64 / 2.0 - 2.0;
1124        let n_total = n * n * n;
1125        let mut mag = vec![0.0f64; n_total];
1126        let mut mask = vec![0u8; n_total];
1127
1128        for k in 0..n {
1129            for j in 0..n {
1130                for i in 0..n {
1131                    let dx = i as f64 - center;
1132                    let dy = j as f64 - center;
1133                    let dz = k as f64 - center;
1134                    let dist = (dx * dx + dy * dy + dz * dz).sqrt();
1135                    let idx = i + j * n + k * n * n;
1136
1137                    if dist < radius {
1138                        // Base tissue intensity
1139                        let base = 100.0;
1140                        // Apply smooth bias field if requested
1141                        let bias_val = if bias {
1142                            1.0 + 0.5 * (i as f64 / n as f64)
1143                        } else {
1144                            1.0
1145                        };
1146                        mag[idx] = base * bias_val;
1147                        mask[idx] = 1;
1148                    } else {
1149                        // Background noise
1150                        mag[idx] = 1.0 + 0.5 * ((i + j + k) % 3) as f64;
1151                    }
1152                }
1153            }
1154        }
1155
1156        (mag, mask)
1157    }
1158
1159    // =====================================================================
1160    // Tests for get_box_sizes edge cases
1161    // =====================================================================
1162
1163    #[test]
1164    fn test_get_box_sizes_zero_sigma() {
1165        let sizes = get_box_sizes(0.0, 3);
1166        assert_eq!(sizes, vec![0, 0, 0]);
1167    }
1168
1169    #[test]
1170    fn test_get_box_sizes_zero_n() {
1171        let sizes = get_box_sizes(5.0, 0);
1172        assert!(sizes.is_empty());
1173    }
1174
1175    #[test]
1176    fn test_get_box_sizes_negative_sigma() {
1177        let sizes = get_box_sizes(-1.0, 3);
1178        assert_eq!(sizes, vec![0, 0, 0]);
1179    }
1180
1181    #[test]
1182    fn test_get_box_sizes_large_sigma() {
1183        let sizes = get_box_sizes(20.0, 4);
1184        assert_eq!(sizes.len(), 4);
1185        for &s in &sizes {
1186            assert!(s % 2 == 1, "Box size should be odd, got {}", s);
1187            assert!(s >= 3, "Box size should be at least 3, got {}", s);
1188        }
1189    }
1190
1191    // =====================================================================
1192    // Tests for check_box_sizes
1193    // =====================================================================
1194
1195    #[test]
1196    fn test_check_box_sizes_clamps_to_half_image() {
1197        // Box size larger than half the image dimension should be clamped
1198        let mut boxsizes = vec![vec![99], vec![99], vec![99]];
1199        let dims = [10, 10, 10];
1200        check_box_sizes(&mut boxsizes, &dims);
1201        for bs in &boxsizes {
1202            for &b in bs {
1203                assert!(b <= dims[0], "Box size should be clamped, got {}", b);
1204                assert!(b % 2 == 1, "Box size should be odd, got {}", b);
1205            }
1206        }
1207    }
1208
1209    #[test]
1210    fn test_check_box_sizes_makes_even_odd() {
1211        let mut boxsizes = vec![vec![4], vec![6], vec![8]];
1212        let dims = [100, 100, 100];
1213        check_box_sizes(&mut boxsizes, &dims);
1214        for bs in &boxsizes {
1215            for &b in bs {
1216                assert!(b % 2 == 1, "Box size should be odd, got {}", b);
1217            }
1218        }
1219    }
1220
1221    // =====================================================================
1222    // Tests for box_filter_line edge cases
1223    // =====================================================================
1224
1225    #[test]
1226    fn test_box_filter_line_too_small_boxsize() {
1227        // boxsize < 3 should be a no-op
1228        let mut line = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1229        let original = line.clone();
1230        box_filter_line(&mut line, 1);
1231        assert_eq!(line, original);
1232    }
1233
1234    #[test]
1235    fn test_box_filter_line_larger_than_data() {
1236        // boxsize > line length should be a no-op
1237        let mut line = vec![1.0, 2.0];
1238        let original = line.clone();
1239        box_filter_line(&mut line, 5);
1240        assert_eq!(line, original);
1241    }
1242
1243    #[test]
1244    fn test_box_filter_line_smoothing_effect() {
1245        // A spike should be smoothed out
1246        let mut line = vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1247        box_filter_line(&mut line, 5);
1248        // The spike at index 3 should be reduced
1249        assert!(line[3] < 10.0, "Spike should be smoothed");
1250        // Neighbors should have received some intensity
1251        assert!(line[2] > 0.0 || line[4] > 0.0, "Neighbors should gain intensity");
1252        // All values should be finite
1253        for &v in &line {
1254            assert!(v.is_finite(), "All values should be finite");
1255        }
1256    }
1257
1258    // =====================================================================
1259    // Tests for box_filter_line_weighted
1260    // =====================================================================
1261
1262    #[test]
1263    fn test_box_filter_line_weighted_too_small() {
1264        let mut line = vec![1.0, 2.0];
1265        let mut weight = vec![1.0, 1.0];
1266        let orig_l = line.clone();
1267        let orig_w = weight.clone();
1268        box_filter_line_weighted(&mut line, &mut weight, 1);
1269        assert_eq!(line, orig_l);
1270        assert_eq!(weight, orig_w);
1271    }
1272
1273    #[test]
1274    fn test_box_filter_line_weighted_uniform() {
1275        let mut line = vec![5.0; 10];
1276        let mut weight = vec![1.0; 10];
1277        box_filter_line_weighted(&mut line, &mut weight, 3);
1278        // With uniform values and uniform weights, the middle portion
1279        // should remain close to 5.0
1280        for i in 2..8 {
1281            assert!(
1282                (line[i] - 5.0).abs() < 0.5,
1283                "Uniform weighted line should stay near 5.0, got {} at index {}",
1284                line[i], i
1285            );
1286        }
1287    }
1288
1289    #[test]
1290    fn test_box_filter_line_weighted_varying_weights() {
1291        let mut line = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1292        let mut weight = vec![1.0, 1.0, 1.0, 0.5, 0.5, 0.5, 1.0, 1.0, 1.0, 1.0];
1293        box_filter_line_weighted(&mut line, &mut weight, 3);
1294        // All values should remain finite
1295        for &v in &line {
1296            assert!(v.is_finite(), "All values should be finite after weighted filter");
1297        }
1298        for &w in &weight {
1299            assert!(w.is_finite(), "All weights should be finite after weighted filter");
1300        }
1301    }
1302
1303    // =====================================================================
1304    // Tests for nan_box_filter_line
1305    // =====================================================================
1306
1307    #[test]
1308    fn test_nan_box_filter_line_too_small() {
1309        let mut line = vec![1.0, 2.0];
1310        let original = line.clone();
1311        nan_box_filter_line(&mut line, 5, &mut Vec::new());
1312        assert_eq!(line, original);
1313    }
1314
1315    #[test]
1316    fn test_nan_box_filter_line_no_nans() {
1317        // All valid data => the nan filter processes it and produces finite results
1318        let mut line = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1319        nan_box_filter_line(&mut line, 3, &mut Vec::new());
1320        // All values should remain finite
1321        for &v in &line {
1322            assert!(v.is_finite(), "All values should be finite after nan box filter");
1323        }
1324        // The nan box filter in Normal mode should act as a smoothing filter
1325        // Check that some middle values have changed (been smoothed)
1326        // The function's Normal mode applies running sum averaging
1327    }
1328
1329    #[test]
1330    fn test_nan_box_filter_line_with_nans() {
1331        // Data with NaN gaps
1332        let mut line = vec![5.0, 5.0, 5.0, f64::NAN, f64::NAN, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0];
1333        nan_box_filter_line(&mut line, 3, &mut Vec::new());
1334        // Non-NaN regions should still be mostly finite
1335        let finite_count = line.iter().filter(|v| v.is_finite()).count();
1336        assert!(finite_count >= 6, "Most values should be finite, got {}", finite_count);
1337    }
1338
1339    #[test]
1340    fn test_nan_box_filter_line_all_nan_except_edges() {
1341        // Mostly NaN
1342        let mut line = vec![1.0; 12];
1343        for i in 2..10 {
1344            line[i] = f64::NAN;
1345        }
1346        nan_box_filter_line(&mut line, 3, &mut Vec::new());
1347        // The function should not panic and edge values should remain finite
1348        assert!(line[0].is_finite() || line[0].is_nan());
1349    }
1350
1351    // =====================================================================
1352    // Tests for gaussian_smooth_3d (main 3D smoothing)
1353    // =====================================================================
1354
1355    #[test]
1356    fn test_gaussian_smooth_3d_uniform_no_mask() {
1357        let n = 8;
1358        let data = vec![5.0; n * n * n];
1359        let result = gaussian_smooth_3d(&data, [2.0, 2.0, 2.0], None, None, 3, &grid(n, n, n));
1360        assert_eq!(result.len(), n * n * n);
1361        // Uniform data should stay nearly uniform after smoothing
1362        for &v in &result {
1363            assert!(
1364                (v - 5.0).abs() < 0.5,
1365                "Uniform data should stay near 5.0, got {}",
1366                v
1367            );
1368        }
1369    }
1370
1371    #[test]
1372    fn test_gaussian_smooth_3d_with_mask() {
1373        let n = 10;
1374        let (mag, mask) = make_sphere_phantom(n, false);
1375        // Smooth with mask
1376        let result = gaussian_smooth_3d(&mag, [1.5, 1.5, 1.5], Some(&mask), None, 4, &grid(n, n, n));
1377        assert_eq!(result.len(), n * n * n);
1378        // Inside mask, values should still be finite
1379        for i in 0..result.len() {
1380            if mask[i] > 0 {
1381                assert!(result[i].is_finite(), "Masked voxel at {} should be finite", i);
1382            }
1383        }
1384    }
1385
1386    #[test]
1387    fn test_gaussian_smooth_3d_with_weights() {
1388        let n = 8;
1389        let data = vec![10.0; n * n * n];
1390        let weight = vec![1.0; n * n * n];
1391        let result = gaussian_smooth_3d(
1392            &data, [1.5, 1.5, 1.5], None, Some(&weight), 3, &grid(n, n, n),
1393        );
1394        assert_eq!(result.len(), n * n * n);
1395        for &v in &result {
1396            assert!(v.is_finite(), "Result should be finite");
1397        }
1398    }
1399
1400    // =====================================================================
1401    // Tests for gaussian_smooth_3d_boxsizes
1402    // =====================================================================
1403
1404    #[test]
1405    fn test_gaussian_smooth_3d_boxsizes_uniform() {
1406        let n = 8;
1407        let data = vec![3.0; n * n * n];
1408        let boxsizes = vec![vec![3, 3], vec![3, 3], vec![3, 3]];
1409        let result = gaussian_smooth_3d_boxsizes(&data, &boxsizes, 2, &grid(n, n, n));
1410        assert_eq!(result.len(), n * n * n);
1411        for &v in &result {
1412            assert!(
1413                (v - 3.0).abs() < 0.5,
1414                "Uniform data should stay near 3.0, got {}",
1415                v
1416            );
1417        }
1418    }
1419
1420    #[test]
1421    fn test_gaussian_smooth_3d_boxsizes_spike() {
1422        let n = 10;
1423        let n_total = n * n * n;
1424        let mut data = vec![0.0; n_total];
1425        // Place a spike at the center
1426        let center = n / 2 + (n / 2) * n + (n / 2) * n * n;
1427        data[center] = 100.0;
1428        let boxsizes = vec![vec![5, 5], vec![5, 5], vec![5, 5]];
1429        let result = gaussian_smooth_3d_boxsizes(&data, &boxsizes, 2, &grid(n, n, n));
1430        assert_eq!(result.len(), n_total);
1431        // Spike should be reduced
1432        assert!(
1433            result[center] < 100.0,
1434            "Spike should be smoothed, got {}",
1435            result[center]
1436        );
1437        // Sum should be approximately conserved (box filter is mean-preserving)
1438        for &v in &result {
1439            assert!(v.is_finite(), "All values should be finite");
1440        }
1441    }
1442
1443    // =====================================================================
1444    // Tests for robust_mask (more comprehensive)
1445    // =====================================================================
1446
1447    #[test]
1448    fn test_robust_mask_sphere() {
1449        let n = 12;
1450        let (mag, _) = make_sphere_phantom(n, false);
1451        let mask = robust_mask(&mag, &grid(n, n, n));
1452        assert_eq!(mask.len(), n * n * n);
1453        let masked_count: usize = mask.iter().map(|&v| v as usize).sum();
1454        // The sphere should produce some masked voxels
1455        assert!(masked_count > 0, "Should have masked voxels for sphere phantom");
1456        // Center should be masked (high intensity)
1457        let center = n / 2 + (n / 2) * n + (n / 2) * n * n;
1458        assert_eq!(mask[center], 1, "Center of sphere should be masked");
1459    }
1460
1461    #[test]
1462    fn test_robust_mask_empty() {
1463        // All zero magnitude -> empty mask
1464        let mag = vec![0.0; 27];
1465        let mask = robust_mask(&mag, &grid(3, 3, 3));
1466        let count: usize = mask.iter().map(|&v| v as usize).sum();
1467        assert_eq!(count, 0, "Zero magnitude should produce empty mask");
1468    }
1469
1470    #[test]
1471    fn test_robust_mask_nan_values() {
1472        // Magnitude with NaN values
1473        let mut mag = vec![100.0; 125];
1474        mag[0] = f64::NAN;
1475        mag[10] = f64::NAN;
1476        mag[50] = f64::INFINITY;
1477        let mask = robust_mask(&mag, &grid(5, 5, 5));
1478        assert_eq!(mask.len(), 125);
1479        // Should still produce a valid mask
1480        for &v in &mask {
1481            assert!(v == 0 || v == 1, "Mask values should be 0 or 1");
1482        }
1483    }
1484
1485    // =====================================================================
1486    // Tests for box_segment
1487    // =====================================================================
1488
1489    #[test]
1490    fn test_box_segment_uniform() {
1491        let n = 10;
1492        let (mag, mask) = make_sphere_phantom(n, false);
1493        let seg = box_segment(&mag, &mask, 3, n, n, n);
1494        assert_eq!(seg.len(), n * n * n);
1495        // In a uniform sphere, most interior voxels should be segmented as tissue
1496        let seg_count: usize = seg.iter().map(|&v| v as usize).sum();
1497        assert!(seg_count > 0, "Box segment should find some tissue voxels");
1498    }
1499
1500    #[test]
1501    fn test_box_segment_empty_mask() {
1502        let n = 8;
1503        let mag = vec![100.0; n * n * n];
1504        let mask = vec![0u8; n * n * n]; // empty mask
1505        let seg = box_segment(&mag, &mask, 3, n, n, n);
1506        let count: usize = seg.iter().map(|&v| v as usize).sum();
1507        assert_eq!(count, 0, "Empty mask should produce no segmentation");
1508    }
1509
1510    // =====================================================================
1511    // Tests for fill_and_smooth
1512    // =====================================================================
1513
1514    #[test]
1515    fn test_fill_and_smooth_basic() {
1516        let n = 10;
1517        let n_total = n * n * n;
1518        let stable_mean = 100.0;
1519        let mut lowpass = vec![stable_mean; n_total];
1520        // Add some holes (very low values)
1521        lowpass[0] = 0.0;
1522        lowpass[100] = f64::NAN;
1523        lowpass[200] = 2000.0; // outlier > 10*stable_mean
1524
1525        let sigma2 = [2.0, 2.0, 2.0];
1526        fill_and_smooth(&mut lowpass, stable_mean, sigma2, &grid(n, n, n));
1527
1528        // All values should be finite after fill and smooth
1529        for (i, &v) in lowpass.iter().enumerate() {
1530            assert!(v.is_finite(), "Value at {} should be finite, got {}", i, v);
1531        }
1532    }
1533
1534    #[test]
1535    fn test_fill_and_smooth_preserves_approximate_mean() {
1536        let n = 8;
1537        let n_total = n * n * n;
1538        let stable_mean = 50.0;
1539        let mut lowpass = vec![stable_mean; n_total];
1540        let sigma2 = [1.5, 1.5, 1.5];
1541        fill_and_smooth(&mut lowpass, stable_mean, sigma2, &grid(n, n, n));
1542
1543        // Mean should be approximately preserved
1544        let mean: f64 = lowpass.iter().sum::<f64>() / n_total as f64;
1545        assert!(
1546            (mean - stable_mean).abs() < stable_mean * 0.5,
1547            "Mean should be approximately preserved, got {} vs {}",
1548            mean,
1549            stable_mean
1550        );
1551    }
1552
1553    // =====================================================================
1554    // Tests for get_sensitivity
1555    // =====================================================================
1556
1557    #[test]
1558    fn test_get_sensitivity_sphere() {
1559        let n = 12;
1560        let (mag, _) = make_sphere_phantom(n, true);
1561        let sensitivity = get_sensitivity(&mag, &grid(n, n, n), 4.0, 5).unwrap();
1562        assert_eq!(sensitivity.len(), n * n * n);
1563        // Sensitivity should be finite and mostly positive in the sphere
1564        let center = n / 2 + (n / 2) * n + (n / 2) * n * n;
1565        assert!(
1566            sensitivity[center].is_finite(),
1567            "Sensitivity at center should be finite"
1568        );
1569        assert!(
1570            sensitivity[center] > 0.0,
1571            "Sensitivity at center should be positive, got {}",
1572            sensitivity[center]
1573        );
1574    }
1575
1576    #[test]
1577    fn test_get_sensitivity_output_size() {
1578        let n = 10;
1579        let (mag, _) = make_sphere_phantom(n, false);
1580        let sensitivity = get_sensitivity(&mag, &grid(n, n, n), 3.0, 3).unwrap();
1581        assert_eq!(sensitivity.len(), n * n * n);
1582    }
1583
1584    // =====================================================================
1585    // Tests for makehomogeneous (main entry point)
1586    // =====================================================================
1587
1588    #[test]
1589    fn test_makehomogeneous_output_size_and_finite() {
1590        let n = 12;
1591        let (mag, _) = make_sphere_phantom(n, true);
1592        let result = makehomogeneous(&mag, &grid(n, n, n), 4.0, 5).unwrap();
1593        assert_eq!(result.len(), n * n * n);
1594        // All output values should be finite
1595        for (i, &v) in result.iter().enumerate() {
1596            assert!(v.is_finite(), "Output at {} should be finite, got {}", i, v);
1597        }
1598    }
1599
1600    #[test]
1601    fn test_makehomogeneous_reduces_bias() {
1602        let n = 12;
1603        let (mag_biased, mask) = make_sphere_phantom(n, true);
1604        let result = makehomogeneous(&mag_biased, &grid(n, n, n), 4.0, 5).unwrap();
1605
1606        // Collect values inside the sphere
1607        let mut original_vals = Vec::new();
1608        let mut corrected_vals = Vec::new();
1609        for i in 0..(n * n * n) {
1610            if mask[i] > 0 {
1611                original_vals.push(mag_biased[i]);
1612                corrected_vals.push(result[i]);
1613            }
1614        }
1615
1616        // The coefficient of variation should be reduced (or at least not much worse)
1617        let orig_mean = original_vals.iter().sum::<f64>() / original_vals.len() as f64;
1618        let orig_std = (original_vals
1619            .iter()
1620            .map(|v| (v - orig_mean).powi(2))
1621            .sum::<f64>()
1622            / original_vals.len() as f64)
1623            .sqrt();
1624        let orig_cv = orig_std / orig_mean;
1625
1626        let corr_mean = corrected_vals.iter().sum::<f64>() / corrected_vals.len() as f64;
1627        let corr_std = (corrected_vals
1628            .iter()
1629            .map(|v| (v - corr_mean).powi(2))
1630            .sum::<f64>()
1631            / corrected_vals.len() as f64)
1632            .sqrt();
1633        let corr_cv = corr_std / corr_mean;
1634
1635        assert!(
1636            corr_cv <= orig_cv * 2.0,
1637            "Corrected CV ({}) should not be much worse than original CV ({})",
1638            corr_cv,
1639            orig_cv
1640        );
1641    }
1642
1643    #[test]
1644    fn test_makehomogeneous_no_bias_preserves() {
1645        let n = 12;
1646        let (mag, mask) = make_sphere_phantom(n, false);
1647        let result = makehomogeneous(&mag, &grid(n, n, n), 4.0, 5).unwrap();
1648
1649        // With no bias, corrected values should still be positive inside sphere
1650        for i in 0..(n * n * n) {
1651            if mask[i] > 0 {
1652                assert!(
1653                    result[i] > 0.0,
1654                    "Inside sphere voxel {} should be positive, got {}",
1655                    i,
1656                    result[i]
1657                );
1658            }
1659        }
1660    }
1661
1662    #[test]
1663    fn test_makehomogeneous_anisotropic_voxels() {
1664        let n = 12;
1665        let (mag, _) = make_sphere_phantom(n, true);
1666        // Use anisotropic voxel sizes
1667        let result = makehomogeneous(&mag, &Grid::new(n, n, n, 0.5, 0.5, 2.0), 4.0, 5).unwrap();
1668        assert_eq!(result.len(), n * n * n);
1669        for &v in &result {
1670            assert!(v.is_finite(), "All output values should be finite");
1671        }
1672    }
1673
1674    // =====================================================================
1675    // Tests for gaussian_smooth_3d with reversed passes (mask + even pass)
1676    // =====================================================================
1677
1678    #[test]
1679    fn test_gaussian_smooth_3d_mask_reverse_passes() {
1680        // Exercise the reverse pass code paths (mask.is_some() && ibox % 2 == 1)
1681        let n = 10;
1682        let (mag, mask) = make_sphere_phantom(n, false);
1683        // nbox=4 ensures we have even passes (ibox=1,3)
1684        let result = gaussian_smooth_3d(&mag, [1.5, 1.5, 1.5], Some(&mask), None, 4, &grid(n, n, n));
1685        assert_eq!(result.len(), n * n * n);
1686        // Inside mask, values should still be finite
1687        for i in 0..result.len() {
1688            if mask[i] > 0 {
1689                // NaN smoothing can produce NaN near edges, but center should be finite
1690            }
1691        }
1692        // At least some values should be finite
1693        let finite_count = result.iter().filter(|v| v.is_finite()).count();
1694        assert!(finite_count > 0, "Should have some finite values");
1695    }
1696
1697    // =====================================================================
1698    // Issue #70: a volume that does not match its grid must say so
1699    //
1700    // A run is processed on one grid and that grid indexes every other volume,
1701    // so a magnitude of a different matrix size used to read past the end of
1702    // the buffer inside the box filter:
1703    //
1704    //     bias_correction.rs:453:72: index out of bounds:
1705    //     the len is 21807104 but the index is 21807104
1706    //
1707    // which named neither the input nor the grid. Each entry point now refuses
1708    // the mismatch and reports both sizes.
1709    // =====================================================================
1710
1711    #[test]
1712    #[should_panic(expected = "makehomogeneous: magnitude has 216 voxels but the grid is 8x8x8 (512 voxels)")]
1713    fn makehomogeneous_refuses_magnitude_shorter_than_grid() {
1714        let _ = makehomogeneous(&vec![1.0; 6 * 6 * 6], &grid(8, 8, 8), 4.0, 5);
1715    }
1716
1717    #[test]
1718    #[should_panic(expected = "makehomogeneous: magnitude has 1000 voxels but the grid is 8x8x8 (512 voxels)")]
1719    fn makehomogeneous_refuses_magnitude_longer_than_grid() {
1720        // The over-long case never panicked - it silently corrected the first
1721        // 512 voxels and discarded the rest, which is worse than a crash.
1722        let _ = makehomogeneous(&vec![1.0; 10 * 10 * 10], &grid(8, 8, 8), 4.0, 5);
1723    }
1724
1725    #[test]
1726    #[should_panic(expected = "robust_mask: magnitude has 216 voxels but the grid is 8x8x8 (512 voxels)")]
1727    fn robust_mask_refuses_magnitude_not_matching_grid() {
1728        robust_mask(&vec![1.0; 6 * 6 * 6], &grid(8, 8, 8));
1729    }
1730
1731    #[test]
1732    #[should_panic(expected = "gaussian_smooth_3d_boxsizes: data has 216 voxels but the grid is 8x8x8 (512 voxels)")]
1733    fn gaussian_smooth_3d_boxsizes_refuses_data_not_matching_grid() {
1734        // This is the function that carried the reported panic (line 453).
1735        let boxsizes = vec![vec![5], vec![5], vec![5]];
1736        gaussian_smooth_3d_boxsizes(&vec![1.0; 6 * 6 * 6], &boxsizes, 1, &grid(8, 8, 8));
1737    }
1738
1739    #[test]
1740    #[should_panic(expected = "gaussian_smooth_3d: mask has 216 voxels but the grid is 8x8x8 (512 voxels)")]
1741    fn gaussian_smooth_3d_refuses_mask_not_matching_grid() {
1742        let data = vec![1.0; 8 * 8 * 8];
1743        let mask = vec![1u8; 6 * 6 * 6];
1744        gaussian_smooth_3d(&data, [1.5, 1.5, 1.5], Some(&mask), None, 3, &grid(8, 8, 8));
1745    }
1746
1747    #[test]
1748    #[should_panic(expected = "gaussian_smooth_3d: weight has 216 voxels but the grid is 8x8x8 (512 voxels)")]
1749    fn gaussian_smooth_3d_refuses_weight_not_matching_grid() {
1750        let data = vec![1.0; 8 * 8 * 8];
1751        let weight = vec![1.0; 6 * 6 * 6];
1752        gaussian_smooth_3d(&data, [1.5, 1.5, 1.5], None, Some(&weight), 3, &grid(8, 8, 8));
1753    }
1754
1755    #[test]
1756    #[should_panic(expected = "rss_combine: magnitudes hold 600 values but 3 echoes of 216 voxels need 648")]
1757    fn rss_combine_refuses_truncated_echo_stack() {
1758        rss_combine(&vec![1.0; 600], 3, 6 * 6 * 6);
1759    }
1760
1761    #[test]
1762    fn makehomogeneous_accepts_a_matching_volume() {
1763        // The guard must not reject the ordinary case.
1764        let n = 8;
1765        let out = makehomogeneous(&vec![1.0; n * n * n], &grid(n, n, n), 4.0, 5).unwrap();
1766        assert_eq!(out.len(), n * n * n);
1767    }
1768
1769    /// The defaults are MriResearchTools' (`makehomogeneous(mag; sigma_mm=7, nbox=15)`), and the
1770    /// pipeline config's copies agree with them until it holds `HomogeneityParams` itself (#314).
1771    #[test]
1772    fn defaults_are_mriresearchtools_and_agree_with_the_pipeline_config() {
1773        let p = HomogeneityParams::default();
1774        assert_eq!((p.sigma_mm, p.nbox), (7.0, 15));
1775        let m = crate::pipeline::MaskingConfig::default();
1776        assert_eq!((m.homogeneity_sigma_mm, m.homogeneity_nbox), (p.sigma_mm, p.nbox));
1777    }
1778
1779    /// #199: `nbox = 0` divided by zero in the box segmentation; it is an error now.
1780    #[test]
1781    fn zero_nbox_is_an_error() {
1782        let n = 8;
1783        let mag = vec![1.0; n * n * n];
1784        let err = makehomogeneous(&mag, &grid(n, n, n), 4.0, 0).unwrap_err();
1785        assert!(err.0.contains("homogeneity nbox = 0") && err.0.contains(">= 1"), "{err}");
1786        assert!(get_sensitivity(&mag, &grid(n, n, n), 4.0, 0).is_err());
1787    }
1788}