Skip to main content

qsm_core/unwrap/
romeo.rs

1//! ROMEO weight calculation for phase unwrapping
2//!
3//! Calculates edge weights for region-growing phase unwrapping based on:
4//! - Phase coherence
5//! - Phase gradient coherence (multi-echo)
6//! - Magnitude coherence
7//! - Magnitude weights
8//!
9//! Reference:
10//! Dymerska, B., Eckstein, K., Bachrata, B., Siow, B., Trattnig, S., Shmueli, K.,
11//! Robinson, S.D. (2021). "Phase unwrapping with a rapid opensource minimum spanning
12//! tree algorithm (ROMEO)." Magnetic Resonance in Medicine, 85(4):2294-2308.
13//! https://doi.org/10.1002/mrm.28563
14//!
15//! Reference implementation: https://github.com/korbinian90/MriResearchTools.jl
16
17use std::f64::consts::PI;
18
19use crate::region_grow::{grow_region_unwrap, grow_region_unwrap_from_visited, grow_region_unwrap_full};
20use crate::Grid;
21#[cfg(feature = "parallel")]
22use rayon::prelude::*;
23
24/// Weight calculation scheme for ROMEO unwrapping.
25///
26/// Matches ROMEO.jl weight type options. See Dymerska et al. (2021) for details.
27#[derive(Clone, Copy, Debug, PartialEq)]
28pub enum RomeoWeightType {
29    /// Phase coherence + phase gradient coherence + mag coherence.
30    /// Note: phaselinearity component not yet implemented; currently equivalent to Romeo3.
31    Romeo,
32    /// Phase coherence + mag coherence only (no multi-echo temporal info).
33    Romeo2,
34    /// Phase coherence + phase gradient coherence + mag coherence.
35    Romeo3,
36    /// Alias for Romeo.
37    Romeo4,
38    /// All components including magnitude weighting.
39    Romeo6,
40    /// Best-path method (Abdul-Rahman). Not yet implemented.
41    BestPath,
42}
43
44impl RomeoWeightType {
45    /// Map weight type to 6 component flags matching ROMEO.jl:
46    /// \[phase_coherence, phase_gradient_coherence, phase_linearity,
47    ///  mag_coherence, mag_weight, mag_weight2\]
48    fn weight_flags(&self) -> [bool; 6] {
49        match self {
50            RomeoWeightType::Romeo  => [true, true, true, true, false, false],
51            RomeoWeightType::Romeo2 => [true, false, false, true, false, false],
52            RomeoWeightType::Romeo3 => [true, true, false, true, false, false],
53            RomeoWeightType::Romeo4 => [true, true, true, true, false, false],
54            RomeoWeightType::Romeo6 => [true, true, true, true, true, true],
55            RomeoWeightType::BestPath => [false; 6], // uses different calculation
56        }
57    }
58}
59
60/// Parameters for ROMEO phase unwrapping.
61///
62/// Weight components can be toggled individually. Each multiplies into the
63/// final edge weight as `0.1 + 0.9 * component_value`.
64///
65/// The default enables phase coherence, phase gradient coherence,
66/// phase linearity, and magnitude coherence (equivalent to `:romeo` in ROMEO.jl).
67#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
68#[derive(Clone, Debug)]
69pub struct RomeoParams {
70    // -- Weight component flags --
71
72    /// Phase coherence: `1 - |wrap(Δφ)| / π`. Always recommended. (default: true)
73    pub phase_coherence: bool,
74    /// Phase gradient coherence: consistency between echo pairs. Needs multi-echo
75    /// data (phase2 + TEs). Automatically disabled if unavailable. (default: true)
76    pub phase_gradient_coherence: bool,
77    /// Phase linearity: second-derivative smoothness along each edge. (default: true)
78    pub phase_linearity: bool,
79    /// Magnitude coherence: `(min/max)²` of neighbor magnitudes. (default: true)
80    pub mag_coherence: bool,
81    /// Magnitude weight: penalizes low-signal voxels relative to the 95th
82    /// percentile. (default: false)
83    pub mag_weight: bool,
84    /// Magnitude weight 2: penalizes abnormally high signal (flow artifacts).
85    /// (default: false)
86    pub mag_weight2: bool,
87    /// Use Best-path weights (Abdul-Rahman) instead of ROMEO weights.
88    /// When true, the individual weight flags above are ignored. (default: false)
89    pub bestpath: bool,
90
91    // -- Multi-echo options --
92
93    /// Template echo index for spatial unwrapping, 0-indexed (default: 0).
94    /// Only used for multi-echo template-based unwrapping.
95    pub template: usize,
96    /// Unwrap each echo individually instead of template-based (default: false).
97    pub individual: bool,
98    /// Correct global 2π offsets between echoes (default: false).
99    /// Recommended when using individual mode.
100    pub correct_global: bool,
101    /// Quality threshold for re-unwrapping uncertain voxels after temporal
102    /// unwrapping. Range \[0, 1\], set to 0 to disable. (default: 0.5)
103    pub temporal_uncertain_unwrapping: f64,
104    /// Maximum number of seed regions (default: 1, max: 255).
105    pub max_seeds: u8,
106    /// Merge neighboring regions after unwrapping (default: false).
107    pub merge_regions: bool,
108    /// Correct each region's median to nearest 0 by adding n·2π (default: false).
109    pub correct_regions: bool,
110    /// Additional phase tolerance beyond π for neighbor differences.
111    /// Range \[0, π\]. (default: 0.0)
112    pub wrap_addition: f64,
113}
114
115impl Default for RomeoParams {
116    fn default() -> Self {
117        Self {
118            // Default matches :romeo in ROMEO.jl
119            phase_coherence: true,
120            phase_gradient_coherence: true,
121            phase_linearity: true,
122            mag_coherence: true,
123            mag_weight: false,
124            mag_weight2: false,
125            bestpath: false,
126            template: 0,
127            individual: false,
128            correct_global: false,
129            temporal_uncertain_unwrapping: 0.5,
130            max_seeds: 1,
131            merge_regions: false,
132            correct_regions: false,
133            wrap_addition: 0.0,
134        }
135    }
136}
137
138impl RomeoParams {
139    /// Create params matching a ROMEO.jl weight type preset.
140    pub fn from_weight_type(wt: RomeoWeightType) -> Self {
141        if wt == RomeoWeightType::BestPath {
142            return Self { bestpath: true, ..Default::default() };
143        }
144        let f = wt.weight_flags();
145        Self {
146            phase_coherence: f[0],
147            phase_gradient_coherence: f[1],
148            phase_linearity: f[2],
149            mag_coherence: f[3],
150            mag_weight: f[4],
151            mag_weight2: f[5],
152            ..Default::default()
153        }
154    }
155
156    /// Convert the boolean fields to the 6-element flag array used internally.
157    pub fn weight_flags(&self) -> [bool; 6] {
158        [
159            self.phase_coherence,
160            self.phase_gradient_coherence,
161            self.phase_linearity,
162            self.mag_coherence,
163            self.mag_weight,
164            self.mag_weight2,
165        ]
166    }
167}
168
169const TWO_PI: f64 = 2.0 * PI;
170
171/// Wrap angle to [-π, π]
172#[inline]
173fn wrap_angle(angle: f64) -> f64 {
174    let mut a = angle % TWO_PI;
175    if a > PI {
176        a -= TWO_PI;
177    } else if a < -PI {
178        a += TWO_PI;
179    }
180    a
181}
182
183/// Index into a 3D array in Fortran order (column-major, matches NIfTI)
184#[inline(always)]
185fn idx3d(i: usize, j: usize, k: usize, nx: usize, ny: usize) -> usize {
186    i + j * nx + k * nx * ny
187}
188
189/// Calculate ROMEO edge weights for phase unwrapping.
190///
191/// Uses the default Romeo weight type (PC + PGC + PL + MC).
192/// Returns weights array of size 3 * nx * ny * nz in C order \[dim\]\[i\]\[j\]\[k\].
193/// Weights are 1-255 (valid) or 0 (no edge / masked out).
194pub fn calculate_weights_romeo(
195    phase: &[f64],
196    mag: &[f64],
197    phase2: Option<&[f64]>,
198    te1: f64,
199    te2: f64,
200    mask: &[u8],
201    nx: usize, ny: usize, nz: usize,
202) -> Vec<u8> {
203    calculate_weights_romeo_with_flags(
204        phase, mag, phase2, te1, te2, mask, nx, ny, nz,
205        RomeoWeightType::Romeo.weight_flags(),
206    )
207}
208
209/// Calculate ROMEO edge weights with configurable weight components.
210///
211/// Legacy 3-flag interface for backward compatibility.
212pub fn calculate_weights_romeo_configurable(
213    phase: &[f64],
214    mag: &[f64],
215    phase2: Option<&[f64]>,
216    te1: f64,
217    te2: f64,
218    mask: &[u8],
219    nx: usize, ny: usize, nz: usize,
220    use_phase_gradient_coherence: bool,
221    use_mag_coherence: bool,
222    use_mag_weight: bool,
223) -> Vec<u8> {
224    let flags = [
225        true,                           // phase coherence (always on)
226        use_phase_gradient_coherence,
227        false,                          // phase linearity (use weight_type for this)
228        use_mag_coherence,
229        use_mag_weight,
230        false,                          // mag_weight2
231    ];
232    calculate_weights_romeo_with_flags(
233        phase, mag, phase2, te1, te2, mask, nx, ny, nz, flags,
234    )
235}
236
237/// Calculate ROMEO edge weights with full 6-component flag control.
238///
239/// Matches ROMEO.jl `calculateweights_romeo`. The 6 flags are:
240/// 0: phase coherence, 1: phase gradient coherence, 2: phase linearity,
241/// 3: magnitude coherence, 4: magnitude weight, 5: magnitude weight 2.
242///
243/// Each component is scaled as `0.1 + 0.9 * value` (matching ROMEO.jl) to prevent
244/// any single component from zeroing the weight entirely.
245///
246/// Weights are stored as u8: 0 = no edge, 1 = worst valid, 255 = best.
247pub fn calculate_weights_romeo_with_flags(
248    phase: &[f64],
249    mag: &[f64],
250    phase2: Option<&[f64]>,
251    te1: f64,
252    te2: f64,
253    mask: &[u8],
254    nx: usize, ny: usize, nz: usize,
255    flags: [bool; 6],
256) -> Vec<u8> {
257    let n_total = nx * ny * nz;
258    let mut weights = vec![0u8; 3 * n_total];
259
260    let has_mag = !mag.is_empty();
261    let has_phase2 = phase2.is_some();
262    let te_ratio = if te2.abs() > 1e-10 { te1 / te2 } else { 1.0 };
263
264    // Disable magnitude flags if no magnitude data
265    let f_mc  = flags[3] && has_mag;
266    let f_mw  = flags[4] && has_mag;
267    let f_mw2 = flags[5] && has_mag;
268    // Disable PGC if no phase2/TEs
269    let f_pgc = flags[1] && has_phase2;
270    let f_pl  = flags[2];
271
272    // 95th percentile of magnitude for normalization (matching ROMEO.jl)
273    let max_mag = if has_mag && (f_mw || f_mw2) {
274        percentile_95(mag, mask)
275    } else {
276        1.0
277    };
278    let half_max_mag = 0.5 * max_mag + 1e-12;
279
280    for dim in 0..3_usize {
281        // Each dim writes a disjoint n_total block; voxels within it are
282        // independent, so fan out across voxels (rayon under `parallel`).
283        let block = &mut weights[dim * n_total..(dim + 1) * n_total];
284        maybe_par_iter_mut!(block).enumerate().for_each(|(idx, w)| {
285            let i = idx % nx;
286            let j = (idx / nx) % ny;
287            let k = idx / (nx * ny);
288            let (ni, nj, nk) = match dim {
289                0 => (i + 1, j, k),
290                1 => (i, j + 1, k),
291                _ => (i, j, k + 1),
292            };
293
294            if ni >= nx || nj >= ny || nk >= nz {
295                return;
296            }
297
298            let idx_n = idx3d(ni, nj, nk, nx, ny);
299
300            if mask[idx] == 0 || mask[idx_n] == 0 {
301                return;
302            }
303
304            let mut weight = 1.0_f64;
305
306            // 1. Phase coherence: 1 - |wrap(diff)| / π
307            if flags[0] {
308                let pc = 1.0 - wrap_angle(phase[idx_n] - phase[idx]).abs() / PI;
309                weight *= 0.1 + 0.9 * pc;
310            }
311
312            // 2. Phase gradient coherence (multi-echo)
313            if f_pgc {
314                let phase2_data = phase2.unwrap();
315                let wrapped_p1 = wrap_angle(phase[idx_n] - phase[idx]);
316                let wrapped_p2 = wrap_angle(phase2_data[idx_n] - phase2_data[idx]);
317                let pgc = (1.0 - (wrapped_p1 - wrapped_p2 * te_ratio).abs()).max(0.0);
318                weight *= 0.1 + 0.9 * pgc;
319            }
320
321            // 3. Phase linearity: product of two triplet linearities
322            if f_pl {
323                let pl = phase_linearity_edge(phase, i, j, k, ni, nj, nk, dim, nx, ny, nz);
324                weight *= 0.1 + 0.9 * pl;
325            }
326
327            if has_mag {
328                let m1 = mag[idx];
329                let m2 = mag[idx_n];
330                let small = m1.min(m2);
331                let big = m1.max(m2);
332
333                // 4. Magnitude coherence: (min/max)²
334                if f_mc {
335                    let mc = if big > 1e-12 { (small / big).powi(2) } else { 0.0 };
336                    weight *= 0.1 + 0.9 * mc;
337                }
338
339                // 5. Magnitude weight: penalize low signal
340                if f_mw {
341                    let mw = 0.5 + 0.5 * (small / half_max_mag).min(1.0);
342                    weight *= 0.1 + 0.9 * mw;
343                }
344
345                // 6. Magnitude weight 2: penalize too-high signal (flow artifacts)
346                if f_mw2 {
347                    let mw2 = 0.5 + 0.5 * (half_max_mag / big.max(1e-12)).min(1.0);
348                    weight *= 0.1 + 0.9 * mw2;
349                }
350            }
351
352            // Rescale to u8: min valid = 1, best = 255, 0 = no edge
353            *w = rescale_weight(weight);
354        });
355    }
356
357    weights
358}
359
360/// Phase linearity for an edge between two voxels.
361///
362/// Computes the product of two triplet linearities: one looking "behind" the edge
363/// and one looking "ahead". Matches ROMEO.jl `phaselinearity(P, i, j)`.
364fn phase_linearity_edge(
365    phase: &[f64],
366    i: usize, j: usize, k: usize,
367    ni: usize, nj: usize, nk: usize,
368    _dim: usize,
369    nx: usize, ny: usize, nz: usize,
370) -> f64 {
371    let idx = idx3d(i, j, k, nx, ny);
372    let idx_n = idx3d(ni, nj, nk, nx, ny);
373
374    // "Behind" triplet: (h, idx, idx_n) where h = 2*idx_pos - idx_n_pos
375    let (hi, hj, hk) = (2 * i as i32 - ni as i32, 2 * j as i32 - nj as i32, 2 * k as i32 - nk as i32);
376    let pl1 = if hi >= 0 && hi < nx as i32 && hj >= 0 && hj < ny as i32 && hk >= 0 && hk < nz as i32 {
377        let h_idx = idx3d(hi as usize, hj as usize, hk as usize, nx, ny);
378        phase_linearity_triplet(phase[h_idx], phase[idx], phase[idx_n])
379    } else {
380        0.9
381    };
382
383    // "Ahead" triplet: (idx, idx_n, k) where k = 2*idx_n_pos - idx_pos
384    let (ki, kj, kk) = (2 * ni as i32 - i as i32, 2 * nj as i32 - j as i32, 2 * nk as i32 - k as i32);
385    let pl2 = if ki >= 0 && ki < nx as i32 && kj >= 0 && kj < ny as i32 && kk >= 0 && kk < nz as i32 {
386        let k_idx = idx3d(ki as usize, kj as usize, kk as usize, nx, ny);
387        phase_linearity_triplet(phase[idx], phase[idx_n], phase[k_idx])
388    } else {
389        0.9
390    };
391
392    pl1 * pl2
393}
394
395/// Phase linearity of three consecutive phase values.
396///
397/// `max(0, 1 - |wrap(a - 2b + c) / 2|)` — measures how linear the phase is.
398/// Matches ROMEO.jl `phaselinearity(P, i, j, k)`.
399#[inline]
400fn phase_linearity_triplet(a: f64, b: f64, c: f64) -> f64 {
401    let second_deriv = wrap_angle(a - 2.0 * b + c);
402    let pl = (1.0 - (second_deriv / 2.0).abs()).max(0.0);
403    if pl.is_nan() { 0.5 } else { pl }
404}
405
406/// Rescale weight from [0,1] to u8 [1,255], with 0 = invalid.
407///
408/// Matches ROMEO.jl convention: valid edges always have weight ≥ 1.
409#[inline]
410fn rescale_weight(w: f64) -> u8 {
411    if w > 0.0 && w <= 1.0 {
412        (w * 254.0).round() as u8 + 1  // [1, 255]
413    } else if w > 1.0 {
414        255
415    } else {
416        0
417    }
418}
419
420/// Compute 95th percentile of magnitude within mask (matching ROMEO.jl).
421fn percentile_95(mag: &[f64], mask: &[u8]) -> f64 {
422    let mut values: Vec<f64> = mag.iter().enumerate()
423        .filter(|(i, v)| mask[*i] > 0 && v.is_finite())
424        .map(|(_, &v)| v)
425        .collect();
426    if values.is_empty() {
427        return 1.0;
428    }
429    values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
430    let idx = ((values.len() as f64 * 0.95) as usize).min(values.len() - 1);
431    values[idx]
432}
433
434/// Calculate BestPath edge weights.
435///
436/// Uses second-order phase differences across all neighbor directions
437/// (Abdul-Rahman, https://doi.org/10.1364/AO.46.006623).
438/// Matches ROMEO.jl `calculateweights_bestpath`.
439pub fn calculate_weights_bestpath(
440    phase: &[f64],
441    mask: &[u8],
442    nx: usize, ny: usize, nz: usize,
443) -> Vec<u8> {
444    let n_total = nx * ny * nz;
445
446    // Compute D² for each voxel: sum of squared wrapped second-order differences
447    // across all unique neighbor offsets from 26-connected directions
448    let strides = [1_i64, nx as i64, (nx * ny) as i64];
449    let mut neighbor_offsets: Vec<i64> = Vec::new();
450    for dx in -1i64..=1 {
451        for dy in -1i64..=1 {
452            for dz in -1i64..=1 {
453                if dx == 0 && dy == 0 && dz == 0 { continue; }
454                let offset = (dx * strides[0] + dy * strides[1] + dz * strides[2]).abs();
455                if !neighbor_offsets.contains(&offset) {
456                    neighbor_offsets.push(offset);
457                }
458            }
459        }
460    }
461
462    let mut d2 = vec![0.0_f64; n_total];
463    for &n in &neighbor_offsets {
464        let nu = n as usize;
465        for i in nu..(n_total - nu) {
466            let v1 = wrap_angle(phase[i - nu] - phase[i]);
467            let v2 = wrap_angle(phase[i] - phase[i + nu]);
468            let diff = v1 - v2;
469            d2[i] += diff * diff;
470        }
471    }
472
473    // R = 1 / sqrt(D²)
474    let r: Vec<f64> = d2.iter().map(|&v| {
475        let d = v.sqrt();
476        if d > 1e-12 { 1.0 / d } else { 0.0 }
477    }).collect();
478
479    // Edge weights: R[i] + R[i+n] for each direction
480    let mut weights = vec![0u8; 3 * n_total];
481    for dim in 0..3_usize {
482        for i in 0..nx {
483            for j in 0..ny {
484                for k in 0..nz {
485                    let (ni, nj, nk) = match dim {
486                        0 => (i + 1, j, k),
487                        1 => (i, j + 1, k),
488                        _ => (i, j, k + 1),
489                    };
490                    if ni >= nx || nj >= ny || nk >= nz { continue; }
491
492                    let idx = idx3d(i, j, k, nx, ny);
493                    let idx_n = idx3d(ni, nj, nk, nx, ny);
494
495                    if mask[idx] == 0 || mask[idx_n] == 0 { continue; }
496
497                    let w = r[idx] + r[idx_n];
498                    // Scale: lower w = more consistent = higher weight
499                    // ROMEO.jl: scale(w) = min(max(round((1 - w/10) * 255), 1), 255)
500                    let scaled = ((1.0 - w / 10.0) * 255.0).round().clamp(1.0, 255.0) as u8;
501                    let edge_idx = dim * n_total + idx3d(i, j, k, nx, ny);
502                    weights[edge_idx] = scaled;
503                }
504            }
505        }
506    }
507
508    weights
509}
510
511/// Simplified weight calculation for single-echo data (no phase2)
512pub fn calculate_weights_single_echo(
513    phase: &[f64],
514    mag: &[f64],
515    mask: &[u8],
516    nx: usize, ny: usize, nz: usize,
517) -> Vec<u8> {
518    calculate_weights_romeo(phase, mag, None, 1.0, 1.0, mask, nx, ny, nz)
519}
520
521/// Calculate per-voxel quality map from ROMEO edge weights
522///
523/// Computes ROMEO edge weights and then aggregates them per-voxel by averaging
524/// the incident edge weights across all 6 neighboring directions (±x, ±y, ±z).
525/// This produces a quality map where high values indicate voxels with coherent
526/// phase and magnitude, suitable for thresholding into a brain mask.
527///
528/// Reference: MriResearchTools.jl `romeovoxelquality()` function
529///
530/// # Arguments
531/// * `phase` - Wrapped phase data (nx * ny * nz), first echo
532/// * `mag` - Magnitude data (nx * ny * nz), optional (pass empty slice if none)
533/// * `phase2` - Second echo phase for gradient coherence (optional)
534/// * `te1`, `te2` - Echo times for gradient coherence scaling
535/// * `mask` - Binary mask (nx * ny * nz), 1 = process
536/// * `nx`, `ny`, `nz` - Array dimensions
537///
538/// # Returns
539/// Quality map of size nx * ny * nz with values in range [0, 100]
540#[allow(clippy::erasing_op)]
541pub fn voxel_quality_romeo(
542    phase: &[f64],
543    mag: &[f64],
544    phase2: Option<&[f64]>,
545    te1: f64,
546    te2: f64,
547    mask: &[u8],
548    grid: &Grid,
549) -> Vec<f64> {
550    let (nx, ny, nz) = grid.dims;
551    let n_total = nx * ny * nz;
552    let weights = calculate_weights_romeo(phase, mag, phase2, te1, te2, mask, nx, ny, nz);
553
554    let mut quality = vec![0.0_f64; n_total];
555
556    // Each voxel's quality is an independent read of neighbouring edge
557    // weights — fan out across voxels (rayon under the `parallel` feature).
558    maybe_par_iter_mut!(quality).enumerate().for_each(|(idx, q)| {
559        if mask[idx] == 0 {
560            return;
561        }
562        let i = idx % nx;
563        let j = (idx / nx) % ny;
564        let k = idx / (nx * ny);
565
566        let mut sum = 0.0_f64;
567        let mut count = 0u32;
568
569        // +x edge: stored at (i, j, k) in dim=0
570        if i + 1 < nx && mask[idx3d(i + 1, j, k, nx, ny)] != 0 {
571            sum += weights[idx] as f64;
572            count += 1;
573        }
574        // -x edge: stored at (i-1, j, k) in dim=0
575        if i > 0 && mask[idx3d(i - 1, j, k, nx, ny)] != 0 {
576            sum += weights[idx3d(i - 1, j, k, nx, ny)] as f64;
577            count += 1;
578        }
579        // +y edge: stored at (i, j, k) in dim=1
580        if j + 1 < ny && mask[idx3d(i, j + 1, k, nx, ny)] != 0 {
581            sum += weights[n_total + idx] as f64;
582            count += 1;
583        }
584        // -y edge: stored at (i, j-1, k) in dim=1
585        if j > 0 && mask[idx3d(i, j - 1, k, nx, ny)] != 0 {
586            sum += weights[n_total + idx3d(i, j - 1, k, nx, ny)] as f64;
587            count += 1;
588        }
589        // +z edge: stored at (i, j, k) in dim=2
590        if k + 1 < nz && mask[idx3d(i, j, k + 1, nx, ny)] != 0 {
591            sum += weights[2 * n_total + idx] as f64;
592            count += 1;
593        }
594        // -z edge: stored at (i, j, k-1) in dim=2
595        if k > 0 && mask[idx3d(i, j, k - 1, nx, ny)] != 0 {
596            sum += weights[2 * n_total + idx3d(i, j, k - 1, nx, ny)] as f64;
597            count += 1;
598        }
599
600        if count > 0 {
601            // Normalize from 0-255 to 0-1, then scale to 0-100
602            *q = (sum / count as f64) / 255.0 * 100.0;
603        }
604    });
605
606    quality
607}
608
609// =========================================================================
610// Phase unwrapping functions
611// =========================================================================
612
613/// Find a seed point at the center of mass of the mask.
614pub fn find_seed_point(mask: &[u8], nx: usize, ny: usize, nz: usize) -> (usize, usize, usize) {
615    let mut sum_i = 0usize;
616    let mut sum_j = 0usize;
617    let mut sum_k = 0usize;
618    let mut count = 0usize;
619
620    for k in 0..nz {
621        for j in 0..ny {
622            for i in 0..nx {
623                let idx = idx3d(i, j, k, nx, ny);
624                if mask[idx] > 0 {
625                    sum_i += i;
626                    sum_j += j;
627                    sum_k += k;
628                    count += 1;
629                }
630            }
631        }
632    }
633
634    if count == 0 {
635        return (nx / 2, ny / 2, nz / 2);
636    }
637
638    (sum_i / count, sum_j / count, sum_k / count)
639}
640
641/// Compute weights from params (dispatches BestPath vs ROMEO).
642fn compute_weights_from_params(
643    params: &RomeoParams,
644    phase: &[f64],
645    mag: &[f64],
646    phase2: Option<&[f64]>,
647    te1: f64, te2: f64,
648    mask: &[u8],
649    nx: usize, ny: usize, nz: usize,
650) -> Vec<u8> {
651    if params.bestpath {
652        calculate_weights_bestpath(phase, mask, nx, ny, nz)
653    } else {
654        calculate_weights_romeo_with_flags(
655            phase, mag, phase2, te1, te2, mask, nx, ny, nz,
656            params.weight_flags(),
657        )
658    }
659}
660
661/// Unwrap a single 3D phase volume using ROMEO.
662///
663/// Computes ROMEO edge weights and performs region-growing phase unwrapping.
664///
665/// # Arguments
666/// * `phase` - Wrapped phase data (nx * ny * nz)
667/// * `mag` - Magnitude data (pass empty slice if none)
668/// * `phase2` - Optional second echo phase for gradient coherence weights
669/// * `te1`, `te2` - Echo times for phase gradient coherence scaling
670/// * `mask` - Binary mask (1 = process)
671/// * `params` - ROMEO parameters (weight_type used for weight calculation)
672/// * `nx`, `ny`, `nz` - Array dimensions
673///
674/// # Returns
675/// Unwrapped phase (same size as input)
676pub fn unwrap_romeo(
677    phase: &[f64],
678    mag: &[f64],
679    phase2: Option<&[f64]>,
680    te1: f64,
681    te2: f64,
682    mask: &[u8],
683    params: &RomeoParams,
684    grid: &Grid,
685) -> Vec<f64> {
686    let (nx, ny, nz) = grid.dims;
687    let weights = compute_weights_from_params(
688        params, phase, mag, phase2, te1, te2,
689        mask, nx, ny, nz,
690    );
691
692    let mut unwrapped = phase.to_vec();
693    let n_total = nx * ny * nz;
694
695    if params.max_seeds > 1 || params.wrap_addition > 0.0 || params.merge_regions || params.correct_regions {
696        // Full-featured path with multi-seed, wrap_addition, region merging
697        let mut visited = vec![0u8; n_total];
698        let num_regions = grow_region_unwrap_full(
699            &mut unwrapped, &weights, mask, &mut visited,
700            nx, ny, nz, params.wrap_addition, params.max_seeds,
701            phase2, if phase2.is_some() { Some((te1, te2)) } else { None },
702        );
703
704        if params.merge_regions && num_regions > 1 {
705            let remaining = merge_regions_post(
706                &mut unwrapped, &mut visited, num_regions, &weights, nx, ny, nz,
707            );
708            if params.correct_regions {
709                // correct_regions on remaining regions
710                for &r in &remaining {
711                    correct_regions(&mut unwrapped, &visited, r);
712                }
713            }
714        } else if params.correct_regions && num_regions > 0 {
715            correct_regions(&mut unwrapped, &visited, num_regions);
716        }
717    } else {
718        // Simple single-seed path (faster, no region tracking overhead)
719        let (seed_i, seed_j, seed_k) = find_seed_point(mask, nx, ny, nz);
720        let mut work_mask = mask.to_vec();
721        grow_region_unwrap(
722            &mut unwrapped, &weights, &mut work_mask,
723            nx, ny, nz, seed_i, seed_j, seed_k,
724        );
725    }
726
727    // correctglobal for 3D: subtract median n·2π offset
728    if params.correct_global {
729        correct_global_offset(&mut unwrapped, mask);
730    }
731
732    unwrapped
733}
734
735/// Unwrap 4D multi-echo phase data using ROMEO.
736///
737/// Supports two modes matching ROMEO.jl:
738///
739/// **Template-based** (default, `individual=false`):
740/// Spatially unwraps one template echo, then temporally unwraps all others
741/// by scaling with TE ratios. Optionally re-unwraps uncertain voxels spatially.
742///
743/// **Individual** (`individual=true`):
744/// Spatially unwraps each echo independently, then optionally corrects global
745/// 2π offsets between echoes using median wrap counting.
746///
747/// # Arguments
748/// * `phases` - Wrapped phase for each echo \[n_echoes\]\[nx*ny*nz\]
749/// * `mags` - Magnitude for each echo (pass empty `&[]` if none)
750/// * `tes` - Echo times (units don't matter, only ratios are used)
751/// * `mask` - Binary mask (nx * ny * nz)
752/// * `params` - ROMEO parameters
753/// * `nx`, `ny`, `nz` - Array dimensions
754///
755/// # Returns
756/// Unwrapped phase for each echo
757pub fn unwrap_romeo_multi_echo<P: AsRef<[f64]> + Sync, M: AsRef<[f64]> + Sync>(
758    phases: &[P],
759    mags: &[M],
760    tes: &[f64],
761    mask: &[u8],
762    params: &RomeoParams,
763    grid: &Grid,
764) -> Vec<Vec<f64>> {
765    let (nx, ny, nz) = grid.dims;
766    let n_echoes = phases.len();
767    assert!(n_echoes > 0, "phases must have at least one echo");
768    assert_eq!(n_echoes, tes.len(), "phases and tes must have same length");
769
770    if n_echoes == 1 {
771        let mag = if mags.is_empty() { &[] as &[f64] } else { mags[0].as_ref() };
772        return vec![unwrap_romeo(
773            phases[0].as_ref(), mag, None, 0.0, 0.0,
774            mask, params, grid,
775        )];
776    }
777
778    if params.individual {
779        unwrap_individual(phases, mags, tes, mask, params, nx, ny, nz)
780    } else {
781        unwrap_template(phases, mags, tes, mask, params, nx, ny, nz)
782    }
783}
784
785// =========================================================================
786// Template-based multi-echo unwrapping
787// =========================================================================
788
789/// Template-based multi-echo unwrapping.
790///
791/// 1. Spatially unwrap the template echo using ROMEO weights
792/// 2. Temporally unwrap all other echoes outward from the template
793/// 3. Optionally re-unwrap uncertain voxels spatially
794fn unwrap_template<P: AsRef<[f64]>, M: AsRef<[f64]>>(
795    phases: &[P],
796    mags: &[M],
797    tes: &[f64],
798    mask: &[u8],
799    params: &RomeoParams,
800    nx: usize, ny: usize, nz: usize,
801) -> Vec<Vec<f64>> {
802    let n_echoes = phases.len();
803    let n_total = nx * ny * nz;
804    let template = params.template.min(n_echoes - 1);
805
806    // Select phase2 reference (matching ROMEO.jl p2ref default)
807    let p2ref = if template == 0 { 1 } else { template - 1 };
808
809    // Calculate weights using template echo + p2ref
810    let template_mag = if mags.is_empty() { &[] as &[f64] } else { mags[template].as_ref() };
811    let weights = compute_weights_from_params(
812        params,
813        phases[template].as_ref(),
814        template_mag,
815        Some(phases[p2ref].as_ref()),
816        tes[template], tes[p2ref],
817        mask, nx, ny, nz,
818    );
819
820    // Spatially unwrap template echo
821    let mut result: Vec<Vec<f64>> = phases.iter().map(|p| p.as_ref().to_vec()).collect();
822    if params.wrap_addition > 0.0 {
823        let mut visited = vec![0u8; n_total];
824        grow_region_unwrap_full(
825            &mut result[template], &weights, mask, &mut visited,
826            nx, ny, nz, params.wrap_addition, 1,
827            Some(phases[p2ref].as_ref()), Some((tes[template], tes[p2ref])),
828        );
829    } else {
830        let (seed_i, seed_j, seed_k) = find_seed_point(mask, nx, ny, nz);
831        let mut work_mask = mask.to_vec();
832        grow_region_unwrap(
833            &mut result[template], &weights, &mut work_mask,
834            nx, ny, nz, seed_i, seed_j, seed_k,
835        );
836    }
837
838    // Temporally unwrap other echoes outward from template
839    // Order: template-1, template-2, ..., 0, template+1, template+2, ..., n-1
840    // (matching ROMEO.jl: [(template-1):-1:1; (template+1):length(TEs)])
841    let echo_order: Vec<usize> = (0..template).rev()
842        .chain((template + 1)..n_echoes)
843        .collect();
844
845    for ieco in echo_order {
846        let iref = if ieco < template { ieco + 1 } else { ieco - 1 };
847        let te_ratio = tes[ieco] / tes[iref];
848
849        // Temporal unwrap each voxel: align to TE-scaled reference
850        for i in 0..n_total {
851            if mask[i] > 0 {
852                let ref_value = result[iref][i] * te_ratio;
853                result[ieco][i] = unwrap_voxel(result[ieco][i], ref_value);
854            }
855        }
856
857        // Fallback: re-unwrap uncertain voxels spatially
858        if params.temporal_uncertain_unwrapping > 0.0 {
859            // Build scaled reference for quality assessment
860            let ref_scaled: Vec<f64> = (0..n_total).map(|i| {
861                if mask[i] > 0 { result[iref][i] * te_ratio } else { 0.0 }
862            }).collect();
863
864            temporal_uncertain_rewrap(
865                &mut result[ieco],
866                &ref_scaled,
867                &weights,
868                mask,
869                params.temporal_uncertain_unwrapping,
870                nx, ny, nz,
871            );
872        }
873    }
874
875    result
876}
877
878// =========================================================================
879// Individual multi-echo unwrapping
880// =========================================================================
881
882/// Individual multi-echo unwrapping.
883///
884/// Each echo is spatially unwrapped independently using its neighboring echo
885/// as a phase2 reference for weight calculation. Optionally corrects global
886/// 2π offsets between echoes using median wrap counting.
887fn unwrap_individual<P: AsRef<[f64]> + Sync, M: AsRef<[f64]> + Sync>(
888    phases: &[P],
889    mags: &[M],
890    tes: &[f64],
891    mask: &[u8],
892    params: &RomeoParams,
893    nx: usize, ny: usize, nz: usize,
894) -> Vec<Vec<f64>> {
895    let n_echoes = phases.len();
896    let (seed_i, seed_j, seed_k) = find_seed_point(mask, nx, ny, nz);
897
898    // Each echo is unwrapped independently from the same seed, so the echoes
899    // fan out across threads (under the `parallel` feature). Order is preserved
900    // by the parallel `collect`.
901    let echo_indices: Vec<usize> = (0..n_echoes).collect();
902    let mut result: Vec<Vec<f64>> = maybe_par_iter!(echo_indices)
903        .map(|&i| {
904            // Neighboring echo as phase2 reference (matching ROMEO.jl)
905            let e2 = if i == 0 { 1 } else { i - 1 };
906
907            let mag = if mags.is_empty() { &[] as &[f64] } else { mags[i].as_ref() };
908            let weights = compute_weights_from_params(
909                params,
910                phases[i].as_ref(),
911                mag,
912                Some(phases[e2].as_ref()),
913                tes[i], tes[e2],
914                mask, nx, ny, nz,
915            );
916
917            let mut unwrapped = phases[i].as_ref().to_vec();
918            let mut work_mask = mask.to_vec();
919            grow_region_unwrap(
920                &mut unwrapped, &weights, &mut work_mask,
921                nx, ny, nz, seed_i, seed_j, seed_k,
922            );
923            unwrapped
924        })
925        .collect();
926
927    if params.correct_global {
928        correct_multi_echo_wraps(&mut result, tes, mask);
929    }
930
931    result
932}
933
934// =========================================================================
935// Helper functions
936// =========================================================================
937
938/// Temporal unwrap: remove 2π wraps by comparing to a reference value.
939///
940/// Matches ROMEO.jl: `unwrapvoxel(new, old) = new - 2π * round((new - old) / 2π)`
941#[inline]
942fn unwrap_voxel(new: f64, old: f64) -> f64 {
943    new - TWO_PI * ((new - old) / TWO_PI).round()
944}
945
946/// Correct global 2π offsets between echoes using median wrap counting.
947///
948/// For each successive echo pair, computes the median number of 2π wraps
949/// between the TE-scaled reference and the current echo, then corrects.
950///
951/// Matches ROMEO.jl `correct_multi_echo_wraps!`.
952pub fn correct_multi_echo_wraps(
953    phases: &mut [Vec<f64>],
954    tes: &[f64],
955    mask: &[u8],
956) {
957    let n_total = phases[0].len();
958
959    for ieco in 1..phases.len() {
960        let iref = ieco - 1;
961        let te_ratio = tes[ieco] / tes[iref];
962
963        // Collect wrap counts for all masked voxels
964        let mut wrap_counts: Vec<f64> = Vec::new();
965        for i in 0..n_total {
966            if mask[i] > 0 {
967                let expected = phases[iref][i] * te_ratio;
968                let nwraps = ((expected - phases[ieco][i]) / TWO_PI).round();
969                if nwraps.is_finite() {
970                    wrap_counts.push(nwraps);
971                }
972            }
973        }
974
975        if wrap_counts.is_empty() {
976            continue;
977        }
978
979        // Median wrap count
980        wrap_counts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
981        let median_wraps = wrap_counts[wrap_counts.len() / 2];
982
983        if median_wraps.abs() > 0.1 {
984            let correction = TWO_PI * median_wraps;
985            for i in 0..n_total {
986                if mask[i] > 0 {
987                    phases[ieco][i] += correction;
988                }
989            }
990        }
991    }
992}
993
994/// Correct global 2π offset for a single 3D volume.
995///
996/// Subtracts `2π * median(round(phase / 2π))` from the entire volume,
997/// bringing the median phase closest to 0. Matches ROMEO.jl `correctglobal`.
998fn correct_global_offset(phase: &mut [f64], mask: &[u8]) {
999    let mut wraps: Vec<f64> = phase.iter().enumerate()
1000        .filter(|(i, v)| mask[*i] > 0 && v.is_finite())
1001        .map(|(_, &v)| (v / TWO_PI).round())
1002        .collect();
1003
1004    if wraps.is_empty() {
1005        return;
1006    }
1007
1008    wraps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1009    let median_wraps = wraps[wraps.len() / 2];
1010
1011    if median_wraps.abs() > 0.1 {
1012        let correction = TWO_PI * median_wraps;
1013        for i in 0..phase.len() {
1014            if mask[i] > 0 {
1015                phase[i] -= correction;
1016            }
1017        }
1018    }
1019}
1020
1021/// Correct each region's median to nearest 0 by subtracting n·2π.
1022///
1023/// Matches ROMEO.jl `correct_regions!`.
1024fn correct_regions(phase: &mut [f64], visited: &[u8], num_regions: u8) {
1025    for region in 1..=num_regions {
1026        let mut wraps: Vec<f64> = Vec::new();
1027        for i in 0..phase.len() {
1028            if visited[i] == region && phase[i].is_finite() {
1029                wraps.push((phase[i] / TWO_PI).round());
1030            }
1031        }
1032        if wraps.is_empty() { continue; }
1033        wraps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1034        let median_wraps = wraps[wraps.len() / 2];
1035        if median_wraps.abs() > 0.1 {
1036            let correction = TWO_PI * median_wraps;
1037            for i in 0..phase.len() {
1038                if visited[i] == region {
1039                    phase[i] -= correction;
1040                }
1041            }
1042        }
1043    }
1044}
1045
1046/// Merge adjacent regions by computing inter-region phase offsets.
1047///
1048/// Calculates weighted phase offset between adjacent region pairs,
1049/// then iteratively merges regions starting from the largest.
1050/// Matches ROMEO.jl `merge_regions!`.
1051fn merge_regions_post(
1052    phase: &mut [f64],
1053    visited: &mut [u8],
1054    num_regions: u8,
1055    weights: &[u8],
1056    nx: usize, ny: usize, nz: usize,
1057) -> Vec<u8> {
1058    let n_total = nx * ny * nz;
1059    let nr = num_regions as usize;
1060    if nr <= 1 { return vec![1]; }
1061
1062    // Count region sizes
1063    let mut region_size = vec![0usize; nr + 1]; // 1-indexed
1064    for &v in visited.iter() {
1065        if v > 0 && (v as usize) <= nr {
1066            region_size[v as usize] += 1;
1067        }
1068    }
1069
1070    // Compute weighted offset between adjacent regions
1071    let mut offsets = vec![0.0_f64; (nr + 1) * (nr + 1)];
1072    let mut offset_counts = vec![0i64; (nr + 1) * (nr + 1)];
1073    let flat = |r1: usize, r2: usize| r1 * (nr + 1) + r2;
1074
1075    for dim in 0..3_usize {
1076        for i in 0..nx {
1077            for j in 0..ny {
1078                for k in 0..nz {
1079                    let (ni, nj, nk) = match dim {
1080                        0 => (i + 1, j, k),
1081                        1 => (i, j + 1, k),
1082                        _ => (i, j, k + 1),
1083                    };
1084                    if ni >= nx || nj >= ny || nk >= nz { continue; }
1085                    let idx = idx3d(i, j, k, nx, ny);
1086                    let idx_n = idx3d(ni, nj, nk, nx, ny);
1087                    let ri = visited[idx] as usize;
1088                    let rj = visited[idx_n] as usize;
1089                    if ri == 0 || rj == 0 || ri == rj { continue; }
1090
1091                    let edge_idx = dim * n_total + idx3d(i, j, k, nx, ny);
1092                    let w = 255u16.saturating_sub(weights[edge_idx] as u16);
1093                    let w = if w == 255 { 0 } else { w as i64 };
1094
1095                    offsets[flat(ri, rj)] += (phase[idx] - phase[idx_n]) * w as f64;
1096                    offset_counts[flat(ri, rj)] += w;
1097                }
1098            }
1099        }
1100    }
1101
1102    // Symmetrize
1103    for i in 1..=nr {
1104        for j in i..=nr {
1105            offset_counts[flat(i, j)] += offset_counts[flat(j, i)];
1106            offset_counts[flat(j, i)] = offset_counts[flat(i, j)];
1107            offsets[flat(i, j)] -= offsets[flat(j, i)];
1108            offsets[flat(j, i)] = -offsets[flat(i, j)];
1109        }
1110    }
1111
1112    // Iteratively merge: process largest uncorrected region first
1113    let mut corrected = vec![false; nr + 1];
1114    let mut remaining = Vec::new();
1115
1116    while corrected[1..=nr].iter().any(|&c| !c) {
1117        // Find largest uncorrected region
1118        let mut best_region = 0;
1119        let mut best_size = 0;
1120        for r in 1..=nr {
1121            if !corrected[r] && region_size[r] > best_size {
1122                best_size = region_size[r];
1123                best_region = r;
1124            }
1125        }
1126        if best_region == 0 { break; }
1127        corrected[best_region] = true;
1128        remaining.push(best_region as u8);
1129
1130        // Find regions to merge: corrected[i] && !corrected[j] && offset_counts > 0
1131        // Sort by offset_counts (highest first = best connection)
1132        let mut merge_pairs: Vec<(usize, usize, i64)> = Vec::new();
1133        for i in 1..=nr {
1134            for j in 1..=nr {
1135                if corrected[i] && !corrected[j] && offset_counts[flat(i, j)] > 0 {
1136                    merge_pairs.push((i, j, offset_counts[flat(i, j)]));
1137                }
1138            }
1139        }
1140        merge_pairs.sort_by(|a, b| b.2.cmp(&a.2));
1141
1142        for &(ri, rj, count) in &merge_pairs {
1143            if corrected[rj] { continue; }
1144            let offset = (offsets[flat(ri, rj)] / count as f64 / TWO_PI).round();
1145            if offset != 0.0 {
1146                let correction = offset * TWO_PI;
1147                for v in 0..n_total {
1148                    if visited[v] == rj as u8 {
1149                        phase[v] += correction;
1150                        visited[v] = ri as u8;
1151                    }
1152                }
1153            }
1154            corrected[rj] = true;
1155            offset_counts[flat(ri, rj)] = -1;
1156            offset_counts[flat(rj, ri)] = -1;
1157        }
1158    }
1159
1160    remaining
1161}
1162
1163/// Re-unwrap voxels that are uncertain after temporal unwrapping.
1164///
1165/// Computes a quality metric comparing the unwrapped phase to the TE-scaled
1166/// reference. Voxels with low quality (likely wrap errors) are re-unwrapped
1167/// spatially using the certain voxels as seeds.
1168///
1169/// Matches ROMEO.jl `temporal_uncertain_unwrapping!`.
1170fn temporal_uncertain_rewrap(
1171    phase: &mut [f64],
1172    ref_scaled: &[f64],
1173    weights: &[u8],
1174    mask: &[u8],
1175    threshold: f64,
1176    nx: usize, ny: usize, nz: usize,
1177) {
1178    let n_total = nx * ny * nz;
1179
1180    // Compute quality: compare phase/2 with ref_scaled/2 using voxel quality
1181    // (halving increases sensitivity to single-wrap errors)
1182    // Matches ROMEO.jl: unwrapped_quality(phase, refphase) = voxelquality(phase/2; phase2=refphase/2, TEs=[1,1])
1183    let half_phase: Vec<f64> = phase.iter().map(|&v| v * 0.5).collect();
1184    let half_ref: Vec<f64> = ref_scaled.iter().map(|&v| v * 0.5).collect();
1185
1186    let grid = crate::Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1187    let quality = voxel_quality_romeo(
1188        &half_phase, &[], Some(&half_ref), 1.0, 1.0,
1189        mask, &grid,
1190    );
1191
1192    // Build visited mask:
1193    // quality > threshold*100 → certain (visited=2, acts as seed)
1194    // quality <= threshold*100 → uncertain (visited=1, needs re-unwrapping)
1195    // outside mask → 0
1196    let threshold_scaled = threshold * 100.0; // voxel_quality_romeo returns 0-100
1197    let mut visited = vec![0u8; n_total];
1198    let mut any_uncertain = false;
1199    let mut any_certain = false;
1200
1201    for i in 0..n_total {
1202        if mask[i] == 0 {
1203            visited[i] = 0;
1204        } else if quality[i] > threshold_scaled {
1205            visited[i] = 2;
1206            any_certain = true;
1207        } else {
1208            visited[i] = 1;
1209            any_uncertain = true;
1210        }
1211    }
1212
1213    if !any_uncertain || !any_certain {
1214        return;
1215    }
1216
1217    // Re-unwrap uncertain voxels using spatial growing from the certain boundary
1218    grow_region_unwrap_from_visited(phase, weights, &mut visited, nx, ny, nz);
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    use super::*;
1224    use crate::Grid;
1225
1226    fn grid(n: usize) -> Grid { Grid::new(n, n, n, 1.0, 1.0, 1.0) }
1227
1228    #[test]
1229    fn test_wrap_angle() {
1230        assert!((wrap_angle(0.0) - 0.0).abs() < 1e-10);
1231        assert!((wrap_angle(PI) - PI).abs() < 1e-10);
1232        assert!((wrap_angle(-PI) - (-PI)).abs() < 1e-10);
1233        assert!((wrap_angle(TWO_PI) - 0.0).abs() < 1e-10);
1234        assert!((wrap_angle(3.0 * PI) - PI).abs() < 1e-10);
1235        assert!((wrap_angle(-3.0 * PI) - (-PI)).abs() < 1e-10);
1236    }
1237
1238    #[test]
1239    fn test_weights_constant_phase() {
1240        // Constant phase should give high weights (phase coherence = 1)
1241        let n = 4;
1242        let phase = vec![0.0; n * n * n];
1243        let mag = vec![1.0; n * n * n];
1244        let mask = vec![1u8; n * n * n];
1245
1246        let weights = calculate_weights_single_echo(&phase, &mag, &mask, n, n, n);
1247
1248        // All interior weights should be 255 (constant phase, uniform magnitude)
1249        let mut high_weight_count = 0;
1250        for &w in weights.iter() {
1251            if w == 255 {
1252                high_weight_count += 1;
1253            }
1254        }
1255        assert!(high_weight_count > 0, "Should have some high weights for constant phase");
1256    }
1257
1258    #[test]
1259    fn test_weights_wrapped_jump() {
1260        // Phase with 2π jump should give low weights at jump location
1261        let n = 4;
1262        let mut phase = vec![0.0; n * n * n];
1263
1264        // Create a 2π jump in x direction at i=2
1265        for i in 2..n {
1266            for j in 0..n {
1267                for k in 0..n {
1268                    phase[idx3d(i, j, k, n, n)] = TWO_PI;
1269                }
1270            }
1271        }
1272
1273        let mask = vec![1u8; n * n * n];
1274        let _weights = calculate_weights_single_echo(&phase, &[], &mask, n, n, n);
1275
1276        // Weight at x=1 to x=2 edge should be low (wrapped difference = 0, but that's ok)
1277        // Actually, for a 2π jump, the wrapped difference is 0, so coherence is 1
1278        // This is correct - ROMEO uses wrapped differences, not raw differences
1279    }
1280
1281    #[test]
1282    fn test_weights_mask() {
1283        // Weights should be 0 where mask is 0
1284        let n = 4;
1285        let phase = vec![0.5; n * n * n];
1286        let mut mask = vec![1u8; n * n * n];
1287
1288        // Set some voxels outside mask
1289        mask[0] = 0;
1290        mask[1] = 0;
1291
1292        let weights = calculate_weights_single_echo(&phase, &[], &mask, n, n, n);
1293
1294        // Edges connected to masked-out voxels should be 0
1295        // Weight at edge (0,0,0)-(1,0,0) should be 0 since idx 0 is masked out
1296        assert_eq!(weights[0], 0);  // x-direction edge at (0,0,0)
1297    }
1298
1299    #[test]
1300    fn test_voxel_quality_constant_phase() {
1301        // Constant phase + uniform magnitude → all quality values should be 100
1302        let n = 4;
1303        let phase = vec![0.0; n * n * n];
1304        let mag = vec![1.0; n * n * n];
1305        let mask = vec![1u8; n * n * n];
1306
1307        let quality = voxel_quality_romeo(&phase, &mag, None, 1.0, 1.0, &mask, &grid(n));
1308
1309        assert_eq!(quality.len(), n * n * n);
1310
1311        // Interior voxels should have high quality (may be < 100 due to
1312        // phaselinearity boundary fallback at edges of small test cube)
1313        let interior_q = quality[idx3d(1, 1, 1, n, n)];
1314        assert!(interior_q > 90.0,
1315                "Interior voxel quality should be >90, got {}", interior_q);
1316    }
1317
1318    #[test]
1319    fn test_voxel_quality_masked() {
1320        // Masked-out voxels should have quality = 0
1321        let n = 4;
1322        let phase = vec![0.0; n * n * n];
1323        let mut mask = vec![1u8; n * n * n];
1324        mask[idx3d(1, 1, 1, n, n)] = 0;
1325
1326        let quality = voxel_quality_romeo(&phase, &[], None, 1.0, 1.0, &mask, &grid(n));
1327
1328        assert_eq!(quality[idx3d(1, 1, 1, n, n)], 0.0,
1329                   "Masked-out voxel should have quality 0");
1330    }
1331
1332    #[test]
1333    fn test_voxel_quality_range() {
1334        // Quality values should be in range [0, 100]
1335        let n = 6;
1336        let phase: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.7).collect();
1337        let mag: Vec<f64> = (0..n * n * n).map(|i| (i as f64) / (n * n * n) as f64).collect();
1338        let mask = vec![1u8; n * n * n];
1339
1340        let quality = voxel_quality_romeo(&phase, &mag, None, 1.0, 1.0, &mask, &grid(n));
1341
1342        for &q in quality.iter() {
1343            assert!(q >= 0.0 && q <= 100.0,
1344                    "Quality should be in [0, 100], got {}", q);
1345        }
1346    }
1347
1348    // =========================================================================
1349    // find_seed_point
1350    // =========================================================================
1351
1352    #[test]
1353    fn test_find_seed_point_full_mask() {
1354        let (nx, ny, nz) = (8, 8, 8);
1355        let mask = vec![1u8; nx * ny * nz];
1356        let (si, sj, sk) = find_seed_point(&mask, nx, ny, nz);
1357        assert_eq!(si, 3);
1358        assert_eq!(sj, 3);
1359        assert_eq!(sk, 3);
1360    }
1361
1362    #[test]
1363    fn test_find_seed_point_empty_mask() {
1364        let (nx, ny, nz) = (8, 8, 8);
1365        let mask = vec![0u8; nx * ny * nz];
1366        let (si, sj, sk) = find_seed_point(&mask, nx, ny, nz);
1367        assert_eq!(si, 4);
1368        assert_eq!(sj, 4);
1369        assert_eq!(sk, 4);
1370    }
1371
1372    // =========================================================================
1373    // unwrap_romeo (3D)
1374    // =========================================================================
1375
1376    #[test]
1377    fn test_unwrap_romeo_3d_smooth() {
1378        let n = 8;
1379        let total = n * n * n;
1380        let phase: Vec<f64> = (0..total).map(|i| {
1381            (i % n) as f64 / n as f64 * 0.5
1382        }).collect();
1383        let mag = vec![1.0; total];
1384        let mask = vec![1u8; total];
1385
1386        let unwrapped = unwrap_romeo(
1387            &phase, &mag, None, 0.0, 0.0,
1388            &mask, &RomeoParams::default(), &grid(n),
1389        );
1390
1391        assert_eq!(unwrapped.len(), total);
1392        for &v in &unwrapped {
1393            assert!(v.is_finite());
1394        }
1395    }
1396
1397    #[test]
1398    fn test_unwrap_romeo_3d_with_wrap() {
1399        // Phase with a 2π wrap that should be removed
1400        let n = 8;
1401        let total = n * n * n;
1402        let mut phase = vec![0.0; total];
1403
1404        // Create a smooth ramp 0..0.7 in x, then add 2π at x>=4
1405        for i in 0..n {
1406            for j in 0..n {
1407                for k in 0..n {
1408                    let idx = idx3d(i, j, k, n, n);
1409                    let val = 0.1 * i as f64;
1410                    phase[idx] = if i >= 4 { val + TWO_PI } else { val };
1411                }
1412            }
1413        }
1414
1415        let mask = vec![1u8; total];
1416        let unwrapped = unwrap_romeo(
1417            &phase, &[], None, 0.0, 0.0,
1418            &mask, &RomeoParams::default(), &grid(n),
1419        );
1420
1421        // After unwrapping, the 2π jump should be removed
1422        // Check that adjacent voxels don't differ by more than π
1423        for j in 0..n {
1424            for k in 0..n {
1425                for i in 1..n {
1426                    let curr = unwrapped[idx3d(i, j, k, n, n)];
1427                    let prev = unwrapped[idx3d(i - 1, j, k, n, n)];
1428                    let diff = (curr - prev).abs();
1429                    assert!(diff < PI + 0.5,
1430                        "Adjacent voxels differ by {:.2} at x={}", diff, i);
1431                }
1432            }
1433        }
1434    }
1435
1436    // =========================================================================
1437    // unwrap_romeo_multi_echo (4D template-based)
1438    // =========================================================================
1439
1440    #[test]
1441    fn test_multi_echo_template_output_sizes() {
1442        let n = 8;
1443        let total = n * n * n;
1444        let tes = [5.0, 10.0, 15.0];
1445        let slope = 0.05;
1446
1447        let phases: Vec<Vec<f64>> = tes.iter().map(|&te| {
1448            (0..total).map(|i| {
1449                let x = (i % n) as f64;
1450                wrap_angle(slope * x * te)
1451            }).collect()
1452        }).collect();
1453        let mags: Vec<Vec<f64>> = tes.iter().map(|_| vec![1.0; total]).collect();
1454        let mask = vec![1u8; total];
1455
1456        let result = unwrap_romeo_multi_echo(
1457            &phases, &mags, &tes, &mask,
1458            &RomeoParams::default(), &grid(n),
1459        );
1460
1461        assert_eq!(result.len(), 3);
1462        for echo in &result {
1463            assert_eq!(echo.len(), total);
1464            for &v in echo {
1465                assert!(v.is_finite());
1466            }
1467        }
1468    }
1469
1470    #[test]
1471    fn test_multi_echo_template_temporal_consistency() {
1472        // After template-based unwrapping, echoes should be temporally consistent:
1473        // phase[e] ≈ phase[0] * TE[e] / TE[0]
1474        let n = 8;
1475        let total = n * n * n;
1476        let tes = [5.0, 10.0, 15.0];
1477        let slope = 0.03; // small slope so no wrapping needed
1478
1479        let phases: Vec<Vec<f64>> = tes.iter().map(|&te| {
1480            (0..total).map(|i| {
1481                let x = (i % n) as f64;
1482                slope * x * te // no wrapping needed
1483            }).collect()
1484        }).collect();
1485        let mags: Vec<Vec<f64>> = tes.iter().map(|_| vec![1.0; total]).collect();
1486        let mask = vec![1u8; total];
1487
1488        let result = unwrap_romeo_multi_echo(
1489            &phases, &mags, &tes, &mask,
1490            &RomeoParams::default(), &grid(n),
1491        );
1492
1493        // Check TE-scaling consistency at interior voxels
1494        for i in 0..total {
1495            if mask[i] > 0 && result[0][i].abs() > 1e-10 {
1496                let ratio_12 = result[1][i] / result[0][i];
1497                let expected_ratio = tes[1] / tes[0];
1498                assert!((ratio_12 - expected_ratio).abs() < 0.5,
1499                    "Echo 1/0 ratio {:.3} vs expected {:.3} at voxel {}",
1500                    ratio_12, expected_ratio, i);
1501            }
1502        }
1503    }
1504
1505    #[test]
1506    fn test_multi_echo_single_echo_fallback() {
1507        let n = 4;
1508        let total = n * n * n;
1509        let phase = vec![0.1; total];
1510        let mask = vec![1u8; total];
1511
1512        let result = unwrap_romeo_multi_echo(
1513            &[&phase[..]], &[] as &[&[f64]], &[5.0], &mask,
1514            &RomeoParams::default(), &grid(n),
1515        );
1516
1517        assert_eq!(result.len(), 1);
1518        assert_eq!(result[0].len(), total);
1519    }
1520
1521    // =========================================================================
1522    // unwrap_romeo_multi_echo (individual mode)
1523    // =========================================================================
1524
1525    #[test]
1526    fn test_multi_echo_individual_mode() {
1527        let n = 8;
1528        let total = n * n * n;
1529        let tes = [5.0, 10.0, 15.0];
1530        let slope = 0.03;
1531
1532        let phases: Vec<Vec<f64>> = tes.iter().map(|&te| {
1533            (0..total).map(|i| {
1534                let x = (i % n) as f64;
1535                slope * x * te
1536            }).collect()
1537        }).collect();
1538        let mags: Vec<Vec<f64>> = tes.iter().map(|_| vec![1.0; total]).collect();
1539        let mask = vec![1u8; total];
1540
1541        let params = RomeoParams {
1542            individual: true,
1543            correct_global: true,
1544            ..Default::default()
1545        };
1546
1547        let result = unwrap_romeo_multi_echo(
1548            &phases, &mags, &tes, &mask,
1549            &params, &grid(n),
1550        );
1551
1552        assert_eq!(result.len(), 3);
1553        for echo in &result {
1554            assert_eq!(echo.len(), total);
1555            for &v in echo {
1556                assert!(v.is_finite());
1557            }
1558        }
1559    }
1560
1561    // =========================================================================
1562    // correct_multi_echo_wraps
1563    // =========================================================================
1564
1565    #[test]
1566    fn test_correct_multi_echo_wraps_no_correction_needed() {
1567        let n = 64;
1568        let tes = [5.0, 10.0, 15.0];
1569        let mask = vec![1u8; n];
1570
1571        // Perfectly scaled echoes: no correction needed
1572        let mut phases: Vec<Vec<f64>> = tes.iter().map(|&te| {
1573            vec![0.1 * te; n]
1574        }).collect();
1575
1576        let original: Vec<Vec<f64>> = phases.iter().map(|p| p.clone()).collect();
1577        correct_multi_echo_wraps(&mut phases, &tes, &mask);
1578
1579        for e in 0..3 {
1580            for i in 0..n {
1581                assert!((phases[e][i] - original[e][i]).abs() < 1e-10,
1582                    "No correction should be applied");
1583            }
1584        }
1585    }
1586
1587    #[test]
1588    fn test_correct_multi_echo_wraps_with_offset() {
1589        let n = 64;
1590        let tes = [5.0, 10.0];
1591        let mask = vec![1u8; n];
1592
1593        // Echo 0: phase = 0.5 for all voxels
1594        // Echo 1: should be 1.0 (TE ratio = 2) but is offset by +2π
1595        let mut phases = vec![
1596            vec![0.5; n],
1597            vec![1.0 + TWO_PI; n],
1598        ];
1599
1600        correct_multi_echo_wraps(&mut phases, &tes, &mask);
1601
1602        // After correction, echo 1 should be close to 1.0
1603        for i in 0..n {
1604            assert!((phases[1][i] - 1.0).abs() < 0.1,
1605                "Expected ~1.0, got {}", phases[1][i]);
1606        }
1607    }
1608
1609    // =========================================================================
1610    // RomeoWeightType
1611    // =========================================================================
1612
1613    #[test]
1614    fn test_weight_type_flags() {
1615        // Romeo: PC + PGC + PL + MC
1616        let f = RomeoWeightType::Romeo.weight_flags();
1617        assert_eq!(f, [true, true, true, true, false, false]);
1618
1619        // Romeo2: PC + MC only
1620        let f = RomeoWeightType::Romeo2.weight_flags();
1621        assert_eq!(f, [true, false, false, true, false, false]);
1622
1623        // Romeo6: all 6
1624        let f = RomeoWeightType::Romeo6.weight_flags();
1625        assert_eq!(f, [true, true, true, true, true, true]);
1626    }
1627
1628    #[test]
1629    fn test_romeo4_is_romeo_alias() {
1630        assert_eq!(
1631            RomeoWeightType::Romeo.weight_flags(),
1632            RomeoWeightType::Romeo4.weight_flags(),
1633        );
1634    }
1635
1636    #[test]
1637    fn test_params_weight_flags_roundtrip() {
1638        // Default params should match Romeo preset
1639        let default_params = RomeoParams::default();
1640        assert_eq!(default_params.weight_flags(), RomeoWeightType::Romeo.weight_flags());
1641        assert!(!default_params.bestpath);
1642
1643        // from_weight_type preserves flags
1644        for wt in [RomeoWeightType::Romeo, RomeoWeightType::Romeo2,
1645                    RomeoWeightType::Romeo3, RomeoWeightType::Romeo6] {
1646            let params = RomeoParams::from_weight_type(wt);
1647            assert_eq!(params.weight_flags(), wt.weight_flags());
1648            assert!(!params.bestpath);
1649        }
1650
1651        // BestPath sets the bestpath flag
1652        let bp = RomeoParams::from_weight_type(RomeoWeightType::BestPath);
1653        assert!(bp.bestpath);
1654    }
1655
1656    #[test]
1657    fn test_params_custom_weight_combination() {
1658        // Custom combination: only phase coherence + mag weight (no preset matches this)
1659        let params = RomeoParams {
1660            phase_coherence: true,
1661            phase_gradient_coherence: false,
1662            phase_linearity: false,
1663            mag_coherence: false,
1664            mag_weight: true,
1665            mag_weight2: false,
1666            ..Default::default()
1667        };
1668        assert_eq!(params.weight_flags(), [true, false, false, false, true, false]);
1669    }
1670
1671    // =========================================================================
1672    // Template selection
1673    // =========================================================================
1674
1675    #[test]
1676    fn test_multi_echo_template_selection() {
1677        let n = 8;
1678        let total = n * n * n;
1679        let tes = [5.0, 10.0, 15.0];
1680        let phases: Vec<Vec<f64>> = tes.iter().map(|&te| {
1681            (0..total).map(|i| 0.02 * (i % n) as f64 * te).collect()
1682        }).collect();
1683        let mags: Vec<Vec<f64>> = tes.iter().map(|_| vec![1.0; total]).collect();
1684        let mask = vec![1u8; total];
1685
1686        // Use echo 2 (index 1) as template
1687        let params = RomeoParams {
1688            template: 1,
1689            ..Default::default()
1690        };
1691
1692        let result = unwrap_romeo_multi_echo(
1693            &phases, &mags, &tes, &mask,
1694            &params, &grid(n),
1695        );
1696
1697        assert_eq!(result.len(), 3);
1698        for echo in &result {
1699            for &v in echo {
1700                assert!(v.is_finite());
1701            }
1702        }
1703    }
1704
1705    // =========================================================================
1706    // Phase linearity
1707    // =========================================================================
1708
1709    #[test]
1710    fn test_phase_linearity_triplet_linear() {
1711        // Perfectly linear: second derivative = 0 → linearity = 1.0
1712        assert!((phase_linearity_triplet(0.1, 0.2, 0.3) - 1.0).abs() < 1e-6);
1713        assert!((phase_linearity_triplet(-0.5, 0.0, 0.5) - 1.0).abs() < 1e-6);
1714    }
1715
1716    #[test]
1717    fn test_phase_linearity_triplet_jump() {
1718        // Large second derivative → low linearity
1719        let pl = phase_linearity_triplet(0.0, 0.0, PI);
1720        assert!(pl < 0.5, "Jump should give low linearity, got {}", pl);
1721    }
1722
1723    // =========================================================================
1724    // BestPath weights
1725    // =========================================================================
1726
1727    #[test]
1728    fn test_bestpath_weights_constant_phase() {
1729        let n = 6;
1730        let total = n * n * n;
1731        let phase = vec![0.0; total];
1732        let mask = vec![1u8; total];
1733
1734        let weights = calculate_weights_bestpath(&phase, &mask, n, n, n);
1735        assert_eq!(weights.len(), 3 * total);
1736
1737        // Constant phase → D=0 → R=0 → edge weight should be low
1738        // (BestPath assigns low weight to ambiguous/constant regions)
1739    }
1740
1741    #[test]
1742    fn test_bestpath_weights_smooth_gradient() {
1743        let n = 8;
1744        let total = n * n * n;
1745        // Small smooth gradient in x
1746        let phase: Vec<f64> = (0..total).map(|i| 0.1 * (i % n) as f64).collect();
1747        let mask = vec![1u8; total];
1748
1749        let weights = calculate_weights_bestpath(&phase, &mask, n, n, n);
1750
1751        // Should produce finite, valid weights
1752        for &w in &weights {
1753            assert!(w <= 255);
1754        }
1755    }
1756
1757    // =========================================================================
1758    // wrap_addition
1759    // =========================================================================
1760
1761    #[test]
1762    fn test_unwrap_with_wrap_addition() {
1763        let n = 8;
1764        let total = n * n * n;
1765        let phase: Vec<f64> = (0..total).map(|i| {
1766            let x = (i % n) as f64;
1767            wrap_angle(0.1 * x)
1768        }).collect();
1769        let mask = vec![1u8; total];
1770
1771        let params = RomeoParams {
1772            wrap_addition: 1.0,
1773            ..Default::default()
1774        };
1775
1776        let unwrapped = unwrap_romeo(
1777            &phase, &[], None, 0.0, 0.0,
1778            &mask, &params, &grid(n),
1779        );
1780
1781        for &v in &unwrapped {
1782            assert!(v.is_finite());
1783        }
1784    }
1785
1786    // =========================================================================
1787    // Multi-seed
1788    // =========================================================================
1789
1790    #[test]
1791    fn test_unwrap_multi_seed() {
1792        let n = 8;
1793        let total = n * n * n;
1794        let phase: Vec<f64> = (0..total).map(|i| {
1795            wrap_angle(0.05 * (i % n) as f64)
1796        }).collect();
1797        let mask = vec![1u8; total];
1798
1799        let params = RomeoParams {
1800            max_seeds: 4,
1801            ..Default::default()
1802        };
1803
1804        let unwrapped = unwrap_romeo(
1805            &phase, &[], None, 0.0, 0.0,
1806            &mask, &params, &grid(n),
1807        );
1808
1809        for &v in &unwrapped {
1810            assert!(v.is_finite());
1811        }
1812    }
1813
1814    #[test]
1815    fn test_unwrap_multi_seed_with_merge() {
1816        let n = 8;
1817        let total = n * n * n;
1818        let phase: Vec<f64> = (0..total).map(|i| {
1819            wrap_angle(0.05 * (i % n) as f64)
1820        }).collect();
1821        let mask = vec![1u8; total];
1822
1823        let params = RomeoParams {
1824            max_seeds: 4,
1825            merge_regions: true,
1826            correct_regions: true,
1827            ..Default::default()
1828        };
1829
1830        let unwrapped = unwrap_romeo(
1831            &phase, &[], None, 0.0, 0.0,
1832            &mask, &params, &grid(n),
1833        );
1834
1835        for &v in &unwrapped {
1836            assert!(v.is_finite());
1837        }
1838    }
1839
1840    // =========================================================================
1841    // correctglobal for 3D
1842    // =========================================================================
1843
1844    #[test]
1845    fn test_correct_global_offset() {
1846        let n = 64;
1847        let mask = vec![1u8; n];
1848        // Phase that's all shifted by 2π
1849        let mut phase: Vec<f64> = vec![TWO_PI + 0.1; n];
1850
1851        correct_global_offset(&mut phase, &mask);
1852
1853        // After correction, should be close to 0.1
1854        for &v in &phase {
1855            assert!((v - 0.1).abs() < 0.2, "Expected ~0.1, got {}", v);
1856        }
1857    }
1858
1859    #[test]
1860    fn test_unwrap_romeo_with_correctglobal() {
1861        let n = 4;
1862        let total = n * n * n;
1863        let phase = vec![0.1; total];
1864        let mask = vec![1u8; total];
1865
1866        let params = RomeoParams {
1867            correct_global: true,
1868            ..Default::default()
1869        };
1870
1871        let unwrapped = unwrap_romeo(
1872            &phase, &[], None, 0.0, 0.0,
1873            &mask, &params, &grid(n),
1874        );
1875
1876        for &v in &unwrapped {
1877            assert!(v.is_finite());
1878        }
1879    }
1880
1881    // =========================================================================
1882    // Weight type: BestPath integration
1883    // =========================================================================
1884
1885    #[test]
1886    fn test_unwrap_romeo_bestpath() {
1887        let n = 8;
1888        let total = n * n * n;
1889        let phase: Vec<f64> = (0..total).map(|i| {
1890            wrap_angle(0.1 * (i % n) as f64)
1891        }).collect();
1892        let mask = vec![1u8; total];
1893
1894        let params = RomeoParams {
1895            bestpath: true,
1896            ..Default::default()
1897        };
1898
1899        let unwrapped = unwrap_romeo(
1900            &phase, &[], None, 0.0, 0.0,
1901            &mask, &params, &grid(n),
1902        );
1903
1904        for &v in &unwrapped {
1905            assert!(v.is_finite());
1906        }
1907    }
1908
1909    // =========================================================================
1910    // rescale_weight
1911    // =========================================================================
1912
1913    #[test]
1914    fn test_rescale_weight() {
1915        assert_eq!(rescale_weight(1.0), 255);    // best
1916        assert_eq!(rescale_weight(0.5), 128);    // mid
1917        assert!(rescale_weight(0.001) >= 1);     // worst valid ≥ 1
1918        assert_eq!(rescale_weight(0.0), 0);      // invalid
1919        assert_eq!(rescale_weight(-0.1), 0);     // invalid
1920    }
1921}