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}