qsm_core/unwrap/prelude.rs
1//! PRELUDE phase unwrapping — best-pair-first region merging.
2//!
3//! After M. Jenkinson, "A Fast, Automated, N-Dimensional Phase Unwrapping
4//! Algorithm", FMRIB Technical Report TR01MJ1 (2001),
5//! <https://www.fmrib.ox.ac.uk/datasets/techrep/tr01mj1/tr01mj1.pdf>, condensed as
6//! Jenkinson M., *Magn Reson Med* 2003;49(1):193–197,
7//! <https://doi.org/10.1002/mrm.10354>.
8//!
9//! PRELUDE is the reference unwrapper that the QSM and field-mapping literature
10//! benchmarks against, which is the reason it is here: it makes this crate's
11//! unwrapper comparisons readable against the published ones.
12//!
13//! The literature generally reports it as slower and less accurate than
14//! [`unwrap_romeo`](super::unwrap_romeo) — Dymerska et al., *Magn Reson Med*
15//! 2021;85(4):2294–2308, <https://doi.org/10.1002/mrm.28563>. That comparison ran FSL's
16//! implementation. The accuracy half carries over, since this module gives FSL's output (below);
17//! the speed half does not. FSL's `prelude` at its defaults took 31 min for the KKI travelling
18//! head's five echoes (256×256×128, 1.52M in-mask voxels); this module takes 4.6 s for the same
19//! output (release build, `parallel`, 8 cores; 0.8–1.1 s per echo). On the `bids/` phantom
20//! (164×205×205, 1.31M in-mask voxels) it takes 0.40 s for the first echo and 1.45 s for the
21//! last at the defaults. With full-3D labelling at 6 partitions it took, per echo:
22//!
23//! | | first echo | last echo |
24//! |---|---|---|
25//! | PRELUDE (full 3D, 6 partitions) | 0.19 s | 0.34 s |
26//! | ROMEO | 0.34 s | 0.29 s |
27//! | best path | 0.32 s | 0.33 s |
28//! | Laplacian | 0.46 s | 0.55 s |
29//!
30//! and the local-field correlations after an identical V-SHARP were 0.615 (PRELUDE),
31//! 0.608 (Laplacian), 0.602 (best path), 0.592 (ROMEO) — a spread narrower than any
32//! of them is from ground truth, which is the agreement the comparison is for rather
33//! than a ranking.
34//!
35//! # Reference pipeline
36//!
37//! The original is FSL's `prelude` (Jenkinson's own implementation, fsl-fugue), and the defaults
38//! ([`PreludeParams::default`]) are its defaults: **8 phase partitions** (its `-n`; the report's
39//! worked example uses 6) and **initial regions labelled slice by slice**, then merged in 3D,
40//! on any volume over 64 voxels along either in-plane axis, and over the whole volume on
41//! smaller ones ([`PreludeLabelling::Auto`]). `--labelslices` labels slice by slice whatever the
42//! size ([`PreludeLabelling::Slices`]), and `-f` over the whole volume
43//! ([`PreludeLabelling::Volume`]). On the in-vivo and simulated volumes measured its output has
44//! a median in-mask wrap count of 0, which [`PreludeParams::correct_global`] (on by default)
45//! reproduces (see "The global constant" below). FSL's own
46//! masking from the magnitude (when no mask is given) is not reproduced; a mask is required here.
47//! Nothing in the algorithm depends on voxel size, field strength or orientation. The QSM
48//! pipeline offers it as [`UnwrappingAlgorithm::Prelude`](crate::pipeline::UnwrappingAlgorithm::Prelude)
49//! (each echo on its own, then whole turns aligned between echoes); it is also
50//! [`UnwrapMethod::Prelude`](super::UnwrapMethod::Prelude) for the slice-wise unwrapper and
51//! MCPC-3D-S's HIP unwrapping.
52//!
53//! **Why slice labelling is the default** (QSM.rs#397, Ashley's decision 2026-11-02): it is the
54//! original's default on every in-vivo volume (FSL's switch to full 3D below 65 voxels in-plane
55//! is kept too, as [`PreludeLabelling::Auto`], so that small volumes also give FSL's output), and
56//! on the KKI travelling head's late echoes full-3D labelling lets
57//! regions straddle wraps that slice labelling does not. Measured as the share of in-mask voxels
58//! whose echo-to-echo step `u_e − u_{e−1}` is not `wrap(φ_e − φ_{e−1})` plus the most common
59//! 2π multiple (a temporal-consistency check that needs no ground truth; lower is better), KKI
60//! echoes 2→5:
61//!
62//! | labelling, partitions | e2 | e3 | e4 | e5 |
63//! |---|---|---|---|---|
64//! | full 3D, 6 (`prelude -f -n 6`) | 3.21 % | 3.48 % | 12.36 % | 6.87 % |
65//! | full 3D, 8 (`prelude -f`) | 3.21 % | 3.47 % | 4.66 % | 11.93 % |
66//! | slices, 6 (`prelude -n 6`) | 3.21 % | 3.47 % | 3.45 % | 3.45 % |
67//! | **slices, 8 (defaults)** | 3.21 % | 3.47 % | 3.45 % | 3.46 % |
68//!
69//! On the simulation (4 echoes at 7 T) the labelling makes no difference to it (5.5–5.7 % for
70//! all; its 8 ms echo spacing sets that floor). Full-3D labelling is kept for parity with
71//! `prelude -f`.
72//!
73//! # Against FSL's `prelude`
74//!
75//! FSL's binary was run as a black box (QSM-CI's `algorithms/_fsl-prelude`: fsl-fugue 2603.0 from
76//! FSL's conda channel; its source was not read) and compared with this module inside the mask,
77//! each echo on its own, on QSM-CI's simulation (164×205×205, 4 echoes, 1.31M in-mask voxels),
78//! the KKI travelling head (256×256×128, 5 echoes, 1.52M) and the Prisma travelling head's first
79//! echo (184×256×128, 1.70M) (QSMxT/QSM-CI#302, QSM.rs#397; measured 2026-11-02). Counted is
80//! every in-mask voxel whose wrap count differs from FSL's, beyond one offset per connected
81//! component of the mask:
82//!
83//! | here | FSL | sim | KKI | Prisma e1 |
84//! |---|---|---|---|---|
85//! | **defaults** (auto = slices here, 8) | `prelude` | 0 on all 4 echoes | 0 on all 5 echoes | 0 |
86//! | slices, 6 | `prelude -n 6` | 0 on all 4 echoes | 0 on echoes 1–4; 7 voxels (0.0005 %) on echo 5 | — |
87//! | full 3D, 8 | `prelude -f` | 0 on all 4 echoes | 0 on all 5 echoes | 0 |
88//! | full 3D, 6 | `prelude -f -n 6` | 0 on all 4 echoes | 0 on all 5 echoes | — |
89//!
90//! At the defaults the output is FSL's exactly, global constant included (largest difference 0
91//! after FSL's single-precision output), and the region counts agree: FSL's `-v` reports 13111,
92//! 23565, 40070, 59635 and 82032 initial regions on KKI's echoes at its defaults (4739 to 40265
93//! with `-f`), which is what 4-connected labelling of the third axis's slices gives, and not the
94//! other two axes'. FSL writes 0 outside the mask. FSL's default run took 31 min on KKI and,
95//! with the simulation's four echoes in one 4-D run, did not finish; run one echo at a time it
96//! took 72 s, 492 s and 1964 s on echoes 2 to 4 (31618 to 118208 regions); this module takes
97//! 0.4 to 1.5 s per echo there.
98//!
99//! **When FSL's default labels slices** ([`PreludeLabelling::Auto`]): only when the volume is over
100//! 64 voxels along its first or second axis. 64×64×5, 64×64×64, 64×64×128, 40×36×200 and
101//! 20×20×300 volumes were labelled in full 3D (`-v` region count equal to `-f`'s), and 65×10×20,
102//! 10×65×20, 65×65×20, 128×32×20 and 256×256×8 slice by slice (equal to `--labelslices`'),
103//! whatever the voxel size (0.4, 1, 3 mm) or the mask's extent (a 128-wide volume masked to its
104//! first 38 columns is still labelled by slices).
105//!
106//! **The global constant.** Each connected set of regions comes out of the merge with an arbitrary
107//! whole number of turns. FSL's output on the three volumes above has a median in-mask wrap count
108//! of 0, which [`PreludeParams::correct_global`] gives; on the small noisy phantoms of
109//! `tests/prelude_fsl_parity.rs` FSL's constant is up to two turns off that median (and is not the
110//! mean, the mode, the first voxel's, the centre voxel's or the largest region's either). No rule
111//! was found for it, so the fixture compares wrap counts up to that one offset: every voxel of all
112//! eight runs (defaults, `--labelslices`, `-f`, `-n 6`, on a 72- and a 64-wide phantom) matches.
113//!
114//! `prelude -s` (everything slice by slice, no 3D merge) differs from
115//! [`unwrap_slicewise`](super::unwrap_slicewise) with [`UnwrapMethod::Prelude`](super::UnwrapMethod::Prelude):
116//! FSL writes 0 in small in-slice components (14k and 23k in-mask voxels on the simulation and
117//! KKI) and, beyond a per-component offset, 0.1–0.6 % of the remaining voxels take another wrap
118//! count. Slice-wise unwrapping is this crate's own, so that is information only.
119//!
120//! # Provenance
121//!
122//! This is a **clean-room implementation written from the technical report alone**.
123//! FSL's `prelude.cc` was not read, fetched, or consulted. That is a licensing
124//! requirement, not a stylistic one: FSL is licensed for non-commercial use only and
125//! the licence reaches "the Software or any derivative of it", so transliterating it
126//! would encumber this MIT crate. The report specifies the whole algorithm, down to
127//! the data-structure choices, so nothing had to be reverse-engineered. Equation
128//! numbers in the comments below refer to it. The slice labelling is the planar restriction
129//! of the report's §4.1; that it is FSL's default, with 8 partitions, and
130//! that it labels slices of the third axis, was established by running FSL's binary as a black
131//! box (its `-v` output and region counts; see above), not from its code.
132//!
133//! # The algorithm
134//!
135//! The unwrapped phase is the wrapped phase plus a whole number of turns,
136//! `θ_j = φ_j + 2π·m_j` (eq 1). Solving for a per-voxel `m_j` is hopeless, so the
137//! volume is first cut into regions that are each assumed wrap-free, and every region
138//! gets a single offset.
139//!
140//! 1. **Initial regions** (§2.4, §4.1). Split `[-π, π)` into `num_phase_partitions` equal
141//! bins. For each bin, mask the in-mask voxels whose wrapped phase falls in it and
142//! label the connected components: within each slice of the third axis, 4-connected
143//! ([`PreludeLabelling::Slices`]; what the default [`PreludeLabelling::Auto`] does on
144//! in-vivo volumes), or over the volume, 6-connected ([`PreludeLabelling::Volume`]). Every component across every bin (and slice) becomes a
145//! region with its own id. Two voxels in one region therefore differ in wrapped
146//! phase by less than one bin width, so a region straddling a wrap is unlikely —
147//! and the narrower the bin, the less likely still. Limiting a region to one slice also
148//! cuts the chance of two distant areas at the same phase being labelled one region
149//! (§4.1), which is how a full-3D region comes to straddle a wrap. The merge below is 3D
150//! either way.
151//!
152//! 2. **Cost** (eq 2–4). Across the interface between regions A and B the cost is
153//! `Σ (φ_Aj − φ_Bk + 2π·M_AB)²`, summed over interfacing voxel pairs, with
154//! `M_AB = M_A − M_B`. Totalled over all interfaces that is the function to
155//! minimise. Differentiating (eq 5–6) gives the continuous minimum
156//! `M_AB = −P_AB / (2π·N_AB)` for `N_AB` interfacing pairs and `P_AB` their summed
157//! phase difference. But `M_AB` has to be an integer, which makes the exact problem
158//! integer programming with `2^N` neighbouring solutions — hence the greedy scheme
159//! below.
160//!
161//! 3. **Best-pair-first merging** (§2.3). Treat each interface as the constraint
162//! `M_AB = round(K_AB)`, `K_AB = −P_AB/(2π·N_AB)` (eq 7). Getting one of these
163//! wrong by ±1 costs `ΔC_AB = 8π²·N_AB·(½ − |K_AB − L_AB|)` (eq 8–9). Merge the
164//! pair that maximises it (eq 10) — in the report's words, *"choose the pair of
165//! regions where getting the offset 'wrong' is the most disastrous"*. A wide
166//! interface whose mean phase step sits close to a whole number of turns is both
167//! the most confident call available and the most expensive one to get wrong, so
168//! it is settled first and the ambiguous interfaces inherit what it decided.
169//! Merging sums the statistics over the merged region's remaining neighbours
170//! (eq 11–12). Repeat until no interfaces are left.
171//!
172//! # Deviations from the report
173//!
174//! - **The merge statistics are kept in the current frame.** Equations 11–12 read
175//! `N_QW = N_AW + N_BW`, `P_QW = P_AW + P_BW`. The counts add directly; the summed
176//! differences do not, until both sides are expressed in one frame. Merging at
177//! `L_AB` only fixes the two sets *relative* to each other, so one of them has to
178//! move — and its `P` to every other region shifts by `±2π·L_AB·N` before the sum.
179//! Which side moves is not free to choose here: the union-find keeps the larger
180//! set's representative, so the absorbed set is the one that moves, and the sign
181//! follows from that rather than always being B's. The report says to merge "using
182//! the 'optimal' offset `L_AB`" and leaves the bookkeeping implicit; `merge_pair`
183//! writes it out. Getting the direction wrong is silent — it biases every later
184//! merge by one turn, and only shows up on volumes where the two sets differ in
185//! size enough for the union-find to pick the other root.
186//! - **A lazily-invalidated binary heap replaces the O(C) scan.** The report searches
187//! its `map` of interfaces linearly for the maximum `ΔC`, at most `R` times, which
188//! it costs as a small penalty next to the cheap insert and delete. That holds at
189//! its worked example's scale (1920 regions); it does not at the hundreds of
190//! thousands a high-resolution 3D volume can produce, where `O(R·C)` dominates
191//! everything else. The merge *order* is identical — the heap returns the same
192//! maximum — only the way it is found differs.
193//! - **Masked voxels keep their original wrapped phase** on output, matching
194//! [`unwrap_bestpath`](super::unwrap_bestpath) and [`unwrap_romeo`](super::unwrap_romeo).
195//!
196//! Each connected set of regions is unwrapped independently and carries its own
197//! arbitrary multiple of 2π — nothing links regions across a gap in the mask. Set
198//! [`PreludeParams::correct_global`] for a defined choice of that constant.
199
200use std::cmp::Ordering;
201use std::collections::{BTreeMap, BinaryHeap, HashMap};
202
203use crate::grid::Grid;
204use crate::utils::connected::{label_components, Connectivity};
205use crate::utils::union_find::UnionFind;
206
207use super::bestpath::correct_global_offset;
208
209const PI: f64 = std::f64::consts::PI;
210const TWO_PI: f64 = 2.0 * PI;
211
212/// Sentinel in the region map for a voxel that belongs to no region — outside the
213/// mask, or carrying a non-finite phase.
214const NO_REGION: u32 = u32::MAX;
215
216/// How the initial regions are labelled (§2.4, §4.1): which voxels of one phase partition count
217/// as connected.
218#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
219#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
220pub enum PreludeLabelling {
221 /// FSL `prelude`'s default: [`Slices`](Self::Slices) when the volume is more than
222 /// [`AUTO_SLICES_ABOVE`] voxels along either in-plane axis (the first two), otherwise
223 /// [`Volume`](Self::Volume). Every in-vivo acquisition is over that size, so in practice
224 /// this is slice labelling; small test volumes get FSL's full-3D labelling, as FSL gives
225 /// them. The rule was found by running FSL's binary on volumes of different shapes (module
226 /// docs).
227 #[default]
228 Auto,
229 /// Label each partition's connected components **within each slice** (4-connected in the
230 /// first two axes' plane, one slice of the third axis at a time), then merge the regions in
231 /// 3D. FSL's `--labelslices`, and its default on volumes over [`AUTO_SLICES_ABOVE`] voxels
232 /// in-plane: the planar restriction of the report's §4.1.
233 Slices,
234 /// Label each partition's 6-connected components over the whole volume, then merge them.
235 /// FSL `prelude -f`; the algorithm as the report's §2.4 states it.
236 Volume,
237}
238
239/// The in-plane size above which [`PreludeLabelling::Auto`] labels slice by slice: FSL `prelude`
240/// labels a 64×64×N volume in full 3D and a 65×N×M or N×65×M one slice by slice, whatever the
241/// slice count, voxel size or mask.
242pub const AUTO_SLICES_ABOVE: usize = 64;
243
244impl PreludeLabelling {
245 /// Stable id (`"auto"`, `"slices"`, `"volume"`).
246 pub fn id(self) -> &'static str {
247 match self {
248 Self::Auto => "auto",
249 Self::Slices => "slices",
250 Self::Volume => "volume",
251 }
252 }
253
254 /// The labelling used on a volume of `dims`: [`Auto`](Self::Auto) resolved, the others as
255 /// they are.
256 pub fn resolve(self, dims: (usize, usize, usize)) -> Self {
257 match self {
258 Self::Auto if dims.0 > AUTO_SLICES_ABOVE || dims.1 > AUTO_SLICES_ABOVE => Self::Slices,
259 Self::Auto => Self::Volume,
260 other => other,
261 }
262 }
263}
264
265/// Parameters for [`unwrap_prelude`].
266///
267/// The defaults are FSL `prelude`'s: 8 partitions, regions labelled slice by slice on volumes
268/// over 64 voxels in-plane. With them this module gives FSL's default output (see the module
269/// docs for the measured agreement).
270#[derive(Clone, Copy, Debug, PartialEq)]
271#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
272pub struct PreludeParams {
273 /// Number of equal bins the phase range `[-π, π)` is cut into when forming the
274 /// initial regions (default: 8, FSL `prelude`'s default, its `-n`; the report's
275 /// worked example uses 6).
276 ///
277 /// More partitions means narrower bins, so a connected component is less likely
278 /// to contain a wrap — at the price of more, smaller regions and a longer merge.
279 /// Fewer means larger regions that are more likely to straddle a wrap, which the
280 /// merge cannot undo: a region is committed to a single offset by construction.
281 /// Must be at least 1; at 1 every in-mask component is one region and nothing is
282 /// unwrapped within it.
283 pub num_phase_partitions: usize,
284 /// How the initial regions are labelled (default: [`PreludeLabelling::Auto`], FSL
285 /// `prelude`'s default, which is slice by slice on any in-vivo volume;
286 /// [`PreludeLabelling::Slices`] is `prelude --labelslices`, [`PreludeLabelling::Volume`] is
287 /// `prelude -f`).
288 pub labelling: PreludeLabelling,
289 /// Subtract the median wrap count from the result so the unwrapped phase stays
290 /// centred near the input range (default: true).
291 ///
292 /// Without it each connected set of regions carries an arbitrary integer multiple
293 /// of 2π. The default is on because FSL's `prelude` output has a median in-mask wrap count
294 /// of 0 on every volume measured (see the module docs), so this is what reproduces it; it
295 /// also gives a multi-echo caller echoes whose constants are already near each other
296 /// before whole turns are aligned between them. Same operation as
297 /// [`BestPathParams::correct_global`](super::BestPathParams::correct_global) and
298 /// [`RomeoParams::correct_global`](super::RomeoParams::correct_global), which default to
299 /// false because their originals do not do it.
300 pub correct_global: bool,
301}
302
303impl Default for PreludeParams {
304 fn default() -> Self {
305 Self {
306 num_phase_partitions: 8,
307 labelling: PreludeLabelling::Auto,
308 correct_global: true,
309 }
310 }
311}
312
313/// Running statistics for one interface between two regions.
314///
315/// `p` is directed: for the entry stored under `adjacency[a][b]` it is the sum taken
316/// as *a minus b*, so `adjacency[b][a]` holds its negation.
317#[derive(Clone, Copy, Debug, PartialEq)]
318struct Interface {
319 /// `N_AB`: the number of interfacing voxel pairs.
320 n: u32,
321 /// `P_AB = Σ (ψ_Aj − ψ_Bk)` over those pairs, where ψ is the phase in the
322 /// *current* working frame — the wrapped phase plus whatever turns previous
323 /// merges have already committed to.
324 p: f64,
325}
326
327impl Interface {
328 /// `K_AB = −P_AB / (2π·N_AB)`, the continuous minimum of eq 6.
329 #[inline]
330 fn k(self) -> f64 {
331 -self.p / (TWO_PI * self.n as f64)
332 }
333
334 /// `L_AB = round(K_AB)`: the integer offset the merge would commit to (eq 7).
335 #[inline]
336 fn offset(self) -> f64 {
337 self.k().round()
338 }
339
340 /// `ΔC_AB = 8π²·N_AB·(½ − |K_AB − L_AB|)`, the smallest cost penalty for getting
341 /// this interface's offset wrong by one turn (eq 9).
342 ///
343 /// Non-negative, since rounding bounds `|K − L|` by ½. It is largest for a wide
344 /// interface whose mean phase step lands near a whole number of turns, and falls
345 /// to zero when the step is exactly half a turn and the rounding is a coin toss.
346 #[inline]
347 fn cost(self) -> f64 {
348 let k = self.k();
349 8.0 * PI * PI * self.n as f64 * (0.5 - (k - k.round()).abs())
350 }
351}
352
353/// One entry in the merge queue: an interface, with the `ΔC` it had when queued.
354///
355/// The cost is carried along so a stale entry can be recognised on the way out and
356/// requeued at its true value, rather than the queue having to be rebuilt on each
357/// merge.
358#[derive(Clone, Copy, PartialEq)]
359struct Candidate {
360 cost: f64,
361 a: u32,
362 b: u32,
363}
364
365impl Eq for Candidate {}
366
367impl Ord for Candidate {
368 /// Orders by cost, then by region id so ties break the same way on every run.
369 ///
370 /// `BinaryHeap` is a max-heap, so the id comparisons are reversed: of two
371 /// interfaces with equal `ΔC`, the one with the lower ids is merged first.
372 fn cmp(&self, other: &Self) -> Ordering {
373 self.cost
374 .total_cmp(&other.cost)
375 .then_with(|| other.a.cmp(&self.a))
376 .then_with(|| other.b.cmp(&self.b))
377 }
378}
379
380impl PartialOrd for Candidate {
381 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
382 Some(self.cmp(other))
383 }
384}
385
386/// Unwrap a 3D phase volume with PRELUDE's region-merging algorithm.
387///
388/// # Arguments
389/// * `phase` — wrapped phase in radians, `nx * ny * nz`, expected in `[-π, π]`
390/// * `mask` — binary mask, non-zero to process
391/// * `grid` — volume dimensions (voxel sizes are unused: regions and interfaces are
392/// defined on the voxel lattice, not in physical space)
393/// * `params` — see [`PreludeParams`]
394///
395/// # Returns
396/// Unwrapped phase, same length as `phase`. Voxels outside the mask are returned
397/// unchanged, as are in-mask voxels whose phase is not finite.
398///
399/// Each connected set of regions is unwrapped independently and carries its own
400/// arbitrary multiple of 2π; there is no information linking them across a gap in
401/// the mask.
402///
403/// # Panics
404/// If `phase` or `mask` does not match the grid, if `params.num_phase_partitions` is
405/// zero, or if the volume has more than `u32::MAX` voxels.
406pub fn unwrap_prelude(
407 phase: &[f64],
408 mask: &[u8],
409 grid: &Grid,
410 params: &PreludeParams,
411) -> Vec<f64> {
412 let (nx, ny, nz) = grid.dims;
413 let n_total = nx * ny * nz;
414 assert_eq!(phase.len(), n_total, "phase length must match grid dimensions");
415 assert_eq!(mask.len(), n_total, "mask length must match grid dimensions");
416 assert!(
417 params.num_phase_partitions > 0,
418 "num_phase_partitions must be at least 1"
419 );
420 assert!(
421 n_total < u32::MAX as usize,
422 "PRELUDE indexes regions by u32 (volume has {n_total} voxels)"
423 );
424
425 let mut unwrapped = phase.to_vec();
426 if n_total == 0 {
427 return unwrapped;
428 }
429
430 let (region_of, n_regions) =
431 label_regions(phase, mask, grid.dims, params.num_phase_partitions, params.labelling);
432 if n_regions == 0 {
433 return unwrapped;
434 }
435
436 let mut adjacency = build_interfaces(phase, ®ion_of, n_regions, grid.dims);
437 let mut uf = UnionFind::new(n_regions);
438 merge_regions(&mut adjacency, &mut uf);
439
440 for i in 0..n_total {
441 if region_of[i] != NO_REGION {
442 let (_, turns) = uf.find(region_of[i] as usize);
443 unwrapped[i] = phase[i] + TWO_PI * turns as f64;
444 }
445 }
446
447 if params.correct_global {
448 correct_global_offset(&mut unwrapped, mask);
449 }
450
451 unwrapped
452}
453
454/// Which phase partition a voxel falls in, or `None` if it has no usable phase.
455///
456/// Clamped rather than asserted: the caller is documented to supply phase in
457/// `[-π, π]`, and exactly `+π` would otherwise fall off the end of the last bin.
458/// Anything that strayed further out joins the nearest bin, which keeps the voxel in
459/// play — any region it lands in still merges on its measured phase differences.
460#[inline]
461fn phase_partition(v: f64, width: f64, partitions: usize) -> Option<usize> {
462 if !v.is_finite() {
463 return None;
464 }
465 let bin = ((v + PI) / width).floor().max(0.0);
466 // `as usize` saturates, so a wildly out-of-range phase lands in the last bin.
467 Some((bin as usize).min(partitions - 1))
468}
469
470/// Partition the phase range, label the connected components of each partition, and
471/// hand every component across every partition a unique region id (§2.4).
472///
473/// With [`PreludeLabelling::Slices`] (or [`PreludeLabelling::Auto`] on a volume it resolves to
474/// slices for) a component is connected within one slice of the third axis only (4-connected in-plane), so no region spans two slices; the merge is 3D either way.
475/// Ids run partition by partition, and within a partition slice by slice.
476///
477/// Returns the per-voxel region map, [`NO_REGION`] where there is none, and the
478/// number of regions created.
479fn label_regions(
480 phase: &[f64],
481 mask: &[u8],
482 dims: (usize, usize, usize),
483 partitions: usize,
484 labelling: PreludeLabelling,
485) -> (Vec<u32>, usize) {
486 let n_total = phase.len();
487 let (nx, ny, nz) = dims;
488 let nxy = nx * ny;
489 let width = TWO_PI / partitions as f64;
490
491 let mut region_of = vec![NO_REGION; n_total];
492 let mut partition_mask = vec![0u8; n_total];
493 let mut next_id: u32 = 0;
494
495 // Label `partition_mask[range]` as a volume of `dims` and give its components the next ids.
496 let mut assign = |partition_mask: &[u8], offset: usize, dims: (usize, usize, usize)| {
497 let (labels, sizes) = label_components(partition_mask, dims, Connectivity::Face);
498 for (i, &l) in labels.iter().enumerate() {
499 if l != 0 {
500 region_of[offset + i] = next_id + l - 1;
501 }
502 }
503 // `sizes[0]` is the background slot, so the component count is one less.
504 next_id += (sizes.len() - 1) as u32;
505 };
506
507 for bin in 0..partitions {
508 for i in 0..n_total {
509 let inside = mask[i] != 0
510 && phase_partition(phase[i], width, partitions) == Some(bin);
511 partition_mask[i] = u8::from(inside);
512 }
513
514 match labelling.resolve(dims) {
515 PreludeLabelling::Volume | PreludeLabelling::Auto => assign(&partition_mask, 0, dims),
516 // A single-slice volume's face neighbours are its in-plane 4-neighbours.
517 PreludeLabelling::Slices => {
518 for z in 0..nz {
519 let slice = &partition_mask[z * nxy..(z + 1) * nxy];
520 if slice.iter().any(|&v| v != 0) {
521 assign(slice, z * nxy, (nx, ny, 1));
522 }
523 }
524 }
525 }
526 }
527
528 (region_of, next_id as usize)
529}
530
531/// Accumulate `N_AB` and `P_AB` for every interface, as a symmetric adjacency list.
532///
533/// Only the three forward neighbours are visited, so each interfacing voxel pair is
534/// counted exactly once — which is what `N_AB` in eq 6 is defined to be.
535fn build_interfaces(
536 phase: &[f64],
537 region_of: &[u32],
538 n_regions: usize,
539 dims: (usize, usize, usize),
540) -> Vec<BTreeMap<u32, Interface>> {
541 let (nx, ny, nz) = dims;
542 let nxy = nx * ny;
543
544 // Keyed on the ordered pair packed into a u64, so the sums below accumulate in
545 // raster order and the result does not depend on hash iteration order.
546 let mut pairs: HashMap<u64, Interface> = HashMap::new();
547
548 for z in 0..nz {
549 for y in 0..ny {
550 for x in 0..nx {
551 let i = z * nxy + y * nx + x;
552 let ra = region_of[i];
553 if ra == NO_REGION {
554 continue;
555 }
556 let accumulate = |j: usize, pairs: &mut HashMap<u64, Interface>| {
557 let rb = region_of[j];
558 if rb == NO_REGION || rb == ra {
559 return;
560 }
561 // Store the sum directed from the lower id to the higher.
562 let (lo, hi, d) = if ra < rb {
563 (ra, rb, phase[i] - phase[j])
564 } else {
565 (rb, ra, phase[j] - phase[i])
566 };
567 let entry = pairs
568 .entry((lo as u64) << 32 | hi as u64)
569 .or_insert(Interface { n: 0, p: 0.0 });
570 entry.n += 1;
571 entry.p += d;
572 };
573 if x + 1 < nx {
574 accumulate(i + 1, &mut pairs);
575 }
576 if y + 1 < ny {
577 accumulate(i + nx, &mut pairs);
578 }
579 if z + 1 < nz {
580 accumulate(i + nxy, &mut pairs);
581 }
582 }
583 }
584 }
585
586 let mut adjacency = vec![BTreeMap::new(); n_regions];
587 for (key, iface) in pairs {
588 let lo = (key >> 32) as u32;
589 let hi = key as u32;
590 adjacency[lo as usize].insert(hi, iface);
591 adjacency[hi as usize].insert(
592 lo,
593 Interface {
594 n: iface.n,
595 p: -iface.p,
596 },
597 );
598 }
599 adjacency
600}
601
602/// Run the best-pair-first merge to exhaustion (§2.3).
603///
604/// On return the union-find holds, for every region, the number of turns separating
605/// it from its set's representative.
606fn merge_regions(adjacency: &mut [BTreeMap<u32, Interface>], uf: &mut UnionFind) {
607 let mut heap: BinaryHeap<Candidate> = BinaryHeap::new();
608 for (a, neighbours) in adjacency.iter().enumerate() {
609 for (&b, iface) in neighbours {
610 if (a as u32) < b {
611 heap.push(Candidate {
612 cost: iface.cost(),
613 a: a as u32,
614 b,
615 });
616 }
617 }
618 }
619
620 while let Some(candidate) = heap.pop() {
621 let (a, b) = (candidate.a as usize, candidate.b as usize);
622 // An endpoint that is no longer its set's representative has been absorbed,
623 // and the merge that absorbed it queued a replacement for this interface.
624 if uf.find(a).0 != a || uf.find(b).0 != b {
625 continue;
626 }
627 let Some(&iface) = adjacency[a].get(&candidate.b) else {
628 continue;
629 };
630 let cost = iface.cost();
631 // An interface carrying no usable cost tells us nothing about the offset, and
632 // guessing one would be worse than leaving the two regions apart. `label_regions`
633 // keeps non-finite phases out of every region, so this does not arise from
634 // ordinary input; it is here so that a future change upstream degrades into
635 // "unmerged" rather than into something silently wrong.
636 if !cost.is_finite() {
637 continue;
638 }
639 // Both operands are finite here — the guard above saw to `cost`, and a queued
640 // NaN would have been dropped by that same guard when its entry was popped — so
641 // `==` is exact, and the loop terminates. Removing that guard reintroduces the
642 // NaN-never-equals-itself case and with it an infinite requeue, which is what
643 // `an_interface_with_no_usable_cost_is_left_unmerged` exists to pin.
644 if cost != candidate.cost {
645 // The interface changed after this entry was queued. Requeue it at its true
646 // cost; the entry that superseded this one is already in the heap. The
647 // recomputation is bit-identical to the one that queued it, so this settles
648 // in one round rather than oscillating.
649 heap.push(Candidate { cost, ..candidate });
650 continue;
651 }
652 merge_pair(adjacency, uf, candidate.a, candidate.b, iface, &mut heap);
653 }
654}
655
656/// Merge regions `a` and `b` at the offset their interface implies, fold their
657/// adjacency together, and queue the interfaces that changed.
658fn merge_pair(
659 adjacency: &mut [BTreeMap<u32, Interface>],
660 uf: &mut UnionFind,
661 a: u32,
662 b: u32,
663 iface: Interface,
664 heap: &mut BinaryHeap<Candidate>,
665) {
666 let turns = iface.offset();
667 debug_assert!(
668 turns.abs() < i32::MAX as f64,
669 "interface offset {turns} is out of range for the union-find's i32 potential"
670 );
671
672 // Committing to M_AB = L_AB means theta_A - theta_B = psi_A - psi_B + 2*pi*L_AB:
673 // the two sets end up L_AB turns apart, with B the lower.
674 let joined = uf.union_with_delta(a as usize, b as usize, -(turns as i32));
675 debug_assert!(joined, "merge_pair called on two regions already in one set");
676 let root = uf.find(a as usize).0 as u32;
677 let absorbed = if root == a { b } else { a };
678
679 // The interface just consumed is gone.
680 adjacency[a as usize].remove(&b);
681 adjacency[b as usize].remove(&a);
682
683 // That constraint fixes only the *difference* between the two sets, so one of them
684 // has to be re-expressed in the other's frame, and the union-find has already
685 // chosen which: it keeps the larger set's representative, and `find` then reports
686 // the absorbed set's potentials against it. So the absorbed set is exactly the one
687 // whose phases moved — by L_AB turns down if that was B, up if it was A — and its
688 // interfaces to everything else move with it. This is what equation 12 leaves
689 // implicit when it says to merge "using the 'optimal' offset"; getting the
690 // direction wrong here silently biases every later merge by 2*pi*L_AB.
691 let shift = if absorbed == b { -TWO_PI * turns } else { TWO_PI * turns };
692
693 // Fold the absorbed region's interfaces into the surviving one (eq 11-12).
694 let moved = std::mem::take(&mut adjacency[absorbed as usize]);
695 let mut touched: Vec<u32> = Vec::with_capacity(moved.len());
696 for (w, from_absorbed) in moved {
697 // Every merge drops the absorbed region's mirror entries, so adjacency only ever
698 // holds live representatives. That is what lets `merge_regions` treat a missing
699 // entry as "already merged" rather than as a lost interface, so it is worth
700 // pinning: skipping the removal below leaks dead ids back in, and nothing else
701 // would notice.
702 debug_assert_eq!(
703 uf.find(w as usize).0,
704 w as usize,
705 "adjacency holds region {w}, which is not its set's representative"
706 );
707 adjacency[w as usize].remove(&absorbed);
708 let merged = {
709 let entry = adjacency[root as usize]
710 .entry(w)
711 .or_insert(Interface { n: 0, p: 0.0 });
712 entry.n += from_absorbed.n;
713 entry.p += from_absorbed.p + shift * from_absorbed.n as f64;
714 *entry
715 };
716 adjacency[w as usize].insert(
717 root,
718 Interface {
719 n: merged.n,
720 p: -merged.p,
721 },
722 );
723 touched.push(w);
724 }
725
726 // Only the folded interfaces changed; the survivor's own are already in its frame,
727 // and their queued candidates stay valid.
728 for w in touched {
729 if let Some(entry) = adjacency[root as usize].get(&w) {
730 heap.push(Candidate {
731 cost: entry.cost(),
732 a: root,
733 b: w,
734 });
735 }
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742
743 fn grid(nx: usize, ny: usize, nz: usize) -> Grid {
744 Grid::new(nx, ny, nz, 1.0, 1.0, 1.0)
745 }
746
747 fn wrap_to_pi(v: f64) -> f64 {
748 v - TWO_PI * (v / TWO_PI).round()
749 }
750
751 /// Build a wrapped volume from a continuous field, plus the field itself.
752 fn wrap_field<F: Fn(f64, f64, f64) -> f64>(
753 nx: usize,
754 ny: usize,
755 nz: usize,
756 f: F,
757 ) -> (Vec<f64>, Vec<f64>) {
758 let mut truth = vec![0.0; nx * ny * nz];
759 let mut wrapped = vec![0.0; nx * ny * nz];
760 for k in 0..nz {
761 for j in 0..ny {
762 for i in 0..nx {
763 let idx = k * nx * ny + j * nx + i;
764 let v = f(i as f64, j as f64, k as f64);
765 truth[idx] = v;
766 wrapped[idx] = wrap_to_pi(v);
767 }
768 }
769 }
770 (truth, wrapped)
771 }
772
773 /// Assert that `got` equals `truth` up to one global 2π offset, over the mask.
774 ///
775 /// The offset is read off the *median* residual, not a single voxel, so the check
776 /// still works when the input carries noise.
777 fn assert_unwrapped_to_constant(got: &[f64], truth: &[f64], mask: &[u8], tol: f64) {
778 let mut residuals: Vec<f64> = (0..got.len())
779 .filter(|&i| mask[i] != 0)
780 .map(|i| got[i] - truth[i])
781 .collect();
782 assert!(!residuals.is_empty(), "empty mask");
783 residuals.sort_by(|a, b| a.partial_cmp(b).unwrap());
784 let median = residuals[residuals.len() / 2];
785 let offset = TWO_PI * (median / TWO_PI).round();
786 assert!(
787 (median - offset).abs() < tol,
788 "median residual {median} is not within {tol} of a multiple of 2pi"
789 );
790 let worst = residuals
791 .iter()
792 .map(|d| (d - offset).abs())
793 .fold(0.0f64, f64::max);
794 assert!(worst < tol, "max deviation {worst} exceeds {tol}");
795 }
796
797 /// A deterministic uniform draw in `[0, 1)`, so the noisy tests do not pull in a
798 /// dependency and do not change answer between runs.
799 fn lcg(state: &mut u64) -> f64 {
800 *state = state
801 .wrapping_mul(6364136223846793005)
802 .wrapping_add(1442695040888963407);
803 ((*state >> 11) as f64) / ((1u64 << 53) as f64)
804 }
805
806 // ---------------------------------------------------------------- recovery
807
808 #[test]
809 fn recovers_a_steep_linear_ramp() {
810 let (nx, ny, nz) = (16, 16, 8);
811 // ~0.9 rad per voxel in x: many wraps, still well under the pi/voxel limit.
812 let (truth, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.9 * i + 0.3 * j + 0.2 * k);
813 let mask = vec![1u8; nx * ny * nz];
814
815 let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
816 assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
817 }
818
819 #[test]
820 fn recovers_a_quadratic_field() {
821 let (nx, ny, nz) = (20, 20, 12);
822 let (truth, wrapped) = wrap_field(nx, ny, nz, |i, j, k| {
823 let (x, y, z) = (i - 10.0, j - 10.0, k - 6.0);
824 0.05 * (x * x + y * y) + 0.1 * z * z
825 });
826 let mask = vec![1u8; nx * ny * nz];
827
828 let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
829 assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
830 }
831
832 #[test]
833 fn a_single_wrap_between_two_regions_is_closed() {
834 // Two voxels 6 rad apart in wrapped phase: one turn, and the whole algorithm
835 // reduces to a single interface with N = 1 and L = 1.
836 let phase = vec![3.0, -3.0];
837 let mask = vec![1u8, 1];
838 let out = unwrap_prelude(&phase, &mask, &grid(2, 1, 1), &PreludeParams::default());
839
840 // Which of the two keeps its phase is the arbitrary constant; what the merge
841 // decides is the step between them.
842 let step = out[1] - out[0];
843 assert!(
844 (step - (TWO_PI - 6.0)).abs() < 1e-12,
845 "expected the 6 rad gap to close to {}, got {step}",
846 TWO_PI - 6.0
847 );
848 for i in 0..2 {
849 let turns = (out[i] - phase[i]) / TWO_PI;
850 assert!(
851 (turns - turns.round()).abs() < 1e-12,
852 "voxel {i} moved by {turns} turns, which is not a whole number"
853 );
854 }
855 }
856
857 #[test]
858 fn unwrapped_result_is_already_smooth() {
859 // The real property of interest: no 2pi jumps between neighbours.
860 let (nx, ny, nz) = (16, 16, 16);
861 let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.8 * i - 0.5 * j + 0.4 * k);
862 let mask = vec![1u8; nx * ny * nz];
863
864 let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
865
866 let nxy = nx * ny;
867 for k in 0..nz {
868 for j in 0..ny {
869 for i in 0..nx {
870 let idx = k * nxy + j * nx + i;
871 for (step, limit, axis) in [(1, i + 1 < nx, 'x'), (nx, j + 1 < ny, 'y'), (nxy, k + 1 < nz, 'z')] {
872 if limit {
873 let d = (out[idx + step] - out[idx]).abs();
874 assert!(d < PI, "{axis} step of {d} at ({i},{j},{k}) exceeds pi");
875 }
876 }
877 }
878 }
879 }
880 }
881
882 #[test]
883 fn survives_noise_in_the_phase() {
884 let (nx, ny, nz) = (24, 24, 12);
885 let (truth, mut wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.5 * i + 0.2 * j + 0.15 * k);
886 let mut state = 0xC0FFEEu64;
887 let mut noisy_truth = truth.clone();
888 for idx in 0..wrapped.len() {
889 let noise = 0.3 * (lcg(&mut state) - 0.5);
890 noisy_truth[idx] += noise;
891 wrapped[idx] = wrap_to_pi(noisy_truth[idx]);
892 }
893 let mask = vec![1u8; nx * ny * nz];
894
895 let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
896 // Every voxel must land on the right turn; the noise itself survives untouched.
897 assert_unwrapped_to_constant(&out, &noisy_truth, &mask, 1e-9);
898 }
899
900 #[test]
901 fn agrees_with_best_path_on_a_smooth_field() {
902 let (nx, ny, nz) = (20, 20, 10);
903 let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| {
904 0.6 * i - 0.35 * j + 0.5 * k + 0.02 * i * j
905 });
906 let mask = vec![1u8; nx * ny * nz];
907 let g = grid(nx, ny, nz);
908
909 let a = unwrap_prelude(&wrapped, &mask, &g, &PreludeParams::default());
910 let b = super::super::unwrap_bestpath(&wrapped, &mask, &Default::default(), &g);
911
912 // Both fix the phase only up to one global turn over the (single) connected
913 // component, so compare after removing that constant.
914 let offset = TWO_PI * ((a[0] - b[0]) / TWO_PI).round();
915 let worst = (0..a.len())
916 .map(|i| (a[i] - b[i] - offset).abs())
917 .fold(0.0f64, f64::max);
918 assert!(worst < 1e-9, "PRELUDE and best path differ by up to {worst} rad");
919 }
920
921 // ------------------------------------------------------------ mask handling
922
923 #[test]
924 fn masked_voxels_are_left_untouched() {
925 let (nx, ny, nz) = (12, 12, 6);
926 let (truth, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.7 * i + 0.4 * j + 0.3 * k);
927 let nxy = nx * ny;
928 let mut mask = vec![0u8; nx * ny * nz];
929 for k in 1..nz - 1 {
930 for j in 2..ny - 2 {
931 for i in 2..nx - 2 {
932 mask[k * nxy + j * nx + i] = 1;
933 }
934 }
935 }
936
937 let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
938 for i in 0..out.len() {
939 if mask[i] == 0 {
940 assert_eq!(out[i], wrapped[i], "voxel {i} is outside the mask");
941 }
942 }
943 assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
944 }
945
946 #[test]
947 fn disconnected_components_are_each_internally_consistent() {
948 // Two slabs with a gap: nothing links them, so each picks its own turn, but
949 // each must be smooth on its own.
950 let (nx, ny, nz) = (10, 10, 10);
951 let (truth, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.9 * i + 0.5 * j + 0.6 * k);
952 let nxy = nx * ny;
953 let mut mask = vec![0u8; nx * ny * nz];
954 for k in 0..nz {
955 if k == 4 || k == 5 {
956 continue; // the gap
957 }
958 for idx in k * nxy..(k + 1) * nxy {
959 mask[idx] = 1;
960 }
961 }
962
963 let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
964
965 for (lo, hi) in [(0, 4), (6, 10)] {
966 let mut part = vec![0u8; nx * ny * nz];
967 part[lo * nxy..hi * nxy].fill(1);
968 assert_unwrapped_to_constant(&out, &truth, &part, 1e-9);
969 }
970 }
971
972 #[test]
973 fn an_empty_mask_returns_the_input() {
974 let (nx, ny, nz) = (6, 6, 6);
975 let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.8 * i + j + k);
976 let mask = vec![0u8; nx * ny * nz];
977 let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
978 assert_eq!(out, wrapped);
979 }
980
981 #[test]
982 fn non_finite_phase_voxels_are_left_alone() {
983 let (nx, ny, nz) = (8, 8, 4);
984 let (truth, mut wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.9 * i + 0.4 * j + 0.3 * k);
985 let mut mask = vec![1u8; nx * ny * nz];
986 for &idx in &[5usize, 40, 100] {
987 wrapped[idx] = f64::NAN;
988 }
989
990 let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
991 for &idx in &[5usize, 40, 100] {
992 assert!(out[idx].is_nan(), "voxel {idx} should have been left as NaN");
993 mask[idx] = 0;
994 }
995 // Everything else still unwraps, around the holes the NaNs punch in the mask.
996 assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
997 }
998
999 #[test]
1000 fn thin_volumes_do_not_panic() {
1001 for dims in [(1, 1, 1), (8, 1, 1), (1, 8, 1), (1, 1, 8), (4, 4, 1)] {
1002 let (nx, ny, nz) = dims;
1003 let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 1.4 * i + 1.1 * j + 0.9 * k);
1004 let mask = vec![1u8; nx * ny * nz];
1005 let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
1006 assert_eq!(out.len(), nx * ny * nz, "dims {dims:?}");
1007 assert!(out.iter().all(|v| v.is_finite()), "dims {dims:?}");
1008 }
1009 }
1010
1011 // ---------------------------------------------------------------- behaviour
1012
1013 #[test]
1014 fn is_deterministic_across_runs() {
1015 let (nx, ny, nz) = (18, 18, 9);
1016 let (_, mut wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.7 * i + 0.45 * j + 0.3 * k);
1017 let mut state = 0x5EEDu64;
1018 for v in wrapped.iter_mut() {
1019 *v = wrap_to_pi(*v + 0.6 * (lcg(&mut state) - 0.5));
1020 }
1021 let mask = vec![1u8; nx * ny * nz];
1022 let g = grid(nx, ny, nz);
1023
1024 let first = unwrap_prelude(&wrapped, &mask, &g, &PreludeParams::default());
1025 for _ in 0..4 {
1026 assert_eq!(
1027 unwrap_prelude(&wrapped, &mask, &g, &PreludeParams::default()),
1028 first,
1029 "repeat runs disagree"
1030 );
1031 }
1032 }
1033
1034 #[test]
1035 fn correct_global_centres_the_result() {
1036 // A ramp spanning tens of radians, so the unwrapped field runs several turns
1037 // clear of zero whichever region ends up carrying the arbitrary constant.
1038 let (nx, ny, nz) = (40, 8, 4);
1039 let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 1.2 * i + 0.4 * j + 0.3 * k);
1040 let mask = vec![1u8; nx * ny * nz];
1041 let g = grid(nx, ny, nz);
1042
1043 let median_turns = |v: &[f64]| {
1044 let mut turns: Vec<f64> = v.iter().map(|x| (x / TWO_PI).round()).collect();
1045 turns.sort_by(|a, b| a.partial_cmp(b).unwrap());
1046 turns[turns.len() / 2]
1047 };
1048
1049 // Full-3D labelling at 6 partitions: a setting where the arbitrary constant this
1050 // volume ends up with is not already zero.
1051 let base = PreludeParams {
1052 num_phase_partitions: 6,
1053 labelling: PreludeLabelling::Volume,
1054 correct_global: false,
1055 };
1056 let plain = unwrap_prelude(&wrapped, &mask, &g, &base);
1057 assert_ne!(
1058 median_turns(&plain), 0.0,
1059 "the uncorrected result was already centred, so the test proves nothing"
1060 );
1061
1062 let centred = unwrap_prelude(
1063 &wrapped,
1064 &mask,
1065 &g,
1066 &PreludeParams { correct_global: true, ..base },
1067 );
1068 assert_eq!(median_turns(¢red), 0.0, "corrected result is not centred");
1069
1070 // The two differ by that constant and nothing else.
1071 let shift = plain[0] - centred[0];
1072 assert!(shift.abs() > 1e-9);
1073 for i in 0..plain.len() {
1074 assert!((plain[i] - centred[i] - shift).abs() < 1e-9);
1075 }
1076 }
1077
1078 #[test]
1079 fn one_partition_leaves_connected_phase_alone() {
1080 // The documented degenerate case: a single bin makes every connected piece of
1081 // the mask one region, there are no interfaces, and nothing is unwrapped.
1082 let (nx, ny, nz) = (10, 10, 4);
1083 let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.9 * i + 0.5 * j + 0.4 * k);
1084 let mask = vec![1u8; nx * ny * nz];
1085 let out = unwrap_prelude(
1086 &wrapped,
1087 &mask,
1088 &grid(nx, ny, nz),
1089 &PreludeParams {
1090 num_phase_partitions: 1,
1091 labelling: PreludeLabelling::Volume,
1092 ..PreludeParams::default()
1093 },
1094 );
1095 assert_eq!(out, wrapped);
1096 }
1097
1098 #[test]
1099 #[should_panic(expected = "num_phase_partitions must be at least 1")]
1100 fn zero_partitions_is_rejected() {
1101 unwrap_prelude(
1102 &[0.0; 8],
1103 &[1u8; 8],
1104 &grid(2, 2, 2),
1105 &PreludeParams { num_phase_partitions: 0, ..PreludeParams::default() },
1106 );
1107 }
1108
1109 #[test]
1110 #[should_panic(expected = "phase length must match grid dimensions")]
1111 fn mismatched_phase_length_is_rejected() {
1112 unwrap_prelude(&[0.0; 7], &[1u8; 8], &grid(2, 2, 2), &PreludeParams::default());
1113 }
1114
1115 // --------------------------------------------------------- initial regions
1116
1117 #[test]
1118 fn every_region_stays_inside_one_phase_partition() {
1119 let (nx, ny, nz) = (16, 16, 8);
1120 let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.7 * i + 0.3 * j + 0.9 * k);
1121 let mask = vec![1u8; nx * ny * nz];
1122
1123 for partitions in [2usize, 3, 6, 12] {
1124 let (region_of, n_regions) =
1125 label_regions(&wrapped, &mask, (nx, ny, nz), partitions, PreludeLabelling::Volume);
1126 assert!(n_regions > 0);
1127 let width = TWO_PI / partitions as f64;
1128 let mut lo = vec![f64::INFINITY; n_regions];
1129 let mut hi = vec![f64::NEG_INFINITY; n_regions];
1130 for i in 0..wrapped.len() {
1131 assert_ne!(region_of[i], NO_REGION, "voxel {i} was left unlabelled");
1132 let r = region_of[i] as usize;
1133 lo[r] = lo[r].min(wrapped[i]);
1134 hi[r] = hi[r].max(wrapped[i]);
1135 }
1136 for r in 0..n_regions {
1137 assert!(
1138 hi[r] - lo[r] < width,
1139 "region {r} spans {} rad, wider than the {width} rad partition",
1140 hi[r] - lo[r]
1141 );
1142 }
1143 }
1144 }
1145
1146 #[test]
1147 fn narrower_partitions_make_more_regions() {
1148 let (nx, ny, nz) = (20, 20, 10);
1149 let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.55 * i + 0.3 * j + 0.2 * k);
1150 let mask = vec![1u8; nx * ny * nz];
1151
1152 let counts: Vec<usize> = [2usize, 4, 6, 12, 24]
1153 .iter()
1154 .map(|&p| label_regions(&wrapped, &mask, (nx, ny, nz), p, PreludeLabelling::Volume).1)
1155 .collect();
1156 for w in counts.windows(2) {
1157 assert!(w[1] > w[0], "region counts are not increasing: {counts:?}");
1158 }
1159 }
1160
1161 #[test]
1162 fn slice_labelling_keeps_every_region_in_one_slice() {
1163 let (nx, ny, nz) = (16, 16, 8);
1164 let nxy = nx * ny;
1165 // Shallow along z, so the 3D components run through several slices.
1166 let (_, wrapped) = wrap_field(nx, ny, nz, |i, j, k| 0.7 * i + 0.3 * j + 0.2 * k);
1167 let mask = vec![1u8; nx * ny * nz];
1168
1169 for partitions in [1usize, 3, 8] {
1170 let (slices, n_slices) =
1171 label_regions(&wrapped, &mask, (nx, ny, nz), partitions, PreludeLabelling::Slices);
1172 let (_, n_volume) =
1173 label_regions(&wrapped, &mask, (nx, ny, nz), partitions, PreludeLabelling::Volume);
1174 let mut slice_of = vec![usize::MAX; n_slices];
1175 for (i, &r) in slices.iter().enumerate() {
1176 assert_ne!(r, NO_REGION, "voxel {i} was left unlabelled");
1177 let z = i / nxy;
1178 let s = &mut slice_of[r as usize];
1179 assert!(*s == usize::MAX || *s == z, "region {r} spans slices {s} and {z}");
1180 *s = z;
1181 }
1182 // Each slice's partitions are labelled on their own, so a 3D component through
1183 // several slices becomes at least one region per slice.
1184 assert!(n_slices >= nz && n_slices > n_volume, "{n_slices} vs {n_volume}");
1185 }
1186 }
1187
1188 #[test]
1189 fn slice_labelling_still_merges_across_slices() {
1190 // One partition: each slice is a single region and the merge across z is all that
1191 // unwraps, so a field that wraps only along z checks the 3D merge.
1192 let (nx, ny, nz) = (6, 6, 12);
1193 let (truth, wrapped) = wrap_field(nx, ny, nz, |_, _, k| 1.3 * k);
1194 let mask = vec![1u8; nx * ny * nz];
1195 let params = PreludeParams {
1196 num_phase_partitions: 1,
1197 labelling: PreludeLabelling::Slices,
1198 ..PreludeParams::default()
1199 };
1200 let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), ¶ms);
1201 assert_unwrapped_to_constant(&out, &truth, &mask, 1e-9);
1202 }
1203
1204 #[test]
1205 fn the_defaults_are_fsls() {
1206 let p = PreludeParams::default();
1207 assert_eq!(p.num_phase_partitions, 8);
1208 assert_eq!(p.labelling, PreludeLabelling::Auto);
1209 assert!(p.correct_global);
1210 let auto = PreludeLabelling::Auto;
1211 assert_eq!(auto.resolve((64, 64, 200)), PreludeLabelling::Volume);
1212 assert_eq!(auto.resolve((65, 10, 20)), PreludeLabelling::Slices);
1213 assert_eq!(auto.resolve((10, 65, 20)), PreludeLabelling::Slices);
1214 assert_eq!(PreludeLabelling::Slices.resolve((8, 8, 8)), PreludeLabelling::Slices);
1215 assert_eq!(PreludeLabelling::Volume.resolve((256, 256, 8)), PreludeLabelling::Volume);
1216 let ids: Vec<_> = [auto, PreludeLabelling::Slices, PreludeLabelling::Volume].map(|l| l.id()).into();
1217 assert_eq!(ids, ["auto", "slices", "volume"]);
1218 }
1219
1220 #[test]
1221 fn the_phase_endpoints_land_in_the_end_partitions() {
1222 let width = TWO_PI / 6.0;
1223 assert_eq!(phase_partition(-PI, width, 6), Some(0));
1224 assert_eq!(phase_partition(-PI + 1e-12, width, 6), Some(0));
1225 assert_eq!(phase_partition(0.0, width, 6), Some(3));
1226 assert_eq!(phase_partition(PI - 1e-12, width, 6), Some(5));
1227 assert_eq!(phase_partition(PI, width, 6), Some(5), "+pi must not fall off the end");
1228 // Out of range is clamped rather than dropped, and NaN is dropped.
1229 assert_eq!(phase_partition(-10.0, width, 6), Some(0));
1230 assert_eq!(phase_partition(10.0, width, 6), Some(5));
1231 assert_eq!(phase_partition(f64::NAN, width, 6), None);
1232 assert_eq!(phase_partition(f64::INFINITY, width, 6), None);
1233 }
1234
1235 /// The report's §2.4 at the scale of its own worked example (§4.1): a
1236 /// 320x320 = 102400-voxel image with 31566 voxels in the mask gave 1920 regions
1237 /// from 6 partitions, 1102 of them single voxels and only 163 larger than 27
1238 /// voxels. Our data is synthetic, so the counts cannot match exactly; what the
1239 /// bounds here catch is a partitioning that is wrong by an order of magnitude.
1240 #[test]
1241 fn region_counts_are_the_order_the_report_reports() {
1242 let (nx, ny, nz) = (320, 320, 1);
1243 let (cx, cy) = (159.5f64, 159.5f64);
1244 // Radius chosen so the disc holds ~31566 voxels, as the report's mask does.
1245 let radius = (31566.0 / PI).sqrt();
1246
1247 let mut state = 0xBEEFu64;
1248 let mut wrapped = vec![0.0; nx * ny];
1249 let mut mask = vec![0u8; nx * ny];
1250 for j in 0..ny {
1251 for i in 0..nx {
1252 let idx = j * nx + i;
1253 let (x, y) = (i as f64 - cx, j as f64 - cy);
1254 if x * x + y * y > radius * radius {
1255 continue;
1256 }
1257 mask[idx] = 1;
1258 // A field with several wraps across the mask, plus noise at the level
1259 // that produces the report's crop of single-voxel regions.
1260 let field = 0.09 * x + 0.004 * (x * x + y * y) / 10.0;
1261 wrapped[idx] = wrap_to_pi(field + 1.2 * (lcg(&mut state) - 0.5));
1262 }
1263 }
1264 let in_mask = mask.iter().filter(|&&m| m != 0).count();
1265 assert!(
1266 (30000..33000).contains(&in_mask),
1267 "test mask has {in_mask} voxels, not the report's ~31566"
1268 );
1269
1270 let (region_of, n_regions) = label_regions(&wrapped, &mask, (nx, ny, nz), 6, PreludeLabelling::Volume);
1271 let mut sizes = vec![0usize; n_regions];
1272 for &r in ®ion_of {
1273 if r != NO_REGION {
1274 sizes[r as usize] += 1;
1275 }
1276 }
1277 let singletons = sizes.iter().filter(|&&s| s == 1).count();
1278 let large = sizes.iter().filter(|&&s| s > 27).count();
1279 println!(
1280 "regions={n_regions} singletons={singletons} larger_than_27={large} \
1281 in_mask={in_mask}"
1282 );
1283
1284 assert!(
1285 (500..6000).contains(&n_regions),
1286 "{n_regions} regions is not the order of the report's 1920"
1287 );
1288 assert!(
1289 singletons * 4 > n_regions && singletons < n_regions,
1290 "{singletons} single-voxel regions out of {n_regions} does not look like the \
1291 report's 1102/1920"
1292 );
1293 assert!(
1294 large < n_regions / 4,
1295 "{large} regions larger than 27 voxels, out of {n_regions}; the report had 163/1920"
1296 );
1297
1298 // And the whole thing still unwraps at that scale.
1299 let out = unwrap_prelude(&wrapped, &mask, &grid(nx, ny, nz), &PreludeParams::default());
1300 for j in 0..ny {
1301 for i in 0..nx - 1 {
1302 let idx = j * nx + i;
1303 if mask[idx] != 0 && mask[idx + 1] != 0 {
1304 let step = (out[idx + 1] - out[idx]).abs();
1305 assert!(step < TWO_PI, "x step of {step} rad at ({i},{j})");
1306 }
1307 }
1308 }
1309 }
1310
1311 #[test]
1312 fn an_interface_with_no_usable_cost_is_left_unmerged() {
1313 // `unwrap_prelude` cannot produce this — `label_regions` drops non-finite phases
1314 // before a region is formed — so drive `merge_regions` directly. What is being
1315 // pinned is that the loop *terminates*: with `==` in place of `total_cmp`, a NaN
1316 // cost compares unequal to itself and the candidate is requeued forever. A
1317 // regression here hangs rather than fails, so a timeout on this test is the
1318 // symptom to look for.
1319 let mut adjacency = vec![BTreeMap::new(); 3];
1320 for (a, b, iface) in [
1321 (0u32, 1u32, Interface { n: 4, p: f64::NAN }),
1322 (1, 2, Interface { n: 6, p: 0.0 }),
1323 ] {
1324 adjacency[a as usize].insert(b, iface);
1325 adjacency[b as usize].insert(a, Interface { n: iface.n, p: -iface.p });
1326 }
1327
1328 let mut uf = UnionFind::new(3);
1329 merge_regions(&mut adjacency, &mut uf);
1330
1331 // The usable interface merged; the one carrying NaN did not.
1332 assert_eq!(uf.find(1).0, uf.find(2).0, "regions 1 and 2 should have merged");
1333 assert_ne!(uf.find(0).0, uf.find(1).0, "region 0 had no usable interface");
1334 assert_eq!(uf.find(0).1, 0, "an unmerged region keeps its own frame");
1335 }
1336
1337 // ------------------------------------------------- the heap against a linear scan
1338
1339 /// Selection exactly as the report describes it: scan every interface for the
1340 /// largest `ΔC` (eq 10), merge that pair, repeat. `O(C)` per iteration.
1341 ///
1342 /// Shares [`merge_pair`] with the real implementation, so the only thing that
1343 /// differs is how the maximum is found — which is the one deviation the module
1344 /// docs claim is free.
1345 fn merge_regions_by_linear_scan(
1346 adjacency: &mut [BTreeMap<u32, Interface>],
1347 uf: &mut UnionFind,
1348 ) {
1349 let mut sink: BinaryHeap<Candidate> = BinaryHeap::new();
1350 loop {
1351 let mut best: Option<(u32, u32, Interface)> = None;
1352 for a in 0..adjacency.len() {
1353 if uf.find(a).0 != a {
1354 continue;
1355 }
1356 for (&b, &iface) in &adjacency[a] {
1357 if (a as u32) < b
1358 && best.is_none_or(|(_, _, chosen)| iface.cost() > chosen.cost())
1359 {
1360 best = Some((a as u32, b, iface));
1361 }
1362 }
1363 }
1364 let Some((a, b, iface)) = best else { break };
1365 merge_pair(adjacency, uf, a, b, iface, &mut sink);
1366 sink.clear();
1367 }
1368 }
1369
1370 /// Random region graphs with deliberately inconsistent interfaces.
1371 ///
1372 /// `p` ranges far wider than any real volume would produce, which is the point: it
1373 /// makes merges that *lower* a neighbouring interface's `ΔC`, so a candidate queued
1374 /// earlier at a higher cost surfaces before the one that replaced it. That is the
1375 /// only situation the heap's staleness check exists for, and on real data it does
1376 /// not arise — 2582 merges over a noisy 26×26×14 volume triggered it zero times, so
1377 /// a test built from volumes cannot reach it.
1378 fn random_region_graph(
1379 state: &mut u64,
1380 n_regions: usize,
1381 edge_chance: f64,
1382 ) -> Vec<BTreeMap<u32, Interface>> {
1383 let mut adjacency = vec![BTreeMap::new(); n_regions];
1384 for a in 0..n_regions {
1385 for b in a + 1..n_regions {
1386 if lcg(state) > edge_chance {
1387 continue;
1388 }
1389 let iface = Interface {
1390 n: 1 + (lcg(state) * 60.0) as u32,
1391 p: (lcg(state) - 0.5) * 400.0,
1392 };
1393 adjacency[a].insert(b as u32, iface);
1394 adjacency[b].insert(
1395 a as u32,
1396 Interface { n: iface.n, p: -iface.p },
1397 );
1398 }
1399 }
1400 adjacency
1401 }
1402
1403 #[test]
1404 fn the_heap_picks_the_same_pair_as_a_linear_scan() {
1405 let mut state = 0x9A5Du64;
1406 let mut compared = 0;
1407 for n_regions in [2usize, 3, 5, 8, 13, 21, 34] {
1408 for &edge_chance in &[0.15, 0.4, 0.9] {
1409 for _ in 0..12 {
1410 let graph = random_region_graph(&mut state, n_regions, edge_chance);
1411
1412 let mut by_heap = graph.clone();
1413 let mut uf_heap = UnionFind::new(n_regions);
1414 merge_regions(&mut by_heap, &mut uf_heap);
1415
1416 let mut by_scan = graph.clone();
1417 let mut uf_scan = UnionFind::new(n_regions);
1418 merge_regions_by_linear_scan(&mut by_scan, &mut uf_scan);
1419
1420 // Both must have merged the same regions together. Which member
1421 // ends up as the set's representative is arbitrary — union-by-size
1422 // decides it, and a different merge order gives a different tree —
1423 // so compare the partition itself, not the roots.
1424 for r in 0..n_regions {
1425 for q in 0..n_regions {
1426 assert_eq!(
1427 uf_heap.find(r).0 == uf_heap.find(q).0,
1428 uf_scan.find(r).0 == uf_scan.find(q).0,
1429 "regions {r} and {q} of {n_regions} were merged by one \
1430 method and not the other"
1431 );
1432 }
1433 }
1434 // And committed the same turns. Each set carries an arbitrary
1435 // constant, so measure every region against the lowest-numbered
1436 // member of its own set.
1437 let anchor = |uf: &mut UnionFind, r: usize| {
1438 let root = uf.find(r).0;
1439 (0..n_regions).find(|&q| uf.find(q).0 == root).expect("r is its own")
1440 };
1441 for r in 0..n_regions {
1442 let (a_h, a_s) = (anchor(&mut uf_heap, r), anchor(&mut uf_scan, r));
1443 let rel_h = uf_heap.find(r).1 - uf_heap.find(a_h).1;
1444 let rel_s = uf_scan.find(r).1 - uf_scan.find(a_s).1;
1445 assert_eq!(
1446 rel_h, rel_s,
1447 "region {r} of {n_regions} sits {rel_h} turns above its set's \
1448 first member under the heap, but {rel_s} under the scan"
1449 );
1450 }
1451 compared += 1;
1452 }
1453 }
1454 }
1455 assert_eq!(compared, 7 * 3 * 12);
1456 }
1457
1458 // ------------------------------------------------ against a literal transcription
1459
1460 /// The report's §2.3 written out with no incremental bookkeeping at all: every
1461 /// interface statistic is recomputed from the voxels after each merge, in the
1462 /// phases as they currently stand.
1463 ///
1464 /// Slow — `O(R)` passes over the volume — but it is the equations and nothing
1465 /// else, which makes it the yardstick for [`merge_regions`]'s incremental
1466 /// updates. Returns the unwrapped phase.
1467 fn merge_from_scratch_each_step(
1468 phase: &[f64],
1469 mask: &[u8],
1470 dims: (usize, usize, usize),
1471 partitions: usize,
1472 labelling: PreludeLabelling,
1473 ) -> Vec<f64> {
1474 let (nx, ny, nz) = dims;
1475 let nxy = nx * ny;
1476 let (region_of, n_regions) = label_regions(phase, mask, dims, partitions, labelling);
1477
1478 // Turns committed so far per region, and which merged set it belongs to.
1479 let mut turns = vec![0i64; n_regions];
1480 let mut set_of: Vec<usize> = (0..n_regions).collect();
1481 let psi = |turns: &[i64], i: usize| {
1482 phase[i] + TWO_PI * turns[region_of[i] as usize] as f64
1483 };
1484
1485 loop {
1486 let mut stats: BTreeMap<(usize, usize), Interface> = BTreeMap::new();
1487 for z in 0..nz {
1488 for y in 0..ny {
1489 for x in 0..nx {
1490 let i = z * nxy + y * nx + x;
1491 if region_of[i] == NO_REGION {
1492 continue;
1493 }
1494 let sa = set_of[region_of[i] as usize];
1495 for (j, inside) in
1496 [(i + 1, x + 1 < nx), (i + nx, y + 1 < ny), (i + nxy, z + 1 < nz)]
1497 {
1498 if !inside || region_of[j] == NO_REGION {
1499 continue;
1500 }
1501 let sb = set_of[region_of[j] as usize];
1502 if sa == sb {
1503 continue;
1504 }
1505 let (lo, hi, d) = if sa < sb {
1506 (sa, sb, psi(&turns, i) - psi(&turns, j))
1507 } else {
1508 (sb, sa, psi(&turns, j) - psi(&turns, i))
1509 };
1510 let e = stats.entry((lo, hi)).or_insert(Interface { n: 0, p: 0.0 });
1511 e.n += 1;
1512 e.p += d;
1513 }
1514 }
1515 }
1516 }
1517
1518 // eq 10: the pair where getting the offset wrong is most disastrous.
1519 let Some((&(a, b), &iface)) = stats
1520 .iter()
1521 .max_by(|x, y| x.1.cost().total_cmp(&y.1.cost()))
1522 else {
1523 break;
1524 };
1525 let offset = iface.offset() as i64;
1526 for r in 0..n_regions {
1527 if set_of[r] == b {
1528 turns[r] -= offset;
1529 set_of[r] = a;
1530 }
1531 }
1532 }
1533
1534 (0..phase.len())
1535 .map(|i| {
1536 if region_of[i] == NO_REGION {
1537 phase[i]
1538 } else {
1539 psi(&turns, i)
1540 }
1541 })
1542 .collect()
1543 }
1544
1545 /// Assert two unwrapped fields are the same solution — equal up to the arbitrary
1546 /// constant each connected piece of the mask carries.
1547 ///
1548 /// Compares neighbour differences, which pin a component's field down completely
1549 /// and are the one thing the constant cannot hide.
1550 fn assert_same_solution(a: &[f64], b: &[f64], mask: &[u8], dims: (usize, usize, usize)) {
1551 let (nx, ny, nz) = dims;
1552 let nxy = nx * ny;
1553 let mut compared = 0usize;
1554 for z in 0..nz {
1555 for y in 0..ny {
1556 for x in 0..nx {
1557 let i = z * nxy + y * nx + x;
1558 if mask[i] == 0 {
1559 continue;
1560 }
1561 for (j, inside) in
1562 [(i + 1, x + 1 < nx), (i + nx, y + 1 < ny), (i + nxy, z + 1 < nz)]
1563 {
1564 if !inside || mask[j] == 0 {
1565 continue;
1566 }
1567 compared += 1;
1568 let d = (a[j] - a[i]) - (b[j] - b[i]);
1569 assert!(
1570 d.abs() < 1e-9,
1571 "the two disagree by {d} rad across ({x},{y},{z})"
1572 );
1573 }
1574 }
1575 }
1576 }
1577 assert!(compared > 0, "nothing was compared");
1578 }
1579
1580 #[test]
1581 fn the_incremental_merge_matches_a_literal_transcription() {
1582 let (nx, ny, nz) = (12, 12, 10);
1583 let nxy = nx * ny;
1584 let dims = (nx, ny, nz);
1585 let g = grid(nx, ny, nz);
1586
1587 let (_, smooth) = wrap_field(nx, ny, nz, |i, j, k| 0.9 * i + 0.5 * j + 0.6 * k);
1588 let mut noisy = smooth.clone();
1589 let mut state = 0x15EEDu64;
1590 for v in noisy.iter_mut() {
1591 *v = wrap_to_pi(*v + 0.8 * (lcg(&mut state) - 0.5));
1592 }
1593
1594 let full = vec![1u8; nx * ny * nz];
1595 // A gap across the volume, so there are two independent sets of regions. It
1596 // was this case that first exposed the frame-shift direction in `merge_pair`.
1597 let mut split = vec![1u8; nx * ny * nz];
1598 split[4 * nxy..6 * nxy].fill(0);
1599 // An irregular blob, so regions have ragged interfaces of very uneven size.
1600 let mut blob = vec![0u8; nx * ny * nz];
1601 for k in 0..nz {
1602 for j in 0..ny {
1603 for i in 0..nx {
1604 let (x, y, z) = (i as f64 - 5.5, j as f64 - 5.5, k as f64 - 4.5);
1605 if x * x + y * y + 1.8 * z * z < 26.0 {
1606 blob[k * nxy + j * nx + i] = 1;
1607 }
1608 }
1609 }
1610 }
1611
1612 for (field_name, phase) in [("smooth", &smooth), ("noisy", &noisy)] {
1613 for (mask_name, mask) in [("full", &full), ("split", &split), ("blob", &blob)] {
1614 for partitions in [2usize, 4, 6, 9] {
1615 for labelling in [PreludeLabelling::Volume, PreludeLabelling::Slices] {
1616 let params = PreludeParams {
1617 num_phase_partitions: partitions,
1618 labelling,
1619 correct_global: false,
1620 };
1621 let fast = unwrap_prelude(phase, mask, &g, ¶ms);
1622 let slow =
1623 merge_from_scratch_each_step(phase, mask, dims, partitions, labelling);
1624 println!("{field_name}/{mask_name}/{partitions} partitions, {labelling:?}");
1625 assert_same_solution(&fast, &slow, mask, dims);
1626 }
1627 }
1628 }
1629 }
1630 }
1631
1632 // ------------------------------------------------------- interfaces and cost
1633
1634 #[test]
1635 fn interface_sums_match_their_definition() {
1636 // 3x1x1, regions [0, 1, 0]: region 0 interfaces region 1 on two voxel pairs.
1637 let phase = vec![1.0, -0.5, 2.0];
1638 let region_of = vec![0u32, 1, 0];
1639 let adjacency = build_interfaces(&phase, ®ion_of, 2, (3, 1, 1));
1640
1641 // P_01 = (phase[0] - phase[1]) + (phase[2] - phase[1]) = 1.5 + 2.5 = 4.0
1642 assert_eq!(adjacency[0][&1], Interface { n: 2, p: 4.0 });
1643 assert_eq!(adjacency[1][&0], Interface { n: 2, p: -4.0 });
1644 }
1645
1646 #[test]
1647 fn voxels_in_the_same_region_do_not_interface() {
1648 let phase = vec![0.0, 0.1, 0.2, 0.3];
1649 let region_of = vec![0u32, 0, 0, 0];
1650 let adjacency = build_interfaces(&phase, ®ion_of, 1, (4, 1, 1));
1651 assert!(adjacency[0].is_empty());
1652 }
1653
1654 #[test]
1655 fn unlabelled_voxels_do_not_interface() {
1656 let phase = vec![0.0, 1.0, 2.0];
1657 let region_of = vec![0u32, NO_REGION, 1];
1658 let adjacency = build_interfaces(&phase, ®ion_of, 2, (3, 1, 1));
1659 assert!(adjacency[0].is_empty(), "a gap must not join the regions either side");
1660 assert!(adjacency[1].is_empty());
1661 }
1662
1663 #[test]
1664 fn each_interfacing_pair_is_counted_once() {
1665 // A 2x2x2 checkerboard of two regions: every one of the 12 lattice edges
1666 // joins different regions, and each must be counted exactly once.
1667 let region_of: Vec<u32> = (0..8u32).map(|i| i.count_ones() % 2).collect();
1668 let phase = vec![0.0; 8];
1669 let adjacency = build_interfaces(&phase, ®ion_of, 2, (2, 2, 2));
1670 assert_eq!(adjacency[0][&1].n, 12);
1671 }
1672
1673 #[test]
1674 fn cost_follows_equation_nine() {
1675 let eight_pi_sq = 8.0 * PI * PI;
1676 // K = 0 exactly: rounding is unambiguous, so getting it wrong is most costly.
1677 let certain = Interface { n: 4, p: 0.0 };
1678 assert_eq!(certain.offset(), 0.0);
1679 assert!((certain.cost() - eight_pi_sq * 4.0 * 0.5).abs() < 1e-9);
1680
1681 // K = 1/4 of a turn off: the penalty drops linearly.
1682 let quarter = Interface { n: 4, p: -TWO_PI * 4.0 * 0.25 };
1683 assert!((quarter.k() - 0.25).abs() < 1e-12);
1684 assert_eq!(quarter.offset(), 0.0);
1685 assert!((quarter.cost() - eight_pi_sq * 4.0 * 0.25).abs() < 1e-9);
1686
1687 // K = half a turn: a coin toss, and worth nothing to decide.
1688 let ambiguous = Interface { n: 4, p: -TWO_PI * 4.0 * 0.5 };
1689 assert!(ambiguous.cost().abs() < 1e-9);
1690
1691 // The count scales the penalty: a wider interface at the same K costs more.
1692 let wide = Interface { n: 40, p: 0.0 };
1693 assert!((wide.cost() - 10.0 * certain.cost()).abs() < 1e-9);
1694
1695 // And the offset rounds, including away from zero at the half-turn.
1696 assert_eq!(Interface { n: 1, p: -TWO_PI * 1.6 }.offset(), 2.0);
1697 assert_eq!(Interface { n: 1, p: TWO_PI * 2.4 }.offset(), -2.0);
1698 }
1699
1700 #[test]
1701 fn cost_is_never_negative() {
1702 let mut state = 0xDEADu64;
1703 for _ in 0..2000 {
1704 let iface = Interface {
1705 n: 1 + (lcg(&mut state) * 50.0) as u32,
1706 p: (lcg(&mut state) - 0.5) * 400.0,
1707 };
1708 assert!(iface.cost() >= 0.0, "{iface:?} gave a negative cost");
1709 assert!((iface.k() - iface.offset()).abs() <= 0.5 + 1e-12);
1710 }
1711 }
1712
1713 #[test]
1714 fn candidates_order_by_cost_then_by_id() {
1715 let mut heap: BinaryHeap<Candidate> = BinaryHeap::new();
1716 heap.push(Candidate { cost: 1.0, a: 0, b: 1 });
1717 heap.push(Candidate { cost: 5.0, a: 7, b: 9 });
1718 heap.push(Candidate { cost: 5.0, a: 2, b: 3 });
1719 heap.push(Candidate { cost: 5.0, a: 2, b: 1 });
1720 let order: Vec<(u32, u32)> =
1721 std::iter::from_fn(|| heap.pop()).map(|c| (c.a, c.b)).collect();
1722 assert_eq!(order, vec![(2, 1), (2, 3), (7, 9), (0, 1)]);
1723 }
1724}