Skip to main content

qsm_core/unwrap/
bestpath.rs

1//! Best-path phase unwrapping (3D-SRNCP).
2//!
3//! Sorting by reliability following a non-continuous path, after
4//! H. Abdul-Rahman, M. Gdeisat, D. Burton and M. Lalor, "Fast three-dimensional
5//! phase-unwrapping algorithm based on sorting by reliability following a
6//! non-continuous path", Proc. SPIE 5856 (2005) 32–40, and the journal version,
7//! H.S. Abdul-Rahman, M.A. Gdeisat, D.R. Burton, M.J. Lalor, F. Lilley, C.J. Moore, "Fast and
8//! robust three-dimensional best path phase unwrapping algorithm", Applied Optics 46(26) (2007)
9//! 6623–6635, <https://doi.org/10.1364/AO.46.006623>.
10//!
11//! # Reference pipeline
12//!
13//! The authors' C code is distributed by scikit-image (`skimage.restoration.unwrap_phase`, see
14//! below); it takes a wrapped volume and an optional mask and has no parameters besides the
15//! periodic-boundary flags, so there are no reference defaults to match beyond those
16//! [`BestPathParams`] documents. It assumes nothing about voxel size, field strength or
17//! orientation (reliabilities are unscaled second differences). It is not selectable in the
18//! QSM pipeline's `field_mapping.unwrapping_algorithm` (ROMEO, Laplacian or PRELUDE); it is available as
19//! [`UnwrapMethod::BestPath`](super::UnwrapMethod::BestPath) to the slice-wise unwrapper and to
20//! MCPC-3D-S's HIP unwrapping, and its reliabilities as ROMEO's `:bestpath` weights.
21//!
22//! The method is a path-following unwrapper that never actually follows a path
23//! through the volume. Instead:
24//!
25//! 1. Every voxel gets a **reliability** — the summed squared second difference
26//!    of the wrapped phase over the 13 directions of the 26-neighbourhood. A
27//!    *low* value means the local phase is smooth and the voxel is trustworthy.
28//! 2. Every 6-connected pair of in-mask voxels becomes an **edge**, whose
29//!    reliability is the sum of its two endpoints'. Each edge also records the
30//!    integer wrap count implied by the phase difference across it.
31//! 3. The edges are **sorted** by reliability, most reliable first, and walked in
32//!    that order. Each edge joins the two voxel groups at its ends, offsetting
33//!    one group by the wraps the edge implies. Edges whose endpoints already share
34//!    a group are skipped.
35//!
36//! So the unwrapping order is global rather than local: the most trustworthy
37//! phase differences in the whole volume are committed first, and noisy regions
38//! are reached last, through whatever reliable route survives. That is what makes
39//! it robust to residues compared with a seeded flood fill.
40//!
41//! # Relationship to ROMEO
42//!
43//! [`calculate_weights_bestpath`](super::romeo::calculate_weights_bestpath) also
44//! implements Abdul-Rahman reliabilities, but as *edge weights fed into ROMEO's
45//! minimum-spanning-tree growing* (matching ROMEO.jl's `calculateweights_bestpath`,
46//! including its inversion to `1/sqrt(D²)` and quantisation to `u8`). That is
47//! ROMEO with different weights; this module is the original algorithm, which
48//! merges groups over a globally sorted edge list rather than growing a region
49//! from a seed.
50//!
51//! # Deviations from the reference implementation
52//!
53//! The algorithm here is a reimplementation, informed by the C reference that
54//! scikit-image distributes as `skimage/restoration/unwrap_3d_ljmu.c` under the
55//! BSD-3-Clause licence (see `THIRD_PARTY_NOTICES.md`). Three deliberate
56//! departures:
57//!
58//! - **Group merging** uses a union-find with integer potentials
59//!   ([`UnionFind`](crate::utils::union_find::UnionFind)) rather than the
60//!   reference's explicit per-group linked lists. The merge decisions and the
61//!   resulting wrap counts are the same; only the bookkeeping cost differs
62//!   (O(α) per edge instead of O(size of the smaller group)).
63//! - **Masked voxels keep their original wrapped phase** on output. The reference
64//!   overwrites them with the minimum of the unwrapped phase, which is a display
65//!   convenience; leaving them alone matches [`unwrap_romeo`](super::unwrap_romeo)
66//!   and the rest of this crate.
67//! - **Wrap-around (periodic) boundary options are not implemented.** They exist
68//!   in the reference for interferometry; MRI volumes are not periodic.
69//!
70//! # An upstream quirk, deliberately preserved
71//!
72//! The reference has an `extend_mask` step that reads as though it should exclude
73//! mask-adjacent voxels from the reliability calculation — it tests whether all 26
74//! neighbours are unmasked. It does not: `extended_mask` is `calloc`'d to
75//! `NOMASK`, and the function only ever *assigns* `NOMASK`, never `MASK`, so the
76//! buffer is uniformly `NOMASK` and the guard that reads it is always true. The
77//! mask therefore plays no part in reliability at all; it only decides which edges
78//! exist.
79//!
80//! This module reproduces the effective behaviour, not the apparent intent, and
81//! that is a deliberate choice rather than bug-compatibility for its own sake.
82//! Implementing the intent — stand-in reliabilities for mask-adjacent voxels —
83//! measurably unwraps *worse* near the ROI boundary on noisy data, because it
84//! throws away a graded quality signal and replaces it with a shuffle. A boundary
85//! voxel whose neighbours lie in noise already earns a poor reliability on its own
86//! and sorts late for the right reason.
87//!
88//! The absolute 2π offset of each connected component is arbitrary — it is fixed
89//! only up to the choice of which voxel ends up as the component's representative.
90//! Set [`BestPathParams::correct_global`] for a defined choice.
91//!
92//! # Verification
93//!
94//! Checked against `skimage.restoration.unwrap_phase` (scikit-image 0.26.0) on
95//! 48×48×32 synthetic fields — smooth, noisy, and masked to an irregular ROI with
96//! holes. Every voxel matched bit-for-bit, across several of scikit-image's own rng
97//! seeds. The two diverge only once the added noise approaches ±π, where the wrap
98//! counts are genuinely ambiguous: there they disagree on 0.07% of voxels, which is
99//! the same margin by which scikit-image disagrees with *itself* between rng seeds
100//! (0.06%).
101//!
102//! `tests/bestpath_skimage_parity.rs` keeps this checked, against scikit-image 0.26.0 in the
103//! verified QSM-CI `py-originals` image (QSMxT/QSM-CI#302; `tests/data/bestpath_skimage_small.py`
104//! regenerates it in Actions): on two small phantoms, one noisy, every voxel has scikit-image's
105//! wrap count, and the values agree to 1.8e-15 rad (measured 2026-10-30). In the noisy one the
106//! whole volume sits one 2π above scikit-image's: the component's absolute offset (above). On a
107//! travelling head, every voxel took scikit-image's wrap count too.
108//!
109//! It was this comparison that turned up the `extend_mask` quirk above. Following
110//! the apparent intent cost an order of magnitude in accuracy near the ROI boundary
111//! at high noise — 0.51% of wrap counts wrong against ground truth, versus 0.06%
112//! for the reference's actual behaviour, which this module now reproduces exactly.
113
114use crate::grid::Grid;
115use crate::utils::union_find::UnionFind;
116#[cfg(feature = "parallel")]
117use rayon::prelude::*;
118
119const PI: f64 = std::f64::consts::PI;
120const TWO_PI: f64 = 2.0 * PI;
121
122/// Parameters for [`unwrap_bestpath`].
123#[derive(Clone, Copy, Debug, PartialEq)]
124#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
125pub struct BestPathParams {
126    /// Subtract the median wrap count from the result so the unwrapped phase
127    /// stays centred near the input range (default: false).
128    ///
129    /// Without it, each connected component carries an arbitrary integer multiple
130    /// of 2π. Mirrors [`RomeoParams::correct_global`](super::RomeoParams::correct_global),
131    /// which defaults to false for the same reason: it is a convention, not a
132    /// correction, and multi-echo callers usually impose their own.
133    pub correct_global: bool,
134    /// Seed for the pseudo-random reliabilities given to voxels on the volume
135    /// border (default: 0x05EEDB4D).
136    ///
137    /// Those voxels have no complete 26-neighbourhood and so no meaningful second
138    /// difference. The reference assigns them `rand()`, which puts them far below
139    /// every real reliability and shuffles them among themselves so the merge
140    /// order along the volume faces carries no raster bias. Reproduced here with a
141    /// fixed default seed, so output is deterministic.
142    pub seed: u64,
143}
144
145impl Default for BestPathParams {
146    fn default() -> Self {
147        Self {
148            correct_global: false,
149            seed: 0x05EE_DB4D,
150        }
151    }
152}
153
154/// The 13 direction vectors of the 26-neighbourhood, one per antipodal pair.
155///
156/// Which representative of a pair is used does not matter: the second difference
157/// `wrap(p[i-d] - p[i]) - wrap(p[i] - p[i+d])` is invariant under `d -> -d`,
158/// because `wrap` is odd.
159const DIRECTIONS: [(i32, i32, i32); 13] = [
160    (1, 0, 0),
161    (0, 1, 0),
162    (0, 0, 1),
163    (1, 1, 0),
164    (-1, 1, 0),
165    (1, 0, 1),
166    (-1, 0, 1),
167    (0, 1, 1),
168    (0, -1, 1),
169    (1, 1, 1),
170    (-1, 1, 1),
171    (1, -1, 1),
172    (-1, -1, 1),
173];
174
175/// One edge of the voxel graph, awaiting its turn in the sorted merge.
176struct Edge {
177    /// Summed reliability of the two endpoints; lower is better.
178    reliability: f64,
179    a: u32,
180    b: u32,
181    /// Wraps implied across the edge, as `find_wrap(phase[a], phase[b])`.
182    wraps: i8,
183}
184
185/// Wrap a value already known to lie within ±3π into `[-π, π]`.
186///
187/// Matches the reference's `wrap()`, which is a single conditional rather than a
188/// general modulo. Every argument here is a difference of two wrapped phases, so
189/// its magnitude is at most 2π and one correction always suffices.
190#[inline]
191fn wrap_pi(v: f64) -> f64 {
192    if v > PI {
193        v - TWO_PI
194    } else if v < -PI {
195        v + TWO_PI
196    } else {
197        v
198    }
199}
200
201/// Number of 2π that separates two wrapped phases, as the reference's `find_wrap`.
202///
203/// The returned `w` satisfies `phase_b + 2π·(-w) ≈ phase_a` in the sense that the
204/// residual difference lies in `[-π, π]`.
205#[inline]
206fn find_wrap(phase_a: f64, phase_b: f64) -> i8 {
207    let d = phase_a - phase_b;
208    if d > PI {
209        -1
210    } else if d < -PI {
211        1
212    } else {
213        0
214    }
215}
216
217/// SplitMix64, used to reproduce the reference's `rand()` initialisation
218/// deterministically and independently of iteration order.
219#[inline]
220fn splitmix64(mut x: u64) -> u64 {
221    x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
222    let mut z = x;
223    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
224    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
225    z ^ (z >> 31)
226}
227
228/// A pseudo-random stand-in reliability, uniform over `[0, 2^31)`.
229///
230/// The range matches C's `RAND_MAX`, which is what the reference draws from. Real
231/// reliabilities are bounded by `13·(2π)² ≈ 513`, so these effectively always sort
232/// last — exactly as in the reference, including its vanishingly rare exceptions.
233#[inline]
234fn stand_in_reliability(seed: u64, index: usize) -> f64 {
235    (splitmix64(seed ^ (index as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)) >> 33) as f64
236}
237
238/// Unwrap a 3D phase volume with the best-path (3D-SRNCP) algorithm.
239///
240/// # Arguments
241/// * `phase` — wrapped phase in radians, `nx * ny * nz`, expected in `[-π, π]`
242/// * `mask` — binary mask, non-zero to process
243/// * `params` — see [`BestPathParams`]
244/// * `grid` — volume dimensions (voxel sizes are unused: the reliability is
245///   defined on the voxel lattice, not in physical space)
246///
247/// # Returns
248/// Unwrapped phase, same length as `phase`. Voxels outside the mask are returned
249/// unchanged.
250///
251/// Each connected in-mask component is unwrapped independently and carries its own
252/// arbitrary multiple of 2π; there is no information linking components across a
253/// gap in the mask.
254pub fn unwrap_bestpath(
255    phase: &[f64],
256    mask: &[u8],
257    params: &BestPathParams,
258    grid: &Grid,
259) -> Vec<f64> {
260    let (nx, ny, nz) = grid.dims;
261    let n_total = nx * ny * nz;
262    assert_eq!(phase.len(), n_total, "phase length must match grid dimensions");
263    assert_eq!(mask.len(), n_total, "mask length must match grid dimensions");
264    assert!(
265        n_total <= u32::MAX as usize,
266        "best-path unwrapping indexes voxels by u32 (volume has {n_total} voxels)"
267    );
268
269    let mut unwrapped = phase.to_vec();
270    if n_total == 0 {
271        return unwrapped;
272    }
273
274    let reliability = compute_reliability(phase, params.seed, nx, ny, nz);
275    let mut edges = build_edges(phase, mask, &reliability, nx, ny, nz);
276    sort_edges(&mut edges);
277
278    // Walk the edges best-first, merging the groups they join.
279    let mut uf = UnionFind::new(n_total);
280    for edge in &edges {
281        uf.union_with_delta(edge.a as usize, edge.b as usize, -(edge.wraps as i32));
282    }
283
284    for i in 0..n_total {
285        if mask[i] != 0 {
286            let (_, wraps) = uf.find(i);
287            unwrapped[i] = phase[i] + TWO_PI * wraps as f64;
288        }
289    }
290
291    if params.correct_global {
292        correct_global_offset(&mut unwrapped, mask);
293    }
294
295    unwrapped
296}
297
298/// Per-voxel reliability: the summed squared second difference of the wrapped
299/// phase over the 13 directions of the 26-neighbourhood. Lower is better.
300///
301/// Every voxel with a complete neighbourhood — that is, every voxel off the volume
302/// border — gets a real value, whether or not it or its neighbours are in the mask.
303/// Border voxels keep the pseudo-random stand-in, which sorts them last. See the
304/// module docs on `extend_mask` for why the mask is not consulted here.
305fn compute_reliability(
306    phase: &[f64],
307    seed: u64,
308    nx: usize,
309    ny: usize,
310    nz: usize,
311) -> Vec<f64> {
312    let n_total = nx * ny * nz;
313    let mut reliability: Vec<f64> = (0..n_total).map(|i| stand_in_reliability(seed, i)).collect();
314
315    if nx < 3 || ny < 3 || nz < 3 {
316        // No voxel has a complete 26-neighbourhood.
317        return reliability;
318    }
319
320    let nxy = nx * ny;
321    let strides: [isize; 13] = DIRECTIONS.map(|(dx, dy, dz)| {
322        dx as isize + dy as isize * nx as isize + dz as isize * nxy as isize
323    });
324
325    crate::maybe_par_chunks_mut!(reliability, nxy)
326        .enumerate()
327        .for_each(|(k, plane)| {
328            if k == 0 || k == nz - 1 {
329                return;
330            }
331            for j in 1..ny - 1 {
332                for i in 1..nx - 1 {
333                    let idx = k * nxy + j * nx + i;
334                    let p = phase[idx];
335                    let mut d2 = 0.0;
336                    for &s in &strides {
337                        let back = (idx as isize - s) as usize;
338                        let fwd = (idx as isize + s) as usize;
339                        let d = wrap_pi(phase[back] - p) - wrap_pi(p - phase[fwd]);
340                        d2 += d * d;
341                    }
342                    plane[j * nx + i] = d2;
343                }
344            }
345        });
346
347    reliability
348}
349
350/// Build the 6-connected edge list, skipping any pair with a masked endpoint.
351///
352/// Emitted in x, then y, then z order, matching the reference's
353/// `horizontalEDGEs`/`verticalEDGEs`/`normalEDGEs` — the order decides how equal
354/// reliabilities are broken in [`sort_edges`].
355fn build_edges(
356    phase: &[f64],
357    mask: &[u8],
358    reliability: &[f64],
359    nx: usize,
360    ny: usize,
361    nz: usize,
362) -> Vec<Edge> {
363    let nxy = nx * ny;
364    let in_mask = mask.iter().filter(|&&m| m != 0).count();
365    let mut edges: Vec<Edge> = Vec::with_capacity(3 * in_mask);
366
367    let push = |edges: &mut Vec<Edge>, a: usize, b: usize| {
368        if mask[a] != 0 && mask[b] != 0 {
369            edges.push(Edge {
370                reliability: reliability[a] + reliability[b],
371                a: a as u32,
372                b: b as u32,
373                wraps: find_wrap(phase[a], phase[b]),
374            });
375        }
376    };
377
378    for k in 0..nz {
379        for j in 0..ny {
380            for i in 0..nx - 1 {
381                let idx = k * nxy + j * nx + i;
382                push(&mut edges, idx, idx + 1);
383            }
384        }
385    }
386    for k in 0..nz {
387        for j in 0..ny - 1 {
388            for i in 0..nx {
389                let idx = k * nxy + j * nx + i;
390                push(&mut edges, idx, idx + nx);
391            }
392        }
393    }
394    for k in 0..nz - 1 {
395        for j in 0..ny {
396            for i in 0..nx {
397                let idx = k * nxy + j * nx + i;
398                push(&mut edges, idx, idx + nxy);
399            }
400        }
401    }
402
403    edges
404}
405
406/// Sort edges most-reliable (lowest value) first.
407///
408/// Ties break on construction order, so the result does not depend on the sort's
409/// internal choices. Real ties are common in synthetic data and essentially absent
410/// in measured phase.
411fn sort_edges(edges: &mut [Edge]) {
412    let key = |e: &Edge| (e.reliability, e.a, e.b);
413    #[cfg(feature = "parallel")]
414    edges.par_sort_unstable_by(|x, y| {
415        key(x).partial_cmp(&key(y)).unwrap_or(std::cmp::Ordering::Equal)
416    });
417    #[cfg(not(feature = "parallel"))]
418    edges.sort_unstable_by(|x, y| {
419        key(x).partial_cmp(&key(y)).unwrap_or(std::cmp::Ordering::Equal)
420    });
421}
422
423/// Subtract the median wrap count over the mask, so the result stays centred near
424/// the input range. Mirrors ROMEO's `correct_global_offset`.
425///
426/// Shared with [`unwrap_prelude`](super::unwrap_prelude), which has the same freedom
427/// in its output and resolves it the same way.
428pub(super) fn correct_global_offset(phase: &mut [f64], mask: &[u8]) {
429    let mut wraps: Vec<f64> = phase
430        .iter()
431        .enumerate()
432        .filter(|(i, v)| mask[*i] != 0 && v.is_finite())
433        .map(|(_, &v)| (v / TWO_PI).round())
434        .collect();
435
436    if wraps.is_empty() {
437        return;
438    }
439
440    wraps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
441    let median = wraps[wraps.len() / 2];
442    if median.abs() > 0.1 {
443        let correction = TWO_PI * median;
444        for (i, v) in phase.iter_mut().enumerate() {
445            if mask[i] != 0 {
446                *v -= correction;
447            }
448        }
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
457        Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
458    }
459
460    fn wrap_to_pi(v: f64) -> f64 {
461        v - TWO_PI * (v / TWO_PI).round()
462    }
463
464    /// Build a wrapped volume from a continuous field, plus the field itself.
465    fn wrap_field<F: Fn(f64, f64, f64) -> f64>(
466        nx: usize,
467        ny: usize,
468        nz: usize,
469        f: F,
470    ) -> (Vec<f64>, Vec<f64>) {
471        let mut truth = vec![0.0; nx * ny * nz];
472        let mut wrapped = vec![0.0; nx * ny * nz];
473        for k in 0..nz {
474            for j in 0..ny {
475                for i in 0..nx {
476                    let idx = k * nx * ny + j * nx + i;
477                    let v = f(i as f64, j as f64, k as f64);
478                    truth[idx] = v;
479                    wrapped[idx] = wrap_to_pi(v);
480                }
481            }
482        }
483        (truth, wrapped)
484    }
485
486    /// Assert that `got` equals `truth` up to one global 2π offset, over the mask.
487    ///
488    /// The offset is read off the *median* residual, not a single voxel, so the
489    /// check still works when the input carries noise.
490    fn assert_unwrapped_to_constant(got: &[f64], truth: &[f64], mask: &[u8], tol: f64) {
491        let mut residuals: Vec<f64> = (0..got.len())
492            .filter(|&i| mask[i] != 0)
493            .map(|i| got[i] - truth[i])
494            .collect();
495        assert!(!residuals.is_empty(), "empty mask");
496        residuals.sort_by(|a, b| a.partial_cmp(b).unwrap());
497        let median = residuals[residuals.len() / 2];
498        let offset = TWO_PI * (median / TWO_PI).round();
499        assert!(
500            (median - offset).abs() < tol,
501            "median residual {median} is not within {tol} of a multiple of 2pi"
502        );
503        let worst = residuals
504            .iter()
505            .map(|d| (d - offset).abs())
506            .fold(0.0f64, f64::max);
507        assert!(worst < tol, "max deviation {worst} exceeds {tol}");
508    }
509
510    #[test]
511    fn recovers_a_steep_linear_ramp() {
512        let (nx, ny, nz) = (16, 16, 8);
513        // ~0.9 rad per voxel in x: many wraps, still well under the π/voxel limit.
514        let (truth, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.9 * i + 0.3 * j + 0.2 * k);
515        let mask = vec![1u8; nx * ny * nz];
516
517        let out = unwrap_bestpath(&wrapped, &mask, &BestPathParams::default(), &grid(nx, ny, nz));
518        assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
519    }
520
521    #[test]
522    fn recovers_a_quadratic_field() {
523        let (nx, ny, nz) = (20, 20, 12);
524        let (truth, wrapped) = wrap_field(nx, ny, nz, |i, j, k| {
525            let (x, y, z) = (i - 10.0, j - 10.0, k - 6.0);
526            0.05 * (x * x + y * y) + 0.1 * z * z
527        });
528        let mask = vec![1u8; nx * ny * nz];
529
530        let out = unwrap_bestpath(&wrapped, &mask, &BestPathParams::default(), &grid(nx, ny, nz));
531        assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
532    }
533
534    #[test]
535    fn unwrapped_result_is_already_smooth() {
536        // The real property of interest: no 2pi jumps between neighbours.
537        let (nx, ny, nz) = (16, 16, 16);
538        let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.8 * i - 0.5 * j + 0.4 * k);
539        let mask = vec![1u8; nx * ny * nz];
540
541        let out = unwrap_bestpath(&wrapped, &mask, &BestPathParams::default(), &grid(nx, ny, nz));
542        for k in 0..nz {
543            for j in 0..ny {
544                for i in 0..nx - 1 {
545                    let idx = k * nx * ny + j * nx + i;
546                    let jump = (out[idx + 1] - out[idx]).abs();
547                    assert!(jump < PI, "2pi jump of {jump} at ({i},{j},{k})");
548                }
549            }
550        }
551    }
552
553    #[test]
554    fn masked_voxels_are_left_untouched() {
555        let (nx, ny, nz) = (12, 12, 12);
556        // Steep enough that the 6-voxel cube below spans several wraps; a gentler
557        // ramp can fit entirely inside one branch and unwrap to a no-op.
558        let (truth, wrapped) = wrap_field(nx, ny, nz, |i, j, _| 0.9 * i + 0.9 * j);
559        // Central cube only.
560        let mut mask = vec![0u8; nx * ny * nz];
561        for k in 3..9 {
562            for j in 3..9 {
563                for i in 3..9 {
564                    mask[k * nx * ny + j * nx + i] = 1;
565                }
566            }
567        }
568
569        let out = unwrap_bestpath(&wrapped, &mask, &BestPathParams::default(), &grid(nx, ny, nz));
570        for i in 0..nx * ny * nz {
571            if mask[i] == 0 {
572                assert_eq!(out[i], wrapped[i], "masked voxel {i} was modified");
573            }
574        }
575        // And the in-mask part really was unwrapped, not just copied through.
576        assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
577        let changed = (0..nx * ny * nz)
578            .filter(|&i| mask[i] != 0 && (out[i] - wrapped[i]).abs() > 1e-9)
579            .count();
580        assert!(changed > 0, "nothing inside the mask was unwrapped");
581    }
582
583    #[test]
584    fn disconnected_components_are_each_internally_consistent() {
585        let (nx, ny, nz) = (24, 8, 8);
586        let (truth, wrapped) = wrap_field(nx, ny, nz, |i, _, _| 0.9 * i);
587        // Two slabs separated by a gap: no edge can link them.
588        let mut mask = vec![0u8; nx * ny * nz];
589        for k in 1..nz - 1 {
590            for j in 1..ny - 1 {
591                for i in 0..nx {
592                    if !(8..16).contains(&i) {
593                        mask[k * nx * ny + j * nx + i] = 1;
594                    }
595                }
596            }
597        }
598
599        let out = unwrap_bestpath(&wrapped, &mask, &BestPathParams::default(), &grid(nx, ny, nz));
600
601        // Each slab matches the truth up to its own constant; the constants need
602        // not agree, and there is no information that could make them agree.
603        for (lo, hi) in [(0usize, 8usize), (16, nx)] {
604            let mut sub_mask = vec![0u8; nx * ny * nz];
605            for k in 0..nz {
606                for j in 0..ny {
607                    for i in lo..hi {
608                        let idx = k * nx * ny + j * nx + i;
609                        sub_mask[idx] = mask[idx];
610                    }
611                }
612            }
613            assert_unwrapped_to_constant(&out, &truth, &sub_mask, 1e-9);
614        }
615    }
616
617    #[test]
618    fn is_deterministic_across_runs() {
619        let (nx, ny, nz) = (14, 14, 10);
620        let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.6 * i + 0.5 * j + 0.7 * k);
621        let mut mask = vec![1u8; nx * ny * nz];
622        // An irregular hole, to exercise the stand-in reliabilities.
623        for k in 4..7 {
624            for j in 4..7 {
625                mask[k * nx * ny + j * nx + 5] = 0;
626            }
627        }
628
629        let p = BestPathParams::default();
630        let a = unwrap_bestpath(&wrapped, &mask, &p, &grid(nx, ny, nz));
631        let b = unwrap_bestpath(&wrapped, &mask, &p, &grid(nx, ny, nz));
632        assert_eq!(a, b);
633    }
634
635    #[test]
636    fn survives_noise_in_the_phase() {
637        let (nx, ny, nz) = (20, 20, 12);
638        let (truth, mut wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.5 * i + 0.3 * j + 0.2 * k);
639        // Deterministic low-amplitude noise, well below the wrap threshold.
640        for (i, v) in wrapped.iter_mut().enumerate() {
641            let r = (splitmix64(i as u64) >> 11) as f64 / (1u64 << 53) as f64;
642            *v = wrap_to_pi(*v + 0.25 * (r - 0.5));
643        }
644        let mask = vec![1u8; nx * ny * nz];
645
646        let out = unwrap_bestpath(&wrapped, &mask, &BestPathParams::default(), &grid(nx, ny, nz));
647        // Noise survives into the output, so compare against the noisy truth
648        // tolerance rather than demanding exactness.
649        assert_unwrapped_to_constant(&out, &truth, &mask, 0.2);
650    }
651
652    #[test]
653    fn correct_global_centres_the_result() {
654        let (nx, ny, nz) = (12, 12, 12);
655        // A field offset by many wraps: without correction the output sits far
656        // from zero, with it the median wrap count is brought back to zero.
657        let (_, wrapped) = wrap_field(nx, ny, nz, |i, _, _| 0.8 * i + 100.0 * TWO_PI);
658        let mask = vec![1u8; nx * ny * nz];
659        let g = grid(nx, ny, nz);
660
661        let corrected = unwrap_bestpath(
662            &wrapped,
663            &mask,
664            &BestPathParams { correct_global: true, ..Default::default() },
665            &g,
666        );
667        let mut wraps: Vec<f64> = corrected.iter().map(|v| (v / TWO_PI).round()).collect();
668        wraps.sort_by(|a, b| a.partial_cmp(b).unwrap());
669        assert_eq!(wraps[wraps.len() / 2], 0.0);
670    }
671
672    #[test]
673    fn an_empty_mask_returns_the_input() {
674        let (nx, ny, nz) = (6, 6, 6);
675        let (_, wrapped) = wrap_field(nx, ny, nz, |i, _, _| 0.9 * i);
676        let mask = vec![0u8; nx * ny * nz];
677        let out = unwrap_bestpath(&wrapped, &mask, &BestPathParams::default(), &grid(nx, ny, nz));
678        assert_eq!(out, wrapped);
679    }
680
681    #[test]
682    fn thin_volumes_do_not_panic() {
683        // Fewer than 3 voxels on an axis means no voxel has a full neighbourhood,
684        // so every reliability is a stand-in. Unwrapping must still work.
685        for dims in [(8, 8, 1), (8, 1, 8), (2, 2, 2), (1, 1, 1)] {
686            let (nx, ny, nz) = dims;
687            let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.9 * i + 0.9 * j + 0.9 * k);
688            let mask = vec![1u8; nx * ny * nz];
689            let out =
690                unwrap_bestpath(&wrapped, &mask, &BestPathParams::default(), &grid(nx, ny, nz));
691            assert_eq!(out.len(), nx * ny * nz);
692            assert!(out.iter().all(|v| v.is_finite()));
693        }
694    }
695
696    #[test]
697    fn reliability_is_lower_where_phase_is_smooth() {
698        let (nx, ny, nz) = (12, 12, 12);
699        let (_, mut wrapped) = wrap_field(nx, ny, nz, |i, _, _| 0.2 * i);
700        // Spike one interior voxel: its own reliability, and its neighbours',
701        // should worsen relative to the smooth background.
702        let spike = 6 * nx * ny + 6 * nx + 6;
703        wrapped[spike] = wrap_to_pi(wrapped[spike] + 2.5);
704
705        let rel = compute_reliability(&wrapped, 0, nx, ny, nz);
706        let smooth = rel[4 * nx * ny + 4 * nx + 4];
707        assert!(
708            rel[spike] > smooth,
709            "spiked voxel reliability {} should exceed smooth {}",
710            rel[spike],
711            smooth
712        );
713    }
714
715    #[test]
716    fn border_voxels_get_stand_in_reliabilities() {
717        let (nx, ny, nz) = (8, 8, 8);
718        let (_, wrapped) = wrap_field(nx, ny, nz, |i, _, _| 0.2 * i);
719        let rel = compute_reliability(&wrapped, 7, nx, ny, nz);
720
721        // Real reliabilities are bounded by 13*(2pi)^2; stand-ins are drawn from
722        // [0, 2^31) and so are astronomically larger in practice.
723        let max_real = 13.0 * TWO_PI * TWO_PI;
724        assert!(rel[0] > max_real, "corner voxel should hold a stand-in value");
725        assert!(rel[4 * nx * ny + 4 * nx + 4] < max_real, "interior voxel should be real");
726    }
727
728    #[test]
729    fn find_wrap_matches_its_contract() {
730        assert_eq!(find_wrap(0.0, 0.0), 0);
731        assert_eq!(find_wrap(3.0, -3.0), -1);
732        assert_eq!(find_wrap(-3.0, 3.0), 1);
733        assert_eq!(find_wrap(1.0, -1.0), 0);
734        // Applying the implied offset must land the difference back in [-pi, pi].
735        for &(a, b) in &[(3.0, -3.0), (-3.0, 3.0), (0.5, -0.5), (PI, -PI)] {
736            let w = find_wrap(a, b) as f64;
737            let residual = a - (b + TWO_PI * -w);
738            assert!(residual.abs() <= PI + 1e-12, "residual {residual} out of range");
739        }
740    }
741
742    #[test]
743    fn second_difference_is_invariant_to_direction_sign() {
744        // Justifies picking one representative per antipodal pair in DIRECTIONS.
745        let (a, c, b) = (0.7, -2.9, 2.6);
746        let forward = wrap_pi(a - c) - wrap_pi(c - b);
747        let reverse = wrap_pi(b - c) - wrap_pi(c - a);
748        assert!((forward - reverse).abs() < 1e-12);
749    }
750}