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//! (`romeo`/`unwrap` re-exported from <https://github.com/korbinian90/ROMEO.jl>).
17//!
18//! # Reference pipeline
19//!
20//! Facts from ROMEO.jl (commit 08776d4, 2026-09-02) and MriResearchTools.jl (771eadf), the
21//! authors' code; file:line refer to those trees.
22//!
23//! - **Weights.** `:romeo` (phase coherence, phase gradient coherence, phase linearity,
24//!   magnitude coherence) is the API's default (`src/weights.jl:56,75-76`), which
25//!   [`RomeoParams::default`] matches; `:romeo4` sets the same four flags (`src/weights.jl:81-82`).
26//!   The CLI's `-w romeo` default becomes `romeo3` (no phase linearity) whenever a magnitude is
27//!   given and `romeo4` otherwise (`ext/RomeoApp/caller.jl:49-55`): [`RomeoParams::romeo_cli`].
28//! - **Weight bins.** Each weight in `[0, 1]` goes to the bin `max(round((1 − w)·255), 1)`
29//!   (`rescale`, `src/weights.jl:139-146`); the bins order the growth, so they are ROMEO.jl's here
30//!   (stored as `256 − bin`). An edge is weighted when its first voxel is in the mask
31//!   (`mask[I] && checkbounds(J)`, `src/weights.jl:99-100`), with the magnitude masked, so the
32//!   growth can step one voxel out of the mask along +x, +y or +z (and through such voxels).
33//! - **Multi-echo mode.** Temporal (template) unwrapping is the default: the template echo
34//!   (`template=1`, i.e. index 0 here) is unwrapped spatially, with the echo after it (or before
35//!   it, for a later template) as `phase2` for the gradient-coherence weight, and the others
36//!   temporally outward from it (`src/unwrapping.jl:68-93`). Individual unwrapping is opt-in,
37//!   and the CLI says it "might be necessary if there is large movement (timeseries) or
38//!   phase-offset-correction is not applicable" (`argparse.jl:95-100`). Both match
39//!   [`RomeoParams::default`] (`template: 0`, `individual: false`).
40//! - **`temporal_uncertain_unwrapping`.** 0.5 in the API (`src/unwrapping.jl:52,70`), which
41//!   [`RomeoParams::default`] and CLEAR-SWI's `romeo_combine` use; the CLI's default is 0 and it
42//!   marks the option EXPERIMENTAL (`argparse.jl:165-172`); [`RomeoParams::romeo_cli`] has 0.
43//! - **`correct_global`** is off by default in both the API and the CLI (`src/unwrapping.jl:43`,
44//!   `argparse.jl:129-135`). (QSMxT's Python releases ran the CLI with
45//!   `--individual-unwrapping --correct-global`, `qsmxt/interfaces/nipype_interface_romeo.py:66`;
46//!   that is QSMxT's choice, not ROMEO's default.) Where it applies, as in ROMEO.jl's 3D `unwrap!`:
47//!   to a single volume, to the template echo before the others are unwrapped temporally from
48//!   it, and in individual mode to each echo and then across echoes
49//!   ([`correct_multi_echo_wraps`]); see `unwrap_individual` for the two places (threads and even
50//!   medians) where QSM.rs is deterministic and ROMEO.jl is not.
51//! - **Undefined phase (NaN).** ROMEO.jl takes NaN phase (e.g. MCPC-3D-S's output where its
52//!   offset is undefined, [`crate::utils::phase_offset_removal`]) as "no data": any edge touching
53//!   it gets a NaN weight, which `rescale` makes invalid, so the growth never reaches it and it
54//!   stays NaN; the global corrections skip non-finite voxels. QSM.rs does the same, including
55//!   Julia's NaN-propagating `max(0, x)` in the gradient-coherence and phase-linearity weights
56//!   (phase linearity is 0.5 next to a NaN, as in ROMEO.jl; before 2026-11 it was 0 here).
57//! - **Mask.** The CLI unwraps inside `robustmask` of the template echo's magnitude by default
58//!   (`argparse.jl:38-42`, `caller.jl:201-204`), a magnitude threshold with hole filling
59//!   (MriResearchTools `src/masking.jl:7-29`), not a brain mask; with no magnitude it unwraps
60//!   unmasked. CLEAR-SWI calls ROMEO with no mask at all
61//!   (CLEARSWI.jl `src/phase_processing.jl:122`). The QSM.rs pipeline passes its brain mask.
62//! - **Phase offsets.** When the CLI computes a B0 map from multi-echo data it turns MCPC-3D-S
63//!   phase-offset correction on (monopolar) unless told otherwise (`caller.jl:80-91`), with
64//!   smoothing sigma 7 mm converted to voxels (`argparse.jl:79-91`, `caller.jl:137-141`);
65//!   bipolar correction is opt-in and needs ≥ 3 echoes (`argparse.jl:71-76`). MCPC-3D-S itself
66//!   is in [`crate::utils::phase_offset_removal`]; its defaults are in the module docs of
67//!   `utils::multi_echo`. `calculateB0_unwrapped` states that "the phase offsets have to be
68//!   removed prior" (MriResearchTools `src/romeofunctions.jl:3-7`).
69//! - **Seed.** ROMEO.jl seeds at the voxel whose three forward edges have the best summed bin
70//!   (ties to the highest linear index) and corrects the seed's 2π count from `phase2` with
71//!   multi-echo input, or wraps it into (−π, π] otherwise (`src/seed.jl`). So does every path here
72//!   ([`crate::region_grow::grow_region_unwrap_full`]). Before, the default single-seed path
73//!   seeded at the mask's centre of mass ([`find_seed_point`], no longer used here), which could
74//!   change the global 2π offset and the growth order.
75//! - **Resolution, field strength, orientation.** Nothing in ROMEO depends on them: the weights
76//!   use phase differences between 6-connected neighbours and echo-time ratios only.
77//!
78//! # Agreement with the reference
79//!
80//! In double precision QSM.rs reproduces ROMEO.jl's `unwrap` exactly (the same number of turns at
81//! every voxel) in template mode with the API's and the CLI's settings and in individual mode
82//! with global correction, and MriResearchTools' `mcpc3ds` to 9e-16 rad
83//! (`tests/romeo_romeojl_parity.rs`, a noisy three-echo phantom; ROMEO.jl 08776d4 and
84//! MriResearchTools 771eadf in the QSM-CI `julia-qsm` image `sha256:b3c048ac…`). Given the
85//! CLI's own MCPC-3D-S output on the QSM-CI in-vivo travelling-head scan (Prisma 2.89 T, 1 mm,
86//! five echoes), QSM.rs's template unwrapping gives the CLI's unwrapped echoes exactly.
87//!
88//! `tests/romeo_validity_romeojl_parity.rs` (ROMEO.jl 1.6.0, MriResearchTools 3.8.0, same image, one
89//! Julia thread) adds a phantom whose brain mask reaches past MCPC-3D-S's mask: the NaN validity,
90//! individual mode with and without global correction, and template mode with and without it,
91//! on the corrected and on the raw phase, all give ROMEO.jl's turns at every voxel and its B0 to
92//! 1e-14 (measured 2026-11-04). **Known gap:** template mode's re-unwrapping of uncertain voxels
93//! (`temporal_uncertain_unwrapping > 0`, the API default) is not yet ROMEO.jl's. ROMEO.jl rates the
94//! voxels with `voxelquality` (Float32 weights before binning, no mask, the sum over six edges
95//! divided by six, NaN for an edge touching a NaN) and grows from every voxel it marks visited,
96//! those outside the mask included (`temporal_uncertain_unwrapping!`); `temporal_uncertain_rewrap`
97//! uses [`voxel_quality_romeo`] (binned weights, in-mask neighbours only) and grows from the
98//! certain voxels inside the mask. On that phantom 14 of 3,000 voxels of echoes 2 and 3 differ
99//! after MCPC-3D-S and 850 of 3,392 on the raw phase; the fixture's template cases therefore set
100//! it to 0 (the CLI's default). QSMxT's default (individual mode) does not use it.
101//!
102//! The `romeo` CLI itself reads the NIfTI phase and magnitude as Float32 and computes in single
103//! precision; QSM.rs computes in f64 (kept: double precision throughout, Ashley 2026-10-28).
104//! That alone leaves a residual on the in-vivo scan. The CLI's defaults (`romeo -B` with a brain
105//! mask) against the pipeline with [`RomeoParams::romeo_cli`]: B0 NRMSE 2.9 % (corr 0.9995),
106//! from 2π differences in ~1,250 voxels of the MCPC-3D-S HIP unwrap that propagate through the
107//! offset smoothing (fed the CLI's own single-precision HIP, QSM.rs differs in 36 voxels; fed
108//! its corrected phase, in none). Before the seed, weight bins, edge masking, `mcpc3ds`'s own
109//! mask and smoothing were ROMEO.jl's, the same comparison gave NRMSE 25 %; with individual
110//! unwrapping, global correction, `romeo4` and a 4 mm offset smoothing on both sides it went
111//! from 6.4 % to 1.1 %.
112
113use std::f64::consts::PI;
114
115use crate::region_grow::{grow_region_unwrap_from_visited, grow_region_unwrap_full};
116use crate::Grid;
117#[cfg(feature = "parallel")]
118use rayon::prelude::*;
119
120/// Weight calculation scheme for ROMEO unwrapping.
121///
122/// Matches ROMEO.jl weight type options. See Dymerska et al. (2021) for details.
123#[derive(Clone, Copy, Debug, PartialEq)]
124pub enum RomeoWeightType {
125    /// Phase coherence + phase gradient coherence + phase linearity + mag coherence
126    /// (ROMEO.jl's default `:romeo`, `src/weights.jl:75-76`).
127    Romeo,
128    /// Phase coherence + mag coherence only (no multi-echo temporal info).
129    Romeo2,
130    /// Phase coherence + phase gradient coherence + mag coherence.
131    Romeo3,
132    /// Alias for Romeo.
133    Romeo4,
134    /// All components including magnitude weighting.
135    Romeo6,
136    /// Best-path reliability weights (Abdul-Rahman), used in place of the ROMEO
137    /// weight components. Equivalent to setting [`RomeoParams::bestpath`].
138    ///
139    /// This is still ROMEO's region growing, just differently weighted. For the
140    /// original best-path algorithm, see
141    /// [`unwrap_bestpath`](crate::unwrap::unwrap_bestpath).
142    BestPath,
143}
144
145impl RomeoWeightType {
146    /// Map weight type to 6 component flags matching ROMEO.jl:
147    /// \[phase_coherence, phase_gradient_coherence, phase_linearity,
148    ///  mag_coherence, mag_weight, mag_weight2\]
149    fn weight_flags(&self) -> [bool; 6] {
150        match self {
151            RomeoWeightType::Romeo  => [true, true, true, true, false, false],
152            RomeoWeightType::Romeo2 => [true, false, false, true, false, false],
153            RomeoWeightType::Romeo3 => [true, true, false, true, false, false],
154            RomeoWeightType::Romeo4 => [true, true, true, true, false, false],
155            RomeoWeightType::Romeo6 => [true, true, true, true, true, true],
156            RomeoWeightType::BestPath => [false; 6], // uses different calculation
157        }
158    }
159}
160
161/// Parameters for ROMEO phase unwrapping.
162///
163/// Weight components can be toggled individually. Each multiplies into the
164/// final edge weight as `0.1 + 0.9 * component_value`.
165///
166/// The default enables phase coherence, phase gradient coherence,
167/// phase linearity, and magnitude coherence (equivalent to `:romeo` in ROMEO.jl).
168#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
169#[derive(Clone, Debug)]
170pub struct RomeoParams {
171    // -- Weight component flags --
172
173    /// Phase coherence: `1 - |wrap(Δφ)| / π`. Always recommended. (default: true)
174    pub phase_coherence: bool,
175    /// Phase gradient coherence: consistency between echo pairs. Needs multi-echo
176    /// data (phase2 + TEs). Automatically disabled if unavailable. (default: true)
177    pub phase_gradient_coherence: bool,
178    /// Phase linearity: second-derivative smoothness along each edge. (default: true)
179    pub phase_linearity: bool,
180    /// Magnitude coherence: `(min/max)²` of neighbor magnitudes. (default: true)
181    pub mag_coherence: bool,
182    /// Magnitude weight: penalizes low-signal voxels relative to the 95th
183    /// percentile. (default: false)
184    pub mag_weight: bool,
185    /// Magnitude weight 2: penalizes abnormally high signal (flow artifacts).
186    /// (default: false)
187    pub mag_weight2: bool,
188    /// Use Best-path weights (Abdul-Rahman) instead of ROMEO weights.
189    /// When true, the individual weight flags above are ignored. (default: false)
190    pub bestpath: bool,
191
192    // -- Multi-echo options --
193
194    /// Template echo index for spatial unwrapping, 0-indexed (default: 0).
195    /// Only used for multi-echo template-based unwrapping.
196    pub template: usize,
197    /// Unwrap each echo individually instead of template-based (default: false).
198    pub individual: bool,
199    /// Correct global 2π offsets between echoes (default: false).
200    /// Recommended when using individual mode.
201    pub correct_global: bool,
202    /// Quality threshold for re-unwrapping uncertain voxels after temporal
203    /// unwrapping. Range \[0, 1\], set to 0 to disable. (default: 0.5)
204    pub temporal_uncertain_unwrapping: f64,
205    /// Maximum number of seed regions (default: 1, max: 255).
206    pub max_seeds: u8,
207    /// Merge neighboring regions after unwrapping (default: false).
208    pub merge_regions: bool,
209    /// Correct each region's median to nearest 0 by adding n·2π (default: false).
210    pub correct_regions: bool,
211    /// Additional phase tolerance beyond π for neighbor differences.
212    /// Range \[0, π\]. (default: 0.0)
213    pub wrap_addition: f64,
214}
215
216impl Default for RomeoParams {
217    fn default() -> Self {
218        Self {
219            // Default matches :romeo in ROMEO.jl
220            phase_coherence: true,
221            phase_gradient_coherence: true,
222            phase_linearity: true,
223            mag_coherence: true,
224            mag_weight: false,
225            mag_weight2: false,
226            bestpath: false,
227            template: 0,
228            individual: false,
229            correct_global: false,
230            temporal_uncertain_unwrapping: 0.5,
231            max_seeds: 1,
232            merge_regions: false,
233            correct_regions: false,
234            wrap_addition: 0.0,
235        }
236    }
237}
238
239impl RomeoParams {
240    /// The `romeo` command line's settings for multi-echo data with a magnitude (ROMEO.jl
241    /// 08776d4), where they differ from the API's ([`RomeoParams::default`]):
242    /// - weights `romeo3` (phase coherence, phase gradient coherence, magnitude coherence): the
243    ///   CLI's `-w romeo` default becomes `romeo3` whenever a magnitude is given, and `romeo4`
244    ///   (the API's `:romeo`) only without one (`load_data_and_resolve_args!`,
245    ///   `ext/RomeoApp/caller.jl:49-55`);
246    /// - `temporal_uncertain_unwrapping` 0, the CLI's default (`argparse.jl:165-172`), where the
247    ///   API's is 0.5.
248    ///
249    /// Everything else (template echo 1, temporal unwrapping, no global correction, one seed)
250    /// is the same in both.
251    pub fn romeo_cli() -> Self {
252        Self { phase_linearity: false, temporal_uncertain_unwrapping: 0.0, ..Self::default() }
253    }
254
255    /// Create params matching a ROMEO.jl weight type preset.
256    pub fn from_weight_type(wt: RomeoWeightType) -> Self {
257        if wt == RomeoWeightType::BestPath {
258            return Self { bestpath: true, ..Default::default() };
259        }
260        let f = wt.weight_flags();
261        Self {
262            phase_coherence: f[0],
263            phase_gradient_coherence: f[1],
264            phase_linearity: f[2],
265            mag_coherence: f[3],
266            mag_weight: f[4],
267            mag_weight2: f[5],
268            ..Default::default()
269        }
270    }
271
272    /// Convert the boolean fields to the 6-element flag array used internally.
273    pub fn weight_flags(&self) -> [bool; 6] {
274        [
275            self.phase_coherence,
276            self.phase_gradient_coherence,
277            self.phase_linearity,
278            self.mag_coherence,
279            self.mag_weight,
280            self.mag_weight2,
281        ]
282    }
283}
284
285const TWO_PI: f64 = 2.0 * PI;
286
287/// Wrap angle to [-π, π]
288#[inline]
289fn wrap_angle(angle: f64) -> f64 {
290    let mut a = angle % TWO_PI;
291    if a > PI {
292        a -= TWO_PI;
293    } else if a < -PI {
294        a += TWO_PI;
295    }
296    a
297}
298
299/// Index into a 3D array in Fortran order (column-major, matches NIfTI)
300#[inline(always)]
301fn idx3d(i: usize, j: usize, k: usize, nx: usize, ny: usize) -> usize {
302    i + j * nx + k * nx * ny
303}
304
305/// Calculate ROMEO edge weights for phase unwrapping.
306///
307/// Uses the default Romeo weight type (PC + PGC + PL + MC).
308/// Returns weights array of size 3 * nx * ny * nz in C order \[dim\]\[i\]\[j\]\[k\].
309/// Weights are 1-255 (valid) or 0 (no edge / masked out).
310pub fn calculate_weights_romeo(
311    phase: &[f64],
312    mag: &[f64],
313    phase2: Option<&[f64]>,
314    te1: f64,
315    te2: f64,
316    mask: &[u8],
317    nx: usize, ny: usize, nz: usize,
318) -> Vec<u8> {
319    calculate_weights_romeo_with_flags(
320        phase, mag, phase2, te1, te2, mask, nx, ny, nz,
321        RomeoWeightType::Romeo.weight_flags(),
322    )
323}
324
325/// Calculate ROMEO edge weights with configurable weight components.
326///
327/// Legacy 3-flag interface for backward compatibility.
328pub fn calculate_weights_romeo_configurable(
329    phase: &[f64],
330    mag: &[f64],
331    phase2: Option<&[f64]>,
332    te1: f64,
333    te2: f64,
334    mask: &[u8],
335    nx: usize, ny: usize, nz: usize,
336    use_phase_gradient_coherence: bool,
337    use_mag_coherence: bool,
338    use_mag_weight: bool,
339) -> Vec<u8> {
340    let flags = [
341        true,                           // phase coherence (always on)
342        use_phase_gradient_coherence,
343        false,                          // phase linearity (use weight_type for this)
344        use_mag_coherence,
345        use_mag_weight,
346        false,                          // mag_weight2
347    ];
348    calculate_weights_romeo_with_flags(
349        phase, mag, phase2, te1, te2, mask, nx, ny, nz, flags,
350    )
351}
352
353/// Calculate ROMEO edge weights with full 6-component flag control.
354///
355/// Matches ROMEO.jl `calculateweights_romeo`. The 6 flags are:
356/// 0: phase coherence, 1: phase gradient coherence, 2: phase linearity,
357/// 3: magnitude coherence, 4: magnitude weight, 5: magnitude weight 2.
358///
359/// Each component is scaled as `0.1 + 0.9 * value` (matching ROMEO.jl) to prevent
360/// any single component from zeroing the weight entirely.
361///
362/// Weights are stored as u8: 0 = no edge, 1 = worst valid, 255 = best.
363pub fn calculate_weights_romeo_with_flags(
364    phase: &[f64],
365    mag: &[f64],
366    phase2: Option<&[f64]>,
367    te1: f64,
368    te2: f64,
369    mask: &[u8],
370    nx: usize, ny: usize, nz: usize,
371    flags: [bool; 6],
372) -> Vec<u8> {
373    let n_total = nx * ny * nz;
374    let mut weights = vec![0u8; 3 * n_total];
375
376    let has_mag = !mag.is_empty();
377    let has_phase2 = phase2.is_some();
378
379    // Disable magnitude flags if no magnitude data
380    let f_mc  = flags[3] && has_mag;
381    let f_mw  = flags[4] && has_mag;
382    let f_mw2 = flags[5] && has_mag;
383    // Disable PGC if no phase2/TEs
384    let f_pgc = flags[1] && has_phase2;
385    let f_pl  = flags[2];
386
387    // 95th percentile of magnitude for normalization (matching ROMEO.jl)
388    let max_mag = if has_mag && (f_mw || f_mw2) {
389        percentile_95(mag, mask)
390    } else {
391        1.0
392    };
393    let half_max_mag = 0.5 * max_mag + 1e-12;
394
395    for dim in 0..3_usize {
396        // Each dim writes a disjoint n_total block; voxels within it are
397        // independent, so fan out across voxels (rayon under `parallel`).
398        let block = &mut weights[dim * n_total..(dim + 1) * n_total];
399        maybe_par_iter_mut!(block).enumerate().for_each(|(idx, w)| {
400            let i = idx % nx;
401            let j = (idx / nx) % ny;
402            let k = idx / (nx * ny);
403            let (ni, nj, nk) = match dim {
404                0 => (i + 1, j, k),
405                1 => (i, j + 1, k),
406                _ => (i, j, k + 1),
407            };
408
409            if ni >= nx || nj >= ny || nk >= nz {
410                return;
411            }
412
413            let idx_n = idx3d(ni, nj, nk, nx, ny);
414
415            // ROMEO.jl tests only the edge's first voxel (`mask[I] && checkbounds(J)`,
416            // `src/weights.jl:99-100`), so an edge from the mask's last voxel to the next one
417            // outside it is valid (weak: the magnitude is masked, so its magnitude coherence is
418            // 0) and the growth can step one voxel out of the mask in +x, +y or +z, and back
419            // in through such voxels.
420            if mask[idx] == 0 {
421                return;
422            }
423
424            let mut weight = 1.0_f64;
425
426            // 1. Phase coherence: 1 - |wrap(diff)| / π
427            if flags[0] {
428                let pc = 1.0 - gamma(phase[idx_n] - phase[idx]).abs() / PI;
429                weight *= 0.1 + 0.9 * pc;
430            }
431
432            // 2. Phase gradient coherence (multi-echo)
433            if f_pgc {
434                let phase2_data = phase2.unwrap();
435                let wrapped_p1 = gamma(phase[idx_n] - phase[idx]);
436                let wrapped_p2 = gamma(phase2_data[idx_n] - phase2_data[idx]);
437                // ROMEO.jl: γ(P2[i] - P2[j]) * TEs[1] / TEs[2], left to right; its
438                // `max(0, x)` keeps a NaN (Julia's `max` propagates NaN, Rust's `f64::max` drops
439                // it), so a NaN phase2 makes the edge invalid there and here.
440                let pgc = julia_max0(1.0 - (wrapped_p1 - wrapped_p2 * te1 / te2).abs());
441                weight *= 0.1 + 0.9 * pgc;
442            }
443
444            // 3. Phase linearity: product of two triplet linearities
445            if f_pl {
446                let pl = phase_linearity_edge(phase, i, j, k, ni, nj, nk, dim, nx, ny, nz);
447                weight *= 0.1 + 0.9 * pl;
448            }
449
450            if has_mag {
451                // ROMEO.jl masks the magnitude (`mag .* mask`, `parsekwargs`)
452                let m1 = mag[idx];
453                let m2 = if mask[idx_n] != 0 { mag[idx_n] } else { 0.0 };
454                let small = m1.min(m2);
455                let big = m1.max(m2);
456
457                // 4. Magnitude coherence: (min/max)²
458                if f_mc {
459                    // 0/0 is NaN in ROMEO.jl, which makes the edge invalid
460                    let mc = if big > 0.0 { (small / big).powi(2) } else { f64::NAN };
461                    weight *= 0.1 + 0.9 * mc;
462                }
463
464                // 5. Magnitude weight: penalize low signal
465                if f_mw {
466                    let mw = 0.5 + 0.5 * (small / half_max_mag).min(1.0);
467                    weight *= 0.1 + 0.9 * mw;
468                }
469
470                // 6. Magnitude weight 2: penalize too-high signal (flow artifacts)
471                if f_mw2 {
472                    let mw2 = 0.5 + 0.5 * (half_max_mag / big.max(1e-12)).min(1.0);
473                    weight *= 0.1 + 0.9 * mw2;
474                }
475            }
476
477            // Rescale to u8: min valid = 1, best = 255, 0 = no edge
478            *w = rescale_weight(weight);
479        });
480    }
481
482    weights
483}
484
485/// Phase linearity for an edge between two voxels.
486///
487/// Computes the product of two triplet linearities: one looking "behind" the edge
488/// and one looking "ahead". Matches ROMEO.jl `phaselinearity(P, i, j)`.
489fn phase_linearity_edge(
490    phase: &[f64],
491    i: usize, j: usize, k: usize,
492    ni: usize, nj: usize, nk: usize,
493    _dim: usize,
494    nx: usize, ny: usize, nz: usize,
495) -> f64 {
496    let idx = idx3d(i, j, k, nx, ny);
497    let idx_n = idx3d(ni, nj, nk, nx, ny);
498
499    // "Behind" triplet: (h, idx, idx_n) where h = 2*idx_pos - idx_n_pos
500    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);
501    let pl1 = if hi >= 0 && hi < nx as i32 && hj >= 0 && hj < ny as i32 && hk >= 0 && hk < nz as i32 {
502        let h_idx = idx3d(hi as usize, hj as usize, hk as usize, nx, ny);
503        phase_linearity_triplet(phase[h_idx], phase[idx], phase[idx_n])
504    } else {
505        0.9
506    };
507
508    // "Ahead" triplet: (idx, idx_n, k) where k = 2*idx_n_pos - idx_pos
509    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);
510    let pl2 = if ki >= 0 && ki < nx as i32 && kj >= 0 && kj < ny as i32 && kk >= 0 && kk < nz as i32 {
511        let k_idx = idx3d(ki as usize, kj as usize, kk as usize, nx, ny);
512        phase_linearity_triplet(phase[idx], phase[idx_n], phase[k_idx])
513    } else {
514        0.9
515    };
516
517    pl1 * pl2
518}
519
520/// Phase linearity of three consecutive phase values.
521///
522/// `max(0, 1 - |wrap(a - 2b + c) / 2|)` — measures how linear the phase is.
523/// Matches ROMEO.jl `phaselinearity(P, i, j, k)`.
524#[inline]
525fn phase_linearity_triplet(a: f64, b: f64, c: f64) -> f64 {
526    let second_deriv = wrap_angle(a - 2.0 * b + c);
527    // `max(0, …)` with Julia's NaN propagation, then ROMEO.jl's `isnan(pl) && return 0.5`: a NaN
528    // phase on either side of an edge (e.g. where MCPC-3D-S's offset is undefined) gives 0.5.
529    // With Rust's `f64::max` the NaN became 0 and the 0.5 branch was dead (fixed 2026-11).
530    let pl = julia_max0(1.0 - (second_deriv / 2.0).abs());
531    if pl.is_nan() { 0.5 } else { pl }
532}
533
534/// ROMEO.jl's `γ` (`src/utility.jl`): one wrap only, `x ± 2π` when `x` is outside [−π, π].
535/// For the difference of two wrapped phases it is the full wrap; for an unwrapped `phase2` (the
536/// previous echo in individual mode) it is not, and ROMEO.jl's weights are this one's.
537#[inline]
538fn gamma(x: f64) -> f64 {
539    if x < -PI {
540        x + TWO_PI
541    } else if x > PI {
542        x - TWO_PI
543    } else {
544        x
545    }
546}
547
548/// Julia's `max(0, x)`: NaN stays NaN (Rust's `f64::max(0.0)` would return 0).
549#[inline]
550fn julia_max0(x: f64) -> f64 {
551    if x.is_nan() { x } else { x.max(0.0) }
552}
553
554/// Rescale a weight in `[0, 1]` (1 best) to the queue priority, as ROMEO.jl bins it.
555///
556/// ROMEO.jl's `rescale` (`src/weights.jl:139-146`, 08776d4) maps `w` to the bin
557/// `max(round((1 − w)·255), 1)` (1 best, 255 worst, Julia's round-half-to-even) and anything
558/// outside `[0, 1]` to 0 (invalid). Here the priority is `256 − bin`, so 255 is best, 1 worst
559/// and 0 invalid, with exactly ROMEO.jl's bins (a weight of 0 is the worst valid edge, as there).
560/// The bins decide the growth order, so they must be ROMEO.jl's for the same result.
561#[inline]
562fn rescale_weight(w: f64) -> u8 {
563    if (0.0..=1.0).contains(&w) {
564        let bin = (((1.0 - w) * 255.0).round_ties_even() as i64).max(1);
565        (256 - bin) as u8
566    } else {
567        0
568    }
569}
570
571/// Compute 95th percentile of magnitude within mask (matching ROMEO.jl).
572fn percentile_95(mag: &[f64], mask: &[u8]) -> f64 {
573    let mut values: Vec<f64> = mag.iter().enumerate()
574        .filter(|(i, v)| mask[*i] > 0 && v.is_finite())
575        .map(|(_, &v)| v)
576        .collect();
577    if values.is_empty() {
578        return 1.0;
579    }
580    values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
581    let idx = ((values.len() as f64 * 0.95) as usize).min(values.len() - 1);
582    values[idx]
583}
584
585/// Calculate BestPath edge weights.
586///
587/// Uses second-order phase differences across all neighbor directions
588/// (Abdul-Rahman, https://doi.org/10.1364/AO.46.006623).
589/// Matches ROMEO.jl `calculateweights_bestpath`.
590pub fn calculate_weights_bestpath(
591    phase: &[f64],
592    mask: &[u8],
593    nx: usize, ny: usize, nz: usize,
594) -> Vec<u8> {
595    let n_total = nx * ny * nz;
596
597    // Compute D² for each voxel: sum of squared wrapped second-order differences
598    // across all unique neighbor offsets from 26-connected directions
599    let strides = [1_i64, nx as i64, (nx * ny) as i64];
600    let mut neighbor_offsets: Vec<i64> = Vec::new();
601    for dx in -1i64..=1 {
602        for dy in -1i64..=1 {
603            for dz in -1i64..=1 {
604                if dx == 0 && dy == 0 && dz == 0 { continue; }
605                let offset = (dx * strides[0] + dy * strides[1] + dz * strides[2]).abs();
606                if !neighbor_offsets.contains(&offset) {
607                    neighbor_offsets.push(offset);
608                }
609            }
610        }
611    }
612
613    let mut d2 = vec![0.0_f64; n_total];
614    for &n in &neighbor_offsets {
615        let nu = n as usize;
616        for i in nu..(n_total - nu) {
617            let v1 = wrap_angle(phase[i - nu] - phase[i]);
618            let v2 = wrap_angle(phase[i] - phase[i + nu]);
619            let diff = v1 - v2;
620            d2[i] += diff * diff;
621        }
622    }
623
624    // R = 1 / sqrt(D²)
625    let r: Vec<f64> = d2.iter().map(|&v| {
626        let d = v.sqrt();
627        if d > 1e-12 { 1.0 / d } else { 0.0 }
628    }).collect();
629
630    // Edge weights: R[i] + R[i+n] for each direction
631    let mut weights = vec![0u8; 3 * n_total];
632    for dim in 0..3_usize {
633        for i in 0..nx {
634            for j in 0..ny {
635                for k in 0..nz {
636                    let (ni, nj, nk) = match dim {
637                        0 => (i + 1, j, k),
638                        1 => (i, j + 1, k),
639                        _ => (i, j, k + 1),
640                    };
641                    if ni >= nx || nj >= ny || nk >= nz { continue; }
642
643                    let idx = idx3d(i, j, k, nx, ny);
644                    let idx_n = idx3d(ni, nj, nk, nx, ny);
645
646                    if mask[idx] == 0 || mask[idx_n] == 0 { continue; }
647
648                    let w = r[idx] + r[idx_n];
649                    // Scale: lower w = more consistent = higher weight
650                    // ROMEO.jl: scale(w) = min(max(round((1 - w/10) * 255), 1), 255)
651                    let scaled = ((1.0 - w / 10.0) * 255.0).round().clamp(1.0, 255.0) as u8;
652                    let edge_idx = dim * n_total + idx3d(i, j, k, nx, ny);
653                    weights[edge_idx] = scaled;
654                }
655            }
656        }
657    }
658
659    weights
660}
661
662/// Simplified weight calculation for single-echo data (no phase2)
663pub fn calculate_weights_single_echo(
664    phase: &[f64],
665    mag: &[f64],
666    mask: &[u8],
667    nx: usize, ny: usize, nz: usize,
668) -> Vec<u8> {
669    calculate_weights_romeo(phase, mag, None, 1.0, 1.0, mask, nx, ny, nz)
670}
671
672/// Calculate per-voxel quality map from ROMEO edge weights
673///
674/// Computes ROMEO edge weights and then aggregates them per-voxel by averaging
675/// the incident edge weights across all 6 neighboring directions (±x, ±y, ±z).
676/// This produces a quality map where high values indicate voxels with coherent
677/// phase and magnitude, suitable for thresholding into a brain mask.
678///
679/// Reference: MriResearchTools.jl `romeovoxelquality()` function
680///
681/// # Arguments
682/// * `phase` - Wrapped phase data (nx * ny * nz), first echo
683/// * `mag` - Magnitude data (nx * ny * nz), optional (pass empty slice if none)
684/// * `phase2` - Second echo phase for gradient coherence (optional)
685/// * `te1`, `te2` - Echo times for gradient coherence scaling
686/// * `mask` - Binary mask (nx * ny * nz), 1 = process
687/// * `nx`, `ny`, `nz` - Array dimensions
688///
689/// # Returns
690/// Quality map of size nx * ny * nz with values in range [0, 100]
691#[allow(clippy::erasing_op)]
692pub fn voxel_quality_romeo(
693    phase: &[f64],
694    mag: &[f64],
695    phase2: Option<&[f64]>,
696    te1: f64,
697    te2: f64,
698    mask: &[u8],
699    grid: &Grid,
700) -> Vec<f64> {
701    let (nx, ny, nz) = grid.dims;
702    let n_total = nx * ny * nz;
703    let weights = calculate_weights_romeo(phase, mag, phase2, te1, te2, mask, nx, ny, nz);
704
705    let mut quality = vec![0.0_f64; n_total];
706
707    // Each voxel's quality is an independent read of neighbouring edge
708    // weights — fan out across voxels (rayon under the `parallel` feature).
709    maybe_par_iter_mut!(quality).enumerate().for_each(|(idx, q)| {
710        if mask[idx] == 0 {
711            return;
712        }
713        let i = idx % nx;
714        let j = (idx / nx) % ny;
715        let k = idx / (nx * ny);
716
717        let mut sum = 0.0_f64;
718        let mut count = 0u32;
719
720        // +x edge: stored at (i, j, k) in dim=0
721        if i + 1 < nx && mask[idx3d(i + 1, j, k, nx, ny)] != 0 {
722            sum += weights[idx] as f64;
723            count += 1;
724        }
725        // -x edge: stored at (i-1, j, k) in dim=0
726        if i > 0 && mask[idx3d(i - 1, j, k, nx, ny)] != 0 {
727            sum += weights[idx3d(i - 1, j, k, nx, ny)] as f64;
728            count += 1;
729        }
730        // +y edge: stored at (i, j, k) in dim=1
731        if j + 1 < ny && mask[idx3d(i, j + 1, k, nx, ny)] != 0 {
732            sum += weights[n_total + idx] as f64;
733            count += 1;
734        }
735        // -y edge: stored at (i, j-1, k) in dim=1
736        if j > 0 && mask[idx3d(i, j - 1, k, nx, ny)] != 0 {
737            sum += weights[n_total + idx3d(i, j - 1, k, nx, ny)] as f64;
738            count += 1;
739        }
740        // +z edge: stored at (i, j, k) in dim=2
741        if k + 1 < nz && mask[idx3d(i, j, k + 1, nx, ny)] != 0 {
742            sum += weights[2 * n_total + idx] as f64;
743            count += 1;
744        }
745        // -z edge: stored at (i, j, k-1) in dim=2
746        if k > 0 && mask[idx3d(i, j, k - 1, nx, ny)] != 0 {
747            sum += weights[2 * n_total + idx3d(i, j, k - 1, nx, ny)] as f64;
748            count += 1;
749        }
750
751        if count > 0 {
752            // Normalize from 0-255 to 0-1, then scale to 0-100
753            *q = (sum / count as f64) / 255.0 * 100.0;
754        }
755    });
756
757    quality
758}
759
760// =========================================================================
761// Phase unwrapping functions
762// =========================================================================
763
764/// Find a seed point for region growing: the centre of mass of the mask, or - when that
765/// falls outside the mask, as it does for a disconnected or concave one - the nearest
766/// voxel that is inside it.
767pub fn find_seed_point(mask: &[u8], nx: usize, ny: usize, nz: usize) -> (usize, usize, usize) {
768    let mut sum_i = 0usize;
769    let mut sum_j = 0usize;
770    let mut sum_k = 0usize;
771    let mut count = 0usize;
772
773    for k in 0..nz {
774        for j in 0..ny {
775            for i in 0..nx {
776                let idx = idx3d(i, j, k, nx, ny);
777                if mask[idx] > 0 {
778                    sum_i += i;
779                    sum_j += j;
780                    sum_k += k;
781                    count += 1;
782                }
783            }
784        }
785    }
786
787    if count == 0 {
788        return (nx / 2, ny / 2, nz / 2);
789    }
790
791    let (ci, cj, ck) = (sum_i / count, sum_j / count, sum_k / count);
792    if mask[idx3d(ci, cj, ck, nx, ny)] > 0 {
793        return (ci, cj, ck);
794    }
795
796    // The centre of mass of a mask that is disconnected or merely concave can fall outside
797    // it - two hemispheres, a horseshoe - and the region growers treat a seed outside the
798    // mask as nothing to do and return the input still wrapped. Fall back to the nearest
799    // voxel that is actually in the mask. For a mask whose centre of mass is inside, which
800    // is the usual case, nothing above this line changes.
801    let mut best = (ci, cj, ck);
802    let mut best_d2 = f64::INFINITY;
803    for k in 0..nz {
804        for j in 0..ny {
805            for i in 0..nx {
806                if mask[idx3d(i, j, k, nx, ny)] == 0 {
807                    continue;
808                }
809                let d2 = (i as f64 - ci as f64).powi(2)
810                    + (j as f64 - cj as f64).powi(2)
811                    + (k as f64 - ck as f64).powi(2);
812                if d2 < best_d2 {
813                    best_d2 = d2;
814                    best = (i, j, k);
815                }
816            }
817        }
818    }
819    best
820}
821
822/// Compute weights from params (dispatches BestPath vs ROMEO).
823fn compute_weights_from_params(
824    params: &RomeoParams,
825    phase: &[f64],
826    mag: &[f64],
827    phase2: Option<&[f64]>,
828    te1: f64, te2: f64,
829    mask: &[u8],
830    nx: usize, ny: usize, nz: usize,
831) -> Vec<u8> {
832    if params.bestpath {
833        calculate_weights_bestpath(phase, mask, nx, ny, nz)
834    } else {
835        calculate_weights_romeo_with_flags(
836            phase, mag, phase2, te1, te2, mask, nx, ny, nz,
837            params.weight_flags(),
838        )
839    }
840}
841
842/// Unwrap a single 3D phase volume using ROMEO.
843///
844/// Computes ROMEO edge weights and performs region-growing phase unwrapping.
845///
846/// # Arguments
847/// * `phase` - Wrapped phase data (nx * ny * nz)
848/// * `mag` - Magnitude data (pass empty slice if none)
849/// * `phase2` - Optional second echo phase for gradient coherence weights
850/// * `te1`, `te2` - Echo times for phase gradient coherence scaling
851/// * `mask` - Binary mask (1 = process)
852/// * `params` - ROMEO parameters (weight_type used for weight calculation)
853/// * `nx`, `ny`, `nz` - Array dimensions
854///
855/// # Returns
856/// Unwrapped phase (same size as input)
857pub fn unwrap_romeo(
858    phase: &[f64],
859    mag: &[f64],
860    phase2: Option<&[f64]>,
861    te1: f64,
862    te2: f64,
863    mask: &[u8],
864    params: &RomeoParams,
865    grid: &Grid,
866) -> Vec<f64> {
867    let (nx, ny, nz) = grid.dims;
868    let weights = compute_weights_from_params(
869        params, phase, mag, phase2, te1, te2,
870        mask, nx, ny, nz,
871    );
872
873    let mut unwrapped = phase.to_vec();
874    let n_total = nx * ny * nz;
875
876    // ROMEO.jl's region growing (`grow_region_unwrap!`), seeded as ROMEO.jl seeds (see the
877    // module docs, *Seed*), for one seed as for several.
878    let mut visited = vec![0u8; n_total];
879    let num_regions = grow_region_unwrap_full(
880        &mut unwrapped, &weights, mask, &mut visited,
881        nx, ny, nz, params.wrap_addition, params.max_seeds,
882        phase2, if phase2.is_some() { Some((te1, te2)) } else { None },
883    );
884
885    finish_3d_unwrap(&mut unwrapped, &mut visited, num_regions, &weights, mask, params, nx, ny, nz);
886    unwrapped
887}
888
889/// What ROMEO.jl's 3D `unwrap!` does after the growth (`src/unwrapping.jl`, `src/algorithm.jl`):
890/// region merging and correction, then, with `correct_global`, `wrapped .-= 2π·median(round(φ[mask]
891/// / 2π))`. Its multi-echo modes call that same 3D `unwrap!` for the template echo and for every
892/// echo in individual mode, so all three paths finish here.
893#[allow(clippy::too_many_arguments)]
894fn finish_3d_unwrap(
895    unwrapped: &mut [f64],
896    visited: &mut [u8],
897    num_regions: u8,
898    weights: &[u8],
899    mask: &[u8],
900    params: &RomeoParams,
901    nx: usize, ny: usize, nz: usize,
902) {
903    if params.merge_regions && num_regions > 1 {
904        let remaining = merge_regions_post(
905            unwrapped, visited, num_regions, weights, nx, ny, nz,
906        );
907        if params.correct_regions {
908            // correct_regions on remaining regions
909            for &r in &remaining {
910                correct_regions(unwrapped, visited, r);
911            }
912        }
913    } else if params.correct_regions && num_regions > 0 {
914        correct_regions(unwrapped, visited, num_regions);
915    }
916
917    // correctglobal for 3D: subtract median n·2π offset
918    if params.correct_global {
919        correct_global_offset(unwrapped, mask);
920    }
921}
922
923/// Unwrap 4D multi-echo phase data using ROMEO.
924///
925/// Supports two modes matching ROMEO.jl:
926///
927/// **Template-based** (default, `individual=false`):
928/// Spatially unwraps one template echo, then temporally unwraps all others
929/// by scaling with TE ratios. Optionally re-unwraps uncertain voxels spatially.
930///
931/// **Individual** (`individual=true`):
932/// Spatially unwraps each echo independently, then optionally corrects global
933/// 2π offsets between echoes using median wrap counting.
934///
935/// # Arguments
936/// * `phases` - Wrapped phase for each echo \[n_echoes\]\[nx*ny*nz\]
937/// * `mags` - Magnitude for each echo (pass empty `&[]` if none)
938/// * `tes` - Echo times (units don't matter, only ratios are used)
939/// * `mask` - Binary mask (nx * ny * nz)
940/// * `params` - ROMEO parameters
941/// * `nx`, `ny`, `nz` - Array dimensions
942///
943/// # Returns
944/// Unwrapped phase for each echo
945pub fn unwrap_romeo_multi_echo<P: AsRef<[f64]> + Sync, M: AsRef<[f64]> + Sync>(
946    phases: &[P],
947    mags: &[M],
948    tes: &[f64],
949    mask: &[u8],
950    params: &RomeoParams,
951    grid: &Grid,
952) -> Vec<Vec<f64>> {
953    let (nx, ny, nz) = grid.dims;
954    let n_echoes = phases.len();
955    assert!(n_echoes > 0, "phases must have at least one echo");
956    assert_eq!(n_echoes, tes.len(), "phases and tes must have same length");
957
958    if n_echoes == 1 {
959        let mag = if mags.is_empty() { &[] as &[f64] } else { mags[0].as_ref() };
960        return vec![unwrap_romeo(
961            phases[0].as_ref(), mag, None, 0.0, 0.0,
962            mask, params, grid,
963        )];
964    }
965
966    if params.individual {
967        unwrap_individual(phases, mags, tes, mask, params, nx, ny, nz)
968    } else {
969        unwrap_template(phases, mags, tes, mask, params, nx, ny, nz)
970    }
971}
972
973// =========================================================================
974// Template-based multi-echo unwrapping
975// =========================================================================
976
977/// Template-based multi-echo unwrapping.
978///
979/// 1. Spatially unwrap the template echo using ROMEO weights
980/// 2. Temporally unwrap all other echoes outward from the template
981/// 3. Optionally re-unwrap uncertain voxels spatially
982fn unwrap_template<P: AsRef<[f64]>, M: AsRef<[f64]>>(
983    phases: &[P],
984    mags: &[M],
985    tes: &[f64],
986    mask: &[u8],
987    params: &RomeoParams,
988    nx: usize, ny: usize, nz: usize,
989) -> Vec<Vec<f64>> {
990    let n_echoes = phases.len();
991    let n_total = nx * ny * nz;
992    let template = params.template.min(n_echoes - 1);
993
994    // Select phase2 reference (matching ROMEO.jl p2ref default)
995    let p2ref = if template == 0 { 1 } else { template - 1 };
996
997    // Calculate weights using template echo + p2ref
998    let template_mag = if mags.is_empty() { &[] as &[f64] } else { mags[template].as_ref() };
999    let weights = compute_weights_from_params(
1000        params,
1001        phases[template].as_ref(),
1002        template_mag,
1003        Some(phases[p2ref].as_ref()),
1004        tes[template], tes[p2ref],
1005        mask, nx, ny, nz,
1006    );
1007
1008    // Spatially unwrap template echo
1009    let mut result: Vec<Vec<f64>> = phases.iter().map(|p| p.as_ref().to_vec()).collect();
1010    // ROMEO.jl: `unwrap!(view(wrapped, :, :, :, template); args..., weights)` with phase2 and
1011    // TEs, so the seed's 2π count is set from phase2 (`seedcorrection!`).
1012    let mut visited = vec![0u8; n_total];
1013    let num_regions = grow_region_unwrap_full(
1014        &mut result[template], &weights, mask, &mut visited,
1015        nx, ny, nz, params.wrap_addition, params.max_seeds,
1016        Some(phases[p2ref].as_ref()), Some((tes[template], tes[p2ref])),
1017    );
1018    // The rest of that 3D `unwrap!`: region handling and, with `correct_global`, the template's
1019    // global turn (until 2026-11 neither ran on the template echo here).
1020    finish_3d_unwrap(&mut result[template], &mut visited, num_regions, &weights, mask, params, nx, ny, nz);
1021
1022    // Temporally unwrap other echoes outward from template
1023    // Order: template-1, template-2, ..., 0, template+1, template+2, ..., n-1
1024    // (matching ROMEO.jl: [(template-1):-1:1; (template+1):length(TEs)])
1025    let echo_order: Vec<usize> = (0..template).rev()
1026        .chain((template + 1)..n_echoes)
1027        .collect();
1028
1029    for ieco in echo_order {
1030        let iref = if ieco < template { ieco + 1 } else { ieco - 1 };
1031        let te_ratio = tes[ieco] / tes[iref];
1032
1033        // Temporal unwrap each voxel: align to TE-scaled reference
1034        for i in 0..n_total {
1035            if mask[i] > 0 {
1036                let ref_value = result[iref][i] * te_ratio;
1037                result[ieco][i] = unwrap_voxel(result[ieco][i], ref_value);
1038            }
1039        }
1040
1041        // Fallback: re-unwrap uncertain voxels spatially
1042        if params.temporal_uncertain_unwrapping > 0.0 {
1043            // Build scaled reference for quality assessment
1044            let ref_scaled: Vec<f64> = (0..n_total).map(|i| {
1045                if mask[i] > 0 { result[iref][i] * te_ratio } else { 0.0 }
1046            }).collect();
1047
1048            temporal_uncertain_rewrap(
1049                &mut result[ieco],
1050                &ref_scaled,
1051                &weights,
1052                mask,
1053                params.temporal_uncertain_unwrapping,
1054                nx, ny, nz,
1055            );
1056        }
1057    }
1058
1059    result
1060}
1061
1062// =========================================================================
1063// Individual multi-echo unwrapping
1064// =========================================================================
1065
1066/// Individual multi-echo unwrapping: ROMEO.jl's `unwrap_individual!` (`src/unwrapping.jl`,
1067/// v1.6.0).
1068///
1069/// Each echo is spatially unwrapped on its own, with its own magnitude and the previous echo (echo
1070/// 2 for echo 1) as `phase2` for the gradient-coherence weight and the seed's turn. With
1071/// `correct_global`, as in ROMEO.jl, two corrections follow:
1072/// 1. per echo, inside the 3D `unwrap!` that `unwrap_individual!` calls with all its keyword
1073///    arguments: `wrapped .-= 2π·median(round(φ[mask] / 2π))` (finite voxels only), so each
1074///    echo's median turn is 0;
1075/// 2. then across echoes, `correct_multi_echo_wraps!` ([`correct_multi_echo_wraps`]).
1076///
1077/// Step 1 was missing here until 2026-11; on the data where it was measured (Q0748 audit,
1078/// workstream C) every echo's median turn was already 0, so it changed nothing there, but in
1079/// general it sets echo 1's global turn and with it every echo's.
1080///
1081/// **`phase2` and threads.** ROMEO.jl runs the echoes in a `Threads.@threads` loop and reads
1082/// `phase2 = wrapped[:,:,:,e2]` while the loop unwraps `wrapped` in place, so whether echo `i`
1083/// sees echo `i − 1` wrapped or already unwrapped depends on the thread schedule (≈1 % of voxels of
1084/// a 16-echo 7 T scan's last echo varied between runs; Q0748 audit). Here the echoes run in order,
1085/// which is ROMEO.jl with one thread, the deterministic reading of its code: echo `i ≥ 2` sees
1086/// echo `i − 1` unwrapped (and globally corrected), echo 1 sees echo 2 wrapped. It matters beyond
1087/// the seed's turn because ROMEO.jl's `γ` wraps only once (`src/utility.jl`), so the
1088/// gradient-coherence weight of an unwrapped `phase2` differs where neighbours differ by more than
1089/// 3π (on `tests/romeo_validity_romeojl_parity.rs`'s phantom, 46 voxels of echo 3 changed their
1090/// turns between the two readings). Until 2026-11 QSM.rs always read the wrapped echo (and ran the
1091/// echoes in parallel), which is what ROMEO.jl reads when the echoes start at the same time.
1092///
1093/// **Medians.** ROMEO.jl's `median` of an even number of turn counts averages the two middle
1094/// ones, which can give a half turn (a shift by π); here the upper middle count is taken, so the
1095/// correction is always a whole number of turns. The two agree whenever the middle counts are
1096/// equal, as they are on any real mask.
1097fn unwrap_individual<P: AsRef<[f64]> + Sync, M: AsRef<[f64]> + Sync>(
1098    phases: &[P],
1099    mags: &[M],
1100    tes: &[f64],
1101    mask: &[u8],
1102    params: &RomeoParams,
1103    nx: usize, ny: usize, nz: usize,
1104) -> Vec<Vec<f64>> {
1105    let n_echoes = phases.len();
1106
1107    // ROMEO.jl's loop, in echo order (as with one Julia thread): `unwrap!` works on `wrapped` in
1108    // place, so echo i ≥ 2's `phase2` is echo i − 1 as already unwrapped (and, with
1109    // `correct_global`, already corrected); echo 1's is echo 2, still wrapped.
1110    let mut result: Vec<Vec<f64>> = phases.iter().map(|p| p.as_ref().to_vec()).collect();
1111    for i in 0..n_echoes {
1112        let e2 = if i == 0 { 1 } else { i - 1 };
1113        let phase2 = result[e2].clone();
1114        let mag = if mags.is_empty() { &[] as &[f64] } else { mags[i].as_ref() };
1115        let weights = compute_weights_from_params(
1116            params, &result[i], mag, Some(&phase2), tes[i], tes[e2], mask, nx, ny, nz,
1117        );
1118        let mut visited = vec![0u8; nx * ny * nz];
1119        let num_regions = grow_region_unwrap_full(
1120            &mut result[i], &weights, mask, &mut visited,
1121            nx, ny, nz, params.wrap_addition, params.max_seeds,
1122            Some(&phase2), Some((tes[i], tes[e2])),
1123        );
1124        // ROMEO.jl's 3D `unwrap!` inside `unwrap_individual!`: regions, then `correctglobal`
1125        finish_3d_unwrap(&mut result[i], &mut visited, num_regions, &weights, mask, params, nx, ny, nz);
1126    }
1127
1128    if params.correct_global {
1129        correct_multi_echo_wraps(&mut result, tes, mask);
1130    }
1131
1132    result
1133}
1134
1135// =========================================================================
1136// Helper functions
1137// =========================================================================
1138
1139/// Temporal unwrap: remove 2π wraps by comparing to a reference value.
1140///
1141/// Matches ROMEO.jl: `unwrapvoxel(new, old) = new - 2π * round((new - old) / 2π)`
1142#[inline]
1143fn unwrap_voxel(new: f64, old: f64) -> f64 {
1144    new - TWO_PI * ((new - old) / TWO_PI).round()
1145}
1146
1147/// Correct global 2π offsets between echoes using median wrap counting.
1148///
1149/// For each successive echo pair, computes the median number of 2π wraps
1150/// between the TE-scaled reference and the current echo, then corrects.
1151///
1152/// Matches ROMEO.jl `correct_multi_echo_wraps!`.
1153pub fn correct_multi_echo_wraps(
1154    phases: &mut [Vec<f64>],
1155    tes: &[f64],
1156    mask: &[u8],
1157) {
1158    let n_total = phases[0].len();
1159
1160    for ieco in 1..phases.len() {
1161        let iref = ieco - 1;
1162        let te_ratio = tes[ieco] / tes[iref];
1163
1164        // Collect wrap counts for all masked voxels
1165        let mut wrap_counts: Vec<f64> = Vec::new();
1166        for i in 0..n_total {
1167            if mask[i] > 0 {
1168                let expected = phases[iref][i] * te_ratio;
1169                let nwraps = ((expected - phases[ieco][i]) / TWO_PI).round();
1170                if nwraps.is_finite() {
1171                    wrap_counts.push(nwraps);
1172                }
1173            }
1174        }
1175
1176        if wrap_counts.is_empty() {
1177            continue;
1178        }
1179
1180        // Median wrap count
1181        wrap_counts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1182        let median_wraps = wrap_counts[wrap_counts.len() / 2];
1183
1184        if median_wraps.abs() > 0.1 {
1185            let correction = TWO_PI * median_wraps;
1186            for i in 0..n_total {
1187                if mask[i] > 0 {
1188                    phases[ieco][i] += correction;
1189                }
1190            }
1191        }
1192    }
1193}
1194
1195/// Correct global 2π offset for a single 3D volume.
1196///
1197/// Subtracts `2π * median(round(phase / 2π))` from the entire volume,
1198/// bringing the median phase closest to 0. Matches ROMEO.jl `correctglobal`.
1199fn correct_global_offset(phase: &mut [f64], mask: &[u8]) {
1200    let mut wraps: Vec<f64> = phase.iter().enumerate()
1201        .filter(|(i, v)| mask[*i] > 0 && v.is_finite())
1202        .map(|(_, &v)| (v / TWO_PI).round())
1203        .collect();
1204
1205    if wraps.is_empty() {
1206        return;
1207    }
1208
1209    wraps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1210    let median_wraps = wraps[wraps.len() / 2];
1211
1212    if median_wraps.abs() > 0.1 {
1213        let correction = TWO_PI * median_wraps;
1214        for i in 0..phase.len() {
1215            if mask[i] > 0 {
1216                phase[i] -= correction;
1217            }
1218        }
1219    }
1220}
1221
1222/// Correct each region's median to nearest 0 by subtracting n·2π.
1223///
1224/// Matches ROMEO.jl `correct_regions!`.
1225fn correct_regions(phase: &mut [f64], visited: &[u8], num_regions: u8) {
1226    for region in 1..=num_regions {
1227        let mut wraps: Vec<f64> = Vec::new();
1228        for i in 0..phase.len() {
1229            if visited[i] == region && phase[i].is_finite() {
1230                wraps.push((phase[i] / TWO_PI).round());
1231            }
1232        }
1233        if wraps.is_empty() { continue; }
1234        wraps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1235        let median_wraps = wraps[wraps.len() / 2];
1236        if median_wraps.abs() > 0.1 {
1237            let correction = TWO_PI * median_wraps;
1238            for i in 0..phase.len() {
1239                if visited[i] == region {
1240                    phase[i] -= correction;
1241                }
1242            }
1243        }
1244    }
1245}
1246
1247/// Merge adjacent regions by computing inter-region phase offsets.
1248///
1249/// Calculates weighted phase offset between adjacent region pairs,
1250/// then iteratively merges regions starting from the largest.
1251/// Matches ROMEO.jl `merge_regions!`.
1252fn merge_regions_post(
1253    phase: &mut [f64],
1254    visited: &mut [u8],
1255    num_regions: u8,
1256    weights: &[u8],
1257    nx: usize, ny: usize, nz: usize,
1258) -> Vec<u8> {
1259    let n_total = nx * ny * nz;
1260    let nr = num_regions as usize;
1261    if nr <= 1 { return vec![1]; }
1262
1263    // Count region sizes
1264    let mut region_size = vec![0usize; nr + 1]; // 1-indexed
1265    for &v in visited.iter() {
1266        if v > 0 && (v as usize) <= nr {
1267            region_size[v as usize] += 1;
1268        }
1269    }
1270
1271    // Compute weighted offset between adjacent regions
1272    let mut offsets = vec![0.0_f64; (nr + 1) * (nr + 1)];
1273    let mut offset_counts = vec![0i64; (nr + 1) * (nr + 1)];
1274    let flat = |r1: usize, r2: usize| r1 * (nr + 1) + r2;
1275
1276    for dim in 0..3_usize {
1277        for i in 0..nx {
1278            for j in 0..ny {
1279                for k in 0..nz {
1280                    let (ni, nj, nk) = match dim {
1281                        0 => (i + 1, j, k),
1282                        1 => (i, j + 1, k),
1283                        _ => (i, j, k + 1),
1284                    };
1285                    if ni >= nx || nj >= ny || nk >= nz { continue; }
1286                    let idx = idx3d(i, j, k, nx, ny);
1287                    let idx_n = idx3d(ni, nj, nk, nx, ny);
1288                    let ri = visited[idx] as usize;
1289                    let rj = visited[idx_n] as usize;
1290                    if ri == 0 || rj == 0 || ri == rj { continue; }
1291
1292                    let edge_idx = dim * n_total + idx3d(i, j, k, nx, ny);
1293                    let w = 255u16.saturating_sub(weights[edge_idx] as u16);
1294                    let w = if w == 255 { 0 } else { w as i64 };
1295
1296                    offsets[flat(ri, rj)] += (phase[idx] - phase[idx_n]) * w as f64;
1297                    offset_counts[flat(ri, rj)] += w;
1298                }
1299            }
1300        }
1301    }
1302
1303    // Symmetrize
1304    for i in 1..=nr {
1305        for j in i..=nr {
1306            offset_counts[flat(i, j)] += offset_counts[flat(j, i)];
1307            offset_counts[flat(j, i)] = offset_counts[flat(i, j)];
1308            offsets[flat(i, j)] -= offsets[flat(j, i)];
1309            offsets[flat(j, i)] = -offsets[flat(i, j)];
1310        }
1311    }
1312
1313    // Iteratively merge: process largest uncorrected region first
1314    let mut corrected = vec![false; nr + 1];
1315    let mut remaining = Vec::new();
1316
1317    while corrected[1..=nr].iter().any(|&c| !c) {
1318        // Find largest uncorrected region
1319        let mut best_region = 0;
1320        let mut best_size = 0;
1321        for r in 1..=nr {
1322            if !corrected[r] && region_size[r] > best_size {
1323                best_size = region_size[r];
1324                best_region = r;
1325            }
1326        }
1327        if best_region == 0 { break; }
1328        corrected[best_region] = true;
1329        remaining.push(best_region as u8);
1330
1331        // Find regions to merge: corrected[i] && !corrected[j] && offset_counts > 0
1332        // Sort by offset_counts (highest first = best connection)
1333        let mut merge_pairs: Vec<(usize, usize, i64)> = Vec::new();
1334        for i in 1..=nr {
1335            for j in 1..=nr {
1336                if corrected[i] && !corrected[j] && offset_counts[flat(i, j)] > 0 {
1337                    merge_pairs.push((i, j, offset_counts[flat(i, j)]));
1338                }
1339            }
1340        }
1341        merge_pairs.sort_by_key(|a| std::cmp::Reverse(a.2));
1342
1343        for &(ri, rj, count) in &merge_pairs {
1344            if corrected[rj] { continue; }
1345            let offset = (offsets[flat(ri, rj)] / count as f64 / TWO_PI).round();
1346            if offset != 0.0 {
1347                let correction = offset * TWO_PI;
1348                for v in 0..n_total {
1349                    if visited[v] == rj as u8 {
1350                        phase[v] += correction;
1351                        visited[v] = ri as u8;
1352                    }
1353                }
1354            }
1355            corrected[rj] = true;
1356            offset_counts[flat(ri, rj)] = -1;
1357            offset_counts[flat(rj, ri)] = -1;
1358        }
1359    }
1360
1361    remaining
1362}
1363
1364/// Re-unwrap voxels that are uncertain after temporal unwrapping.
1365///
1366/// Computes a quality metric comparing the unwrapped phase to the TE-scaled
1367/// reference. Voxels with low quality (likely wrap errors) are re-unwrapped
1368/// spatially using the certain voxels as seeds.
1369///
1370/// Matches ROMEO.jl `temporal_uncertain_unwrapping!`.
1371fn temporal_uncertain_rewrap(
1372    phase: &mut [f64],
1373    ref_scaled: &[f64],
1374    weights: &[u8],
1375    mask: &[u8],
1376    threshold: f64,
1377    nx: usize, ny: usize, nz: usize,
1378) {
1379    let n_total = nx * ny * nz;
1380
1381    // Compute quality: compare phase/2 with ref_scaled/2 using voxel quality
1382    // (halving increases sensitivity to single-wrap errors)
1383    // Matches ROMEO.jl: unwrapped_quality(phase, refphase) = voxelquality(phase/2; phase2=refphase/2, TEs=[1,1])
1384    let half_phase: Vec<f64> = phase.iter().map(|&v| v * 0.5).collect();
1385    let half_ref: Vec<f64> = ref_scaled.iter().map(|&v| v * 0.5).collect();
1386
1387    let grid = crate::Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1388    let quality = voxel_quality_romeo(
1389        &half_phase, &[], Some(&half_ref), 1.0, 1.0,
1390        mask, &grid,
1391    );
1392
1393    // Build visited mask:
1394    // quality > threshold*100 → certain (visited=2, acts as seed)
1395    // quality <= threshold*100 → uncertain (visited=1, needs re-unwrapping)
1396    // outside mask → 0
1397    let threshold_scaled = threshold * 100.0; // voxel_quality_romeo returns 0-100
1398    let mut visited = vec![0u8; n_total];
1399    let mut any_uncertain = false;
1400    let mut any_certain = false;
1401
1402    for i in 0..n_total {
1403        if mask[i] == 0 {
1404            visited[i] = 0;
1405        } else if quality[i] > threshold_scaled {
1406            visited[i] = 2;
1407            any_certain = true;
1408        } else {
1409            visited[i] = 1;
1410            any_uncertain = true;
1411        }
1412    }
1413
1414    if !any_uncertain || !any_certain {
1415        return;
1416    }
1417
1418    // Re-unwrap uncertain voxels using spatial growing from the certain boundary
1419    grow_region_unwrap_from_visited(phase, weights, &mut visited, nx, ny, nz);
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424    use super::*;
1425    use crate::Grid;
1426
1427    fn grid(n: usize) -> Grid { Grid::new(n, n, n, 1.0, 1.0, 1.0) }
1428
1429    #[test]
1430    fn find_seed_point_returns_a_voxel_inside_the_mask() {
1431        // The growers treat a seed outside the mask as nothing to do and hand back the input
1432        // still wrapped, so a centre of mass in a hollow is a silent total failure. Two
1433        // ordinary brain-mask shapes put it there.
1434        let (nx, ny, nz) = (16usize, 16usize, 4usize);
1435        let at = |m: &mut Vec<u8>, i: usize, j: usize, k: usize| m[i + j * nx + k * nx * ny] = 1;
1436
1437        // two separated blobs: the centre of mass sits in the gap between them
1438        let mut split = vec![0u8; nx * ny * nz];
1439        for k in 0..nz {
1440            for j in 5..11 {
1441                for i in 1..5 { at(&mut split, i, j, k); }
1442                for i in 11..15 { at(&mut split, i, j, k); }
1443            }
1444        }
1445        let (i, j, k) = find_seed_point(&split, nx, ny, nz);
1446        assert_eq!(split[idx3d(i, j, k, nx, ny)], 1, "seed ({i},{j},{k}) is outside the mask");
1447
1448        // a horseshoe: one connected component, centre of mass in the hollow
1449        let mut horseshoe = vec![0u8; nx * ny * nz];
1450        for k in 0..nz {
1451            for j in 2..14 {
1452                for i in 2..5 { at(&mut horseshoe, i, j, k); }
1453                for i in 11..14 { at(&mut horseshoe, i, j, k); }
1454            }
1455            for i in 2..14 { at(&mut horseshoe, i, 2, k); at(&mut horseshoe, i, 3, k); }
1456        }
1457        let (i, j, k) = find_seed_point(&horseshoe, nx, ny, nz);
1458        assert_eq!(horseshoe[idx3d(i, j, k, nx, ny)], 1, "seed ({i},{j},{k}) is outside the mask");
1459
1460        // a convex mask is unaffected: the seed is exactly the centre of mass
1461        let mut block = vec![0u8; nx * ny * nz];
1462        for k in 1..3 {
1463            for j in 4..12 {
1464                for i in 4..12 { at(&mut block, i, j, k); }
1465            }
1466        }
1467        assert_eq!(find_seed_point(&block, nx, ny, nz), (7, 7, 1));
1468    }
1469
1470    #[test]
1471    fn romeo_unwraps_a_mask_whose_centre_of_mass_is_outside_it() {
1472        // The behavioural consequence: before the fallback this returned the input untouched.
1473        let (nx, ny, nz) = (16usize, 16usize, 2usize);
1474        let mut mask = vec![0u8; nx * ny * nz];
1475        for k in 0..nz {
1476            for j in 5..11 {
1477                for i in 1..5 { mask[i + j * nx + k * nx * ny] = 1; }
1478                for i in 11..15 { mask[i + j * nx + k * nx * ny] = 1; }
1479            }
1480        }
1481        // a ramp steep enough to wrap across each blob but not between samples
1482        let mut truth = vec![0.0; nx * ny * nz];
1483        for k in 0..nz {
1484            for j in 0..ny {
1485                for i in 0..nx {
1486                    truth[i + j * nx + k * nx * ny] = 1.2 * i as f64;
1487                }
1488            }
1489        }
1490        let wrapped: Vec<f64> = truth.iter().map(|&v| wrap_angle(v)).collect();
1491        assert!(
1492            wrapped.iter().zip(&truth).any(|(w, t)| (w - t).abs() > 1.0),
1493            "the ramp does not wrap, so this proves nothing"
1494        );
1495
1496        let grid = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
1497        let out = unwrap_romeo(
1498            &wrapped, &[], None, 0.0, 0.0, &mask, &RomeoParams::default(), &grid,
1499        );
1500        let changed = out.iter().zip(&wrapped)
1501            .enumerate()
1502            .filter(|(i, (o, w))| mask[*i] != 0 && (*o - *w).abs() > 1e-9)
1503            .count();
1504        assert!(changed > 0, "nothing in the mask was unwrapped - the seed landed outside it");
1505    }
1506
1507    #[test]
1508    fn test_wrap_angle() {
1509        assert!((wrap_angle(0.0) - 0.0).abs() < 1e-10);
1510        assert!((wrap_angle(PI) - PI).abs() < 1e-10);
1511        assert!((wrap_angle(-PI) - (-PI)).abs() < 1e-10);
1512        assert!((wrap_angle(TWO_PI) - 0.0).abs() < 1e-10);
1513        assert!((wrap_angle(3.0 * PI) - PI).abs() < 1e-10);
1514        assert!((wrap_angle(-3.0 * PI) - (-PI)).abs() < 1e-10);
1515    }
1516
1517    #[test]
1518    fn test_weights_constant_phase() {
1519        // Constant phase should give high weights (phase coherence = 1)
1520        let n = 4;
1521        let phase = vec![0.0; n * n * n];
1522        let mag = vec![1.0; n * n * n];
1523        let mask = vec![1u8; n * n * n];
1524
1525        let weights = calculate_weights_single_echo(&phase, &mag, &mask, n, n, n);
1526
1527        // All interior weights should be 255 (constant phase, uniform magnitude)
1528        let mut high_weight_count = 0;
1529        for &w in weights.iter() {
1530            if w == 255 {
1531                high_weight_count += 1;
1532            }
1533        }
1534        assert!(high_weight_count > 0, "Should have some high weights for constant phase");
1535    }
1536
1537    #[test]
1538    fn test_weights_wrapped_jump() {
1539        // Phase with 2π jump should give low weights at jump location
1540        let n = 4;
1541        let mut phase = vec![0.0; n * n * n];
1542
1543        // Create a 2π jump in x direction at i=2
1544        for i in 2..n {
1545            for j in 0..n {
1546                for k in 0..n {
1547                    phase[idx3d(i, j, k, n, n)] = TWO_PI;
1548                }
1549            }
1550        }
1551
1552        let mask = vec![1u8; n * n * n];
1553        let _weights = calculate_weights_single_echo(&phase, &[], &mask, n, n, n);
1554
1555        // Weight at x=1 to x=2 edge should be low (wrapped difference = 0, but that's ok)
1556        // Actually, for a 2π jump, the wrapped difference is 0, so coherence is 1
1557        // This is correct - ROMEO uses wrapped differences, not raw differences
1558    }
1559
1560    #[test]
1561    fn test_weights_mask() {
1562        // Weights should be 0 where mask is 0
1563        let n = 4;
1564        let phase = vec![0.5; n * n * n];
1565        let mut mask = vec![1u8; n * n * n];
1566
1567        // Set some voxels outside mask
1568        mask[0] = 0;
1569        mask[1] = 0;
1570
1571        let weights = calculate_weights_single_echo(&phase, &[], &mask, n, n, n);
1572
1573        // Edges connected to masked-out voxels should be 0
1574        // Weight at edge (0,0,0)-(1,0,0) should be 0 since idx 0 is masked out
1575        assert_eq!(weights[0], 0);  // x-direction edge at (0,0,0)
1576    }
1577
1578    #[test]
1579    fn test_voxel_quality_constant_phase() {
1580        // Constant phase + uniform magnitude → all quality values should be 100
1581        let n = 4;
1582        let phase = vec![0.0; n * n * n];
1583        let mag = vec![1.0; n * n * n];
1584        let mask = vec![1u8; n * n * n];
1585
1586        let quality = voxel_quality_romeo(&phase, &mag, None, 1.0, 1.0, &mask, &grid(n));
1587
1588        assert_eq!(quality.len(), n * n * n);
1589
1590        // Interior voxels should have high quality (may be < 100 due to
1591        // phaselinearity boundary fallback at edges of small test cube)
1592        let interior_q = quality[idx3d(1, 1, 1, n, n)];
1593        assert!(interior_q > 90.0,
1594                "Interior voxel quality should be >90, got {}", interior_q);
1595    }
1596
1597    #[test]
1598    fn test_voxel_quality_masked() {
1599        // Masked-out voxels should have quality = 0
1600        let n = 4;
1601        let phase = vec![0.0; n * n * n];
1602        let mut mask = vec![1u8; n * n * n];
1603        mask[idx3d(1, 1, 1, n, n)] = 0;
1604
1605        let quality = voxel_quality_romeo(&phase, &[], None, 1.0, 1.0, &mask, &grid(n));
1606
1607        assert_eq!(quality[idx3d(1, 1, 1, n, n)], 0.0,
1608                   "Masked-out voxel should have quality 0");
1609    }
1610
1611    #[test]
1612    fn test_voxel_quality_range() {
1613        // Quality values should be in range [0, 100]
1614        let n = 6;
1615        let phase: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.7).collect();
1616        let mag: Vec<f64> = (0..n * n * n).map(|i| (i as f64) / (n * n * n) as f64).collect();
1617        let mask = vec![1u8; n * n * n];
1618
1619        let quality = voxel_quality_romeo(&phase, &mag, None, 1.0, 1.0, &mask, &grid(n));
1620
1621        for &q in quality.iter() {
1622            assert!((0.0..=100.0).contains(&q),
1623                    "Quality should be in [0, 100], got {}", q);
1624        }
1625    }
1626
1627    // =========================================================================
1628    // find_seed_point
1629    // =========================================================================
1630
1631    #[test]
1632    fn test_find_seed_point_full_mask() {
1633        let (nx, ny, nz) = (8, 8, 8);
1634        let mask = vec![1u8; nx * ny * nz];
1635        let (si, sj, sk) = find_seed_point(&mask, nx, ny, nz);
1636        assert_eq!(si, 3);
1637        assert_eq!(sj, 3);
1638        assert_eq!(sk, 3);
1639    }
1640
1641    #[test]
1642    fn test_find_seed_point_empty_mask() {
1643        let (nx, ny, nz) = (8, 8, 8);
1644        let mask = vec![0u8; nx * ny * nz];
1645        let (si, sj, sk) = find_seed_point(&mask, nx, ny, nz);
1646        assert_eq!(si, 4);
1647        assert_eq!(sj, 4);
1648        assert_eq!(sk, 4);
1649    }
1650
1651    // =========================================================================
1652    // unwrap_romeo (3D)
1653    // =========================================================================
1654
1655    #[test]
1656    fn test_unwrap_romeo_3d_smooth() {
1657        let n = 8;
1658        let total = n * n * n;
1659        let phase: Vec<f64> = (0..total).map(|i| {
1660            (i % n) as f64 / n as f64 * 0.5
1661        }).collect();
1662        let mag = vec![1.0; total];
1663        let mask = vec![1u8; total];
1664
1665        let unwrapped = unwrap_romeo(
1666            &phase, &mag, None, 0.0, 0.0,
1667            &mask, &RomeoParams::default(), &grid(n),
1668        );
1669
1670        assert_eq!(unwrapped.len(), total);
1671        for &v in &unwrapped {
1672            assert!(v.is_finite());
1673        }
1674    }
1675
1676    #[test]
1677    fn test_unwrap_romeo_3d_with_wrap() {
1678        // Phase with a 2π wrap that should be removed
1679        let n = 8;
1680        let total = n * n * n;
1681        let mut phase = vec![0.0; total];
1682
1683        // Create a smooth ramp 0..0.7 in x, then add 2π at x>=4
1684        for i in 0..n {
1685            for j in 0..n {
1686                for k in 0..n {
1687                    let idx = idx3d(i, j, k, n, n);
1688                    let val = 0.1 * i as f64;
1689                    phase[idx] = if i >= 4 { val + TWO_PI } else { val };
1690                }
1691            }
1692        }
1693
1694        let mask = vec![1u8; total];
1695        let unwrapped = unwrap_romeo(
1696            &phase, &[], None, 0.0, 0.0,
1697            &mask, &RomeoParams::default(), &grid(n),
1698        );
1699
1700        // After unwrapping, the 2π jump should be removed
1701        // Check that adjacent voxels don't differ by more than π
1702        for j in 0..n {
1703            for k in 0..n {
1704                for i in 1..n {
1705                    let curr = unwrapped[idx3d(i, j, k, n, n)];
1706                    let prev = unwrapped[idx3d(i - 1, j, k, n, n)];
1707                    let diff = (curr - prev).abs();
1708                    assert!(diff < PI + 0.5,
1709                        "Adjacent voxels differ by {:.2} at x={}", diff, i);
1710                }
1711            }
1712        }
1713    }
1714
1715    // =========================================================================
1716    // unwrap_romeo_multi_echo (4D template-based)
1717    // =========================================================================
1718
1719    #[test]
1720    fn test_multi_echo_template_output_sizes() {
1721        let n = 8;
1722        let total = n * n * n;
1723        let tes = [5.0, 10.0, 15.0];
1724        let slope = 0.05;
1725
1726        let phases: Vec<Vec<f64>> = tes.iter().map(|&te| {
1727            (0..total).map(|i| {
1728                let x = (i % n) as f64;
1729                wrap_angle(slope * x * te)
1730            }).collect()
1731        }).collect();
1732        let mags: Vec<Vec<f64>> = tes.iter().map(|_| vec![1.0; total]).collect();
1733        let mask = vec![1u8; total];
1734
1735        let result = unwrap_romeo_multi_echo(
1736            &phases, &mags, &tes, &mask,
1737            &RomeoParams::default(), &grid(n),
1738        );
1739
1740        assert_eq!(result.len(), 3);
1741        for echo in &result {
1742            assert_eq!(echo.len(), total);
1743            for &v in echo {
1744                assert!(v.is_finite());
1745            }
1746        }
1747    }
1748
1749    #[test]
1750    fn test_multi_echo_template_temporal_consistency() {
1751        // After template-based unwrapping, echoes should be temporally consistent:
1752        // phase[e] ≈ phase[0] * TE[e] / TE[0]
1753        let n = 8;
1754        let total = n * n * n;
1755        let tes = [5.0, 10.0, 15.0];
1756        let slope = 0.03; // small slope so no wrapping needed
1757
1758        let phases: Vec<Vec<f64>> = tes.iter().map(|&te| {
1759            (0..total).map(|i| {
1760                let x = (i % n) as f64;
1761                slope * x * te // no wrapping needed
1762            }).collect()
1763        }).collect();
1764        let mags: Vec<Vec<f64>> = tes.iter().map(|_| vec![1.0; total]).collect();
1765        let mask = vec![1u8; total];
1766
1767        let result = unwrap_romeo_multi_echo(
1768            &phases, &mags, &tes, &mask,
1769            &RomeoParams::default(), &grid(n),
1770        );
1771
1772        // Check TE-scaling consistency at interior voxels
1773        for i in 0..total {
1774            if mask[i] > 0 && result[0][i].abs() > 1e-10 {
1775                let ratio_12 = result[1][i] / result[0][i];
1776                let expected_ratio = tes[1] / tes[0];
1777                assert!((ratio_12 - expected_ratio).abs() < 0.5,
1778                    "Echo 1/0 ratio {:.3} vs expected {:.3} at voxel {}",
1779                    ratio_12, expected_ratio, i);
1780            }
1781        }
1782    }
1783
1784    #[test]
1785    fn test_multi_echo_single_echo_fallback() {
1786        let n = 4;
1787        let total = n * n * n;
1788        let phase = vec![0.1; total];
1789        let mask = vec![1u8; total];
1790
1791        let result = unwrap_romeo_multi_echo(
1792            &[&phase[..]], &[] as &[&[f64]], &[5.0], &mask,
1793            &RomeoParams::default(), &grid(n),
1794        );
1795
1796        assert_eq!(result.len(), 1);
1797        assert_eq!(result[0].len(), total);
1798    }
1799
1800    // =========================================================================
1801    // unwrap_romeo_multi_echo (individual mode)
1802    // =========================================================================
1803
1804    #[test]
1805    fn test_multi_echo_individual_mode() {
1806        let n = 8;
1807        let total = n * n * n;
1808        let tes = [5.0, 10.0, 15.0];
1809        let slope = 0.03;
1810
1811        let phases: Vec<Vec<f64>> = tes.iter().map(|&te| {
1812            (0..total).map(|i| {
1813                let x = (i % n) as f64;
1814                slope * x * te
1815            }).collect()
1816        }).collect();
1817        let mags: Vec<Vec<f64>> = tes.iter().map(|_| vec![1.0; total]).collect();
1818        let mask = vec![1u8; total];
1819
1820        let params = RomeoParams {
1821            individual: true,
1822            correct_global: true,
1823            ..Default::default()
1824        };
1825
1826        let result = unwrap_romeo_multi_echo(
1827            &phases, &mags, &tes, &mask,
1828            &params, &grid(n),
1829        );
1830
1831        assert_eq!(result.len(), 3);
1832        for echo in &result {
1833            assert_eq!(echo.len(), total);
1834            for &v in echo {
1835                assert!(v.is_finite());
1836            }
1837        }
1838    }
1839
1840    // =========================================================================
1841    // correct_multi_echo_wraps
1842    // =========================================================================
1843
1844    #[test]
1845    fn test_correct_multi_echo_wraps_no_correction_needed() {
1846        let n = 64;
1847        let tes = [5.0, 10.0, 15.0];
1848        let mask = vec![1u8; n];
1849
1850        // Perfectly scaled echoes: no correction needed
1851        let mut phases: Vec<Vec<f64>> = tes.iter().map(|&te| {
1852            vec![0.1 * te; n]
1853        }).collect();
1854
1855        let original: Vec<Vec<f64>> = phases.clone();
1856        correct_multi_echo_wraps(&mut phases, &tes, &mask);
1857
1858        for e in 0..3 {
1859            for i in 0..n {
1860                assert!((phases[e][i] - original[e][i]).abs() < 1e-10,
1861                    "No correction should be applied");
1862            }
1863        }
1864    }
1865
1866    #[test]
1867    fn test_correct_multi_echo_wraps_with_offset() {
1868        let n = 64;
1869        let tes = [5.0, 10.0];
1870        let mask = vec![1u8; n];
1871
1872        // Echo 0: phase = 0.5 for all voxels
1873        // Echo 1: should be 1.0 (TE ratio = 2) but is offset by +2π
1874        let mut phases = vec![
1875            vec![0.5; n],
1876            vec![1.0 + TWO_PI; n],
1877        ];
1878
1879        correct_multi_echo_wraps(&mut phases, &tes, &mask);
1880
1881        // After correction, echo 1 should be close to 1.0
1882        for i in 0..n {
1883            assert!((phases[1][i] - 1.0).abs() < 0.1,
1884                "Expected ~1.0, got {}", phases[1][i]);
1885        }
1886    }
1887
1888    // =========================================================================
1889    // RomeoWeightType
1890    // =========================================================================
1891
1892    #[test]
1893    fn test_weight_type_flags() {
1894        // Romeo: PC + PGC + PL + MC
1895        let f = RomeoWeightType::Romeo.weight_flags();
1896        assert_eq!(f, [true, true, true, true, false, false]);
1897
1898        // Romeo2: PC + MC only
1899        let f = RomeoWeightType::Romeo2.weight_flags();
1900        assert_eq!(f, [true, false, false, true, false, false]);
1901
1902        // Romeo6: all 6
1903        let f = RomeoWeightType::Romeo6.weight_flags();
1904        assert_eq!(f, [true, true, true, true, true, true]);
1905    }
1906
1907    #[test]
1908    fn test_romeo4_is_romeo_alias() {
1909        assert_eq!(
1910            RomeoWeightType::Romeo.weight_flags(),
1911            RomeoWeightType::Romeo4.weight_flags(),
1912        );
1913    }
1914
1915    #[test]
1916    fn test_params_weight_flags_roundtrip() {
1917        // Default params should match Romeo preset
1918        let default_params = RomeoParams::default();
1919        assert_eq!(default_params.weight_flags(), RomeoWeightType::Romeo.weight_flags());
1920        assert!(!default_params.bestpath);
1921
1922        // from_weight_type preserves flags
1923        for wt in [RomeoWeightType::Romeo, RomeoWeightType::Romeo2,
1924                    RomeoWeightType::Romeo3, RomeoWeightType::Romeo6] {
1925            let params = RomeoParams::from_weight_type(wt);
1926            assert_eq!(params.weight_flags(), wt.weight_flags());
1927            assert!(!params.bestpath);
1928        }
1929
1930        // BestPath sets the bestpath flag
1931        let bp = RomeoParams::from_weight_type(RomeoWeightType::BestPath);
1932        assert!(bp.bestpath);
1933    }
1934
1935    #[test]
1936    fn test_params_custom_weight_combination() {
1937        // Custom combination: only phase coherence + mag weight (no preset matches this)
1938        let params = RomeoParams {
1939            phase_coherence: true,
1940            phase_gradient_coherence: false,
1941            phase_linearity: false,
1942            mag_coherence: false,
1943            mag_weight: true,
1944            mag_weight2: false,
1945            ..Default::default()
1946        };
1947        assert_eq!(params.weight_flags(), [true, false, false, false, true, false]);
1948    }
1949
1950    // =========================================================================
1951    // Template selection
1952    // =========================================================================
1953
1954    #[test]
1955    fn test_multi_echo_template_selection() {
1956        let n = 8;
1957        let total = n * n * n;
1958        let tes = [5.0, 10.0, 15.0];
1959        let phases: Vec<Vec<f64>> = tes.iter().map(|&te| {
1960            (0..total).map(|i| 0.02 * (i % n) as f64 * te).collect()
1961        }).collect();
1962        let mags: Vec<Vec<f64>> = tes.iter().map(|_| vec![1.0; total]).collect();
1963        let mask = vec![1u8; total];
1964
1965        // Use echo 2 (index 1) as template
1966        let params = RomeoParams {
1967            template: 1,
1968            ..Default::default()
1969        };
1970
1971        let result = unwrap_romeo_multi_echo(
1972            &phases, &mags, &tes, &mask,
1973            &params, &grid(n),
1974        );
1975
1976        assert_eq!(result.len(), 3);
1977        for echo in &result {
1978            for &v in echo {
1979                assert!(v.is_finite());
1980            }
1981        }
1982    }
1983
1984    // =========================================================================
1985    // Phase linearity
1986    // =========================================================================
1987
1988    #[test]
1989    fn test_phase_linearity_triplet_linear() {
1990        // Perfectly linear: second derivative = 0 → linearity = 1.0
1991        assert!((phase_linearity_triplet(0.1, 0.2, 0.3) - 1.0).abs() < 1e-6);
1992        assert!((phase_linearity_triplet(-0.5, 0.0, 0.5) - 1.0).abs() < 1e-6);
1993    }
1994
1995    #[test]
1996    fn test_phase_linearity_triplet_jump() {
1997        // Large second derivative → low linearity
1998        let pl = phase_linearity_triplet(0.0, 0.0, PI);
1999        assert!(pl < 0.5, "Jump should give low linearity, got {}", pl);
2000    }
2001
2002    // =========================================================================
2003    // BestPath weights
2004    // =========================================================================
2005
2006    #[test]
2007    fn test_bestpath_weights_constant_phase() {
2008        let n = 6;
2009        let total = n * n * n;
2010        let phase = vec![0.0; total];
2011        let mask = vec![1u8; total];
2012
2013        let weights = calculate_weights_bestpath(&phase, &mask, n, n, n);
2014        assert_eq!(weights.len(), 3 * total);
2015
2016        // Constant phase → D=0 → R=0 → edge weight should be low
2017        // (BestPath assigns low weight to ambiguous/constant regions)
2018    }
2019
2020    #[test]
2021    fn test_bestpath_weights_smooth_gradient() {
2022        let n = 8;
2023        let total = n * n * n;
2024        // Small smooth gradient in x
2025        let phase: Vec<f64> = (0..total).map(|i| 0.1 * (i % n) as f64).collect();
2026        let mask = vec![1u8; total];
2027
2028        let weights = calculate_weights_bestpath(&phase, &mask, n, n, n);
2029
2030        // The previous assertion here was `w <= 255` on a `u8`, which is true by type and so
2031        // could never fail. What is actually worth pinning is that the weights respond to the
2032        // phase at all, which needs two assertions pulling in opposite directions -- a bound
2033        // alone is satisfied by a function that ignores its input and returns a constant.
2034        //
2035        // `scale(w) = clamp((1 - w/10) * 255, 1, 255)` with `w` the summed second-order phase
2036        // inconsistency, so a smooth field sits near the top of the range. Note the range is
2037        // *not* very discriminating: measured means are 255.0 constant, 249.8 for this
2038        // gradient and 247.6 for uniform noise, so a high mean on its own says little.
2039        let assigned: Vec<u8> = weights.iter().copied().filter(|&w| w > 0).collect();
2040        assert!(!assigned.is_empty(), "no edge weight was assigned at all");
2041        assert!(
2042            assigned.iter().all(|&w| w >= 200),
2043            "a smooth gradient should score near the top of the range; min was {:?}",
2044            assigned.iter().min()
2045        );
2046
2047        // A perfectly flat field has zero second-order difference everywhere, so every assigned
2048        // edge scores exactly 255. This gradient wraps every `n` voxels, and that discontinuity
2049        // pulls some edges below 255. A function returning a constant passes the bound above
2050        // and fails here.
2051        let flat = calculate_weights_bestpath(&vec![0.5f64; total], &mask, n, n, n);
2052        assert!(
2053            flat.iter().filter(|&&w| w > 0).all(|&w| w == 255),
2054            "a constant field should score a flat 255"
2055        );
2056        assert!(
2057            assigned.iter().any(|&w| w < 255),
2058            "the gradient's wrap discontinuity should pull some edges below 255"
2059        );
2060    }
2061
2062    // =========================================================================
2063    // wrap_addition
2064    // =========================================================================
2065
2066    #[test]
2067    fn test_unwrap_with_wrap_addition() {
2068        let n = 8;
2069        let total = n * n * n;
2070        let phase: Vec<f64> = (0..total).map(|i| {
2071            let x = (i % n) as f64;
2072            wrap_angle(0.1 * x)
2073        }).collect();
2074        let mask = vec![1u8; total];
2075
2076        let params = RomeoParams {
2077            wrap_addition: 1.0,
2078            ..Default::default()
2079        };
2080
2081        let unwrapped = unwrap_romeo(
2082            &phase, &[], None, 0.0, 0.0,
2083            &mask, &params, &grid(n),
2084        );
2085
2086        for &v in &unwrapped {
2087            assert!(v.is_finite());
2088        }
2089    }
2090
2091    // =========================================================================
2092    // Multi-seed
2093    // =========================================================================
2094
2095    #[test]
2096    fn test_unwrap_multi_seed() {
2097        let n = 8;
2098        let total = n * n * n;
2099        let phase: Vec<f64> = (0..total).map(|i| {
2100            wrap_angle(0.05 * (i % n) as f64)
2101        }).collect();
2102        let mask = vec![1u8; total];
2103
2104        let params = RomeoParams {
2105            max_seeds: 4,
2106            ..Default::default()
2107        };
2108
2109        let unwrapped = unwrap_romeo(
2110            &phase, &[], None, 0.0, 0.0,
2111            &mask, &params, &grid(n),
2112        );
2113
2114        for &v in &unwrapped {
2115            assert!(v.is_finite());
2116        }
2117    }
2118
2119    #[test]
2120    fn test_unwrap_multi_seed_with_merge() {
2121        let n = 8;
2122        let total = n * n * n;
2123        let phase: Vec<f64> = (0..total).map(|i| {
2124            wrap_angle(0.05 * (i % n) as f64)
2125        }).collect();
2126        let mask = vec![1u8; total];
2127
2128        let params = RomeoParams {
2129            max_seeds: 4,
2130            merge_regions: true,
2131            correct_regions: true,
2132            ..Default::default()
2133        };
2134
2135        let unwrapped = unwrap_romeo(
2136            &phase, &[], None, 0.0, 0.0,
2137            &mask, &params, &grid(n),
2138        );
2139
2140        for &v in &unwrapped {
2141            assert!(v.is_finite());
2142        }
2143    }
2144
2145    // =========================================================================
2146    // correctglobal for 3D
2147    // =========================================================================
2148
2149    #[test]
2150    fn test_correct_global_offset() {
2151        let n = 64;
2152        let mask = vec![1u8; n];
2153        // Phase that's all shifted by 2π
2154        let mut phase: Vec<f64> = vec![TWO_PI + 0.1; n];
2155
2156        correct_global_offset(&mut phase, &mask);
2157
2158        // After correction, should be close to 0.1
2159        for &v in &phase {
2160            assert!((v - 0.1).abs() < 0.2, "Expected ~0.1, got {}", v);
2161        }
2162    }
2163
2164    #[test]
2165    fn test_unwrap_romeo_with_correctglobal() {
2166        let n = 4;
2167        let total = n * n * n;
2168        let phase = vec![0.1; total];
2169        let mask = vec![1u8; total];
2170
2171        let params = RomeoParams {
2172            correct_global: true,
2173            ..Default::default()
2174        };
2175
2176        let unwrapped = unwrap_romeo(
2177            &phase, &[], None, 0.0, 0.0,
2178            &mask, &params, &grid(n),
2179        );
2180
2181        for &v in &unwrapped {
2182            assert!(v.is_finite());
2183        }
2184    }
2185
2186    // =========================================================================
2187    // Weight type: BestPath integration
2188    // =========================================================================
2189
2190    #[test]
2191    fn test_unwrap_romeo_bestpath() {
2192        let n = 8;
2193        let total = n * n * n;
2194        let phase: Vec<f64> = (0..total).map(|i| {
2195            wrap_angle(0.1 * (i % n) as f64)
2196        }).collect();
2197        let mask = vec![1u8; total];
2198
2199        let params = RomeoParams {
2200            bestpath: true,
2201            ..Default::default()
2202        };
2203
2204        let unwrapped = unwrap_romeo(
2205            &phase, &[], None, 0.0, 0.0,
2206            &mask, &params, &grid(n),
2207        );
2208
2209        for &v in &unwrapped {
2210            assert!(v.is_finite());
2211        }
2212    }
2213
2214    // =========================================================================
2215    // rescale_weight
2216    // =========================================================================
2217
2218    #[test]
2219    fn test_rescale_weight() {
2220        // ROMEO.jl: bin = max(round((1 - w) * 255), 1); priority = 256 - bin
2221        assert_eq!(rescale_weight(1.0), 255);    // best (bin 1)
2222        assert_eq!(rescale_weight(0.5), 128);    // bin round(127.5) = 128 (ties to even)
2223        assert_eq!(rescale_weight(0.0), 1);      // worst valid (bin 255)
2224        assert_eq!(rescale_weight(-0.1), 0);     // invalid
2225        assert_eq!(rescale_weight(f64::NAN), 0); // invalid
2226    }
2227}