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/// Parameters for inhomogeneity correction (bias field removal).
14#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
15#[derive(Clone, Debug)]
16pub struct HomogeneityParams {
17    /// Gaussian smoothing sigma in mm (default: 7.0)
18    pub sigma_mm: f64,
19    /// Number of box filter passes for Gaussian approximation (default: 3)
20    pub nbox: usize,
21}
22
23impl Default for HomogeneityParams {
24    fn default() -> Self {
25        Self {
26            sigma_mm: 7.0,
27            nbox: 3,
28        }
29    }
30}
31
32use std::collections::VecDeque;
33use crate::Grid;
34
35/// Index into 3D array (Fortran/column-major order)
36#[inline(always)]
37fn idx3d(i: usize, j: usize, k: usize, nx: usize, ny: usize) -> usize {
38    i + j * nx + k * nx * ny
39}
40
41//=============================================================================
42// Box Filter Gaussian Approximation (matching MriResearchTools.jl)
43//=============================================================================
44
45/// Calculate box sizes to approximate Gaussian with given sigma using n box filters
46///
47/// This implements the algorithm from MriResearchTools.jl:
48/// Multiple box filter passes approximate a Gaussian convolution.
49fn get_box_sizes(sigma: f64, n: usize) -> Vec<usize> {
50    if sigma <= 0.0 || n == 0 {
51        return vec![0; n];
52    }
53
54    // wideal = sqrt((12*sigma^2 / n) + 1)
55    let wideal = ((12.0 * sigma * sigma / n as f64) + 1.0).sqrt();
56
57    // wl = next lower odd integer
58    let wl_float = wideal - (wideal + 1.0) % 2.0;
59    let wl = wl_float.round() as usize;
60    let wl = if wl.is_multiple_of(2) { wl + 1 } else { wl }; // ensure odd
61    let wu = wl + 2;
62
63    // mideal = (12*sigma^2 - n*wl^2 - 4*n*wl - 3*n) / (-4*wl - 4)
64    let wl_f = wl as f64;
65    let n_f = n as f64;
66    let mideal = (12.0 * sigma * sigma - n_f * wl_f * wl_f - 4.0 * n_f * wl_f - 3.0 * n_f)
67                 / (-4.0 * wl_f - 4.0);
68    let m = mideal.round() as usize;
69
70    (0..n).map(|i| if i < m { wl } else { wu }).collect()
71}
72
73/// Check and adjust box sizes to fit image dimensions
74fn check_box_sizes(boxsizes: &mut [Vec<usize>], dims: &[usize]) {
75    for (dim, bs) in boxsizes.iter_mut().enumerate() {
76        if dim >= dims.len() {
77            continue;
78        }
79        for b in bs.iter_mut() {
80            // Ensure odd
81            if *b % 2 == 0 {
82                *b += 1;
83            }
84            // Limit to half image size
85            let max_size = dims[dim] / 2;
86            if *b > max_size {
87                *b = if max_size.is_multiple_of(2) { max_size + 1 } else { max_size };
88            }
89        }
90    }
91}
92
93/// 1D box filter on a line (in-place), matching Julia's boxfilterline!
94///
95/// Uses running sum with edge handling.
96fn box_filter_line(line: &mut [f64], boxsize: usize) {
97    if boxsize < 3 || line.len() < boxsize {
98        return;
99    }
100
101    let n = line.len();
102    let r = boxsize / 2;
103
104    // Use a circular buffer approach
105    let mut queue: VecDeque<f64> = VecDeque::with_capacity(boxsize);
106
107    // Initialize with first r values
108    let mut lsum: f64 = line[..r].iter().sum();
109    for i in 0..r {
110        queue.push_back(line[i]);
111    }
112
113    // Start with edge effect (growing window)
114    for i in 0..=r {
115        lsum += line[i + r];
116        queue.push_back(line[i + r]);
117        line[i] = lsum / (r + i + 1) as f64;
118    }
119
120    // Middle part (full window)
121    for i in (r + 1)..(n - r) {
122        let old = queue.pop_front().unwrap();
123        lsum += line[i + r] - old;
124        queue.push_back(line[i + r]);
125        line[i] = lsum / boxsize as f64;
126    }
127
128    // End with edge effect (shrinking window)
129    for i in (n - r)..n {
130        let old = queue.pop_front().unwrap();
131        lsum -= old;
132        line[i] = lsum / (r + n - i) as f64;
133    }
134}
135
136/// 1D weighted box filter on a line (in-place), matching Julia's weighted boxfilterline!
137fn box_filter_line_weighted(line: &mut [f64], weight: &mut [f64], boxsize: usize) {
138    if boxsize < 3 || line.len() < boxsize {
139        return;
140    }
141
142    let n = line.len();
143    let r = boxsize / 2;
144
145    let mut lq: VecDeque<f64> = VecDeque::with_capacity(boxsize);
146    let mut wq: VecDeque<f64> = VecDeque::with_capacity(boxsize);
147
148    // Initialize with first boxsize values
149    let mut sum = f64::EPSILON; // slightly bigger than 0 to avoid division by 0
150    let mut wsum = f64::EPSILON;
151    let mut wsmooth = f64::EPSILON;
152
153    for i in 0..boxsize {
154        sum += line[i] * weight[i];
155        wsum += weight[i];
156        wsmooth += weight[i] * weight[i];
157        lq.push_back(line[i]);
158        wq.push_back(weight[i]);
159    }
160
161    // Middle part
162    for i in (r + 1)..(n - r) {
163        let w = weight[i + r];
164        let l = line[i + r];
165        let wold = wq.pop_front().unwrap();
166        let lold = lq.pop_front().unwrap();
167        wq.push_back(w);
168        lq.push_back(l);
169
170        sum += l * w - lold * wold;
171        wsum += w - wold;
172        line[i] = sum / wsum;
173        wsmooth += w * w - wold * wold;
174        weight[i] = wsmooth / wsum;
175    }
176}
177
178/// 1D box filter with NaN handling (for masked smoothing)
179/// Matches Julia's nanboxfilterline!
180fn nan_box_filter_line(line: &mut [f64], boxsize: usize) {
181    if boxsize < 3 || line.len() < boxsize {
182        return;
183    }
184
185    let n = line.len();
186    let r = boxsize / 2;
187    let maxfills = r;
188
189    // Create padded buffer with NaN padding
190    let mut orig = vec![f64::NAN; n + boxsize - 1];
191    orig[r..r + n].copy_from_slice(line);
192
193    // Initial sum of first window (excluding NaN)
194    let mut lsum = 0.0;
195    for i in (r + 1)..=(2 * r) {
196        if !orig[i].is_nan() {
197            lsum += orig[i];
198        }
199    }
200
201    let mut nfills = 0usize;
202    let mut nvalids = 0usize;
203
204    #[derive(PartialEq, Clone, Copy)]
205    enum Mode { Nan, Normal, Fill }
206    let mut mode = Mode::Nan;
207
208    for i in 0..n {
209        // Check for mode change
210        match mode {
211            Mode::Normal => {
212                if orig[i + 2 * r].is_nan() {
213                    mode = Mode::Fill;
214                }
215            }
216            Mode::Nan => {
217                if orig[i + 2 * r].is_nan() {
218                    nvalids = 0;
219                } else {
220                    nvalids += 1;
221                }
222                if nvalids == boxsize {
223                    mode = Mode::Normal;
224                    lsum = 0.0;
225                    for j in i..=(i + 2 * r) {
226                        lsum += orig[j];
227                    }
228                    line[i] = lsum / boxsize as f64;
229                    continue;
230                }
231            }
232            Mode::Fill => {
233                if orig[i + 2 * r].is_nan() {
234                    nfills += 1;
235                    if nfills > maxfills {
236                        mode = Mode::Nan;
237                        nfills = 0;
238                        lsum = 0.0;
239                        nvalids = 0;
240                    }
241                } else {
242                    mode = Mode::Normal;
243                    nfills = 0;
244                }
245            }
246        }
247
248        // Perform operation
249        match mode {
250            Mode::Normal => {
251                if i > 0 {
252                    lsum += orig[i + 2 * r] - orig[i - 1];
253                }
254                line[i] = lsum / boxsize as f64;
255            }
256            Mode::Fill => {
257                if i > 0 {
258                    lsum -= orig[i - 1];
259                }
260                line[i] = (lsum - orig[i]) / (boxsize - 2) as f64;
261
262                // Extrapolate the NaN value
263                let extrapolated = if i >= r {
264                    2.0 * line[i] - line[i - r]
265                } else {
266                    line[i]
267                };
268                orig[i + 2 * r] = extrapolated;
269                if i + r < n {
270                    line[i + r] = extrapolated;
271                }
272                lsum += orig[i + 2 * r];
273            }
274            Mode::Nan => {
275                // Keep as NaN or 0
276            }
277        }
278    }
279}
280
281/// 3D Gaussian smoothing using box filter approximation
282///
283/// This matches MriResearchTools.jl's gaussiansmooth3d function.
284///
285/// Parameters:
286/// - data: input 3D data (will be copied)
287/// - sigma: sigma values for each dimension [sx, sy, sz]
288/// - mask: optional mask (None = no masking)
289/// - weight: optional weights (None = no weighting)
290/// - nbox: number of box filter passes (default 3, or 4 with mask)
291/// - nx, ny, nz: dimensions
292pub fn gaussian_smooth_3d(
293    data: &[f64],
294    sigma: [f64; 3],
295    mask: Option<&[u8]>,
296    mut weight: Option<&mut [f64]>,
297    nbox: usize,
298    grid: &Grid,
299) -> Vec<f64> {
300    let (nx, ny, nz) = grid.dims;
301    let n_total = nx * ny * nz;
302    let mut result: Vec<f64> = data.iter().map(|&v| v as f64).collect();
303
304    // Calculate box sizes for each dimension
305    let mut boxsizes: Vec<Vec<usize>> = sigma.iter()
306        .map(|&s| get_box_sizes(s, nbox))
307        .collect();
308
309    check_box_sizes(&mut boxsizes, &[nx, ny, nz]);
310
311    // Apply mask: set masked-out voxels to NaN
312    if let Some(m) = mask {
313        for i in 0..n_total {
314            if m[i] == 0 {
315                result[i] = f64::NAN;
316            }
317        }
318    }
319
320    // Apply box filters for each pass and dimension
321    for ibox in 0..nbox {
322        // X direction
323        let bsize_x = boxsizes[0][ibox];
324        if nx > 1 && bsize_x >= 3 {
325            // Alternate direction for masked smoothing on even passes
326            let reverse = mask.is_some() && ibox % 2 == 1;
327
328            for k in 0..nz {
329                for j in 0..ny {
330                    let mut line: Vec<f64> = (0..nx).map(|i| {
331                        let idx = if reverse { nx - 1 - i } else { i };
332                        result[idx3d(idx, j, k, nx, ny)]
333                    }).collect();
334
335                    if mask.is_some() {
336                        nan_box_filter_line(&mut line, bsize_x);
337                    } else if let Some(ref mut w) = weight.as_deref_mut() {
338                        let mut wline: Vec<f64> = (0..nx).map(|i| {
339                            let idx = if reverse { nx - 1 - i } else { i };
340                            w[idx3d(idx, j, k, nx, ny)]
341                        }).collect();
342                        box_filter_line_weighted(&mut line, &mut wline, bsize_x);
343                        for i in 0..nx {
344                            let idx = if reverse { nx - 1 - i } else { i };
345                            w[idx3d(idx, j, k, nx, ny)] = wline[i];
346                        }
347                    } else {
348                        box_filter_line(&mut line, bsize_x);
349                    }
350
351                    for i in 0..nx {
352                        let idx = if reverse { nx - 1 - i } else { i };
353                        result[idx3d(idx, j, k, nx, ny)] = line[i];
354                    }
355                }
356            }
357        }
358
359        // Y direction
360        let bsize_y = boxsizes[1][ibox];
361        if ny > 1 && bsize_y >= 3 {
362            let reverse = mask.is_some() && ibox % 2 == 1;
363
364            for k in 0..nz {
365                for i in 0..nx {
366                    let mut line: Vec<f64> = (0..ny).map(|j| {
367                        let idx = if reverse { ny - 1 - j } else { j };
368                        result[idx3d(i, idx, k, nx, ny)]
369                    }).collect();
370
371                    if mask.is_some() {
372                        nan_box_filter_line(&mut line, bsize_y);
373                    } else if let Some(ref mut w) = weight.as_deref_mut() {
374                        let mut wline: Vec<f64> = (0..ny).map(|j| {
375                            let idx = if reverse { ny - 1 - j } else { j };
376                            w[idx3d(i, idx, k, nx, ny)]
377                        }).collect();
378                        box_filter_line_weighted(&mut line, &mut wline, bsize_y);
379                        for j in 0..ny {
380                            let idx = if reverse { ny - 1 - j } else { j };
381                            w[idx3d(i, idx, k, nx, ny)] = wline[j];
382                        }
383                    } else {
384                        box_filter_line(&mut line, bsize_y);
385                    }
386
387                    for j in 0..ny {
388                        let idx = if reverse { ny - 1 - j } else { j };
389                        result[idx3d(i, idx, k, nx, ny)] = line[j];
390                    }
391                }
392            }
393        }
394
395        // Z direction
396        let bsize_z = boxsizes[2][ibox];
397        if nz > 1 && bsize_z >= 3 {
398            let reverse = mask.is_some() && ibox % 2 == 1;
399
400            for j in 0..ny {
401                for i in 0..nx {
402                    let mut line: Vec<f64> = (0..nz).map(|k| {
403                        let idx = if reverse { nz - 1 - k } else { k };
404                        result[idx3d(i, j, idx, nx, ny)]
405                    }).collect();
406
407                    if mask.is_some() {
408                        nan_box_filter_line(&mut line, bsize_z);
409                    } else if let Some(ref mut w) = weight.as_deref_mut() {
410                        let mut wline: Vec<f64> = (0..nz).map(|k| {
411                            let idx = if reverse { nz - 1 - k } else { k };
412                            w[idx3d(i, j, idx, nx, ny)]
413                        }).collect();
414                        box_filter_line_weighted(&mut line, &mut wline, bsize_z);
415                        for k in 0..nz {
416                            let idx = if reverse { nz - 1 - k } else { k };
417                            w[idx3d(i, j, idx, nx, ny)] = wline[k];
418                        }
419                    } else {
420                        box_filter_line(&mut line, bsize_z);
421                    }
422
423                    for k in 0..nz {
424                        let idx = if reverse { nz - 1 - k } else { k };
425                        result[idx3d(i, j, idx, nx, ny)] = line[k];
426                    }
427                }
428            }
429        }
430    }
431
432    result
433}
434
435/// Simplified smoothing with explicit box sizes (for robustmask post-processing)
436pub fn gaussian_smooth_3d_boxsizes(
437    data: &[f64],
438    boxsizes: &[Vec<usize>],
439    nbox: usize,
440    grid: &Grid,
441) -> Vec<f64> {
442    let (nx, ny, nz) = grid.dims;
443    let mut result = data.to_vec();
444
445    // Apply box filters for each pass and dimension
446    for ibox in 0..nbox {
447        // X direction
448        if nx > 1 && ibox < boxsizes[0].len() {
449            let bsize = boxsizes[0][ibox];
450            if bsize >= 3 {
451                for k in 0..nz {
452                    for j in 0..ny {
453                        let mut line: Vec<f64> = (0..nx).map(|i| result[idx3d(i, j, k, nx, ny)]).collect();
454                        box_filter_line(&mut line, bsize);
455                        for i in 0..nx {
456                            result[idx3d(i, j, k, nx, ny)] = line[i];
457                        }
458                    }
459                }
460            }
461        }
462
463        // Y direction
464        if ny > 1 && ibox < boxsizes[1].len() {
465            let bsize = boxsizes[1][ibox];
466            if bsize >= 3 {
467                for k in 0..nz {
468                    for i in 0..nx {
469                        let mut line: Vec<f64> = (0..ny).map(|j| result[idx3d(i, j, k, nx, ny)]).collect();
470                        box_filter_line(&mut line, bsize);
471                        for j in 0..ny {
472                            result[idx3d(i, j, k, nx, ny)] = line[j];
473                        }
474                    }
475                }
476            }
477        }
478
479        // Z direction
480        if nz > 1 && ibox < boxsizes[2].len() {
481            let bsize = boxsizes[2][ibox];
482            if bsize >= 3 {
483                for j in 0..ny {
484                    for i in 0..nx {
485                        let mut line: Vec<f64> = (0..nz).map(|k| result[idx3d(i, j, k, nx, ny)]).collect();
486                        box_filter_line(&mut line, bsize);
487                        for k in 0..nz {
488                            result[idx3d(i, j, k, nx, ny)] = line[k];
489                        }
490                    }
491                }
492            }
493        }
494    }
495
496    result
497}
498
499//=============================================================================
500// Connected Components and Hole Filling
501//=============================================================================
502
503/// Find connected component using flood fill (6-connectivity in 3D)
504fn flood_fill_component(
505    mask: &[u8],
506    visited: &mut [bool],
507    start: usize,
508    nx: usize, ny: usize, nz: usize,
509) -> Vec<usize> {
510    let mut component = Vec::new();
511    let mut stack = vec![start];
512
513    while let Some(idx) = stack.pop() {
514        if visited[idx] || mask[idx] != 0 {
515            continue;
516        }
517
518        visited[idx] = true;
519        component.push(idx);
520
521        // Get 3D coordinates
522        let k = idx / (nx * ny);
523        let rem = idx % (nx * ny);
524        let j = rem / nx;
525        let i = rem % nx;
526
527        // 6-connectivity neighbors
528        if i > 0 {
529            let n = idx3d(i - 1, j, k, nx, ny);
530            if !visited[n] && mask[n] == 0 { stack.push(n); }
531        }
532        if i + 1 < nx {
533            let n = idx3d(i + 1, j, k, nx, ny);
534            if !visited[n] && mask[n] == 0 { stack.push(n); }
535        }
536        if j > 0 {
537            let n = idx3d(i, j - 1, k, nx, ny);
538            if !visited[n] && mask[n] == 0 { stack.push(n); }
539        }
540        if j + 1 < ny {
541            let n = idx3d(i, j + 1, k, nx, ny);
542            if !visited[n] && mask[n] == 0 { stack.push(n); }
543        }
544        if k > 0 {
545            let n = idx3d(i, j, k - 1, nx, ny);
546            if !visited[n] && mask[n] == 0 { stack.push(n); }
547        }
548        if k + 1 < nz {
549            let n = idx3d(i, j, k + 1, nx, ny);
550            if !visited[n] && mask[n] == 0 { stack.push(n); }
551        }
552    }
553
554    component
555}
556
557/// Fill holes in a binary mask
558///
559/// Matches MriResearchTools.jl's fill_holes function.
560/// Fills connected components of zeros (holes) up to max_hole_size.
561/// Uses 6-connectivity for 3D.
562pub fn fill_holes(mask: &[u8], grid: &Grid, max_hole_size: usize) -> Vec<u8> {
563    let (nx, ny, nz) = grid.dims;
564    let n_total = nx * ny * nz;
565    let mut result = mask.to_vec();
566    let mut visited = vec![false; n_total];
567
568    // Find all connected components of zeros (potential holes)
569    for idx in 0..n_total {
570        if mask[idx] == 0 && !visited[idx] {
571            let component = flood_fill_component(mask, &mut visited, idx, nx, ny, nz);
572
573            // Check if this component touches the boundary
574            let mut touches_boundary = false;
575            for &cidx in &component {
576                let k = cidx / (nx * ny);
577                let rem = cidx % (nx * ny);
578                let j = rem / nx;
579                let i = rem % nx;
580
581                if i == 0 || i == nx - 1 || j == 0 || j == ny - 1 || k == 0 || k == nz - 1 {
582                    touches_boundary = true;
583                    break;
584                }
585            }
586
587            // Fill if it's a hole (doesn't touch boundary) and small enough
588            if !touches_boundary && component.len() <= max_hole_size {
589                for cidx in component {
590                    result[cidx] = 1;
591                }
592            }
593        }
594    }
595
596    result
597}
598
599//=============================================================================
600// Robust Mask (matching MriResearchTools.jl)
601//=============================================================================
602
603/// Create robust mask from magnitude using quantile-based thresholding
604///
605/// This matches MriResearchTools.jl's robustmask function, including
606/// post-processing with smoothing and hole filling.
607pub fn robust_mask(mag: &[f64], grid: &Grid) -> Vec<u8> {
608    let (nx, ny, nz) = grid.dims;
609    let n_total = nx * ny * nz;
610
611    // Collect valid (positive, finite) samples and sort
612    let mut samples: Vec<f64> = mag.iter()
613        .filter(|&&v| v.is_finite() && v > 0.0)
614        .copied()
615        .collect();
616
617    if samples.is_empty() {
618        return vec![0u8; n_total];
619    }
620
621    samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
622
623    let len = samples.len();
624
625    // Calculate quantiles
626    let q05_idx = ((0.05 * len as f64) as usize).min(len - 1);
627    let q15_idx = ((0.15 * len as f64) as usize).min(len - 1);
628    let q80_idx = ((0.80 * len as f64) as usize).min(len - 1);
629    let q99_idx = ((0.99 * len as f64) as usize).min(len - 1);
630
631    let q05 = samples[q05_idx];
632    let q15 = samples[q15_idx];
633    let q80 = samples[q80_idx];
634    let q99 = samples[q99_idx];
635
636    // Calculate high intensity mean (between 80th and 99th percentile)
637    let high_samples: Vec<f64> = samples.iter()
638        .filter(|&&v| v >= q80 && v <= q99)
639        .copied()
640        .collect();
641
642    let high_intensity = if high_samples.is_empty() {
643        q99
644    } else {
645        high_samples.iter().sum::<f64>() / high_samples.len() as f64
646    };
647
648    // Estimate noise level from low-intensity voxels
649    let low_samples: Vec<f64> = samples.iter()
650        .filter(|&&v| v <= q15)
651        .copied()
652        .collect();
653
654    let mut noise = if low_samples.is_empty() {
655        0.0
656    } else {
657        low_samples.iter().sum::<f64>() / low_samples.len() as f64
658    };
659
660    // If noise estimate is too high, try using lower percentile
661    if noise > high_intensity / 10.0 {
662        let very_low_samples: Vec<f64> = samples.iter()
663            .filter(|&&v| v <= q05)
664            .copied()
665            .collect();
666
667        noise = if very_low_samples.is_empty() {
668            0.0
669        } else {
670            very_low_samples.iter().sum::<f64>() / very_low_samples.len() as f64
671        };
672
673        if noise > high_intensity / 10.0 {
674            noise = 0.0;
675        }
676    }
677
678    // Calculate threshold: max(5*noise, high_intensity/5)
679    let threshold = (5.0 * noise).max(high_intensity / 5.0);
680
681    // Create initial binary mask
682    let mut mask_f64: Vec<f64> = mag.iter()
683        .map(|&v| if v > threshold { 1.0 } else { 0.0 })
684        .collect();
685
686    // Post-processing Step 1: Smooth with nbox=1, boxsize=5, threshold at 0.4
687    let boxsizes1 = vec![vec![5], vec![5], vec![5]];
688    mask_f64 = gaussian_smooth_3d_boxsizes(&mask_f64, &boxsizes1, 1, grid);
689    let mut mask: Vec<u8> = mask_f64.iter()
690        .map(|&v| if v > 0.4 { 1 } else { 0 })
691        .collect();
692
693    // Post-processing Step 2: Fill holes
694    let max_hole_size = n_total / 20;
695    mask = fill_holes(&mask, grid, max_hole_size);
696
697    // Post-processing Step 3: Smooth with nbox=2, boxsizes=[3,3], threshold at 0.6
698    mask_f64 = mask.iter().map(|&v| v as f64).collect();
699    let boxsizes2 = vec![vec![3, 3], vec![3, 3], vec![3, 3]];
700    mask_f64 = gaussian_smooth_3d_boxsizes(&mask_f64, &boxsizes2, 2, grid);
701    mask = mask_f64.iter()
702        .map(|&v| if v > 0.6 { 1 } else { 0 })
703        .collect();
704
705    mask
706}
707
708//=============================================================================
709// Box Segmentation
710//=============================================================================
711
712/// Box segmentation for finding tissue regions
713///
714/// Divides the image into nbox^3 boxes and identifies voxels that
715/// consistently appear in the high-intensity range across multiple boxes.
716fn box_segment(
717    image: &[f64],
718    mask: &[u8],
719    nbox: usize,
720    nx: usize, ny: usize, nz: usize,
721) -> Vec<u8> {
722    let n_total = nx * ny * nz;
723    let mut vote_count = vec![0u8; n_total];
724
725    // Calculate box shift (stride between box centers)
726    let box_shift_x = nx.div_ceil(nbox);
727    let box_shift_y = ny.div_ceil(nbox);
728    let box_shift_z = nz.div_ceil(nbox);
729
730    // For each box center
731    let mut cz = 0;
732    while cz < nz {
733        let mut cy = 0;
734        while cy < ny {
735            let mut cx = 0;
736            while cx < nx {
737                // Calculate box bounds (2x box_shift around center)
738                let x_start = cx.saturating_sub(box_shift_x);
739                let x_end = (cx + box_shift_x).min(nx);
740                let y_start = cy.saturating_sub(box_shift_y);
741                let y_end = (cy + box_shift_y).min(ny);
742                let z_start = cz.saturating_sub(box_shift_z);
743                let z_end = (cz + box_shift_z).min(nz);
744
745                // Collect values in this box
746                let mut box_vals: Vec<f64> = Vec::new();
747                for z in z_start..z_end {
748                    for y in y_start..y_end {
749                        for x in x_start..x_end {
750                            let idx = idx3d(x, y, z, nx, ny);
751                            if mask[idx] > 0 && image[idx].is_finite() {
752                                box_vals.push(image[idx]);
753                            }
754                        }
755                    }
756                }
757
758                if box_vals.is_empty() {
759                    cx += box_shift_x;
760                    continue;
761                }
762
763                // Sort and find 90th percentile
764                box_vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
765                let q90_idx = ((0.9 * box_vals.len() as f64) as usize).min(box_vals.len() - 1);
766                let q90 = box_vals[q90_idx];
767
768                // Define tissue range around 90th percentile
769                let width = 0.1;
770                let low = (1.0 - width) * q90;
771                let high = (1.0 + width) * q90;
772
773                // Vote for voxels in tissue range
774                for z in z_start..z_end {
775                    for y in y_start..y_end {
776                        for x in x_start..x_end {
777                            let idx = idx3d(x, y, z, nx, ny);
778                            if mask[idx] > 0 {
779                                let v = image[idx];
780                                if v > low && v < high {
781                                    vote_count[idx] = vote_count[idx].saturating_add(1);
782                                }
783                            }
784                        }
785                    }
786                }
787
788                cx += box_shift_x;
789            }
790            cy += box_shift_y;
791        }
792        cz += box_shift_z;
793    }
794
795    // Threshold: must be identified as tissue in >= 2 boxes
796    let mut segmented = vec![0u8; n_total];
797    for i in 0..n_total {
798        if vote_count[i] >= 2 && mask[i] > 0 {
799            segmented[i] = 1;
800        }
801    }
802
803    segmented
804}
805
806//=============================================================================
807// Fill and Smooth (with weighted smoothing)
808//=============================================================================
809
810/// Fill holes and smooth the lowpass field with weighted smoothing
811///
812/// Matches MriResearchTools.jl's fillandsmooth! function.
813/// Uses weighted smoothing where filled holes get weight 0.2.
814fn fill_and_smooth(
815    lowpass: &mut [f64],
816    stable_mean: f64,
817    sigma2: [f64; 3],
818    grid: &Grid,
819) {
820    let n_total = grid.n_total();
821
822    // Identify holes/outliers and create weight mask
823    // lowpassweight = 1.2 - lowpassmask (so holes get 0.2, normal get 1.2)
824    let mut weight = vec![1.2f64; n_total];
825
826    for i in 0..n_total {
827        if lowpass[i] < stable_mean / 4.0 ||
828           lowpass[i].is_nan() ||
829           lowpass[i] > 10.0 * stable_mean {
830            lowpass[i] = 3.0 * stable_mean;
831            weight[i] = 0.2; // Filled holes get less weight
832        }
833    }
834
835    // Apply weighted smoothing
836    let nbox = 3; // default for non-masked smoothing
837    let smoothed = gaussian_smooth_3d(lowpass, sigma2, None, Some(&mut weight), nbox, grid);
838    lowpass.copy_from_slice(&smoothed);
839}
840
841//=============================================================================
842// Main API
843//=============================================================================
844
845/// Get sensitivity (bias field) from magnitude
846///
847/// This estimates the RF receive field inhomogeneity (sensitivity map)
848/// that can be divided out to correct the image.
849pub fn get_sensitivity(
850    mag: &[f64],
851    grid: &Grid,
852    sigma_mm: f64,
853    nbox: usize,
854) -> Vec<f64> {
855    let (nx, ny, nz) = grid.dims;
856    let (vx, vy, vz) = grid.voxel_size;
857    let n_total = nx * ny * nz;
858
859    // Convert mm to voxels
860    let sigma = [sigma_mm / vx, sigma_mm / vy, sigma_mm / vz];
861
862    // Create initial mask (with full post-processing)
863    let mask = robust_mask(mag, grid);
864
865    // Box segmentation to find tissue
866    let segmentation = box_segment(mag, &mask, nbox, nx, ny, nz);
867
868    // Split sigma into two parts (matching MriResearchTools.jl)
869    let factor: f64 = 0.7;
870    let sigma1 = [
871        (1.0_f64 - factor * factor).sqrt() * sigma[0],
872        (1.0_f64 - factor * factor).sqrt() * sigma[1],
873        (1.0_f64 - factor * factor).sqrt() * sigma[2],
874    ];
875    let sigma2 = [
876        factor * sigma[0],
877        factor * sigma[1],
878        factor * sigma[2],
879    ];
880
881    // First smoothing with tissue mask (nbox=8 for masked smoothing)
882    let mut lowpass = gaussian_smooth_3d(mag, sigma1, Some(&segmentation), None, 8, grid);
883
884    // Calculate stable mean for filling
885    let mut sum = 0.0;
886    let mut count = 0usize;
887    for i in 0..n_total {
888        if mask[i] > 0 && mag[i].is_finite() {
889            sum += mag[i];
890            count += 1;
891        }
892    }
893    let stable_mean = if count > 0 { sum / count as f64 } else { 1.0 };
894
895    // Fill holes and apply weighted second smoothing
896    fill_and_smooth(&mut lowpass, stable_mean, sigma2, grid);
897
898    lowpass
899}
900
901/// Make magnitude homogeneous by dividing by bias field
902///
903/// This is the main entry point for bias field correction.
904///
905/// # Arguments
906/// * `mag` - Input magnitude data (nx * ny * nz)
907/// * `nx`, `ny`, `nz` - Dimensions
908/// * `vx`, `vy`, `vz` - Voxel sizes in mm
909/// * `sigma_mm` - Smoothing sigma in mm (default 7, will be clamped to 10% FOV)
910/// * `nbox` - Number of boxes per dimension for segmentation (default 15)
911///
912/// # Returns
913/// Bias-corrected magnitude
914pub fn makehomogeneous(
915    mag: &[f64],
916    grid: &Grid,
917    sigma_mm: f64,
918    nbox: usize,
919) -> Vec<f64> {
920    let sensitivity = get_sensitivity(mag, grid, sigma_mm, nbox);
921    let n_total = grid.n_total();
922
923    let mut result = vec![0.0; n_total];
924    for i in 0..n_total {
925        if sensitivity[i] > 1e-10 && !sensitivity[i].is_nan() {
926            result[i] = mag[i] / sensitivity[i];
927        } else {
928            result[i] = mag[i];
929        }
930    }
931
932    result
933}
934
935/// RSS (Root Sum of Squares) magnitude combination
936///
937/// Combines multi-echo magnitude images using RSS.
938///
939/// # Arguments
940/// * `mags_flat` - Flattened magnitudes [echo0, echo1, ...]
941/// * `n_echoes` - Number of echoes
942/// * `n_total` - Voxels per echo (nx * ny * nz)
943///
944/// # Returns
945/// RSS-combined magnitude
946pub fn rss_combine(
947    mags_flat: &[f64],
948    n_echoes: usize,
949    n_total: usize,
950) -> Vec<f64> {
951    let mut result = vec![0.0; n_total];
952
953    for e in 0..n_echoes {
954        let offset = e * n_total;
955        for i in 0..n_total {
956            let v = mags_flat[offset + i];
957            result[i] += v * v;
958        }
959    }
960
961    for i in 0..n_total {
962        result[i] = result[i].sqrt();
963    }
964
965    result
966}
967
968#[cfg(test)]
969mod tests {
970    use super::*;
971
972    fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
973        Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
974    }
975
976    #[test]
977    fn test_get_box_sizes() {
978        // Test box size calculation matches Julia
979        let sizes = get_box_sizes(5.0, 3);
980        assert_eq!(sizes.len(), 3);
981        // For sigma=5, n=3: wideal ≈ 5.77
982        // All sizes should be odd and reasonable
983        for &s in &sizes {
984            assert!(s % 2 == 1, "Box size should be odd, got {}", s);
985            assert!(s >= 3 && s <= 11, "Box size should be in reasonable range, got {}", s);
986        }
987    }
988
989    #[test]
990    fn test_box_filter_line() {
991        // Simple test: uniform values should stay uniform
992        let mut line = vec![1.0; 10];
993        box_filter_line(&mut line, 3);
994        for &v in &line {
995            assert!((v - 1.0).abs() < 1e-10, "Uniform line should stay uniform");
996        }
997    }
998
999    #[test]
1000    fn test_fill_holes_basic() {
1001        // 3x3x3 cube with a hole in the center
1002        let mut mask = vec![1u8; 27];
1003        mask[13] = 0; // center voxel
1004
1005        let filled = fill_holes(&mask, &grid(3, 3, 3), 5);
1006        assert_eq!(filled[13], 1, "Center hole should be filled");
1007    }
1008
1009    #[test]
1010    fn test_robust_mask_basic() {
1011        // Simple test with uniform high values
1012        let mag = vec![100.0; 27];
1013        let mask = robust_mask(&mag, &grid(3, 3, 3));
1014        // All values are the same, so all should be masked
1015        let masked_count: usize = mask.iter().map(|&v| v as usize).sum();
1016        assert!(masked_count > 0, "Should have some masked voxels");
1017    }
1018
1019    #[test]
1020    fn test_rss_combine() {
1021        // Two echoes, 4 voxels each
1022        let mags = vec![
1023            3.0, 0.0, 0.0, 5.0,  // echo 0
1024            4.0, 0.0, 0.0, 12.0, // echo 1
1025        ];
1026        let result = rss_combine(&mags, 2, 4);
1027
1028        // sqrt(3^2 + 4^2) = 5
1029        assert!((result[0] - 5.0).abs() < 1e-10);
1030        // sqrt(0 + 0) = 0
1031        assert!((result[1] - 0.0).abs() < 1e-10);
1032        // sqrt(5^2 + 12^2) = 13
1033        assert!((result[3] - 13.0).abs() < 1e-10);
1034    }
1035
1036    // =====================================================================
1037    // Helper: create a 3D sphere magnitude phantom with optional bias field
1038    // =====================================================================
1039
1040    /// Create a 3D sphere phantom with a smooth bias field applied.
1041    /// Returns (magnitude_data, mask) where mask marks inside-sphere voxels.
1042    fn make_sphere_phantom(n: usize, bias: bool) -> (Vec<f64>, Vec<u8>) {
1043        let center = n as f64 / 2.0;
1044        let radius = n as f64 / 2.0 - 2.0;
1045        let n_total = n * n * n;
1046        let mut mag = vec![0.0f64; n_total];
1047        let mut mask = vec![0u8; n_total];
1048
1049        for k in 0..n {
1050            for j in 0..n {
1051                for i in 0..n {
1052                    let dx = i as f64 - center;
1053                    let dy = j as f64 - center;
1054                    let dz = k as f64 - center;
1055                    let dist = (dx * dx + dy * dy + dz * dz).sqrt();
1056                    let idx = i + j * n + k * n * n;
1057
1058                    if dist < radius {
1059                        // Base tissue intensity
1060                        let base = 100.0;
1061                        // Apply smooth bias field if requested
1062                        let bias_val = if bias {
1063                            1.0 + 0.5 * (i as f64 / n as f64)
1064                        } else {
1065                            1.0
1066                        };
1067                        mag[idx] = base * bias_val;
1068                        mask[idx] = 1;
1069                    } else {
1070                        // Background noise
1071                        mag[idx] = 1.0 + 0.5 * ((i + j + k) % 3) as f64;
1072                    }
1073                }
1074            }
1075        }
1076
1077        (mag, mask)
1078    }
1079
1080    // =====================================================================
1081    // Tests for get_box_sizes edge cases
1082    // =====================================================================
1083
1084    #[test]
1085    fn test_get_box_sizes_zero_sigma() {
1086        let sizes = get_box_sizes(0.0, 3);
1087        assert_eq!(sizes, vec![0, 0, 0]);
1088    }
1089
1090    #[test]
1091    fn test_get_box_sizes_zero_n() {
1092        let sizes = get_box_sizes(5.0, 0);
1093        assert!(sizes.is_empty());
1094    }
1095
1096    #[test]
1097    fn test_get_box_sizes_negative_sigma() {
1098        let sizes = get_box_sizes(-1.0, 3);
1099        assert_eq!(sizes, vec![0, 0, 0]);
1100    }
1101
1102    #[test]
1103    fn test_get_box_sizes_large_sigma() {
1104        let sizes = get_box_sizes(20.0, 4);
1105        assert_eq!(sizes.len(), 4);
1106        for &s in &sizes {
1107            assert!(s % 2 == 1, "Box size should be odd, got {}", s);
1108            assert!(s >= 3, "Box size should be at least 3, got {}", s);
1109        }
1110    }
1111
1112    // =====================================================================
1113    // Tests for check_box_sizes
1114    // =====================================================================
1115
1116    #[test]
1117    fn test_check_box_sizes_clamps_to_half_image() {
1118        // Box size larger than half the image dimension should be clamped
1119        let mut boxsizes = vec![vec![99], vec![99], vec![99]];
1120        let dims = [10, 10, 10];
1121        check_box_sizes(&mut boxsizes, &dims);
1122        for bs in &boxsizes {
1123            for &b in bs {
1124                assert!(b <= dims[0], "Box size should be clamped, got {}", b);
1125                assert!(b % 2 == 1, "Box size should be odd, got {}", b);
1126            }
1127        }
1128    }
1129
1130    #[test]
1131    fn test_check_box_sizes_makes_even_odd() {
1132        let mut boxsizes = vec![vec![4], vec![6], vec![8]];
1133        let dims = [100, 100, 100];
1134        check_box_sizes(&mut boxsizes, &dims);
1135        for bs in &boxsizes {
1136            for &b in bs {
1137                assert!(b % 2 == 1, "Box size should be odd, got {}", b);
1138            }
1139        }
1140    }
1141
1142    // =====================================================================
1143    // Tests for box_filter_line edge cases
1144    // =====================================================================
1145
1146    #[test]
1147    fn test_box_filter_line_too_small_boxsize() {
1148        // boxsize < 3 should be a no-op
1149        let mut line = vec![1.0, 2.0, 3.0, 4.0, 5.0];
1150        let original = line.clone();
1151        box_filter_line(&mut line, 1);
1152        assert_eq!(line, original);
1153    }
1154
1155    #[test]
1156    fn test_box_filter_line_larger_than_data() {
1157        // boxsize > line length should be a no-op
1158        let mut line = vec![1.0, 2.0];
1159        let original = line.clone();
1160        box_filter_line(&mut line, 5);
1161        assert_eq!(line, original);
1162    }
1163
1164    #[test]
1165    fn test_box_filter_line_smoothing_effect() {
1166        // A spike should be smoothed out
1167        let mut line = vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1168        box_filter_line(&mut line, 5);
1169        // The spike at index 3 should be reduced
1170        assert!(line[3] < 10.0, "Spike should be smoothed");
1171        // Neighbors should have received some intensity
1172        assert!(line[2] > 0.0 || line[4] > 0.0, "Neighbors should gain intensity");
1173        // All values should be finite
1174        for &v in &line {
1175            assert!(v.is_finite(), "All values should be finite");
1176        }
1177    }
1178
1179    // =====================================================================
1180    // Tests for box_filter_line_weighted
1181    // =====================================================================
1182
1183    #[test]
1184    fn test_box_filter_line_weighted_too_small() {
1185        let mut line = vec![1.0, 2.0];
1186        let mut weight = vec![1.0, 1.0];
1187        let orig_l = line.clone();
1188        let orig_w = weight.clone();
1189        box_filter_line_weighted(&mut line, &mut weight, 1);
1190        assert_eq!(line, orig_l);
1191        assert_eq!(weight, orig_w);
1192    }
1193
1194    #[test]
1195    fn test_box_filter_line_weighted_uniform() {
1196        let mut line = vec![5.0; 10];
1197        let mut weight = vec![1.0; 10];
1198        box_filter_line_weighted(&mut line, &mut weight, 3);
1199        // With uniform values and uniform weights, the middle portion
1200        // should remain close to 5.0
1201        for i in 2..8 {
1202            assert!(
1203                (line[i] - 5.0).abs() < 0.5,
1204                "Uniform weighted line should stay near 5.0, got {} at index {}",
1205                line[i], i
1206            );
1207        }
1208    }
1209
1210    #[test]
1211    fn test_box_filter_line_weighted_varying_weights() {
1212        let mut line = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1213        let mut weight = vec![1.0, 1.0, 1.0, 0.5, 0.5, 0.5, 1.0, 1.0, 1.0, 1.0];
1214        box_filter_line_weighted(&mut line, &mut weight, 3);
1215        // All values should remain finite
1216        for &v in &line {
1217            assert!(v.is_finite(), "All values should be finite after weighted filter");
1218        }
1219        for &w in &weight {
1220            assert!(w.is_finite(), "All weights should be finite after weighted filter");
1221        }
1222    }
1223
1224    // =====================================================================
1225    // Tests for nan_box_filter_line
1226    // =====================================================================
1227
1228    #[test]
1229    fn test_nan_box_filter_line_too_small() {
1230        let mut line = vec![1.0, 2.0];
1231        let original = line.clone();
1232        nan_box_filter_line(&mut line, 5);
1233        assert_eq!(line, original);
1234    }
1235
1236    #[test]
1237    fn test_nan_box_filter_line_no_nans() {
1238        // All valid data => the nan filter processes it and produces finite results
1239        let mut line = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0];
1240        nan_box_filter_line(&mut line, 3);
1241        // All values should remain finite
1242        for &v in &line {
1243            assert!(v.is_finite(), "All values should be finite after nan box filter");
1244        }
1245        // The nan box filter in Normal mode should act as a smoothing filter
1246        // Check that some middle values have changed (been smoothed)
1247        // The function's Normal mode applies running sum averaging
1248    }
1249
1250    #[test]
1251    fn test_nan_box_filter_line_with_nans() {
1252        // Data with NaN gaps
1253        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];
1254        nan_box_filter_line(&mut line, 3);
1255        // Non-NaN regions should still be mostly finite
1256        let finite_count = line.iter().filter(|v| v.is_finite()).count();
1257        assert!(finite_count >= 6, "Most values should be finite, got {}", finite_count);
1258    }
1259
1260    #[test]
1261    fn test_nan_box_filter_line_all_nan_except_edges() {
1262        // Mostly NaN
1263        let mut line = vec![1.0; 12];
1264        for i in 2..10 {
1265            line[i] = f64::NAN;
1266        }
1267        nan_box_filter_line(&mut line, 3);
1268        // The function should not panic and edge values should remain finite
1269        assert!(line[0].is_finite() || line[0].is_nan());
1270    }
1271
1272    // =====================================================================
1273    // Tests for gaussian_smooth_3d (main 3D smoothing)
1274    // =====================================================================
1275
1276    #[test]
1277    fn test_gaussian_smooth_3d_uniform_no_mask() {
1278        let n = 8;
1279        let data = vec![5.0; n * n * n];
1280        let result = gaussian_smooth_3d(&data, [2.0, 2.0, 2.0], None, None, 3, &grid(n, n, n));
1281        assert_eq!(result.len(), n * n * n);
1282        // Uniform data should stay nearly uniform after smoothing
1283        for &v in &result {
1284            assert!(
1285                (v - 5.0).abs() < 0.5,
1286                "Uniform data should stay near 5.0, got {}",
1287                v
1288            );
1289        }
1290    }
1291
1292    #[test]
1293    fn test_gaussian_smooth_3d_with_mask() {
1294        let n = 10;
1295        let (mag, mask) = make_sphere_phantom(n, false);
1296        // Smooth with mask
1297        let result = gaussian_smooth_3d(&mag, [1.5, 1.5, 1.5], Some(&mask), None, 4, &grid(n, n, n));
1298        assert_eq!(result.len(), n * n * n);
1299        // Inside mask, values should still be finite
1300        for i in 0..result.len() {
1301            if mask[i] > 0 {
1302                assert!(result[i].is_finite(), "Masked voxel at {} should be finite", i);
1303            }
1304        }
1305    }
1306
1307    #[test]
1308    fn test_gaussian_smooth_3d_with_weights() {
1309        let n = 8;
1310        let data = vec![10.0; n * n * n];
1311        let mut weight = vec![1.0; n * n * n];
1312        let result = gaussian_smooth_3d(
1313            &data, [1.5, 1.5, 1.5], None, Some(&mut weight), 3, &grid(n, n, n),
1314        );
1315        assert_eq!(result.len(), n * n * n);
1316        for &v in &result {
1317            assert!(v.is_finite(), "Result should be finite");
1318        }
1319    }
1320
1321    // =====================================================================
1322    // Tests for gaussian_smooth_3d_boxsizes
1323    // =====================================================================
1324
1325    #[test]
1326    fn test_gaussian_smooth_3d_boxsizes_uniform() {
1327        let n = 8;
1328        let data = vec![3.0; n * n * n];
1329        let boxsizes = vec![vec![3, 3], vec![3, 3], vec![3, 3]];
1330        let result = gaussian_smooth_3d_boxsizes(&data, &boxsizes, 2, &grid(n, n, n));
1331        assert_eq!(result.len(), n * n * n);
1332        for &v in &result {
1333            assert!(
1334                (v - 3.0).abs() < 0.5,
1335                "Uniform data should stay near 3.0, got {}",
1336                v
1337            );
1338        }
1339    }
1340
1341    #[test]
1342    fn test_gaussian_smooth_3d_boxsizes_spike() {
1343        let n = 10;
1344        let n_total = n * n * n;
1345        let mut data = vec![0.0; n_total];
1346        // Place a spike at the center
1347        let center = n / 2 + (n / 2) * n + (n / 2) * n * n;
1348        data[center] = 100.0;
1349        let boxsizes = vec![vec![5, 5], vec![5, 5], vec![5, 5]];
1350        let result = gaussian_smooth_3d_boxsizes(&data, &boxsizes, 2, &grid(n, n, n));
1351        assert_eq!(result.len(), n_total);
1352        // Spike should be reduced
1353        assert!(
1354            result[center] < 100.0,
1355            "Spike should be smoothed, got {}",
1356            result[center]
1357        );
1358        // Sum should be approximately conserved (box filter is mean-preserving)
1359        for &v in &result {
1360            assert!(v.is_finite(), "All values should be finite");
1361        }
1362    }
1363
1364    // =====================================================================
1365    // Tests for fill_holes (more complex cases)
1366    // =====================================================================
1367
1368    #[test]
1369    fn test_fill_holes_boundary_hole_not_filled() {
1370        // A hole touching the boundary should NOT be filled
1371        let mut mask = vec![1u8; 125]; // 5x5x5
1372        mask[0] = 0; // corner voxel - touches boundary
1373        let filled = fill_holes(&mask, &grid(5, 5, 5), 100);
1374        assert_eq!(filled[0], 0, "Boundary hole should not be filled");
1375    }
1376
1377    #[test]
1378    fn test_fill_holes_large_hole_not_filled() {
1379        // A hole larger than max_hole_size should NOT be filled
1380        let n = 7;
1381        let n_total = n * n * n;
1382        let mut mask = vec![1u8; n_total];
1383        // Create a large internal hole (3x3x3 = 27 voxels)
1384        for k in 2..5 {
1385            for j in 2..5 {
1386                for i in 2..5 {
1387                    mask[i + j * n + k * n * n] = 0;
1388                }
1389            }
1390        }
1391        let filled = fill_holes(&mask, &grid(n, n, n), 5); // max_hole_size=5, hole is 27
1392        // Hole should NOT be filled because it's too large
1393        let center = 3 + 3 * n + 3 * n * n;
1394        assert_eq!(filled[center], 0, "Large hole should not be filled");
1395    }
1396
1397    // =====================================================================
1398    // Tests for robust_mask (more comprehensive)
1399    // =====================================================================
1400
1401    #[test]
1402    fn test_robust_mask_sphere() {
1403        let n = 12;
1404        let (mag, _) = make_sphere_phantom(n, false);
1405        let mask = robust_mask(&mag, &grid(n, n, n));
1406        assert_eq!(mask.len(), n * n * n);
1407        let masked_count: usize = mask.iter().map(|&v| v as usize).sum();
1408        // The sphere should produce some masked voxels
1409        assert!(masked_count > 0, "Should have masked voxels for sphere phantom");
1410        // Center should be masked (high intensity)
1411        let center = n / 2 + (n / 2) * n + (n / 2) * n * n;
1412        assert_eq!(mask[center], 1, "Center of sphere should be masked");
1413    }
1414
1415    #[test]
1416    fn test_robust_mask_empty() {
1417        // All zero magnitude -> empty mask
1418        let mag = vec![0.0; 27];
1419        let mask = robust_mask(&mag, &grid(3, 3, 3));
1420        let count: usize = mask.iter().map(|&v| v as usize).sum();
1421        assert_eq!(count, 0, "Zero magnitude should produce empty mask");
1422    }
1423
1424    #[test]
1425    fn test_robust_mask_nan_values() {
1426        // Magnitude with NaN values
1427        let mut mag = vec![100.0; 125];
1428        mag[0] = f64::NAN;
1429        mag[10] = f64::NAN;
1430        mag[50] = f64::INFINITY;
1431        let mask = robust_mask(&mag, &grid(5, 5, 5));
1432        assert_eq!(mask.len(), 125);
1433        // Should still produce a valid mask
1434        for &v in &mask {
1435            assert!(v == 0 || v == 1, "Mask values should be 0 or 1");
1436        }
1437    }
1438
1439    // =====================================================================
1440    // Tests for box_segment
1441    // =====================================================================
1442
1443    #[test]
1444    fn test_box_segment_uniform() {
1445        let n = 10;
1446        let (mag, mask) = make_sphere_phantom(n, false);
1447        let seg = box_segment(&mag, &mask, 3, n, n, n);
1448        assert_eq!(seg.len(), n * n * n);
1449        // In a uniform sphere, most interior voxels should be segmented as tissue
1450        let seg_count: usize = seg.iter().map(|&v| v as usize).sum();
1451        assert!(seg_count > 0, "Box segment should find some tissue voxels");
1452    }
1453
1454    #[test]
1455    fn test_box_segment_empty_mask() {
1456        let n = 8;
1457        let mag = vec![100.0; n * n * n];
1458        let mask = vec![0u8; n * n * n]; // empty mask
1459        let seg = box_segment(&mag, &mask, 3, n, n, n);
1460        let count: usize = seg.iter().map(|&v| v as usize).sum();
1461        assert_eq!(count, 0, "Empty mask should produce no segmentation");
1462    }
1463
1464    // =====================================================================
1465    // Tests for fill_and_smooth
1466    // =====================================================================
1467
1468    #[test]
1469    fn test_fill_and_smooth_basic() {
1470        let n = 10;
1471        let n_total = n * n * n;
1472        let stable_mean = 100.0;
1473        let mut lowpass = vec![stable_mean; n_total];
1474        // Add some holes (very low values)
1475        lowpass[0] = 0.0;
1476        lowpass[100] = f64::NAN;
1477        lowpass[200] = 2000.0; // outlier > 10*stable_mean
1478
1479        let sigma2 = [2.0, 2.0, 2.0];
1480        fill_and_smooth(&mut lowpass, stable_mean, sigma2, &grid(n, n, n));
1481
1482        // All values should be finite after fill and smooth
1483        for (i, &v) in lowpass.iter().enumerate() {
1484            assert!(v.is_finite(), "Value at {} should be finite, got {}", i, v);
1485        }
1486    }
1487
1488    #[test]
1489    fn test_fill_and_smooth_preserves_approximate_mean() {
1490        let n = 8;
1491        let n_total = n * n * n;
1492        let stable_mean = 50.0;
1493        let mut lowpass = vec![stable_mean; n_total];
1494        let sigma2 = [1.5, 1.5, 1.5];
1495        fill_and_smooth(&mut lowpass, stable_mean, sigma2, &grid(n, n, n));
1496
1497        // Mean should be approximately preserved
1498        let mean: f64 = lowpass.iter().sum::<f64>() / n_total as f64;
1499        assert!(
1500            (mean - stable_mean).abs() < stable_mean * 0.5,
1501            "Mean should be approximately preserved, got {} vs {}",
1502            mean,
1503            stable_mean
1504        );
1505    }
1506
1507    // =====================================================================
1508    // Tests for get_sensitivity
1509    // =====================================================================
1510
1511    #[test]
1512    fn test_get_sensitivity_sphere() {
1513        let n = 12;
1514        let (mag, _) = make_sphere_phantom(n, true);
1515        let sensitivity = get_sensitivity(&mag, &grid(n, n, n), 4.0, 5);
1516        assert_eq!(sensitivity.len(), n * n * n);
1517        // Sensitivity should be finite and mostly positive in the sphere
1518        let center = n / 2 + (n / 2) * n + (n / 2) * n * n;
1519        assert!(
1520            sensitivity[center].is_finite(),
1521            "Sensitivity at center should be finite"
1522        );
1523        assert!(
1524            sensitivity[center] > 0.0,
1525            "Sensitivity at center should be positive, got {}",
1526            sensitivity[center]
1527        );
1528    }
1529
1530    #[test]
1531    fn test_get_sensitivity_output_size() {
1532        let n = 10;
1533        let (mag, _) = make_sphere_phantom(n, false);
1534        let sensitivity = get_sensitivity(&mag, &grid(n, n, n), 3.0, 3);
1535        assert_eq!(sensitivity.len(), n * n * n);
1536    }
1537
1538    // =====================================================================
1539    // Tests for makehomogeneous (main entry point)
1540    // =====================================================================
1541
1542    #[test]
1543    fn test_makehomogeneous_output_size_and_finite() {
1544        let n = 12;
1545        let (mag, _) = make_sphere_phantom(n, true);
1546        let result = makehomogeneous(&mag, &grid(n, n, n), 4.0, 5);
1547        assert_eq!(result.len(), n * n * n);
1548        // All output values should be finite
1549        for (i, &v) in result.iter().enumerate() {
1550            assert!(v.is_finite(), "Output at {} should be finite, got {}", i, v);
1551        }
1552    }
1553
1554    #[test]
1555    fn test_makehomogeneous_reduces_bias() {
1556        let n = 12;
1557        let (mag_biased, mask) = make_sphere_phantom(n, true);
1558        let result = makehomogeneous(&mag_biased, &grid(n, n, n), 4.0, 5);
1559
1560        // Collect values inside the sphere
1561        let mut original_vals = Vec::new();
1562        let mut corrected_vals = Vec::new();
1563        for i in 0..(n * n * n) {
1564            if mask[i] > 0 {
1565                original_vals.push(mag_biased[i]);
1566                corrected_vals.push(result[i]);
1567            }
1568        }
1569
1570        // The coefficient of variation should be reduced (or at least not much worse)
1571        let orig_mean = original_vals.iter().sum::<f64>() / original_vals.len() as f64;
1572        let orig_std = (original_vals
1573            .iter()
1574            .map(|v| (v - orig_mean).powi(2))
1575            .sum::<f64>()
1576            / original_vals.len() as f64)
1577            .sqrt();
1578        let orig_cv = orig_std / orig_mean;
1579
1580        let corr_mean = corrected_vals.iter().sum::<f64>() / corrected_vals.len() as f64;
1581        let corr_std = (corrected_vals
1582            .iter()
1583            .map(|v| (v - corr_mean).powi(2))
1584            .sum::<f64>()
1585            / corrected_vals.len() as f64)
1586            .sqrt();
1587        let corr_cv = corr_std / corr_mean;
1588
1589        assert!(
1590            corr_cv <= orig_cv * 2.0,
1591            "Corrected CV ({}) should not be much worse than original CV ({})",
1592            corr_cv,
1593            orig_cv
1594        );
1595    }
1596
1597    #[test]
1598    fn test_makehomogeneous_no_bias_preserves() {
1599        let n = 12;
1600        let (mag, mask) = make_sphere_phantom(n, false);
1601        let result = makehomogeneous(&mag, &grid(n, n, n), 4.0, 5);
1602
1603        // With no bias, corrected values should still be positive inside sphere
1604        for i in 0..(n * n * n) {
1605            if mask[i] > 0 {
1606                assert!(
1607                    result[i] > 0.0,
1608                    "Inside sphere voxel {} should be positive, got {}",
1609                    i,
1610                    result[i]
1611                );
1612            }
1613        }
1614    }
1615
1616    #[test]
1617    fn test_makehomogeneous_anisotropic_voxels() {
1618        let n = 12;
1619        let (mag, _) = make_sphere_phantom(n, true);
1620        // Use anisotropic voxel sizes
1621        let result = makehomogeneous(&mag, &Grid::new(n, n, n, 0.5, 0.5, 2.0), 4.0, 5);
1622        assert_eq!(result.len(), n * n * n);
1623        for &v in &result {
1624            assert!(v.is_finite(), "All output values should be finite");
1625        }
1626    }
1627
1628    // =====================================================================
1629    // Tests for flood_fill_component
1630    // =====================================================================
1631
1632    #[test]
1633    fn test_flood_fill_component_single() {
1634        let mask = vec![1u8; 27]; // 3x3x3 all filled => no zeros
1635        let mut visited = vec![false; 27];
1636        // Start at a filled voxel -> should find nothing (mask[idx] != 0)
1637        let component = flood_fill_component(&mask, &mut visited, 0, 3, 3, 3);
1638        assert!(component.is_empty(), "All filled mask should have no zero component");
1639    }
1640
1641    #[test]
1642    fn test_flood_fill_component_connected_zeros() {
1643        let n = 5;
1644        let n_total = n * n * n;
1645        let mut mask = vec![1u8; n_total];
1646        // Create a connected line of zeros along x at j=2, k=2
1647        for i in 1..4 {
1648            mask[i + 2 * n + 2 * n * n] = 0;
1649        }
1650        let mut visited = vec![false; n_total];
1651        let start = 1 + 2 * n + 2 * n * n;
1652        let component = flood_fill_component(&mask, &mut visited, start, n, n, n);
1653        assert_eq!(component.len(), 3, "Should find 3 connected zero voxels");
1654    }
1655
1656    // =====================================================================
1657    // Tests for gaussian_smooth_3d with reversed passes (mask + even pass)
1658    // =====================================================================
1659
1660    #[test]
1661    fn test_gaussian_smooth_3d_mask_reverse_passes() {
1662        // Exercise the reverse pass code paths (mask.is_some() && ibox % 2 == 1)
1663        let n = 10;
1664        let (mag, mask) = make_sphere_phantom(n, false);
1665        // nbox=4 ensures we have even passes (ibox=1,3)
1666        let result = gaussian_smooth_3d(&mag, [1.5, 1.5, 1.5], Some(&mask), None, 4, &grid(n, n, n));
1667        assert_eq!(result.len(), n * n * n);
1668        // Inside mask, values should still be finite
1669        for i in 0..result.len() {
1670            if mask[i] > 0 {
1671                // NaN smoothing can produce NaN near edges, but center should be finite
1672            }
1673        }
1674        // At least some values should be finite
1675        let finite_count = result.iter().filter(|v| v.is_finite()).count();
1676        assert!(finite_count > 0, "Should have some finite values");
1677    }
1678}