Skip to main content

qsm_core/pipeline/
field_mapping.rs

1//! Field mapping stage
2//!
3//! Multi-echo phase → B0 field map in ppm.
4//! Implements the canonical field mapping pipeline:
5//! phase offset removal → bipolar correction → unwrapping → B0 estimation.
6
7use super::config::*;
8use super::phase_utils::{hz_to_ppm, rads_to_ppm};
9
10/// Run field mapping: convert per-echo phase data to a B0 field map in ppm.
11///
12/// # Arguments
13/// * `phases` - Per-echo wrapped phase arrays (in [-pi, pi])
14/// * `magnitudes` - Per-echo magnitude arrays (optional; uniform weights if None)
15/// * `mask` - Binary brain mask
16/// * `metadata` - Scan metadata (dims, voxel size, echo times in seconds, field strength)
17/// * `config` - Field mapping configuration
18/// * `progress` - Progress callback (current_step, total_steps)
19///
20/// # Returns
21/// `FieldMappingResult` with B0 field in ppm and optional phase offset
22pub fn run_field_mapping(
23    phases: &[&[f64]],
24    magnitudes: Option<&[&[f64]]>,
25    mask: &[u8],
26    metadata: &ScanMetadata,
27    config: &FieldMappingConfig,
28    progress: &mut dyn FnMut(usize, usize),
29) -> Result<FieldMappingResult, PipelineError> {
30    let (nx, ny, nz) = metadata.dims;
31    let (vsx, vsy, vsz) = metadata.voxel_size;
32    let n_voxels = nx * ny * nz;
33    let n_echoes = phases.len();
34
35    if n_echoes == 0 {
36        return Err(PipelineError::InvalidInput("no phase echoes provided".into()));
37    }
38    if metadata.echo_times.len() != n_echoes {
39        return Err(PipelineError::DimensionMismatch {
40            expected: n_echoes,
41            got: metadata.echo_times.len(),
42        });
43    }
44    for (i, p) in phases.iter().enumerate() {
45        if p.len() != n_voxels {
46            return Err(PipelineError::DimensionMismatch {
47                expected: n_voxels,
48                got: p.len(),
49            });
50        }
51        if let Some(ref mags) = magnitudes {
52            if i < mags.len() && mags[i].len() != n_voxels {
53                return Err(PipelineError::DimensionMismatch {
54                    expected: n_voxels,
55                    got: mags[i].len(),
56                });
57            }
58        }
59    }
60
61    let is_laplacian = config.unwrapping_algorithm == UnwrappingAlgorithm::Laplacian;
62    let do_offset = n_echoes > 1 && config.phase_offset_removal && !is_laplacian;
63
64    // Create uniform magnitude fallback
65    let uniform_mag = vec![1.0f64; n_voxels];
66    let uniform_mags: Vec<&[f64]> = (0..n_echoes).map(|_| uniform_mag.as_slice()).collect();
67    let mag_slices: &[&[f64]] = magnitudes.unwrap_or(&uniform_mags);
68
69    progress(0, 4);
70
71    if n_echoes > 1 && do_offset {
72        // ---- Path A: Phase offset removal + unwrap + B0 estimation ----
73        field_mapping_with_offset(
74            phases, mag_slices, mask, metadata, config, n_voxels, nx, ny, nz, vsx, vsy, vsz, progress,
75        )
76    } else if n_echoes > 1 {
77        // ---- Path B: Direct per-echo unwrapping + linear fit ----
78        field_mapping_direct(
79            phases, mag_slices, mask, metadata, config, n_voxels, nx, ny, nz, vsx, vsy, vsz, progress,
80        )
81    } else {
82        // ---- Path C: Single echo ----
83        field_mapping_single_echo(
84            phases[0], mag_slices[0], mask, metadata, config, n_voxels, nx, ny, nz, vsx, vsy, vsz, progress,
85        )
86    }
87}
88
89/// Path A: Multi-echo with phase offset removal
90#[allow(clippy::too_many_arguments)]
91fn field_mapping_with_offset(
92    phases: &[&[f64]],
93    mag_slices: &[&[f64]],
94    mask: &[u8],
95    metadata: &ScanMetadata,
96    config: &FieldMappingConfig,
97    _n_voxels: usize,
98    nx: usize, ny: usize, nz: usize,
99    vsx: f64, vsy: f64, vsz: f64,
100    progress: &mut dyn FnMut(usize, usize),
101) -> Result<FieldMappingResult, PipelineError> {
102    let tes = &metadata.echo_times;
103    let n_echoes = phases.len();
104
105    // Step 1: Phase offset removal
106    progress(1, 4);
107    let grid = crate::Grid::new(nx, ny, nz, vsx, vsy, vsz);
108    let (mut corrected, phase_offset) = crate::utils::phase_offset_removal(
109        phases, mag_slices, tes, mask,
110        config.phase_offset_sigma, [0, 1],
111        crate::unwrap::UnwrapMethod::Romeo,
112        &grid,
113    );
114
115    // Step 2: Bipolar correction (optional, >= 3 echoes)
116    if config.bipolar_correction && n_echoes >= 3 {
117        crate::utils::bipolar_correction(
118            &mut corrected, mag_slices, tes, mask,
119            config.phase_offset_sigma, &grid,
120        );
121    }
122
123    // Step 3: Multi-echo unwrapping
124    progress(2, 4);
125    let unwrapped: Vec<Vec<f64>> = match config.unwrapping_algorithm {
126        UnwrappingAlgorithm::Laplacian => {
127            // Neumann, not the ROI-masked variant: this stage wants unwrapping only.
128            // Background removal is a later stage, and the masked variant would remove it
129            // here first — measurably worse than doing it once, properly.
130            corrected.iter()
131                .map(|p| crate::unwrap::laplacian_unwrap(p, mask, &grid))
132                .collect()
133        }
134        UnwrappingAlgorithm::Romeo => {
135            crate::unwrap::unwrap_romeo_multi_echo(
136                &corrected, mag_slices, tes, mask,
137                &config.romeo_params, &grid,
138            )
139        }
140    };
141
142    // Step 4: B0 estimation
143    progress(3, 4);
144    let b0_hz = match config.b0_estimation {
145        B0EstimationMethod::WeightedAvg => {
146            crate::utils::calculate_b0_weighted(
147                &unwrapped, mag_slices, tes, mask,
148                config.b0_weight_type, &grid,
149            )
150        }
151        B0EstimationMethod::LinearFit => {
152            let uw_refs: Vec<&[f64]> = unwrapped.iter().map(|u| u.as_slice()).collect();
153            let fit = crate::utils::multi_echo_linear_fit(
154                &uw_refs, mag_slices, tes, mask,
155                config.linear_fit_params.estimate_offset,
156                config.linear_fit_params.reliability_threshold_percentile,
157            );
158            crate::utils::field_to_hz(&fit.field)
159        }
160    };
161
162    progress(4, 4);
163    Ok(FieldMappingResult {
164        b0_field_ppm: hz_to_ppm(&b0_hz, metadata.field_strength),
165        phase_offset: Some(phase_offset),
166    })
167}
168
169/// Path B: Multi-echo without phase offset removal (per-echo unwrap + linear fit)
170#[allow(clippy::too_many_arguments)]
171fn field_mapping_direct(
172    phases: &[&[f64]],
173    mag_slices: &[&[f64]],
174    mask: &[u8],
175    metadata: &ScanMetadata,
176    config: &FieldMappingConfig,
177    _n_voxels: usize,
178    nx: usize, ny: usize, nz: usize,
179    vsx: f64, vsy: f64, vsz: f64,
180    progress: &mut dyn FnMut(usize, usize),
181) -> Result<FieldMappingResult, PipelineError> {
182    let tes = &metadata.echo_times;
183    let n_echoes = phases.len();
184
185    // Per-echo unwrapping
186    progress(1, 4);
187    let mut unwrapped: Vec<Vec<f64>> = Vec::with_capacity(n_echoes);
188    for e in 0..n_echoes {
189        let uw = unwrap_single(
190            phases[e], mag_slices.first().copied().unwrap_or(&[]),
191            mask, &config, nx, ny, nz, vsx, vsy, vsz,
192            if e + 1 < n_echoes { Some(phases[e + 1]) } else { None },
193            tes[e],
194            if e + 1 < n_echoes { tes[e + 1] } else { 0.0 },
195        );
196        unwrapped.push(uw);
197    }
198
199    // Linear fit (always used in the no-offset path, matching qsmxt.rs reference)
200    progress(3, 4);
201    let uw_refs: Vec<&[f64]> = unwrapped.iter().map(|u| u.as_slice()).collect();
202    let fit = crate::utils::multi_echo_linear_fit(
203        &uw_refs, mag_slices, tes, mask,
204        config.linear_fit_params.estimate_offset,
205        config.linear_fit_params.reliability_threshold_percentile,
206    );
207
208    progress(4, 4);
209    Ok(FieldMappingResult {
210        b0_field_ppm: rads_to_ppm(&fit.field, metadata.field_strength),
211        phase_offset: None,
212    })
213}
214
215/// Path C: Single echo unwrap
216#[allow(clippy::too_many_arguments)]
217fn field_mapping_single_echo(
218    phase: &[f64],
219    mag: &[f64],
220    mask: &[u8],
221    metadata: &ScanMetadata,
222    config: &FieldMappingConfig,
223    _n_voxels: usize,
224    nx: usize, ny: usize, nz: usize,
225    vsx: f64, vsy: f64, vsz: f64,
226    progress: &mut dyn FnMut(usize, usize),
227) -> Result<FieldMappingResult, PipelineError> {
228    progress(1, 4);
229    let unwrapped = unwrap_single(
230        phase, mag, mask, config, nx, ny, nz, vsx, vsy, vsz,
231        None, metadata.echo_times[0], 0.0,
232    );
233
234    // field = unwrapped / TE → rad/s
235    let te = metadata.echo_times[0];
236    let field_rads: Vec<f64> = unwrapped.iter().map(|&v| v / te).collect();
237
238    progress(4, 4);
239    Ok(FieldMappingResult {
240        b0_field_ppm: rads_to_ppm(&field_rads, metadata.field_strength),
241        phase_offset: None,
242    })
243}
244
245/// Unwrap a single echo using the configured algorithm.
246#[allow(clippy::too_many_arguments)]
247fn unwrap_single(
248    phase: &[f64],
249    mag: &[f64],
250    mask: &[u8],
251    config: &FieldMappingConfig,
252    nx: usize, ny: usize, nz: usize,
253    vsx: f64, vsy: f64, vsz: f64,
254    phase2: Option<&[f64]>,
255    te1: f64, te2: f64,
256) -> Vec<f64> {
257    let grid = crate::Grid::new(nx, ny, nz, vsx, vsy, vsz);
258    match config.unwrapping_algorithm {
259        UnwrappingAlgorithm::Laplacian => {
260            crate::unwrap::laplacian_unwrap(phase, mask, &grid)
261        }
262        UnwrappingAlgorithm::Romeo => {
263            crate::unwrap::unwrap_romeo(
264                phase, mag, phase2, te1, te2,
265                mask, &config.romeo_params, &grid,
266            )
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use std::f64::consts::PI;
275
276    fn make_test_metadata(n_echoes: usize) -> ScanMetadata {
277        let tes: Vec<f64> = (0..n_echoes).map(|i| 0.005 + 0.005 * i as f64).collect();
278        ScanMetadata {
279            dims: (8, 8, 8),
280            voxel_size: (1.0, 1.0, 1.0),
281            echo_times: tes,
282            field_strength: 3.0,
283            b0_direction: (0.0, 0.0, 1.0),
284        }
285    }
286
287    #[test]
288    fn test_single_echo_recovers_frequency() {
289        // Use ROMEO (not Laplacian, which removes constant component)
290        // Uniform phase across all voxels → uniform B0
291        // phase(rad) = 2π * f * TE  →  f = phase / (2π * TE)
292        let meta = make_test_metadata(1);
293        let te = meta.echo_times[0]; // 0.005 s
294        let n = 8 * 8 * 8;
295        let mask = vec![1u8; n];
296
297        let freq_hz = 50.0;
298        let phase_val = 2.0 * PI * freq_hz * te; // ~1.57 rad (no wrapping)
299        let phase = vec![phase_val; n];
300
301        let config = FieldMappingConfig {
302            unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
303            ..Default::default()
304        };
305
306        let phase_s: &[f64] = &phase;
307        let result = run_field_mapping(
308            &[phase_s], None, &mask, &meta, &config, &mut |_, _| {},
309        ).unwrap();
310
311        let gamma = 42.576e6;
312        let expected_ppm = freq_hz * 1e6 / (gamma * 3.0);
313
314        // ROMEO preserves the constant phase, so B0 should be recovered
315        for &v in &result.b0_field_ppm {
316            assert!((v - expected_ppm).abs() < 0.01,
317                "expected ~{:.4} ppm, got {:.4}", expected_ppm, v);
318        }
319    }
320
321    #[test]
322    fn test_multi_echo_direct_recovers_slope() {
323        // 3 echoes with phase = slope * TE (no wrapping, small slope)
324        // Use ROMEO so constant field is preserved
325        let meta = make_test_metadata(3); // TEs: 0.005, 0.010, 0.015
326        let n = 8 * 8 * 8;
327        let mask = vec![1u8; n];
328        let slope = 100.0; // rad/s → f ≈ 15.9 Hz
329
330        let phases: Vec<Vec<f64>> = meta.echo_times.iter()
331            .map(|&te| vec![slope * te; n])
332            .collect();
333        let phase_refs: Vec<&[f64]> = phases.iter().map(|p| p.as_slice()).collect();
334        let mag = vec![1.0; n];
335        let mag_refs: Vec<&[f64]> = (0..3).map(|_| mag.as_slice()).collect();
336
337        let config = FieldMappingConfig {
338            phase_offset_removal: false,
339            unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
340            ..Default::default()
341        };
342
343        let result = run_field_mapping(
344            &phase_refs, Some(&mag_refs), &mask, &meta, &config, &mut |_, _| {},
345        ).unwrap();
346
347        // Expected ppm: slope(rad/s) → ppm via rads_to_ppm
348        let gamma = 42.576e6;
349        let expected_ppm = slope * 1e6 / (2.0 * PI * gamma * 3.0);
350
351        let masked_values: Vec<f64> = result.b0_field_ppm.iter()
352            .zip(mask.iter())
353            .filter(|(_, &m)| m > 0)
354            .map(|(&v, _)| v)
355            .collect();
356
357        let mean: f64 = masked_values.iter().sum::<f64>() / masked_values.len() as f64;
358        assert!((mean - expected_ppm).abs() < 0.001,
359            "expected ~{:.6} ppm, got {:.6}", expected_ppm, mean);
360    }
361
362    #[test]
363    fn test_multi_echo_with_offset_removal() {
364        // Verify the offset-removal path produces output and has phase_offset
365        let meta = make_test_metadata(3);
366        let n = 8 * 8 * 8;
367        let mask = vec![1u8; n];
368
369        let phases: Vec<Vec<f64>> = meta.echo_times.iter()
370            .map(|&te| vec![50.0 * te; n]) // small linear phase
371            .collect();
372        let phase_refs: Vec<&[f64]> = phases.iter().map(|p| p.as_slice()).collect();
373        let mag = vec![1.0; n];
374        let mag_refs: Vec<&[f64]> = (0..3).map(|_| mag.as_slice()).collect();
375
376        let config = FieldMappingConfig {
377            phase_offset_removal: true,
378            unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
379            b0_estimation: B0EstimationMethod::WeightedAvg,
380            ..Default::default()
381        };
382
383        let result = run_field_mapping(
384            &phase_refs, Some(&mag_refs), &mask, &meta, &config, &mut |_, _| {},
385        ).unwrap();
386
387        assert_eq!(result.b0_field_ppm.len(), n);
388        assert!(result.phase_offset.is_some(), "offset removal path should return phase offset");
389        let offset = result.phase_offset.unwrap();
390        assert_eq!(offset.len(), n);
391
392        // All values should be finite
393        for &v in &result.b0_field_ppm {
394            assert!(v.is_finite(), "B0 field should be finite");
395        }
396    }
397
398    #[test]
399    fn test_validates_echo_time_mismatch() {
400        let meta = make_test_metadata(2);
401        let n = 8 * 8 * 8;
402        let mask = vec![1u8; n];
403        let phase = vec![0.0; n];
404
405        let result = run_field_mapping(
406            &[&phase[..]], None, &mask, &meta,
407            &FieldMappingConfig::default(), &mut |_, _| {},
408        );
409        assert!(result.is_err());
410    }
411
412    #[test]
413    fn test_validates_empty_phases() {
414        let meta = ScanMetadata {
415            dims: (4, 4, 4),
416            voxel_size: (1.0, 1.0, 1.0),
417            echo_times: vec![],
418            field_strength: 3.0,
419            b0_direction: (0.0, 0.0, 1.0),
420        };
421        let mask = vec![1u8; 64];
422        let result = run_field_mapping(
423            &[], None, &mask, &meta,
424            &FieldMappingConfig::default(), &mut |_, _| {},
425        );
426        assert!(result.is_err());
427    }
428
429    #[test]
430    fn test_b0_estimation_methods_agree_on_linear_data() {
431        // For perfectly linear phase data (no offset), WeightedAvg and LinearFit
432        // should produce the same B0 estimate. Use ROMEO to preserve constant fields.
433        let meta = make_test_metadata(3);
434        let n = 8 * 8 * 8;
435        let mask = vec![1u8; n];
436        let slope = 80.0; // rad/s
437
438        let phases: Vec<Vec<f64>> = meta.echo_times.iter()
439            .map(|&te| vec![slope * te; n])
440            .collect();
441        let phase_refs: Vec<&[f64]> = phases.iter().map(|p| p.as_slice()).collect();
442        let mag = vec![1.0; n];
443        let mag_refs: Vec<&[f64]> = (0..3).map(|_| mag.as_slice()).collect();
444
445        // Path A: offset removal + weighted avg (uses ROMEO)
446        let config_a = FieldMappingConfig {
447            phase_offset_removal: true,
448            b0_estimation: B0EstimationMethod::WeightedAvg,
449            unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
450            ..Default::default()
451        };
452        let result_a = run_field_mapping(
453            &phase_refs, Some(&mag_refs), &mask, &meta, &config_a, &mut |_, _| {},
454        ).unwrap();
455
456        // Path B: no offset removal → linear fit (uses ROMEO)
457        let config_b = FieldMappingConfig {
458            phase_offset_removal: false,
459            unwrapping_algorithm: UnwrappingAlgorithm::Romeo,
460            ..Default::default()
461        };
462        let result_b = run_field_mapping(
463            &phase_refs, Some(&mag_refs), &mask, &meta, &config_b, &mut |_, _| {},
464        ).unwrap();
465
466        // Both should recover ~same ppm for perfectly linear data
467        let count_a = result_a.b0_field_ppm.iter().filter(|v| v.is_finite() && **v != 0.0).count();
468        let count_b = result_b.b0_field_ppm.iter().filter(|v| v.is_finite() && **v != 0.0).count();
469        assert!(count_a > 0, "Path A should have non-zero voxels");
470        assert!(count_b > 0, "Path B should have non-zero voxels");
471
472        let mean_a: f64 = result_a.b0_field_ppm.iter()
473            .filter(|v| v.is_finite() && **v != 0.0).sum::<f64>() / count_a as f64;
474        let mean_b: f64 = result_b.b0_field_ppm.iter()
475            .filter(|v| v.is_finite() && **v != 0.0).sum::<f64>() / count_b as f64;
476
477        assert!((mean_a - mean_b).abs() < 0.05,
478            "WeightedAvg ({:.4}) and LinearFit ({:.4}) should agree on linear data", mean_a, mean_b);
479    }
480}