1use crate::error::InputError;
83use crate::unwrap::{laplacian_unwrap, unwrap_romeo_multi_echo, LaplacianSolver, RomeoParams};
84use crate::utils::{calculate_b0_weighted, gaussian_smooth_3d, get_sensitivity, robust_mask, B0WeightType};
85use crate::Grid;
86
87#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
89#[derive(Clone, Debug)]
90pub struct SwiParams {
91 pub mag_combine: MagCombine,
93 pub sensitivity: Sensitivity,
96 pub softplus: bool,
99 pub unwrap: SwiUnwrap,
101 pub echo_weighting: B0WeightType,
104 pub hp_sigma: [f64; 3],
106 pub scaling: PhaseScaling,
108 pub strength: f64,
110 pub mip_window: usize,
112}
113
114impl Default for SwiParams {
115 fn default() -> Self {
116 Self {
117 mag_combine: MagCombine::Rss,
118 sensitivity: Sensitivity::Estimate { sigma_mm: None },
119 softplus: true,
120 unwrap: SwiUnwrap::Laplacian,
121 echo_weighting: B0WeightType::PhaseSNR,
122 hp_sigma: [4.0, 4.0, 0.0],
123 scaling: PhaseScaling::Tanh,
124 strength: 4.0,
125 mip_window: 7,
126 }
127 }
128}
129
130#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum MagCombine {
134 Rss,
136 Average,
138 Last,
140 Echo(usize),
142}
143
144#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
146#[derive(Debug, Clone, Copy, PartialEq)]
147pub enum Sensitivity {
148 Off,
150 Estimate {
154 sigma_mm: Option<f64>,
156 },
157}
158
159#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum SwiUnwrap {
163 Laplacian,
166 LaplacianSlice,
169 Romeo,
172}
173
174#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
176#[derive(Debug, Clone, Copy, PartialEq)]
177pub enum PhaseScaling {
178 Tanh,
181 NegativeTanh,
183 Positive,
185 Negative,
187 Triangular,
189}
190
191#[derive(Debug, Clone)]
193pub struct SwiResult {
194 pub swi: Vec<f64>,
196 pub magnitude: Vec<f64>,
198 pub phase_mask: Vec<f64>,
200 pub filtered_phase: Vec<f64>,
203 pub mask: Vec<u8>,
205}
206
207pub fn highpass_filter(
221 data: &[f64],
222 mask: &[u8],
223 grid: &Grid,
224 sigma: [f64; 3],
225) -> Vec<f64> {
226 let nbox = 4; let smoothed = gaussian_smooth_3d(data, sigma, Some(mask), None, nbox, grid);
228 let n_total = grid.n_total();
229 let mut result = vec![0.0; n_total];
230 for i in 0..n_total {
231 if mask[i] != 0 {
232 result[i] = data[i] - smoothed[i];
233 }
234 }
235 result
236}
237
238pub fn create_phase_mask(
254 phase: &[f64],
255 mask: &[u8],
256 scaling: PhaseScaling,
257 strength: f64,
258) -> Vec<f64> {
259 let mut result: Vec<f64> = phase
260 .iter()
261 .zip(mask)
262 .map(|(&p, &m)| if m != 0 { p } else { 0.0 })
263 .collect();
264 let pos: Vec<bool> = result.iter().map(|&v| v > 0.0).collect();
266
267 let scaling = if scaling == PhaseScaling::NegativeTanh {
268 for v in result.iter_mut() {
269 *v = -*v;
270 }
271 PhaseScaling::Tanh
272 } else {
273 scaling
274 };
275
276 match scaling {
277 PhaseScaling::Tanh => {
278 let mut positives: Vec<f64> = result
279 .iter()
280 .zip(mask)
281 .filter(|&(&v, &m)| m != 0 && v > 0.0)
282 .map(|(&v, _)| v)
283 .collect();
284 let m = match median(&mut positives) {
285 Some(med) if med > 0.0 => med * 10.0 / strength,
286 _ => 1.0,
287 };
288 for v in result.iter_mut() {
289 *v = (1.0 + (1.0 - *v / m).tanh()) / 2.0;
290 }
291 }
292 PhaseScaling::Positive => {
293 rescale_selected(&mut result, &pos, true, 1.0, 0.0, strength);
294 fill_selected(&mut result, &pos, false, 1.0);
295 }
296 PhaseScaling::Negative => {
297 rescale_selected(&mut result, &pos, false, 0.0, 1.0, strength);
298 fill_selected(&mut result, &pos, true, 1.0);
299 }
300 PhaseScaling::Triangular => {
301 rescale_selected(&mut result, &pos, true, 1.0, 0.0, strength);
302 rescale_selected(&mut result, &pos, false, 0.0, 1.0, strength);
303 }
304 PhaseScaling::NegativeTanh => unreachable!(),
305 }
306
307 for v in &mut result {
308 if *v < 0.0 || v.is_nan() {
309 *v = 0.0;
310 }
311 }
312 result
313}
314
315pub fn combine_magnitude<M: AsRef<[f64]>>(
322 mags: &[M],
323 combine: MagCombine,
324) -> Result<Vec<f64>, InputError> {
325 let n_echoes = mags.len();
326 if n_echoes == 0 {
327 return Err(InputError::new("SWI needs at least one magnitude echo"));
328 }
329 if n_echoes == 1 {
330 return Ok(mags[0].as_ref().to_vec());
331 }
332 let n = mags[0].as_ref().len();
333 Ok(match combine {
334 MagCombine::Rss => (0..n)
335 .map(|i| mags.iter().map(|m| m.as_ref()[i].powi(2)).sum::<f64>().sqrt())
336 .collect(),
337 MagCombine::Average => (0..n).map(|i| mags.iter().map(|m| m.as_ref()[i]).sum()).collect(),
338 MagCombine::Last => mags[n_echoes - 1].as_ref().to_vec(),
339 MagCombine::Echo(e) => mags
340 .get(e)
341 .ok_or_else(|| {
342 InputError::new(format!("SWI magnitude echo {} requested, but there are {n_echoes}", e + 1))
343 })?
344 .as_ref()
345 .to_vec(),
346 })
347}
348
349pub fn swi_magnitude<M: AsRef<[f64]>>(
353 mags: &[M],
354 grid: &Grid,
355 params: &SwiParams,
356) -> Result<Vec<f64>, InputError> {
357 let mut combined = combine_magnitude(mags, params.mag_combine)?;
358 if let Sensitivity::Estimate { sigma_mm } = params.sensitivity {
359 let sigma_mm = sigma_mm.unwrap_or_else(|| default_sensitivity_sigma_mm(grid));
360 let sensitivity = get_sensitivity(mags[0].as_ref(), grid, sigma_mm, 15)?;
361 for (c, &s) in combined.iter_mut().zip(&sensitivity) {
362 if s.is_finite() && s > 0.0 {
363 *c /= s;
364 }
365 }
366 }
367 if params.softplus {
368 let q = estimate_quantile(&combined, 0.8);
369 combined = softplus_scaling(&combined, q / 2.0, 2.0);
370 }
371 Ok(combined)
372}
373
374fn default_sensitivity_sigma_mm(grid: &Grid) -> f64 {
376 let (nx, ny, nz) = grid.dims;
377 let (vx, vy, vz) = grid.voxel_size;
378 let mut fov = [nx as f64 * vx, ny as f64 * vy, nz as f64 * vz];
379 fov.sort_by(f64::total_cmp);
380 fov[1].min(7.0)
381}
382
383pub fn swi_phase<P: AsRef<[f64]> + Sync, M: AsRef<[f64]> + Sync>(
389 phases: &[P],
390 mags: &[M],
391 tes: &[f64],
392 mask: &[u8],
393 grid: &Grid,
394 params: &SwiParams,
395) -> Vec<f64> {
396 let n_total = grid.n_total();
397 let everywhere = vec![1u8; n_total];
398 let combine = |filtered: &[Vec<f64>]| -> Vec<f64> {
399 if filtered.len() == 1 {
400 filtered[0].clone()
401 } else {
402 calculate_b0_weighted(filtered, mags, tes, mask, params.echo_weighting, grid)
403 }
404 };
405
406 match params.unwrap {
407 SwiUnwrap::Laplacian => {
408 let filtered: Vec<Vec<f64>> = phases
409 .iter()
410 .map(|p| {
411 let unwrapped = laplacian_unwrap(p.as_ref(), &everywhere, grid, LaplacianSolver::Dct);
412 highpass_filter(&unwrapped, mask, grid, params.hp_sigma)
413 })
414 .collect();
415 combine(&filtered)
416 }
417 SwiUnwrap::LaplacianSlice => {
418 let (nx, ny, nz) = grid.dims;
419 let (vx, vy, vz) = grid.voxel_size;
420 let slice_grid = Grid::new(nx, ny, 1, vx, vy, vz);
421 let nxy = nx * ny;
422 let ones = vec![1u8; nxy];
423 let sigma = [params.hp_sigma[0], params.hp_sigma[1], 0.0];
424 let filtered: Vec<Vec<f64>> = phases
425 .iter()
426 .map(|p| {
427 let mut out = vec![0.0; n_total];
428 for k in 0..nz {
429 let range = k * nxy..(k + 1) * nxy;
430 let unwrapped =
431 laplacian_unwrap(&p.as_ref()[range.clone()], &ones, &slice_grid, LaplacianSolver::Dct);
432 let hp = highpass_filter(&unwrapped, &mask[range.clone()], &slice_grid, sigma);
433 out[range].copy_from_slice(&hp);
434 }
435 out
436 })
437 .collect();
438 combine(&filtered)
439 }
440 SwiUnwrap::Romeo => {
441 let unwrapped = unwrap_romeo_multi_echo(phases, mags, tes, &everywhere, &RomeoParams::default(), grid);
443 let combined = combine(&unwrapped);
444 highpass_filter(&combined, mask, grid, [params.hp_sigma[0], params.hp_sigma[1], 0.0])
446 }
447 }
448}
449
450pub fn calculate_swi<P: AsRef<[f64]> + Sync, M: AsRef<[f64]> + Sync>(
468 phases: &[P],
469 mags: &[M],
470 tes: &[f64],
471 mask: Option<&[u8]>,
472 grid: &Grid,
473 params: &SwiParams,
474) -> Result<SwiResult, InputError> {
475 let n_total = grid.n_total();
476 let n_echoes = phases.len();
477 if n_echoes == 0 {
478 return Err(InputError::new("SWI needs at least one echo"));
479 }
480 if mags.len() != n_echoes || tes.len() != n_echoes {
481 return Err(InputError::new(format!(
482 "SWI got {} phase echoes, {} magnitude echoes and {} echo times; they must match",
483 n_echoes, mags.len(), tes.len(),
484 )));
485 }
486 if n_echoes > 1 && !tes.iter().all(|&t| t.is_finite() && t > 0.0) {
487 return Err(InputError::new(format!("SWI echo times must be positive, got {tes:?}")));
488 }
489 for (e, (p, m)) in phases.iter().zip(mags).enumerate() {
490 crate::error::check_len(&format!("SWI phase echo {}", e + 1), p.as_ref().len(), n_total)?;
491 crate::error::check_len(&format!("SWI magnitude echo {}", e + 1), m.as_ref().len(), n_total)?;
492 }
493 if let Some(m) = mask {
494 crate::error::check_len("SWI mask", m.len(), n_total)?;
495 }
496
497 let mask: Vec<u8> = match mask {
498 Some(m) => m.iter().map(|&v| u8::from(v != 0)).collect(),
499 None => robust_mask(mags[0].as_ref(), grid),
500 };
501 let magnitude = swi_magnitude(mags, grid, params)?;
502 let filtered_phase = swi_phase(phases, mags, tes, &mask, grid, params);
503 let phase_mask = create_phase_mask(&filtered_phase, &mask, params.scaling, params.strength);
504 let swi = magnitude.iter().zip(&phase_mask).map(|(&m, &p)| m * p).collect();
505 Ok(SwiResult { swi, magnitude, phase_mask, filtered_phase, mask })
506}
507
508
509#[derive(Debug, Clone)]
516pub struct Mip {
517 pub data: Vec<f64>,
519 pub grid: Grid,
521 pub affine: [f64; 16],
523}
524
525pub fn mip_geometry(
540 grid: &Grid,
541 affine: &[f64; 16],
542 window: usize,
543) -> Result<(Grid, [f64; 16]), String> {
544 let (nx, ny, nz) = grid.dims;
545 if window == 0 {
546 return Err("MIP window must be at least 1 slice".to_string());
547 }
548 if window > nz {
549 return Err(format!(
550 "MIP window of {} slices is deeper than the {}-slice volume",
551 window, nz,
552 ));
553 }
554
555 let (vsx, vsy, vsz) = grid.voxel_size;
556 let mip_grid = Grid::new(nx, ny, nz - window + 1, vsx, vsy, vsz);
557
558 let mut mip_affine = *affine;
559 let slabs = (window - 1) as f64 / 2.0;
560 for row in 0..3 {
561 mip_affine[row * 4 + 3] = affine[row * 4 + 3] + affine[row * 4 + 2] * slabs;
563 }
564
565 Ok((mip_grid, mip_affine))
566}
567
568pub fn create_mip(
585 data: &[f64],
586 grid: &Grid,
587 affine: &[f64; 16],
588 window: usize,
589) -> Result<Mip, String> {
590 let (nx, ny, nz) = grid.dims;
591 if data.len() != grid.n_total() {
592 return Err(format!(
593 "MIP input is {} values but its {}x{}x{} grid has {} voxels",
594 data.len(), nx, ny, nz, grid.n_total(),
595 ));
596 }
597 let (mip_grid, mip_affine) = mip_geometry(grid, affine, window)?;
598
599 let nz_out = mip_grid.nz();
600 let nxy = nx * ny;
601 let mut mip = vec![0.0; nxy * nz_out];
602
603 for k_out in 0..nz_out {
604 for j in 0..ny {
605 for i in 0..nx {
606 let idx_xy = i + j * nx;
607 let mut min_val = data[idx_xy + k_out * nxy];
608 for kw in 1..window {
609 let val = data[idx_xy + (k_out + kw) * nxy];
610 if val < min_val {
611 min_val = val;
612 }
613 }
614 mip[idx_xy + k_out * nxy] = min_val;
615 }
616 }
617 }
618
619 debug_assert_eq!(mip.len(), mip_grid.n_total());
620 Ok(Mip { data: mip, grid: mip_grid, affine: mip_affine })
621}
622
623pub fn softplus_scaling(
637 magnitude: &[f64],
638 offset: f64,
639 factor: f64,
640) -> Vec<f64> {
641 if offset.abs() < 1e-20 {
642 return magnitude.to_vec();
643 }
644
645 let f = factor / offset;
646
647 let arg0 = f * (0.0 - offset);
649 let sp0 = ((1.0 + (-arg0.abs()).exp()).ln() + arg0.max(0.0)) / f;
650
651 magnitude.iter().map(|&val| {
652 let arg = f * (val - offset);
653 let sp = ((1.0 + (-arg.abs()).exp()).ln() + arg.max(0.0)) / f;
654 sp - sp0
655 }).collect()
656}
657
658#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
662#[derive(Clone, Debug)]
663pub struct SmwiParams {
664 pub threshold_ppm: f64,
667 pub power: f64,
669 pub mip_window: usize,
671}
672
673impl Default for SmwiParams {
674 fn default() -> Self {
675 Self {
676 threshold_ppm: 1.0,
677 power: 4.0,
678 mip_window: 4,
679 }
680 }
681}
682
683#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
686pub enum SmwiContrast {
687 Paramagnetic,
689 Diamagnetic,
691}
692
693pub fn susceptibility_mask(
711 chi_ppm: &[f64],
712 threshold_ppm: f64,
713 contrast: SmwiContrast,
714) -> Vec<f64> {
715 let t = if threshold_ppm > 0.0 { threshold_ppm } else { 1.0 };
716 let sign = match contrast {
717 SmwiContrast::Paramagnetic => -1.0,
718 SmwiContrast::Diamagnetic => 1.0,
719 };
720
721 chi_ppm
722 .iter()
723 .map(|&chi| {
724 if chi.is_finite() {
725 (1.0 + sign * chi / t).clamp(0.0, 1.0)
726 } else {
727 0.0
728 }
729 })
730 .collect()
731}
732
733pub fn calculate_smwi(
759 magnitude: &[f64],
760 chi_ppm: &[f64],
761 mask: Option<&[u8]>,
762 grid: &Grid,
763 params: &SmwiParams,
764) -> (Vec<f64>, Vec<f64>) {
765 let n_total = grid.n_total();
766 assert_eq!(
767 chi_ppm.len(),
768 n_total,
769 "SMWI: susceptibility map has {} voxels, grid has {}",
770 chi_ppm.len(),
771 n_total
772 );
773 assert!(
774 !magnitude.is_empty() && magnitude.len().is_multiple_of(n_total),
775 "SMWI: magnitude length {} is not a non-zero multiple of the {} grid voxels",
776 magnitude.len(),
777 n_total
778 );
779
780 let p_mask = susceptibility_mask(chi_ppm, params.threshold_ppm, SmwiContrast::Paramagnetic);
781 let d_mask = susceptibility_mask(chi_ppm, params.threshold_ppm, SmwiContrast::Diamagnetic);
782
783 let p_weight: Vec<f64> = p_mask.iter().map(|&w| w.powf(params.power)).collect();
785 let d_weight: Vec<f64> = d_mask.iter().map(|&w| w.powf(params.power)).collect();
786
787 let mut p_smwi = vec![0.0; magnitude.len()];
788 let mut d_smwi = vec![0.0; magnitude.len()];
789
790 let inside = |v: usize| mask.is_none_or(|m| m[v] != 0);
791
792 for (i, &m) in magnitude.iter().enumerate() {
793 let v = i % n_total;
794 if !inside(v) {
795 continue;
796 }
797 p_smwi[i] = m * p_weight[v];
798 d_smwi[i] = m * d_weight[v];
799 }
800
801 (p_smwi, d_smwi)
802}
803
804pub fn average_echoes(data: &[f64], grid: &Grid) -> Vec<f64> {
813 let n_total = grid.n_total();
814 assert!(
815 !data.is_empty() && data.len().is_multiple_of(n_total),
816 "average_echoes: length {} is not a non-zero multiple of the {} grid voxels",
817 data.len(),
818 n_total
819 );
820
821 let n_echoes = data.len() / n_total;
822 let mut out = vec![0.0; n_total];
823 for (i, &v) in data.iter().enumerate() {
824 out[i % n_total] += v;
825 }
826 for v in &mut out {
827 *v /= n_echoes as f64;
828 }
829 out
830}
831
832fn median(v: &mut [f64]) -> Option<f64> {
836 if v.is_empty() {
837 return None;
838 }
839 v.sort_unstable_by(f64::total_cmp);
840 let mid = v.len() / 2;
841 Some(if v.len().is_multiple_of(2) { (v[mid - 1] + v[mid]) / 2.0 } else { v[mid] })
842}
843
844fn sample(data: &[f64]) -> Vec<f64> {
847 let n = data.len().min(100_000);
848 if n == 0 {
849 return Vec::new();
850 }
851 let len = (n as f64).sqrt().ceil() as usize;
852 let last_start = (data.len() - len) as f64;
853 let mut out = Vec::with_capacity(len * len);
854 for b in 0..len {
855 let start = if len == 1 { 0.0 } else { last_start * b as f64 / (len - 1) as f64 };
857 let start = start.round_ties_even() as usize;
858 out.extend(data[start..start + len].iter().copied().filter(|v| v.is_finite()));
859 }
860 if out.is_empty() {
861 out = data.iter().copied().filter(|v| v.is_finite()).collect();
862 }
863 out
864}
865
866fn estimate_quantiles(data: &[f64], ps: &[f64]) -> Vec<f64> {
869 let mut s = sample(data);
870 if s.is_empty() {
871 return vec![f64::NAN; ps.len()];
872 }
873 s.sort_unstable_by(f64::total_cmp);
874 ps.iter()
875 .map(|&p| {
876 let h = (s.len() - 1) as f64 * p;
877 let lo = h.floor() as usize;
878 let hi = (lo + 1).min(s.len() - 1);
879 s[lo] + (h - lo as f64) * (s[hi] - s[lo])
880 })
881 .collect()
882}
883
884fn estimate_quantile(data: &[f64], p: f64) -> f64 {
885 estimate_quantiles(data, &[p])[0]
886}
887
888fn rescale_selected(
892 data: &mut [f64],
893 pos: &[bool],
894 positive: bool,
895 new_min: f64,
896 new_max: f64,
897 strength: f64,
898) {
899 let selected: Vec<f64> = data.iter().zip(pos).filter(|&(_, &p)| p == positive).map(|(&v, _)| v).collect();
900 if selected.is_empty() {
901 return;
902 }
903 let q = estimate_quantiles(&selected, &[0.01, 0.99]);
904 let old_range = (q[1] - q[0]) / 0.98;
905 let old_min = q[0] - 0.01 * old_range;
906 for (v, &p) in data.iter_mut().zip(pos) {
907 if p != positive {
908 continue;
909 }
910 let r = if old_range.abs() > 1e-20 && old_range.is_finite() {
911 (*v - old_min) * ((new_max - new_min) / old_range) + new_min
912 } else {
913 (new_min + new_max) / 2.0
914 };
915 *v = r.clamp(0.0, 1.0).powf(strength);
916 }
917}
918
919fn fill_selected(data: &mut [f64], pos: &[bool], positive: bool, value: f64) {
921 for (v, &p) in data.iter_mut().zip(pos) {
922 if p == positive {
923 *v = value;
924 }
925 }
926}
927
928#[cfg(test)]
929mod tests {
930 use super::*;
931
932 const F0: f64 = 0.880_797_077_977_882_3; fn ball(n: usize, r: f64) -> Vec<bool> {
936 let c = (n as f64 - 1.0) / 2.0;
937 (0..n * n * n)
938 .map(|i| {
939 let (x, y, z) = ((i % n) as f64, ((i / n) % n) as f64, (i / (n * n)) as f64);
940 (x - c).powi(2) + (y - c).powi(2) + (z - c).powi(2) <= r * r
941 })
942 .collect()
943 }
944
945 fn echoes(omega: &[f64], tes: &[f64]) -> Vec<Vec<f64>> {
947 let wrap = |p: f64| (p + std::f64::consts::PI).rem_euclid(2.0 * std::f64::consts::PI) - std::f64::consts::PI;
948 tes.iter().map(|&te| omega.iter().map(|&w| wrap(w * te)).collect()).collect()
949 }
950
951 fn frequency(n: usize, background: f64, bump: f64) -> Vec<f64> {
953 let c = (n as f64 - 1.0) / 2.0;
954 (0..n * n * n)
955 .map(|i| {
956 let (x, y, z) = ((i % n) as f64, ((i / n) % n) as f64, (i / (n * n)) as f64);
957 let d2 = (x - c - 3.0).powi(2) + (y - c).powi(2) + (z - c).powi(2);
958 background * (x - c) / n as f64 + bump * (-d2 / 4.0).exp()
959 })
960 .collect()
961 }
962
963 #[test]
964 fn test_calculate_swi_zero_phase() {
965 let n = 8;
966 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
967 let phase = vec![vec![0.0; n * n * n]];
968 let mag = vec![vec![1.0; n * n * n]];
969 let mask = vec![1u8; n * n * n];
970 let r = calculate_swi(&phase, &mag, &[0.01], Some(&mask), &grid, &SwiParams::default()).unwrap();
971 for (&p, &s) in r.phase_mask.iter().zip(&r.swi) {
973 assert!((p - F0).abs() < 1e-12, "{p}");
974 assert!(s.is_finite() && s >= 0.0);
975 }
976 }
977
978 #[test]
979 fn test_phase_mask_outside_mask_is_the_zero_phase_weight() {
980 let phase = vec![0.3, -0.2, 0.5, 0.7];
983 let mask = vec![1u8, 1, 0, 0];
984 let tanh = create_phase_mask(&phase, &mask, PhaseScaling::Tanh, 4.0);
985 assert!((tanh[2] - F0).abs() < 1e-12 && (tanh[3] - F0).abs() < 1e-12, "{tanh:?}");
986 let positive = create_phase_mask(&phase, &mask, PhaseScaling::Positive, 4.0);
987 assert_eq!(&positive[2..], &[1.0, 1.0]);
988 assert_eq!(positive[1], 1.0);
990 }
991
992 #[test]
993 fn test_tanh_mask_formula() {
994 let phase = vec![0.1, 0.2, 0.3, -0.4];
996 let mask = vec![1u8; 4];
997 let pm = create_phase_mask(&phase, &mask, PhaseScaling::Tanh, 4.0);
998 let m = 0.2 * 10.0 / 4.0;
999 for (i, &p) in phase.iter().enumerate() {
1000 let want = (1.0 + (1.0 - p / m).tanh()) / 2.0;
1001 assert!((pm[i] - want).abs() < 1e-12, "{i}: {} vs {want}", pm[i]);
1002 }
1003 let neg: Vec<f64> = phase.iter().map(|p| -p).collect();
1005 assert_eq!(
1006 create_phase_mask(&phase, &mask, PhaseScaling::NegativeTanh, 4.0),
1007 create_phase_mask(&neg, &mask, PhaseScaling::Tanh, 4.0),
1008 );
1009 }
1010
1011 #[test]
1012 fn test_estimate_quantile_matches_mriresearchtools() {
1013 let x: Vec<f64> = (1..=10).map(|i| (i * i) as f64).collect();
1015 assert_eq!(estimate_quantile(&x, 0.8), 64.0);
1016 let y: Vec<f64> = (1..=1000).map(|i| (0.37 * i as f64).sin() * i as f64).collect();
1017 let q = estimate_quantiles(&y, &[0.01, 0.99]);
1018 assert!((q[0] - -893.956_867_457_096_1).abs() < 1e-9, "{q:?}");
1019 assert!((q[1] - 898.654_032_022_898_7).abs() < 1e-9, "{q:?}");
1020 let z: Vec<f64> = (1..=300_000).map(|i| (0.011 * i as f64).cos() + 0.001 * i as f64).collect();
1021 assert!((estimate_quantile(&z, 0.8) - 240.122_157_209_175_4).abs() < 1e-9);
1022 }
1023
1024 #[test]
1025 fn test_combine_magnitude() {
1026 let mags = vec![vec![3.0, 1.0], vec![4.0, 2.0]];
1027 assert_eq!(combine_magnitude(&mags, MagCombine::Rss).unwrap(), vec![5.0, 5f64.sqrt()]);
1028 assert_eq!(combine_magnitude(&mags, MagCombine::Average).unwrap(), vec![7.0, 3.0]);
1029 assert_eq!(combine_magnitude(&mags, MagCombine::Last).unwrap(), vec![4.0, 2.0]);
1030 assert_eq!(combine_magnitude(&mags, MagCombine::Echo(0)).unwrap(), vec![3.0, 1.0]);
1031 assert!(combine_magnitude(&mags, MagCombine::Echo(2)).is_err());
1032 assert_eq!(combine_magnitude(&mags[..1], MagCombine::Rss).unwrap(), vec![3.0, 1.0]);
1034 }
1035
1036 #[test]
1037 fn test_calculate_swi_rejects_mismatched_echoes() {
1038 let grid = Grid::new(2, 2, 2, 1.0, 1.0, 1.0);
1039 let v = vec![vec![0.0; 8]; 2];
1040 let err = calculate_swi(&v, &v[..1], &[0.01, 0.02], None, &grid, &SwiParams::default()).unwrap_err();
1041 assert!(err.0.contains("must match"), "{err}");
1042 let err = calculate_swi(&v, &v, &[0.01, 0.0], None, &grid, &SwiParams::default()).unwrap_err();
1043 assert!(err.0.contains("positive"), "{err}");
1044 let short = vec![vec![0.0; 7]];
1045 assert!(calculate_swi(&short, &short, &[0.01], None, &grid, &SwiParams::default()).is_err());
1046 let none: Vec<Vec<f64>> = Vec::new();
1047 assert!(calculate_swi(&none, &none, &[], None, &grid, &SwiParams::default()).is_err());
1048 }
1049
1050 #[test]
1051 fn test_multi_echo_phase_matches_single_echo_without_wraps() {
1052 let n = 24;
1056 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1057 let omega = frequency(n, 20.0, 60.0);
1058 let tes = [0.004, 0.008, 0.012];
1059 let phases = echoes(&omega, &tes);
1060 let mags = vec![vec![100.0; n * n * n], vec![80.0; n * n * n], vec![60.0; n * n * n]];
1061 let mask: Vec<u8> = ball(n, 10.0).iter().map(|&b| u8::from(b)).collect();
1062 let params = SwiParams::default();
1063
1064 let multi = calculate_swi(&phases, &mags, &tes, Some(&mask), &grid, ¶ms).unwrap();
1065 let single = calculate_swi(&phases[2..], &mags[2..], &tes[2..], Some(&mask), &grid, ¶ms).unwrap();
1066 for i in 0..n * n * n {
1067 if mask[i] != 0 {
1068 let want = single.filtered_phase[i] / (2.0 * std::f64::consts::PI * tes[2]);
1069 assert!((multi.filtered_phase[i] - want).abs() < 1e-6, "{i}: {} vs {want}", multi.filtered_phase[i]);
1070 }
1071 assert!((multi.phase_mask[i] - single.phase_mask[i]).abs() < 1e-9, "{i}");
1072 }
1073 let centre = (n / 2 + 3) + (n / 2) * n + (n / 2) * n * n;
1075 assert!(multi.phase_mask[centre] < 0.5, "{}", multi.phase_mask[centre]);
1076 }
1077
1078 #[test]
1079 fn test_romeo_and_laplacian_agree_on_wrapped_phase() {
1080 let n = 24;
1083 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1084 let omega = frequency(n, 1200.0, 300.0);
1085 let tes = [0.005, 0.010, 0.015];
1086 let phases = echoes(&omega, &tes);
1087 let peak = omega.iter().fold(0.0f64, |a, w| a.max(w.abs())) * tes[2];
1088 assert!(peak > 2.0 * std::f64::consts::PI, "test phase should wrap, peak {peak}");
1089 let mags = vec![vec![100.0; n * n * n]; 3];
1090 let mask: Vec<u8> = ball(n, 10.0).iter().map(|&b| u8::from(b)).collect();
1091 let centre = (n / 2 + 3) + (n / 2) * n + (n / 2) * n * n;
1092 for unwrap in [SwiUnwrap::Laplacian, SwiUnwrap::LaplacianSlice, SwiUnwrap::Romeo] {
1093 let params = SwiParams { unwrap, ..SwiParams::default() };
1094 let r = calculate_swi(&phases, &mags, &tes, Some(&mask), &grid, ¶ms).unwrap();
1095 assert!(r.phase_mask[centre] < 0.5, "{unwrap:?}: bump weight {}", r.phase_mask[centre]);
1096 assert!(r.swi.iter().all(|v| v.is_finite()), "{unwrap:?}");
1097 }
1098 }
1099
1100 #[test]
1101 fn test_default_mask_is_robust_mask_of_first_echo() {
1102 let n = 16;
1103 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1104 let inside = ball(n, 6.0);
1105 let mag: Vec<f64> = inside.iter().map(|&b| if b { 100.0 } else { 1.0 }).collect();
1106 let phase = vec![0.0; n * n * n];
1107 let r = calculate_swi(&[phase], std::slice::from_ref(&mag), &[0.02], None, &grid, &SwiParams::default()).unwrap();
1108 assert_eq!(r.mask, robust_mask(&mag, &grid));
1109 }
1110
1111 #[test]
1112 fn test_phase_mask_range() {
1113 let n = 10;
1114 let nn = n * n * n;
1115 let phase: Vec<f64> = (0..nn).map(|i| (i as f64 * 0.01) - 5.0).collect();
1116 let mask = vec![1u8; nn];
1117
1118 for scaling in &[
1119 PhaseScaling::Tanh,
1120 PhaseScaling::NegativeTanh,
1121 PhaseScaling::Positive,
1122 PhaseScaling::Negative,
1123 PhaseScaling::Triangular,
1124 ] {
1125 let pm = create_phase_mask(&phase, &mask, *scaling, 4.0);
1126 for (i, &v) in pm.iter().enumerate() {
1127 assert!(v >= 0.0, "{:?}: value at {} = {} < 0", scaling, i, v);
1128 assert!(v <= 1.0 + 1e-10, "{:?}: value at {} = {} > 1", scaling, i, v);
1129 }
1130 }
1131 }
1132
1133 #[test]
1134 fn test_highpass_filter_constant() {
1135 let n = 16;
1137 let nn = n * n * n;
1138 let data = vec![5.0; nn];
1139 let mask = vec![1u8; nn];
1140 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
1141
1142 let result = highpass_filter(&data, &mask, &grid, [2.0, 2.0, 0.0]);
1143
1144 for &v in &result {
1145 assert!(v.abs() < 1.0, "High-pass of constant should be near zero, got {}", v);
1146 }
1147 }
1148
1149 const IDENTITY: [f64; 16] = [
1150 1.0, 0.0, 0.0, 0.0,
1151 0.0, 1.0, 0.0, 0.0,
1152 0.0, 0.0, 1.0, 0.0,
1153 0.0, 0.0, 0.0, 1.0,
1154 ];
1155
1156 #[test]
1157 fn test_mip_basic() {
1158 let (nx, ny, nz) = (3, 3, 5);
1160 let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1161 let mut data = vec![10.0; nx * ny * nz];
1162 let idx = 1 + nx + 2 * nx * ny; data[idx] = 1.0;
1165
1166 let mip = create_mip(&data, &grid, &IDENTITY, 3).unwrap();
1167 assert_eq!(mip.data.len(), nx * ny * 3);
1168
1169 let mip_idx = |window: usize| 1 + nx + window * nx * ny;
1174 for window in 0..3 {
1175 assert_eq!(
1176 mip.data[mip_idx(window)], 1.0,
1177 "window starting at z={window} covers z=2 and must see the minimum"
1178 );
1179 }
1180 }
1181
1182 #[test]
1185 fn test_mip_reports_its_own_dimensions() {
1186 let grid = Grid::new(32, 32, 32, 1.0, 1.0, 1.0);
1187 let mip = create_mip(&vec![1.0; grid.n_total()], &grid, &IDENTITY, 7).unwrap();
1188 assert_eq!(mip.grid.dims, (32, 32, 26));
1189 assert_eq!(mip.data.len(), mip.grid.n_total());
1190 assert_eq!(mip.grid.voxel_size, grid.voxel_size);
1192 }
1193
1194 #[test]
1195 fn test_mip_origin_moves_to_the_slab_centre() {
1196 let grid = Grid::new(4, 4, 16, 1.0, 1.0, 2.0);
1197 let mut affine = IDENTITY;
1198 affine[10] = 2.0; affine[11] = 10.0;
1200 let (mip_grid, mip_affine) = mip_geometry(&grid, &affine, 7).unwrap();
1201 assert_eq!(mip_grid.dims, (4, 4, 10));
1202 assert_eq!(mip_affine[11], 16.0);
1204 assert_eq!(mip_affine[..11], affine[..11]);
1206 }
1207
1208 #[test]
1209 fn test_mip_origin_follows_an_oblique_slice_direction() {
1210 let s = 2.0 / 2f64.sqrt();
1212 let affine = [
1213 1.0, 0.0, 0.0, 0.0,
1214 0.0, 1.0, s, 0.0,
1215 0.0, 0.0, s, 0.0,
1216 0.0, 0.0, 0.0, 1.0,
1217 ];
1218 let grid = Grid::new(8, 8, 20, 1.0, 1.0, 2.0);
1219 let (mip_grid, mip_affine) = mip_geometry(&grid, &affine, 7).unwrap();
1220 assert_eq!(mip_grid.dims, (8, 8, 14));
1221 assert!((mip_affine[7] - 3.0 * s).abs() < 1e-12, "{:?}", mip_affine);
1223 assert!((mip_affine[11] - 3.0 * s).abs() < 1e-12, "{:?}", mip_affine);
1224 assert_eq!(mip_affine[3], 0.0);
1225 }
1226
1227 #[test]
1228 fn test_mip_even_window_lands_on_a_half_slice() {
1229 let grid = Grid::new(4, 4, 16, 1.0, 1.0, 1.0);
1230 let (_, mip_affine) = mip_geometry(&grid, &IDENTITY, 4).unwrap();
1231 assert_eq!(mip_affine[11], 1.5);
1232 }
1233
1234 #[test]
1235 fn test_mip_window_of_one_is_the_volume_itself() {
1236 let grid = Grid::new(3, 3, 5, 1.0, 1.0, 1.0);
1237 let data: Vec<f64> = (0..grid.n_total()).map(|i| i as f64).collect();
1238 let mip = create_mip(&data, &grid, &IDENTITY, 1).unwrap();
1239 assert_eq!(mip.grid.dims, grid.dims);
1240 assert_eq!(mip.data, data);
1241 assert_eq!(mip.affine, IDENTITY);
1242 }
1243
1244 #[test]
1245 fn test_mip_window_of_full_depth_leaves_one_slice() {
1246 let grid = Grid::new(3, 3, 5, 1.0, 1.0, 1.0);
1247 let mip = create_mip(&vec![1.0; grid.n_total()], &grid, &IDENTITY, 5).unwrap();
1248 assert_eq!(mip.grid.dims, (3, 3, 1));
1249 assert_eq!(mip.affine[11], 2.0);
1250 }
1251
1252 #[test]
1253 fn test_mip_window_too_large() {
1254 let grid = Grid::new(3, 3, 3, 1.0, 1.0, 1.0);
1255 let err = create_mip(&[1.0; 27], &grid, &IDENTITY, 10).unwrap_err();
1256 assert!(err.contains("deeper than the 3-slice volume"), "{}", err);
1257 }
1258
1259 #[test]
1260 fn test_mip_zero_window() {
1261 let grid = Grid::new(3, 3, 3, 1.0, 1.0, 1.0);
1262 let err = create_mip(&[1.0; 27], &grid, &IDENTITY, 0).unwrap_err();
1263 assert!(err.contains("at least 1 slice"), "{}", err);
1264 }
1265
1266 #[test]
1267 fn test_mip_rejects_data_that_does_not_match_the_grid() {
1268 let grid = Grid::new(3, 3, 3, 1.0, 1.0, 1.0);
1269 let err = create_mip(&[1.0; 26], &grid, &IDENTITY, 3).unwrap_err();
1270 assert!(err.contains("26 values"), "{}", err);
1271 assert!(err.contains("27 voxels"), "{}", err);
1272 }
1273
1274 #[test]
1275 fn test_softplus_scaling() {
1276 let mag = vec![0.0, 0.5, 1.0, 2.0];
1277 let result = softplus_scaling(&mag, 1.0, 2.0);
1278
1279 assert!(result[0].abs() < 1e-10, "softplus(0) should be ~0, got {}", result[0]);
1281 for i in 1..result.len() {
1283 assert!(result[i] >= result[i - 1], "softplus should be monotonically increasing");
1284 }
1285 }
1286
1287 #[test]
1290 fn test_susceptibility_mask_paramagnetic() {
1291 let chi = vec![-1.0, -0.001, 0.0, 0.125, 0.25, 0.5, 0.9];
1293 let w = susceptibility_mask(&chi, 0.5, SmwiContrast::Paramagnetic);
1294 let expected = [1.0, 1.0, 1.0, 0.75, 0.5, 0.0, 0.0];
1295 for (i, (&got, &want)) in w.iter().zip(expected.iter()).enumerate() {
1296 assert!((got - want).abs() < 1e-12, "index {}: got {}, want {}", i, got, want);
1297 }
1298 }
1299
1300 #[test]
1301 fn test_susceptibility_mask_diamagnetic() {
1302 let chi = vec![-0.9, -0.5, -0.25, 0.0, 0.25, 1.0];
1304 let w = susceptibility_mask(&chi, 0.5, SmwiContrast::Diamagnetic);
1305 let expected = [0.0, 0.0, 0.5, 1.0, 1.0, 1.0];
1306 for (i, (&got, &want)) in w.iter().zip(expected.iter()).enumerate() {
1307 assert!((got - want).abs() < 1e-12, "index {}: got {}, want {}", i, got, want);
1308 }
1309 }
1310
1311 #[test]
1312 fn test_susceptibility_mask_edge_cases() {
1313 let chi = vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY];
1315 for contrast in &[SmwiContrast::Paramagnetic, SmwiContrast::Diamagnetic] {
1316 let w = susceptibility_mask(&chi, 1.0, *contrast);
1317 assert!(w.iter().all(|&v| v == 0.0), "{:?}: non-finite χ should give 0", contrast);
1318 }
1319
1320 let chi = vec![0.5];
1322 let fallback = susceptibility_mask(&chi, -1.0, SmwiContrast::Paramagnetic);
1323 let one_ppm = susceptibility_mask(&chi, 1.0, SmwiContrast::Paramagnetic);
1324 assert_eq!(fallback, one_ppm);
1325 }
1326
1327 #[test]
1328 fn test_smwi_reference_formula() {
1329 let grid = Grid::new(2, 1, 1, 1.0, 1.0, 1.0);
1331 let chi = vec![0.05, -0.05];
1332 let magnitude = vec![100.0, 100.0];
1333 let params = SmwiParams { threshold_ppm: 0.2, power: 4.0, mip_window: 4 };
1334
1335 let (para, dia) = calculate_smwi(&magnitude, &chi, None, &grid, ¶ms);
1336
1337 assert!((para[0] - 31.640625).abs() < 1e-9, "got {}", para[0]);
1339 assert!((dia[0] - 100.0).abs() < 1e-9, "got {}", dia[0]);
1340 assert!((para[1] - 100.0).abs() < 1e-9, "got {}", para[1]);
1342 assert!((dia[1] - 31.640625).abs() < 1e-9, "got {}", dia[1]);
1343 }
1344
1345 #[test]
1346 fn test_smwi_multi_echo_broadcast() {
1347 let grid = Grid::new(2, 2, 2, 1.0, 1.0, 1.0);
1349 let n = grid.n_total();
1350 let chi: Vec<f64> = (0..n).map(|i| i as f64 * 0.1).collect();
1351 let magnitude: Vec<f64> = (0..3 * n).map(|i| (i + 1) as f64).collect();
1352 let params = SmwiParams::default();
1353
1354 let (para, _dia) = calculate_smwi(&magnitude, &chi, None, &grid, ¶ms);
1355 assert_eq!(para.len(), magnitude.len());
1356
1357 let weight = susceptibility_mask(&chi, params.threshold_ppm, SmwiContrast::Paramagnetic);
1358 for i in 0..magnitude.len() {
1359 let want = magnitude[i] * weight[i % n].powf(params.power);
1360 assert!((para[i] - want).abs() < 1e-12, "index {}: got {}, want {}", i, para[i], want);
1361 }
1362
1363 let (para1, _) = calculate_smwi(&magnitude[..n], &chi, None, &grid, ¶ms);
1365 assert_eq!(¶[..n], ¶1[..]);
1366 }
1367
1368 #[test]
1369 fn test_smwi_brain_mask() {
1370 let grid = Grid::new(2, 2, 2, 1.0, 1.0, 1.0);
1371 let n = grid.n_total();
1372 let chi = vec![0.0; n];
1373 let magnitude = vec![5.0; 2 * n];
1374 let mut mask = vec![1u8; n];
1375 mask[0] = 0;
1376
1377 let (para, dia) = calculate_smwi(&magnitude, &chi, Some(&mask), &grid, &SmwiParams::default());
1378
1379 assert_eq!(para[0], 0.0);
1381 assert_eq!(para[n], 0.0);
1382 assert_eq!(dia[0], 0.0);
1383 assert!((para[1] - 5.0).abs() < 1e-12);
1384 assert!((para[n + 1] - 5.0).abs() < 1e-12);
1385 }
1386
1387 #[test]
1388 fn test_average_echoes() {
1389 let grid = Grid::new(2, 1, 1, 1.0, 1.0, 1.0);
1390 let data = vec![1.0, 2.0, 3.0, 4.0, 8.0, 12.0];
1391 let avg = average_echoes(&data, &grid);
1392 assert_eq!(avg, vec![4.0, 6.0]);
1393
1394 let single = vec![7.0, 9.0];
1396 assert_eq!(average_echoes(&single, &grid), single);
1397 }
1398
1399 #[test]
1400 #[should_panic(expected = "not a non-zero multiple")]
1401 fn test_smwi_rejects_ragged_magnitude() {
1402 let grid = Grid::new(2, 2, 2, 1.0, 1.0, 1.0);
1403 let chi = vec![0.0; grid.n_total()];
1404 let magnitude = vec![1.0; grid.n_total() + 1];
1405 let _ = calculate_smwi(&magnitude, &chi, None, &grid, &SmwiParams::default());
1406 }
1407}