1use super::config::*;
8use super::phase_utils::{hz_to_ppm, rads_to_ppm};
9
10pub 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 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 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 field_mapping_direct(
79 phases, mag_slices, mask, metadata, config, n_voxels, nx, ny, nz, vsx, vsy, vsz, progress,
80 )
81 } else {
82 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#[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 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 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 progress(2, 4);
125 let unwrapped: Vec<Vec<f64>> = match config.unwrapping_algorithm {
126 UnwrappingAlgorithm::Laplacian => {
127 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 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#[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 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 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#[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 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#[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 let meta = make_test_metadata(1);
293 let te = meta.echo_times[0]; 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; 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 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 let meta = make_test_metadata(3); let n = 8 * 8 * 8;
327 let mask = vec![1u8; n];
328 let slope = 100.0; 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 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 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]) .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 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 let meta = make_test_metadata(3);
434 let n = 8 * 8 * 8;
435 let mask = vec![1u8; n];
436 let slope = 80.0; 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 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 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 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}