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 ¶ms, &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 ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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, ¶ms, &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}