Skip to main content

qsm_core/pipeline/
masking.rs

1//! Masking stage
2//!
3//! Generates a binary brain mask from magnitude/phase data using a
4//! configurable sequence of operations (threshold, BET, morphological ops).
5//! Multiple mask sections can be OR'd together.
6//!
7//! ## Each section builds its own input
8//!
9//! The stage takes every echo's magnitude, and each [`MaskSection`] builds what its
10//! [`MaskingInput`] asks for, independently of the other sections:
11//!
12//! | input | thresholded image | magnitude for BET / HD-BET / RS2-Net / signal-gated erosion |
13//! |---|---|---|
14//! | [`MaskingInput::MagnitudeFirst`] | echo 1 | echo 1 |
15//! | [`MaskingInput::Magnitude`] | root-sum-of-squares over echoes | the RSS |
16//! | [`MaskingInput::MagnitudeLast`] | last echo | last echo |
17//! | [`MaskingInput::PhaseQuality`] | ROMEO voxel quality of echoes 1–2, weighted by the RSS | the RSS |
18//!
19//! **Phase quality is weighted by the RSS**, not echo 1: it is the highest-SNR magnitude there
20//! is, it is the same whatever other sections ask for, and it is what ROMEO itself weights by
21//! when unwrapping multi-echo data. With one echo the RSS is that echo.
22//!
23//! Any homogeneity correction is the caller's, applied to all echoes before they are passed in.
24//!
25//! ## Reference pipeline
26//!
27//! The default section (ROMEO phase quality → Otsu threshold → dilate 1, fill holes, erode 1;
28//! [`MaskingConfig::default`]) is QSMxT's phase-based masking: Stewart, A.W., Robinson, S.D.,
29//! O'Brien, K., et al. (2022). "QSMxT: Robust masking and artifact reduction for quantitative
30//! susceptibility mapping." *Magn Reson Med* 87(3):1289-1300,
31//! <https://doi.org/10.1002/mrm.29048>. The Python QSMxT releases' defaults
32//! (`qsmxt/qsm_pipelines.json:31-45,53`, local copy 75d537c) differ from this one: Otsu scaled by
33//! `threshold_algorithm_factor` 1.5 (and 1.3 for the second pass), hole filling `both`
34//! (morphological and Gaussian), 2 erosions, two-pass masking, and no inhomogeneity correction
35//! (`MaskingConfig::default` has it on). The ROMEO authors' own quality mask is a threshold of
36//! 0.1 on ROMEO's voxel quality (ROMEO.jl `ext/RomeoApp/argparse.jl:38-40`, `caller.jl:205-215`).
37//! The brain-extraction generators (BET, HD-BET, RS2-Net, MindGrab) document their own reference
38//! inputs in their modules; of them only MindGrab needs [`ScanMetadata::affine`], and HD-BET
39//! expects a volume already in MNI-like orientation.
40
41use super::config::*;
42use super::phase_utils::rss_combine;
43use crate::utils::morphology::{erode_mask, dilate_mask, morphological_close, fill_holes, signal_gated_erosion};
44
45/// Check every volume is the grid's size.
46fn check_volumes(name: &str, volumes: &[&[f64]], n_voxels: usize) -> Result<(), PipelineError> {
47    for (e, v) in volumes.iter().enumerate() {
48        if v.len() != n_voxels {
49            return Err(PipelineError::InvalidInput(format!(
50                "{name} echo {} has {} voxels, the grid has {n_voxels}", e + 1, v.len()
51            )));
52        }
53    }
54    Ok(())
55}
56
57/// The magnitude a section with this `input` works from (see the module docs): echo 1, the
58/// root-sum-of-squares over echoes, or the last echo. [`MaskingInput::PhaseQuality`] uses the
59/// RSS. `None` when there are no magnitudes.
60///
61/// This is also the magnitude the section's BET, HD-BET, RS2-Net and signal-gated erosion see,
62/// so a host that applies a section's ops itself through [`apply_mask_ops`] should pass it as
63/// `magnitude`.
64///
65/// # Errors
66/// [`PipelineError::InvalidInput`] if an echo does not match the grid.
67pub fn section_magnitude(
68    input: MaskingInput,
69    magnitudes: &[&[f64]],
70    metadata: &ScanMetadata,
71) -> Result<Option<Vec<f64>>, PipelineError> {
72    check_volumes("magnitude", magnitudes, metadata.grid().n_total())?;
73    let (Some(first), Some(last)) = (magnitudes.first(), magnitudes.last()) else {
74        return Ok(None);
75    };
76    Ok(Some(match input {
77        MaskingInput::MagnitudeFirst => first.to_vec(),
78        MaskingInput::MagnitudeLast => last.to_vec(),
79        MaskingInput::Magnitude | MaskingInput::PhaseQuality => rss_combine(magnitudes)?,
80    }))
81}
82
83/// The image a section with this `input` thresholds, built from `magnitude` (that section's
84/// [`section_magnitude`]) and, for phase quality, the phases.
85fn section_input(
86    input: MaskingInput,
87    phases: &[&[f64]],
88    magnitude: Option<&[f64]>,
89    metadata: &ScanMetadata,
90) -> Result<Vec<f64>, PipelineError> {
91    match input {
92        MaskingInput::MagnitudeFirst | MaskingInput::Magnitude | MaskingInput::MagnitudeLast => {
93            magnitude.map(<[f64]>::to_vec).ok_or_else(|| {
94                PipelineError::InvalidInput(format!("a {input:?} mask section needs magnitude data"))
95            })
96        }
97        MaskingInput::PhaseQuality => {
98            let n_voxels = metadata.grid().n_total();
99            check_volumes("phase", phases, n_voxels)?;
100            let Some(&phase1) = phases.first() else {
101                return Err(PipelineError::InvalidInput(
102                    "a PhaseQuality mask section needs phase data".into(),
103                ));
104            };
105            let ones;
106            let weight = match magnitude {
107                Some(m) => m,
108                None => {
109                    ones = vec![1.0; n_voxels];
110                    &ones
111                }
112            };
113            let all_ones = vec![1u8; n_voxels];
114            let grid = metadata.grid();
115            Ok(if phases.len() >= 2 && metadata.echo_times.len() >= 2 {
116                crate::unwrap::voxel_quality_romeo(
117                    phase1, weight,
118                    Some(phases[1]),
119                    metadata.echo_times[0], metadata.echo_times[1],
120                    &all_ones, &grid,
121                )
122            } else {
123                crate::unwrap::voxel_quality_romeo(
124                    phase1, weight,
125                    None,
126                    metadata.echo_times.first().copied().unwrap_or(0.02),
127                    0.0, &all_ones, &grid,
128                )
129            })
130        }
131    }
132}
133
134/// The image a section with this `input` thresholds (see the module docs).
135///
136/// # Arguments
137/// * `input` - Which data source to use
138/// * `phases` - Per-echo wrapped phase (for [`MaskingInput::PhaseQuality`])
139/// * `magnitudes` - Per-echo magnitude, all echoes (optionally homogeneity-corrected)
140/// * `metadata` - Scan metadata
141///
142/// # Errors
143/// [`PipelineError::InvalidInput`] if the input needs data that was not supplied, or a volume
144/// does not match the grid.
145pub fn resolve_masking_input(
146    input: MaskingInput,
147    phases: &[&[f64]],
148    magnitudes: &[&[f64]],
149    metadata: &ScanMetadata,
150) -> Result<Vec<f64>, PipelineError> {
151    let magnitude = section_magnitude(input, magnitudes, metadata)?;
152    section_input(input, phases, magnitude.as_deref(), metadata)
153}
154
155/// Build a mask from a single section (generator + refinements). The section builds its own
156/// input and magnitude from the echoes (see the module docs).
157///
158/// # Arguments
159/// * `section` - Mask section config (input type, generator, refinements)
160/// * `phases` - Per-echo wrapped phase (for [`MaskingInput::PhaseQuality`])
161/// * `magnitudes` - Per-echo magnitude, all echoes (optionally homogeneity-corrected)
162/// * `metadata` - Scan metadata
163pub fn build_mask_section(
164    section: &MaskSection,
165    phases: &[&[f64]],
166    magnitudes: &[&[f64]],
167    metadata: &ScanMetadata,
168) -> Result<Vec<u8>, PipelineError> {
169    let n_voxels = metadata.dims.0 * metadata.dims.1 * metadata.dims.2;
170    let magnitude = section_magnitude(section.input, magnitudes, metadata)?;
171    let input_data = section_input(section.input, phases, magnitude.as_deref(), metadata)?;
172    apply_mask_ops(vec![1u8; n_voxels], &section.all_ops(), &input_data, magnitude.as_deref(), metadata)
173}
174
175/// Apply mask operations to an existing mask, in order.
176///
177/// This is the one implementation of what every mask op *means*; [`build_mask_section`] is this
178/// function starting from an all-ones mask, and hosts that drive masking themselves (e.g. an
179/// interactive UI applying one refinement at a time) should call it rather than reimplement the
180/// operations, so a mask built step-by-step matches the one a `--mask` section would produce.
181///
182/// **Two different images.** `input_data` is what a generator looks at — thresholding may use a
183/// phase-quality map, for instance. `magnitude` is the magnitude image, and is what the ops that
184/// need real signal use: [`MaskOp::Bet`], [`MaskOp::HdBet`], [`MaskOp::Rs2Net`], [`MaskOp::MindGrab`] and
185/// [`MaskOp::SignalErode`]. Passing
186/// the section input as `magnitude` is a bug: signal-gated erosion divides out a receive-coil bias
187/// estimate and gates on the in-mask median, which only means anything for a magnitude image.
188/// Those ops error when `magnitude` is `None`. To match what [`build_mask_section`] does, pass
189/// [`resolve_masking_input`] as `input_data` and [`section_magnitude`] as `magnitude`.
190///
191/// # Arguments
192/// * `mask` - Starting mask (0/1), length `nx*ny*nz`
193/// * `ops` - Operations to apply, in order
194/// * `input_data` - Image the generators threshold (may be a phase-quality map)
195/// * `magnitude` - Magnitude image, for BET / HD-BET / signal-gated erosion
196/// * `metadata` - Scan metadata (dims + voxel size)
197pub fn apply_mask_ops(
198    mask: Vec<u8>,
199    ops: &[MaskOp],
200    input_data: &[f64],
201    magnitude: Option<&[f64]>,
202    metadata: &ScanMetadata,
203) -> Result<Vec<u8>, PipelineError> {
204    let (nx, ny, nz) = metadata.dims;
205    let grid = metadata.grid();
206    let n_voxels = nx * ny * nz;
207    if mask.len() != n_voxels {
208        return Err(PipelineError::DimensionMismatch { expected: n_voxels, got: mask.len() });
209    }
210    let mut mask = mask;
211
212    for op in ops {
213        match op {
214            MaskOp::Threshold { method, value } => {
215                let threshold = match method {
216                    MaskThresholdMethod::Otsu => {
217                        crate::utils::otsu_threshold(input_data, 256)
218                    }
219                    MaskThresholdMethod::Fixed => value.unwrap_or(0.5),
220                    MaskThresholdMethod::Percentile => {
221                        let pct = value.unwrap_or(75.0) / 100.0;
222                        let mut sorted: Vec<f64> = input_data.iter()
223                            .filter(|v| v.is_finite() && **v > 0.0)
224                            .copied().collect();
225                        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
226                        if sorted.is_empty() { 0.0 }
227                        else {
228                            let idx = ((sorted.len() as f64 * pct) as usize).min(sorted.len() - 1);
229                            sorted[idx]
230                        }
231                    }
232                };
233                mask = input_data.iter()
234                    .map(|&v| if v > threshold { 1u8 } else { 0u8 })
235                    .collect();
236            }
237            MaskOp::Bet(params) => {
238                let mag_data = magnitude.ok_or_else(|| {
239                    PipelineError::InvalidInput("BET requires magnitude data".into())
240                })?;
241                // bet2 flips neurologically stored images (FSL's read_volume); the order comes
242                // from the scan's affine.
243                let order = crate::bet::FslLeftRightOrder::from_affine(metadata.affine.as_ref());
244                mask = crate::bet::run_bet(mag_data, &grid, params, order, |_, _| {});
245            }
246            MaskOp::Erode { iterations } => {
247                mask = erode_mask(&mask, &grid, *iterations);
248            }
249            MaskOp::Dilate { iterations } => {
250                mask = dilate_mask(&mask, &grid, *iterations);
251            }
252            MaskOp::Close { radius } => {
253                mask = morphological_close(&mask, &grid, *radius as i32);
254            }
255            MaskOp::FillHoles { max_size } => {
256                let effective_size = if *max_size == 0 { n_voxels / 20 } else { *max_size };
257                mask = fill_holes(&mask, &grid, effective_size);
258            }
259            MaskOp::GaussianSmooth { sigma_mm } => {
260                let sigma = *sigma_mm;
261                let mask_f64: Vec<f64> = mask.iter().map(|&m| m as f64).collect();
262                let smoothed = crate::utils::gaussian_smooth_3d(
263                    &mask_f64,
264                    [sigma, sigma, sigma],
265                    None, None, 3,
266                    &grid,
267                );
268                mask = smoothed.iter().map(|&v| if v > 0.5 { 1u8 } else { 0u8 }).collect();
269            }
270            MaskOp::SignalErode(params) => {
271                let mag_data = magnitude.ok_or_else(|| {
272                    PipelineError::InvalidInput("signal-gated erosion requires magnitude data".into())
273                })?;
274                mask = signal_gated_erosion(&mask, mag_data, &grid, params);
275            }
276            MaskOp::HdBet(params) => {
277                let mag_data = magnitude.ok_or_else(|| {
278                    PipelineError::InvalidInput("HD-BET requires magnitude data".into())
279                })?;
280                mask = run_hd_bet(mag_data, &grid, params)?;
281            }
282            MaskOp::Rs2Net(params) => {
283                let mag_data = magnitude.ok_or_else(|| {
284                    PipelineError::InvalidInput("RS2-Net requires magnitude data".into())
285                })?;
286                mask = run_rs2_net(mag_data, &grid, params)?;
287            }
288            MaskOp::MindGrab(params) => {
289                let mag_data = magnitude.ok_or_else(|| {
290                    PipelineError::InvalidInput("MindGrab requires magnitude data".into())
291                })?;
292                let affine = metadata.affine.as_ref().ok_or_else(|| {
293                    PipelineError::InvalidInput("MindGrab requires the image affine".into())
294                })?;
295                mask = run_mindgrab(mag_data, &grid, affine, params)?;
296            }
297        }
298    }
299
300    Ok(mask)
301}
302
303/// Source the HD-BET weights and run it. Requires the `onnx` feature; weights come from the
304/// model registry through [`crate::models`] (supplied bytes first, then local/download on native).
305#[cfg(feature = "onnx")]
306fn run_hd_bet(
307    magnitude: &[f64],
308    grid: &crate::Grid,
309    params: &crate::bet::HdBetParams,
310) -> Result<Vec<u8>, PipelineError> {
311    let bytes = crate::models::primary_weight("hd-bet").map_err(PipelineError::InvalidConfig)?;
312    crate::bet::hd_bet(magnitude, grid, &bytes, params, |_, _| {})
313        .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
314}
315
316#[cfg(not(feature = "onnx"))]
317fn run_hd_bet(
318    _magnitude: &[f64],
319    _grid: &crate::Grid,
320    _params: &crate::bet::HdBetParams,
321) -> Result<Vec<u8>, PipelineError> {
322    Err(PipelineError::InvalidConfig(
323        "HD-BET requires building qsm-core with the 'onnx' feature".into(),
324    ))
325}
326
327/// Source the RS2-Net weights and run it. Requires the `onnx` feature; weights come from the
328/// model registry through [`crate::models`] (supplied bytes first, then local/download on native).
329#[cfg(feature = "onnx")]
330fn run_rs2_net(
331    magnitude: &[f64],
332    grid: &crate::Grid,
333    params: &crate::bet::Rs2NetParams,
334) -> Result<Vec<u8>, PipelineError> {
335    let bytes = crate::models::primary_weight("rs2-net").map_err(PipelineError::InvalidConfig)?;
336    crate::bet::rs2_net(magnitude, grid, &bytes, params, |_, _| {})
337        .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
338}
339
340#[cfg(not(feature = "onnx"))]
341fn run_rs2_net(
342    _magnitude: &[f64],
343    _grid: &crate::Grid,
344    _params: &crate::bet::Rs2NetParams,
345) -> Result<Vec<u8>, PipelineError> {
346    Err(PipelineError::InvalidConfig(
347        "RS2-Net requires building qsm-core with the 'onnx' feature".into(),
348    ))
349}
350
351/// Source the MindGrab weights and run it. Requires the `onnx` feature; weights come from the
352/// model registry through [`crate::models`] (supplied bytes first, then local/download on native).
353#[cfg(feature = "onnx")]
354fn run_mindgrab(
355    magnitude: &[f64],
356    grid: &crate::Grid,
357    affine: &[f64; 16],
358    params: &crate::bet::MindGrabParams,
359) -> Result<Vec<u8>, PipelineError> {
360    let bytes = crate::models::primary_weight("mindgrab").map_err(PipelineError::InvalidConfig)?;
361    crate::bet::mindgrab(magnitude, grid, affine, &bytes, params, |_, _| {})
362        .map_err(|e| PipelineError::AlgorithmError(e.to_string()))
363}
364
365#[cfg(not(feature = "onnx"))]
366fn run_mindgrab(
367    _magnitude: &[f64],
368    _grid: &crate::Grid,
369    _affine: &[f64; 16],
370    _params: &crate::bet::MindGrabParams,
371) -> Result<Vec<u8>, PipelineError> {
372    Err(PipelineError::InvalidConfig(
373        "MindGrab requires building qsm-core with the 'onnx' feature".into(),
374    ))
375}
376
377/// Build a mask from multiple sections, OR'd together.
378///
379/// Each section specifies an input source, a generator (threshold/BET),
380/// and optional refinements (erode, dilate, close, fill holes, smooth). Each builds its own
381/// input from the echoes, so sections with different inputs do not affect each other.
382///
383/// # Arguments
384/// * `sections` - Mask section configs
385/// * `phases` - Per-echo wrapped phase (for [`MaskingInput::PhaseQuality`])
386/// * `magnitudes` - Per-echo magnitude, all echoes (optionally homogeneity-corrected)
387/// * `metadata` - Scan metadata
388pub fn run_masking(
389    sections: &[MaskSection],
390    phases: &[&[f64]],
391    magnitudes: &[&[f64]],
392    metadata: &ScanMetadata,
393) -> Result<Vec<u8>, PipelineError> {
394    let (nx, ny, nz) = metadata.dims;
395    let n_voxels = nx * ny * nz;
396
397    if sections.is_empty() {
398        return Err(PipelineError::InvalidConfig("no mask sections configured".into()));
399    }
400
401    let mut final_mask = vec![0u8; n_voxels];
402    for section in sections {
403        let section_mask = build_mask_section(section, phases, magnitudes, metadata)?;
404        for (f, s) in final_mask.iter_mut().zip(&section_mask) {
405            *f |= s;
406        }
407    }
408
409    Ok(final_mask)
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    fn test_metadata() -> ScanMetadata {
417        ScanMetadata {
418            dims: (8, 8, 8),
419            voxel_size: (1.0, 1.0, 1.0),
420            echo_times: vec![0.005, 0.010],
421            field_strength: 3.0,
422            b0_direction: (0.0, 0.0, 1.0),
423            slice_geometry: None,
424            affine: None,
425        }
426    }
427
428    #[test]
429    fn test_run_masking_otsu_threshold() {
430        let meta = test_metadata();
431        let n = 8 * 8 * 8;
432        // Half bright, half dark
433        let mut mag = vec![0.1; n];
434        for i in n / 2..n {
435            mag[i] = 10.0;
436        }
437
438        let sections = vec![MaskSection {
439            input: MaskingInput::Magnitude,
440            generator: MaskOp::Threshold {
441                method: MaskThresholdMethod::Otsu,
442                value: None,
443            },
444            refinements: vec![],
445        }];
446
447        let result = run_masking(&sections, &[], &[&mag], &meta).unwrap();
448        assert_eq!(result.len(), n);
449
450        // Bright half should be masked in, dark half masked out
451        let bright_count: usize = result[n / 2..].iter().map(|&m| m as usize).sum();
452        let dark_count: usize = result[..n / 2].iter().map(|&m| m as usize).sum();
453        assert!(bright_count > dark_count, "Otsu should separate bright from dark");
454    }
455
456    #[test]
457    fn test_run_masking_with_refinements() {
458        let meta = test_metadata();
459        let n = 8 * 8 * 8;
460        let mag = vec![10.0; n]; // all bright → all masked in
461
462        let sections = vec![MaskSection {
463            input: MaskingInput::Magnitude,
464            generator: MaskOp::Threshold {
465                method: MaskThresholdMethod::Fixed,
466                value: Some(0.5),
467            },
468            refinements: vec![
469                MaskOp::Erode { iterations: 1 },
470                MaskOp::Dilate { iterations: 1 },
471            ],
472        }];
473
474        let result = run_masking(&sections, &[], &[&mag], &meta).unwrap();
475        assert_eq!(result.len(), n);
476        // After erode+dilate, interior should still be masked
477        let (nx, ny, nz) = meta.dims;
478        let center = nx / 2 + (ny / 2) * nx + (nz / 2) * nx * ny;
479        assert_eq!(result[center], 1, "center should survive erode+dilate");
480    }
481
482    #[test]
483    fn test_run_masking_or_sections() {
484        let meta = test_metadata();
485        let n = 8 * 8 * 8;
486        let mag = vec![10.0; n];
487
488        // Section 1: mask only first half via fixed threshold
489        // Section 2: mask only second half
490        // OR → should get everything
491        let sections = vec![
492            MaskSection {
493                input: MaskingInput::Magnitude,
494                generator: MaskOp::Threshold {
495                    method: MaskThresholdMethod::Fixed,
496                    value: Some(0.5),
497                },
498                refinements: vec![],
499            },
500            MaskSection {
501                input: MaskingInput::Magnitude,
502                generator: MaskOp::Threshold {
503                    method: MaskThresholdMethod::Fixed,
504                    value: Some(0.5),
505                },
506                refinements: vec![],
507            },
508        ];
509
510        let result = run_masking(&sections, &[], &[&mag], &meta).unwrap();
511        let count: usize = result.iter().map(|&m| m as usize).sum();
512        assert_eq!(count, n, "OR of identical sections should give full mask");
513    }
514
515    #[test]
516    fn test_masking_fixed_threshold() {
517        let meta = test_metadata();
518        let n = 8 * 8 * 8;
519        let mag = vec![10.0; n];
520        let sections = vec![MaskSection {
521            input: MaskingInput::Magnitude,
522            generator: MaskOp::Threshold { method: MaskThresholdMethod::Fixed, value: Some(5.0) },
523            refinements: vec![],
524        }];
525        let result = run_masking(&sections, &[], &[&mag], &meta).unwrap();
526        let count: usize = result.iter().map(|&m| m as usize).sum();
527        assert_eq!(count, n, "all voxels above threshold=5");
528    }
529
530    #[test]
531    fn test_masking_percentile_threshold() {
532        let meta = test_metadata();
533        let n = 8 * 8 * 8;
534        let mag: Vec<f64> = (0..n).map(|i| i as f64).collect();
535        let sections = vec![MaskSection {
536            input: MaskingInput::Magnitude,
537            generator: MaskOp::Threshold { method: MaskThresholdMethod::Percentile, value: Some(50.0) },
538            refinements: vec![],
539        }];
540        let result = run_masking(&sections, &[], &[&mag], &meta).unwrap();
541        let count: usize = result.iter().map(|&m| m as usize).sum();
542        assert!(count > 0 && count < n, "percentile should mask ~half");
543    }
544
545    #[test]
546    fn test_masking_close_and_fill_holes() {
547        let meta = test_metadata();
548        let n = 8 * 8 * 8;
549        let mag = vec![10.0; n];
550        let sections = vec![MaskSection {
551            input: MaskingInput::Magnitude,
552            generator: MaskOp::Threshold { method: MaskThresholdMethod::Fixed, value: Some(0.5) },
553            refinements: vec![
554                MaskOp::Close { radius: 1 },
555                MaskOp::FillHoles { max_size: 0 },
556                MaskOp::GaussianSmooth { sigma_mm: 1.0 },
557            ],
558        }];
559        let result = run_masking(&sections, &[], &[&mag], &meta).unwrap();
560        assert_eq!(result.len(), n);
561    }
562
563    #[test]
564    fn test_masking_phase_quality_input() {
565        let meta = test_metadata();
566        let n = 8 * 8 * 8;
567        let phase1 = vec![0.5; n];
568        let phase2 = vec![1.0; n];
569        let mag = vec![1.0; n];
570        let sections = vec![MaskSection {
571            input: MaskingInput::PhaseQuality,
572            generator: MaskOp::Threshold { method: MaskThresholdMethod::Otsu, value: None },
573            refinements: vec![],
574        }];
575        let result = run_masking(&sections, &[&phase1, &phase2], &[&mag], &meta).unwrap();
576        assert_eq!(result.len(), n);
577    }
578
579    #[test]
580    fn test_masking_signal_erode() {
581        let meta = test_metadata();
582        let n = 8 * 8 * 8;
583        let mag = vec![10.0; n];
584        let section = |refinement| vec![MaskSection {
585            input: MaskingInput::Magnitude,
586            generator: MaskOp::Threshold { method: MaskThresholdMethod::Fixed, value: Some(5.0) },
587            refinements: vec![refinement],
588        }];
589        let params = crate::utils::morphology::SignalErosionParams { min_component: 1, ..Default::default() };
590        let result = run_masking(&section(MaskOp::SignalErode(params.clone())), &[], &[&mag], &meta).unwrap();
591        // Uniform signal: nothing is gated, so only the one global erosion applies.
592        let plain = run_masking(&section(MaskOp::Erode { iterations: 1 }), &[], &[&mag], &meta).unwrap();
593        assert_eq!(result, plain);
594        // Needs magnitude.
595        assert!(run_masking(&section(MaskOp::SignalErode(params)), &[], &[], &meta).is_err());
596    }
597
598    #[test]
599    fn test_masking_hd_bet_requires_magnitude() {
600        let meta = test_metadata();
601        let sections = vec![MaskSection {
602            input: MaskingInput::Magnitude,
603            generator: MaskOp::HdBet(Default::default()),
604            refinements: vec![],
605        }];
606        assert!(run_masking(&sections, &[], &[], &meta).is_err());
607        #[cfg(not(feature = "onnx"))]
608        assert!(run_masking(&sections, &[], &[&vec![1.0; 512]], &meta).is_err());
609    }
610
611    #[test]
612    fn test_masking_rs2_net_requires_magnitude() {
613        let meta = test_metadata();
614        let sections = vec![MaskSection {
615            input: MaskingInput::Magnitude,
616            generator: MaskOp::Rs2Net(Default::default()),
617            refinements: vec![],
618        }];
619        assert!(run_masking(&sections, &[], &[], &meta).is_err());
620        #[cfg(not(feature = "onnx"))]
621        assert!(run_masking(&sections, &[], &[&vec![1.0; 512]], &meta).is_err());
622    }
623
624    #[test]
625    fn test_masking_mindgrab_requires_magnitude_and_affine() {
626        let mut meta = test_metadata();
627        let sections = vec![MaskSection {
628            input: MaskingInput::Magnitude,
629            generator: MaskOp::MindGrab(Default::default()),
630            refinements: vec![],
631        }];
632        let mag = vec![1.0; 512];
633        assert!(run_masking(&sections, &[], &[], &meta).is_err());
634        // no affine
635        let err = run_masking(&sections, &[], &[&mag], &meta).unwrap_err();
636        assert!(err.to_string().contains("affine"), "{err}");
637        meta.affine = Some([1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]);
638        #[cfg(not(feature = "onnx"))]
639        assert!(run_masking(&sections, &[], &[&mag], &meta).is_err());
640    }
641
642    /// Applying ops one at a time (what an interactive host does) must equal building the whole
643    /// section in one call — same implementation, so a step-by-step mask matches the `--mask`
644    /// section a host prints alongside it.
645    #[test]
646    fn test_apply_mask_ops_matches_build_mask_section() {
647        let meta = test_metadata();
648        let n = 8 * 8 * 8;
649        let e1: Vec<f64> = (0..n).map(|i| 50.0 + ((i * 7) % 13) as f64).collect();
650        let e2: Vec<f64> = (0..n).map(|i| ((i * 37) % 19) as f64).collect();
651        // what the host would hand apply_mask_ops for a MagnitudeLast section
652        let input = section_magnitude(MaskingInput::MagnitudeLast, &[&e1, &e2], &meta).unwrap().unwrap();
653        let mag = input.clone();
654        assert_eq!(input, e2);
655        let section = MaskSection {
656            input: MaskingInput::MagnitudeLast,
657            generator: MaskOp::Threshold { method: MaskThresholdMethod::Fixed, value: Some(5.0) },
658            refinements: vec![
659                MaskOp::Dilate { iterations: 1 },
660                MaskOp::FillHoles { max_size: 0 },
661                MaskOp::Erode { iterations: 1 },
662                MaskOp::SignalErode(crate::utils::morphology::SignalErosionParams { min_component: 1, ..Default::default() }),
663            ],
664        };
665        let whole = build_mask_section(&section, &[], &[&e1, &e2], &meta).unwrap();
666
667        let mut step = vec![1u8; n];
668        for op in section.all_ops() {
669            step = apply_mask_ops(step, std::slice::from_ref(&op), &input, Some(&mag), &meta).unwrap();
670        }
671        assert_eq!(step, whole);
672    }
673
674    #[test]
675    fn test_apply_mask_ops_needs_magnitude_and_matching_length() {
676        let meta = test_metadata();
677        let n = 8 * 8 * 8;
678        let input = vec![10.0; n];
679        // Signal-gated erosion gates on the magnitude, so without one it is an error rather than
680        // silently falling back to the section input.
681        let ops = [MaskOp::SignalErode(Default::default())];
682        assert!(apply_mask_ops(vec![1u8; n], &ops, &input, None, &meta).is_err());
683        // A mask of the wrong size is rejected rather than mis-indexed.
684        assert!(apply_mask_ops(vec![1u8; n - 1], &[MaskOp::Erode { iterations: 1 }], &input, Some(&input), &meta).is_err());
685    }
686
687    /// Synthetic 3-echo volume on the 8³ test grid: x < 4 is bright on echo 1 and has decayed
688    /// by the last echo; x ≥ 4 is dim on echo 1 and bright on the last (#173).
689    fn two_region_echoes() -> Vec<Vec<f64>> {
690        let n = 8 * 8 * 8;
691        let left = |i: usize| i % 8 < 4;
692        vec![
693            (0..n).map(|i| if left(i) { 10.0 } else { 1.0 }).collect(),
694            vec![5.0; n],
695            (0..n).map(|i| if left(i) { 1.0 } else { 10.0 }).collect(),
696        ]
697    }
698
699    fn fixed(input: MaskingInput) -> MaskSection {
700        MaskSection {
701            input,
702            generator: MaskOp::Threshold { method: MaskThresholdMethod::Fixed, value: Some(6.0) },
703            refinements: vec![],
704        }
705    }
706
707    fn count(mask: &[u8]) -> usize {
708        mask.iter().map(|&m| m as usize).sum()
709    }
710
711    /// The three magnitude inputs are three different images.
712    #[test]
713    fn magnitude_inputs_are_distinguishable() {
714        let meta = test_metadata();
715        let echoes = two_region_echoes();
716        let mags: Vec<&[f64]> = echoes.iter().map(Vec::as_slice).collect();
717        let get = |input| resolve_masking_input(input, &[], &mags, &meta).unwrap();
718        assert_eq!(get(MaskingInput::MagnitudeFirst), echoes[0]);
719        assert_eq!(get(MaskingInput::MagnitudeLast), echoes[2]);
720        let rss = get(MaskingInput::Magnitude);
721        assert!((rss[0] - (100.0f64 + 25.0 + 1.0).sqrt()).abs() < 1e-12);
722        assert_ne!(rss, echoes[0]);
723        assert_ne!(rss, echoes[2]);
724    }
725
726    /// Two sections with different inputs each see their own input; swapping one section's
727    /// input changes only that section's mask.
728    #[test]
729    fn each_section_uses_its_own_input() {
730        let meta = test_metadata();
731        let echoes = two_region_echoes();
732        let mags: Vec<&[f64]> = echoes.iter().map(Vec::as_slice).collect();
733        let n = 8 * 8 * 8;
734        let build = |input| build_mask_section(&fixed(input), &[], &mags, &meta).unwrap();
735
736        let first = build(MaskingInput::MagnitudeFirst);
737        let last = build(MaskingInput::MagnitudeLast);
738        // echo 1 picks the left half, the last echo the right half
739        assert_eq!(count(&first), n / 2);
740        assert_eq!(count(&last), n / 2);
741        assert!(first.iter().zip(&last).all(|(a, b)| a + b == 1));
742
743        // both sections together: each contributes its own half
744        let both = run_masking(&[fixed(MaskingInput::MagnitudeFirst), fixed(MaskingInput::MagnitudeLast)], &[], &mags, &meta).unwrap();
745        assert_eq!(count(&both), n);
746
747        // swap the second section to echo 1: only its contribution changes, to the left half
748        let swapped = run_masking(&[fixed(MaskingInput::MagnitudeFirst), fixed(MaskingInput::MagnitudeFirst)], &[], &mags, &meta).unwrap();
749        assert_eq!(swapped, first);
750        // and the first section's own mask is the same whatever the second asks for
751        assert_eq!(build(MaskingInput::MagnitudeFirst), first);
752    }
753
754    /// Signal-gated ops get the section's own magnitude: a MagnitudeLast section's signal
755    /// erosion gates on the last echo, an echo-1 section's on echo 1.
756    #[test]
757    fn signal_gated_ops_use_the_sections_magnitude() {
758        let meta = test_metadata();
759        let n = 8 * 8 * 8;
760        // echo 1 uniform; the last echo has a dropout along the x = 0 face
761        let e1 = vec![10.0; n];
762        let e_last: Vec<f64> = (0..n).map(|i| if i % 8 == 0 { 0.5 } else { 10.0 }).collect();
763        let mags: [&[f64]; 2] = [&e1, &e_last];
764        let params = crate::utils::morphology::SignalErosionParams { min_component: 1, ..Default::default() };
765        let section = |input| MaskSection {
766            input,
767            generator: MaskOp::Threshold { method: MaskThresholdMethod::Fixed, value: Some(0.1) },
768            refinements: vec![MaskOp::SignalErode(params.clone())],
769        };
770        let all = vec![1u8; n];
771        let erode_with = |m: &[f64]| apply_mask_ops(all.clone(), &[MaskOp::SignalErode(params.clone())], m, Some(m), &meta).unwrap();
772        let on_first = erode_with(&e1);
773        let on_last = erode_with(&e_last);
774        assert_ne!(on_first, on_last, "test magnitudes do not separate the two cases");
775
776        assert_eq!(build_mask_section(&section(MaskingInput::MagnitudeFirst), &[], &mags, &meta).unwrap(), on_first);
777        assert_eq!(build_mask_section(&section(MaskingInput::MagnitudeLast), &[], &mags, &meta).unwrap(), on_last);
778    }
779
780    /// Phase quality is weighted by the RSS of all echoes (the documented choice), whatever
781    /// the other sections use.
782    #[test]
783    fn phase_quality_is_weighted_by_the_rss() {
784        let meta = test_metadata();
785        let n = 8 * 8 * 8;
786        let phase1: Vec<f64> = (0..n).map(|i| ((i * 13) % 7) as f64 * 0.4 - 1.2).collect();
787        let phase2: Vec<f64> = phase1.iter().map(|p| 2.0 * p).collect();
788        let echoes = two_region_echoes();
789        let mags: Vec<&[f64]> = echoes.iter().map(Vec::as_slice).collect();
790        let rss = rss_combine(&mags).unwrap();
791        let phases: [&[f64]; 2] = [&phase1, &phase2];
792        let q = resolve_masking_input(MaskingInput::PhaseQuality, &phases, &mags, &meta).unwrap();
793        let q_rss = resolve_masking_input(MaskingInput::PhaseQuality, &phases, &[&rss], &meta).unwrap();
794        let q_e1 = resolve_masking_input(MaskingInput::PhaseQuality, &phases, &mags[..1], &meta).unwrap();
795        assert_eq!(q, q_rss);
796        assert_ne!(q, q_e1);
797        assert_eq!(section_magnitude(MaskingInput::PhaseQuality, &mags, &meta).unwrap(), Some(rss));
798    }
799
800    #[test]
801    fn missing_or_mismatched_data_is_an_error() {
802        let meta = test_metadata();
803        let n = 8 * 8 * 8;
804        let mag = vec![1.0; n];
805        let short = vec![1.0; n - 1];
806        for input in [MaskingInput::MagnitudeFirst, MaskingInput::Magnitude, MaskingInput::MagnitudeLast] {
807            assert!(resolve_masking_input(input, &[], &[], &meta).is_err(), "{input:?} without magnitude");
808            assert!(resolve_masking_input(input, &[], &[&mag, &short], &meta).is_err(), "{input:?} short echo");
809        }
810        assert!(resolve_masking_input(MaskingInput::PhaseQuality, &[], &[&mag], &meta).is_err());
811        assert!(resolve_masking_input(MaskingInput::PhaseQuality, &[&short], &[&mag], &meta).is_err());
812        // phase quality without magnitude is weighted uniformly, as before
813        assert!(resolve_masking_input(MaskingInput::PhaseQuality, &[&mag], &[], &meta).is_ok());
814    }
815
816    #[test]
817    fn test_run_masking_empty_sections() {
818        let meta = test_metadata();
819        let result = run_masking(&[], &[], &[], &meta);
820        assert!(result.is_err());
821    }
822
823    /// Every [`BetParams`](crate::bet::BetParams) field set on `MaskOp::Bet` must reach
824    /// `run_bet` — not just the fractional intensity and voxel scale the op used to carry.
825    #[test]
826    fn test_bet_op_passes_every_bet_param_through() {
827        use crate::bet::{run_bet, BetParams};
828        let n = 20;
829        let meta = ScanMetadata { dims: (n, n, n), ..test_metadata() };
830        let grid = meta.grid();
831        // A bright ellipsoid on a dim background, with a soft edge so the surface parameters matter.
832        let c = (n as f64 - 1.0) / 2.0;
833        let mag: Vec<f64> = (0..n * n * n).map(|idx| {
834            let (i, j, k) = (idx % n, (idx / n) % n, idx / (n * n));
835            let r = (((i as f64 - c) / 7.0).powi(2) + ((j as f64 - c) / 6.0).powi(2)
836                + ((k as f64 - c) / 5.0).powi(2)).sqrt();
837            10.0 + 190.0 / (1.0 + ((r - 1.0) * 2.5).exp())
838        }).collect();
839        let base = BetParams { iterations: 50, subdivisions: 1, ..BetParams::default() };
840        let base_mask = run_bet(&mag, &grid, &base, crate::bet::FslLeftRightOrder::from_affine(meta.affine.as_ref()), |_, _| {});
841
842        let variants = [
843            BetParams { fractional_intensity: 0.2, ..base.clone() },
844            BetParams { voxel_scale: 2.0, ..base.clone() },
845            BetParams { smoothness: 3.0, ..base.clone() },
846            BetParams { gradient_threshold: 0.5, ..base.clone() },
847            BetParams { iterations: 5, ..base.clone() },
848            BetParams { subdivisions: 2, ..base.clone() },
849        ];
850        for params in variants {
851            let direct = run_bet(&mag, &grid, &params, crate::bet::FslLeftRightOrder::from_affine(meta.affine.as_ref()), |_, _| {});
852            assert!(direct != base_mask, "{params:?} should change the mask, or this test proves nothing");
853            let op = MaskOp::Bet(params.clone());
854            let via_op = apply_mask_ops(vec![1; n * n * n], &[op], &mag, Some(&mag), &meta).unwrap();
855            assert_eq!(via_op, direct, "MaskOp::Bet dropped a field of {params:?}");
856        }
857    }
858}