Skip to main content

qsm_core/unwrap/
slicewise.rs

1//! Slice-wise (2D) phase unwrapping for 2D multi-slice acquisitions.
2//!
3//! In a 2D multi-slice acquisition each slice is excited separately and carries its own
4//! constant receive phase offset; interleaved acquisition adds slice-to-slice jumps on top.
5//! Every unwrapper in [`crate::unwrap`] reads across slice boundaries, and all of them are
6//! corrupted by that:
7//!
8//! - [`unwrap_romeo`](crate::unwrap::unwrap_romeo) and
9//!   [`unwrap_bestpath`](crate::unwrap::unwrap_bestpath) grow regions in 3D, so one bad slice
10//!   boundary carries a wrong offset across everything downstream of it.
11//! - [`laplacian_unwrap`](crate::unwrap::laplacian_unwrap) differentiates along z straight
12//!   across the jump. It degrades more gracefully — it is a global FFT solve, so the error
13//!   stays diffuse rather than propagating — but it is still wrong.
14//!
15//! On clean data the region growers look like they get away with it, and it is worth being
16//! precise about why they do not. Their quality weighting puts the incoherent z edges last,
17//! so a slice tends to be entered once and to come out a whole number of wraps from the
18//! truth — the error a multi-echo fit is supposed to absorb. The catch is *which* whole
19//! number: the jump the grower rounds is `offset_step + field_step * TE`, so it depends on
20//! the echo. The per-slice wrap then differs between echoes, the offset stops being
21//! echo-independent, and the fit that was going to cancel it is corrupted instead.
22//! [`correct_multi_echo_wraps`](crate::unwrap::correct_multi_echo_wraps) cannot repair it,
23//! because it corrects the whole volume at once and the error is per slice. Measured on this
24//! module's test phantom, neither 3D path survives both offset patterns on its own — the
25//! template path misreads random offsets, the individual path misreads interleaved ones.
26//!
27//! [`unwrap_slicewise`] runs the chosen unwrapper on each slice independently, so z is never
28//! differentiated across. Each slice is handed to the same code with a grid of
29//! `(d0, d1, 1)`; the ±z neighbours then fall outside the volume and are skipped by the
30//! existing bounds checks, so the unwrappers themselves are untouched.
31//!
32//! # Reference pipeline
33//!
34//! There is no single published reference for this module; the measurements below are QSM.rs's
35//! own. Related options in the ROMEO authors' tools: CLEAR-SWI's `laplacianslice` unwrapping runs
36//! Laplacian unwrapping and its high-pass filter per 2D slice (CLEARSWI.jl 0949bc5,
37//! `src/phase_processing.jl:106-116`; `ext/ClearswiApp/argparse.jl:55-57`), and the ROMEO CLI
38//! has `--fix-ge-phase` for GE phase with slice jumps (ROMEO.jl 08776d4,
39//! `ext/RomeoApp/argparse.jl:116-119`). The QSM pipeline's `field_mapping.unwrapping_algorithm`
40//! does not offer slice-wise unwrapping.
41//!
42//! # What this costs
43//!
44//! Unwrapping a slice on its own fixes it only up to a multiple of 2π: nothing in the slice
45//! says which multiple. That is inherent to the problem, not to this implementation — the
46//! information that ties slices together is exactly the information a 2D multi-slice
47//! acquisition does not record.
48//!
49//! Multi-echo data recovers it. The slice offset is echo-independent, so in
50//! `phi(TE) = phi0 + gamma*dB*TE` it lands entirely in the intercept, and a fit across echoes
51//! returns the field from the slope with `phi0` discarded — slice offset and leftover 2π
52//! alike. QSMxT exposes that as `--b0-estimation linear-fit`.
53//!
54//! # Where inter-echo consistency belongs
55//!
56//! That rescue only works if the leftover 2π is the *same* for every echo of a slice. Unwrap
57//! each (slice, echo) independently and it is not: each one picks its own multiple, the
58//! intercept stops being echo-independent, and the fit is corrupted rather than saved.
59//!
60//! So [`unwrap_slicewise_multi_echo`] repairs it here, in the unwrapper, rather than leaving
61//! it to the B0 fit. The reason is not preference — it is that the repair needs the *wrapped
62//! input* phase, and the unwrapper is the only stage that holds both that and its own output.
63//! Given both, the echo-to-echo evolution is `wrap(p_e - p_{e-1})` with no unwrapping and no
64//! knowledge of the offset, which pins the relative 2π exactly (see
65//! [`enforce_inter_echo_consistency`]). A B0 fit sees only unwrapped phase and would
66//! have to infer the jumps from the fit residual, which is both weaker and specific to one
67//! estimator — QSMxT's other B0 estimators would silently keep the corruption.
68//!
69//! The division of labour that leaves is:
70//!
71//! - this module guarantees the leftover `2*pi*k` is **the same for every echo of a slice**;
72//! - the B0 fit cancels it, together with the physical slice offset, because both are now
73//!   echo-independent.
74//!
75//! # Which half does which job
76//!
77//! Measured on the qsm-forward 2D phantom at 3 mm slices with interleaved offsets, the two
78//! halves fix different things, and it is worth being exact about which, because the obvious
79//! reading — that slice-wise unwrapping is what rescues the field map — is wrong.
80//!
81//! **The consistency pass fixes the fitted field, from any starting point.** Correlation of a
82//! linear-fit B0 estimate against the field the signal was generated from: 3D ROMEO alone
83//! reaches 0.20, and the *same* 3D output put through [`enforce_inter_echo_consistency`]
84//! reaches 0.983. So does the raw wrapped phase with no spatial unwrapping at all. A fitted
85//! slope is blind to a constant per voxel, so whatever wrap state a voxel starts in lands in
86//! the intercept and the fit discards it; all the slope needs is that consecutive echoes differ
87//! by their measured evolution, which is exactly what the pass enforces. **If a linear-fit B0
88//! map is all you want, you do not need this module** — you need the pass, which is public and
89//! works on any unwrapper's output.
90//!
91//! **Slice-wise unwrapping fixes the phase itself.** The clean way to see this is on the *first*
92//! echo, because [`enforce_inter_echo_consistency`] loops `for e in 1..n_echoes` and so provably
93//! cannot touch it — any difference there belongs to the spatial unwrapping and nothing else, with
94//! no null needed to rule the pass out. Counting in-plane neighbour pairs that jump more than π in
95//! echo 1 of that session: **1432** in the wrapped input, **1432** after the pass (identical, as
96//! the structure requires), **0** after slice-wise unwrapping.
97//!
98//! By the last echo both mechanisms are in play and the gap narrows but holds: 1433 wrapped, 2142
99//! with the pass alone, 796 with slice-wise. The pass alone is *worse* there than not unwrapping,
100//! because it propagates echo 1's wrap state faithfully into every later echo.
101//!
102//! Anything reading the unwrapped phase rather than its TE-slope — a single-echo field map, a
103//! non-linear B0 estimator, phase fed straight to background removal — needs the spatial
104//! unwrapping, and on 2D multi-slice data that means per slice.
105//!
106//! # Thick slices need this even without per-slice offsets
107//!
108//! This module was built for slices carrying independent receive phase offsets, and it turns out
109//! not to need them. On the matched session with **uniform receive phase**, differing from a 3D
110//! acquisition only in being 3 mm thick, 3D ROMEO reaches 0.936 against slice-wise's 0.983.
111//!
112//! 3 mm slices by themselves produce through-slice phase steps large enough to mislead a region
113//! grower, so the trigger is slice thickness rather than 2D multi-slice acquisition as such.
114//! Found by an assertion failing: the no-offsets control used to require 3D to reach 0.95, on the
115//! assumption that there is nothing to fix when the receive phase is uniform.
116//!
117//! Slice-wise
118//! [`UnwrapMethod::Laplacian`] reaches only 0.81 on the fit — its Poisson solution is not a
119//! whole number of wraps from the truth to begin with, so the pass cannot re-seat it cleanly.
120//! Prefer ROMEO or best path.
121//!
122//! Single-echo data has no second measurement and so no way to close the gap. [`unwrap_slicewise`]
123//! returns each slice unwrapped and internally consistent, and the between-slice offsets
124//! survive into the field map. That is a property of the acquisition; background field removal
125//! will absorb a smoothly varying part of it, but not jumps.
126//!
127//! # Slice gaps
128//!
129//! This module does not care whether the slices are contiguous. Unwrapping is per slice and
130//! never relates one slice to another spatially, so it is valid on gapped data — unlike the
131//! dipole kernel, which is not (see [`crate::bgremove`]).
132
133use std::f64::consts::PI;
134
135use crate::Grid;
136use super::laplacian::{laplacian_unwrap, wrap, LaplacianSolver};
137use super::{
138    unwrap_bestpath, unwrap_prelude, unwrap_romeo, BestPathParams, RomeoParams,
139    UnwrapMethod,
140};
141use crate::grid::SliceLayout;
142
143const TWO_PI: f64 = 2.0 * PI;
144
145/// Parameters for slice-wise unwrapping.
146#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
147#[derive(Clone, Debug)]
148pub struct SliceWiseParams {
149    /// Axis the slices are stacked along: 0 = x, 1 = y, 2 = z (default: 2).
150    ///
151    /// For an axial 2D acquisition stored in the usual orientation this is 2. A sagittal or
152    /// coronal 2D acquisition stacks along another axis, and setting this is cheaper and
153    /// safer than transposing the volume.
154    pub slice_axis: usize,
155    /// Parameters for [`UnwrapMethod::Romeo`]; ignored by the other methods.
156    pub romeo: RomeoParams,
157    /// Parameters for [`UnwrapMethod::BestPath`]; ignored by the other methods.
158    pub bestpath: BestPathParams,
159    /// Make each slice's leftover `2*pi*k` the same across echoes, so a fit across echoes
160    /// cancels it (default: true). Multi-echo only; see the module docs.
161    pub enforce_inter_echo_consistency: bool,
162}
163
164impl Default for SliceWiseParams {
165    fn default() -> Self {
166        Self {
167            slice_axis: 2,
168            romeo: RomeoParams::default(),
169            bestpath: BestPathParams::default(),
170            enforce_inter_echo_consistency: true,
171        }
172    }
173}
174
175/// Label the connected components of a slice mask, 4-connected.
176///
177/// 4-connectivity, not 8, because that is what the region-growing unwrappers can actually
178/// traverse: their neighbourhood is the six face neighbours, which in plane is four.
179/// Returns labels (0 = outside the mask) and the number of components.
180fn label_components(mask: &[u8], d0: usize, d1: usize) -> (Vec<u32>, u32) {
181    let mut labels = vec![0u32; mask.len()];
182    let mut next = 0u32;
183    let mut stack: Vec<usize> = Vec::new();
184    for seed in 0..mask.len() {
185        if mask[seed] == 0 || labels[seed] != 0 {
186            continue;
187        }
188        next += 1;
189        labels[seed] = next;
190        stack.push(seed);
191        while let Some(p) = stack.pop() {
192            let (a, b) = (p % d0, p / d0);
193            let mut visit = |q: usize, stack: &mut Vec<usize>| {
194                if mask[q] != 0 && labels[q] == 0 {
195                    labels[q] = next;
196                    stack.push(q);
197                }
198            };
199            if a + 1 < d0 { visit(p + 1, &mut stack); }
200            if a > 0 { visit(p - 1, &mut stack); }
201            if b + 1 < d1 { visit(p + d0, &mut stack); }
202            if b > 0 { visit(p - d0, &mut stack); }
203        }
204    }
205    (labels, next)
206}
207
208/// Re-anchor a Laplacian solution so it differs from the wrapped phase by whole 2π.
209///
210/// [`laplacian_unwrap`] solves a Poisson equation and zeroes the DC mode, so its output is
211/// only defined up to an arbitrary real constant. In 3D that constant is global and harmless
212/// — a receive phase offset, which the background removal or a multi-echo fit absorbs. Per
213/// slice it is neither: an arbitrary real offset on each slice is worse than the 2π ambiguity
214/// slice-wise unwrapping is entitled to, and it would make the inter-echo correction
215/// meaningless, since that correction can only move a slice by whole multiples of 2π.
216///
217/// Subtracting the circular mean of `u - p` over each component restores the entitlement:
218/// afterwards `u - p` is a whole multiple of 2π, and the only freedom left is which one.
219/// The region-growing unwrappers already satisfy that by construction, so they skip this.
220fn reanchor_to_wrapped(u: &mut [f64], p: &[f64], labels: &[u32], n_components: u32) {
221    for label in 1..=n_components {
222        let (mut sin_sum, mut cos_sum) = (0.0, 0.0);
223        for i in 0..u.len() {
224            if labels[i] == label {
225                let d = u[i] - p[i];
226                sin_sum += d.sin();
227                cos_sum += d.cos();
228            }
229        }
230        let offset = sin_sum.atan2(cos_sum);
231        for i in 0..u.len() {
232            if labels[i] == label {
233                u[i] -= offset;
234            }
235        }
236    }
237}
238
239/// Unwrap each slice independently, in plane.
240///
241/// `method` selects the unwrapper, which runs unchanged on a `(d0, d1, 1)` grid per slice so
242/// the slice axis is never differentiated across. Masked-out voxels keep whatever the chosen
243/// unwrapper leaves them: zero for [`UnwrapMethod::Laplacian`], the wrapped input for the
244/// region-growing methods.
245///
246/// The result is self-consistent within each slice and determined only up to a multiple of 2π
247/// between slices. For multi-echo data use [`unwrap_slicewise_multi_echo`], which makes that
248/// multiple echo-independent so a fit across echoes cancels it. See the module docs.
249///
250/// # Arguments
251/// * `phase` - Wrapped phase (nx * ny * nz)
252/// * `mag` - Magnitude for the weighting of [`UnwrapMethod::Romeo`]; may be empty
253/// * `mask` - Binary mask (nx * ny * nz), 1 = inside ROI
254/// * `method` - Which unwrapper to run on each slice
255/// * `params` - Slice axis and the per-method parameters
256/// * `grid` - Volume grid
257///
258/// # Panics
259/// If `params.slice_axis` is not 0, 1 or 2, or if `phase`/`mask` do not match `grid`.
260pub fn unwrap_slicewise(
261    phase: &[f64],
262    mag: &[f64],
263    mask: &[u8],
264    method: UnwrapMethod,
265    params: &SliceWiseParams,
266    grid: &Grid,
267) -> Vec<f64> {
268    let n_total = grid.n_total();
269    assert_eq!(phase.len(), n_total, "phase length must match grid dimensions");
270    assert_eq!(mask.len(), n_total, "mask length must match grid dimensions");
271
272    let layout = SliceLayout::new(grid, params.slice_axis);
273    let n = layout.n_in_slice();
274    let mut out = vec![0.0; n_total];
275    let (mut p, mut m, mut k) = (vec![0.0; n], vec![0.0; n], vec![0u8; n]);
276
277    for s in 0..layout.n_slices {
278        layout.gather(phase, s, &mut p);
279        layout.gather(mask, s, &mut k);
280        let mag_slice: &[f64] = if mag.is_empty() {
281            &[]
282        } else {
283            layout.gather(mag, s, &mut m);
284            &m
285        };
286
287        let u = match method {
288            UnwrapMethod::Romeo => {
289                // ROMEO grows outward from one seed, so a slice whose mask has fallen into
290                // several pieces - the temporal lobes low in the head, an eye, anything the
291                // brain mask separates in plane - would leave every piece but the seed's
292                // still wrapped. Run it once per piece. Whole slices usually have one.
293                let (labels, n_components) = label_components(&k, layout.d0, layout.d1);
294                if n_components <= 1 {
295                    unwrap_romeo(&p, mag_slice, None, 0.0, 0.0, &k, &params.romeo, &layout.grid)
296                } else {
297                    let mut u = p.clone();
298                    let mut sub = vec![0u8; n];
299                    for label in 1..=n_components {
300                        for (c, &l) in sub.iter_mut().zip(labels.iter()) {
301                            *c = u8::from(l == label);
302                        }
303                        let part = unwrap_romeo(
304                            &p, mag_slice, None, 0.0, 0.0, &sub, &params.romeo, &layout.grid,
305                        );
306                        for (idx, &l) in labels.iter().enumerate() {
307                            if l == label {
308                                u[idx] = part[idx];
309                            }
310                        }
311                    }
312                    u
313                }
314            }
315            // Best path sorts every edge globally and merges, and no edge crosses between
316            // components, so the pieces are already independent. The same holds for
317            // PRELUDE: regions only interface within a connected piece of the mask.
318            UnwrapMethod::BestPath => unwrap_bestpath(&p, &k, &params.bestpath, &layout.grid),
319            UnwrapMethod::Prelude(prelude) => unwrap_prelude(&p, &k, &layout.grid, &prelude),
320            // Each slice grown from its own middle, over the whole slice (no mask), ordered by
321            // the slice's magnitude.
322            UnwrapMethod::MediRegionGrowing => {
323                assert!(!mag_slice.is_empty(), "MEDI's region growing needs the magnitude");
324                crate::unwrap::unwrap_medi_region_growing(&p, mag_slice, &layout.grid)
325            }
326            UnwrapMethod::Laplacian => {
327                let mut u = laplacian_unwrap(&p, &k, &layout.grid, LaplacianSolver::Dct);
328                let (labels, n_components) = label_components(&k, layout.d0, layout.d1);
329                reanchor_to_wrapped(&mut u, &p, &labels, n_components);
330                // the solve wrote zeros outside the mask; keep them zero after re-anchoring
331                for (v, &inside) in u.iter_mut().zip(k.iter()) {
332                    if inside == 0 {
333                        *v = 0.0;
334                    }
335                }
336                u
337            }
338        };
339        layout.scatter(&mut out, s, &u);
340    }
341    out
342}
343
344/// Unwrap each echo slice-wise, then make each slice's leftover 2π the same across echoes.
345///
346/// Each (slice, echo) is unwrapped independently by [`unwrap_slicewise`] and so picks its own
347/// multiple of 2π. Left alone, that breaks the one thing that rescues 2D multi-slice data: the
348/// slice offset is echo-independent and cancels in a fit across echoes, but only while the
349/// leftover 2π is echo-independent too. [`enforce_inter_echo_consistency`] makes it so,
350/// unless `params.enforce_inter_echo_consistency` is false.
351///
352/// `tes` is used only for the phase-gradient-coherence weighting of [`UnwrapMethod::Romeo`],
353/// which compares each echo against its neighbour. The consistency correction itself does not
354/// use it, and so does not care whether the echoes are evenly spaced.
355///
356/// # Arguments
357/// * `phases` - Wrapped phase per echo, each (nx * ny * nz)
358/// * `mags` - Magnitude per echo; may be empty
359/// * `tes` - Echo times in seconds, one per echo
360/// * `mask` - Binary mask (nx * ny * nz), 1 = inside ROI
361/// * `method` - Which unwrapper to run on each slice
362/// * `params` - Slice axis, per-method parameters, and the consistency switch
363/// * `grid` - Volume grid
364///
365/// # Panics
366/// If `phases` is empty or `tes` has a different length.
367pub fn unwrap_slicewise_multi_echo<P: AsRef<[f64]>, M: AsRef<[f64]>>(
368    phases: &[P],
369    mags: &[M],
370    tes: &[f64],
371    mask: &[u8],
372    method: UnwrapMethod,
373    params: &SliceWiseParams,
374    grid: &Grid,
375) -> Vec<Vec<f64>> {
376    let n_echoes = phases.len();
377    assert!(n_echoes > 0, "phases must have at least one echo");
378    assert_eq!(n_echoes, tes.len(), "phases and tes must have same length");
379
380    let mut result: Vec<Vec<f64>> = (0..n_echoes)
381        .map(|e| {
382            let mag = if mags.is_empty() { &[] as &[f64] } else { mags[e].as_ref() };
383            unwrap_slicewise(phases[e].as_ref(), mag, mask, method, params, grid)
384        })
385        .collect();
386
387    if params.enforce_inter_echo_consistency && n_echoes > 1 {
388        let wrapped: Vec<&[f64]> = phases.iter().map(|p| p.as_ref()).collect();
389        enforce_inter_echo_consistency(&mut result, &wrapped, mask);
390    }
391    result
392}
393
394/// Re-seat every echo on the first, so the echoes of a voxel differ by their measured
395/// phase evolution and nothing else.
396///
397/// Slice-wise unwrapping leaves echo `e` as `phi + 2*pi*k`, and the field is recoverable from
398/// a fit across echoes only if `k` does not depend on `e`. This makes it so. It does not, and
399/// cannot, say what `k` is — the leftover wrap stays in the intercept, along with the physical
400/// slice offset, and the fit discards both.
401///
402/// The relative wrap is pinned by the wrapped input, without unwrapping anything and without
403/// knowing the slice offset. Writing `p` for the wrapped phase and `u` for the unwrapped,
404///
405/// ```text
406/// u_e - u_{e-1} - wrap(p_e - p_{e-1}) = 2*pi * (an integer)
407/// ```
408///
409/// because `wrap(p_e - p_{e-1})` *is* the echo-to-echo evolution, wrapped. Rounding that to
410/// the nearest multiple of 2π and subtracting it leaves `u_e - u_{e-1}` equal to the measured
411/// step exactly. Formulating it on the wrapped difference rather than against a TE-scaled
412/// reference is what lets it survive an echo-independent offset, which the TE-scaled form of
413/// [`correct_multi_echo_wraps`](crate::unwrap::correct_multi_echo_wraps) would read as field.
414///
415/// # This is per voxel, deliberately
416///
417/// An earlier version of this did it per connected component of each slice, taking a median,
418/// on the reasoning that slice-wise unwrapping offsets a whole component at a time. That
419/// reasoning is wrong in practice: a region grower that cannot route through the slice axis
420/// has to cross every in-plane fringe head-on, and where one is ambiguous it leaves a *part*
421/// of the component offset by 2π. Measured on the qsm-forward 2D phantom, the wrap count
422/// varies within a single slice of a single echo (2-3 distinct values), so a single median per
423/// component has nothing it can do. Correcting per voxel repairs those plateaus as well, and
424/// took the fitted field from r = -0.44 to r = 0.98 against ground truth during development.
425/// The per-slice variant was not kept, so that figure records the decision rather than a test.
426///
427/// # This is the half that fixes a linear-fit B0 map
428///
429/// Public, and deliberately not tied to slice-wise unwrapping: it repairs any unwrapper's
430/// output, including none at all. On the phantom's interleaved-offset session it takes 3D ROMEO
431/// from 0.20 to 0.983 and the raw wrapped phase from -0.01 to 0.983. What it does *not* do is
432/// make the phase spatially continuous — it propagates the first echo's wrap state faithfully
433/// into the rest, which on that session leaves more in-plane 2π jumps than not unwrapping at
434/// all. See the module docs for the division of labour.
435///
436/// # What it assumes
437///
438/// That the true phase evolution between consecutive echoes stays inside ±π, which is the
439/// usual design constraint on echo spacing and the same assumption
440/// [`correct_multi_echo_wraps`](crate::unwrap::correct_multi_echo_wraps) makes. A voxel whose
441/// evolution exceeds it is not recoverable from this data by any means that only sees the
442/// wrapped steps, and will carry a slope error of `2*pi / delta_TE`.
443///
444/// # Arguments
445/// * `unwrapped` - Unwrapped phase per echo, modified in place
446/// * `wrapped` - The wrapped input phase per echo
447/// * `mask` - Binary mask (nx * ny * nz), 1 = inside ROI; voxels outside are left alone
448pub fn enforce_inter_echo_consistency(
449    unwrapped: &mut [Vec<f64>],
450    wrapped: &[&[f64]],
451    mask: &[u8],
452) {
453    let n_echoes = unwrapped.len();
454    assert_eq!(n_echoes, wrapped.len(), "unwrapped and wrapped must have same length");
455    if n_echoes < 2 {
456        return;
457    }
458    for e in 1..n_echoes {
459        let (before, after) = unwrapped.split_at_mut(e);
460        let previous = &before[e - 1];
461        for (i, value) in after[0].iter_mut().enumerate() {
462            if mask[i] == 0 {
463                continue;
464            }
465            let step = wrap(wrapped[e][i] - wrapped[e - 1][i]);
466            let excess = (*value - previous[i] - step) / TWO_PI;
467            if excess.is_finite() {
468                *value -= TWO_PI * excess.round();
469            }
470        }
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    // What gates these tests, recorded so the sweep can be reconstructed rather than
479    // re-derived. The script that ran it was scratch; the list is the part worth keeping.
480    //
481    // Each of these was applied and confirmed to fail at least one assertion below: the slice
482    // index dropping its slice offset; the slice grid keeping the volume's depth instead of 1;
483    // in-slice axes transposed; components merged under 8-connectivity; the Laplacian skipping
484    // its re-anchoring; ROMEO run once per slice instead of once per connected component;
485    // `find_seed_point` keeping a centre of mass that lies outside the mask; and five on the
486    // consistency pass - switched off, chained against echo 0 rather than the previous echo,
487    // referenced against a TE-scaled value instead of the wrapped step, truncating instead of
488    // rounding, and sign-flipped.
489    //
490    // Two nulls matter more than any of those.
491    // `the_consistency_pass_is_what_fixes_the_fit_even_without_slice_wise_unwrapping` is the
492    // null for every fit-correlation claim here: the pass alone reaches 0.983 from raw wrapped
493    // phase, so no fit-based assertion in this module may be read as evidence about the spatial
494    // unwrapping. The echo-0 identity in
495    // `slice_wise_unwrapping_is_what_makes_the_phase_spatially_continuous` is the opposite and
496    // stronger case: confound-free by construction, because the pass only ever writes echoes
497    // 1.., so nothing needs ruling out experimentally.
498
499    const NX: usize = 24;
500    const NY: usize = 24;
501    const NZ: usize = 8;
502    const TES: [f64; 4] = [4e-3, 12e-3, 20e-3, 28e-3];
503
504    fn grid() -> Grid {
505        Grid::new(NX, NY, NZ, 1.0, 1.0, 3.0)
506    }
507
508    /// Field in rad/s. Smooth and well below one radian per voxel in plane, but large enough
509    /// that `b * TE` wraps a couple of times by the last echo.
510    fn field() -> Vec<f64> {
511        let mut b = vec![0.0; NX * NY * NZ];
512        for k in 0..NZ {
513            for j in 0..NY {
514                for i in 0..NX {
515                    // the in-plane pattern shifts with z, so the slice-to-slice difference
516                    // varies across the slice as a real field does - without that the
517                    // difference is constant and 3D unwrapping absorbs the offsets cleanly
518                    let fx = (2.0 * PI * i as f64 / NX as f64 + 0.6 * k as f64).cos();
519                    let fy = (2.0 * PI * j as f64 / NY as f64).cos();
520                    b[i + j * NX + k * NX * NY] =
521                        100.0 * (fx + fy) * (1.0 + 0.3 * k as f64 / NZ as f64);
522                }
523            }
524        }
525        b
526    }
527
528    /// Per-slice receive phase offsets, as a 2D multi-slice acquisition produces them.
529    fn slice_offsets(interleaved: bool) -> Vec<f64> {
530        if interleaved {
531            (0..NZ).map(|s| if s % 2 == 0 { -1.5 } else { 1.5 }).collect()
532        } else {
533            // arbitrary but fixed, spread over the full circle
534            (0..NZ).map(|s| -PI + TWO_PI * ((s * 7 + 3) % NZ) as f64 / NZ as f64).collect()
535        }
536    }
537
538    /// Smooth echo-independent receive phase, spanning many radians as a real coil's does
539    /// (~17 rad on the qsm-forward head phantom). Without one the model's phase is only a
540    /// couple of radians, every plausible choice of reference in the consistency pass rounds
541    /// to the same integer, and the tests cannot tell a correct reference from a wrong one.
542    fn receive_phase() -> Vec<f64> {
543        let mut r = vec![0.0; NX * NY * NZ];
544        for k in 0..NZ {
545            for j in 0..NY {
546                for i in 0..NX {
547                    let fx = (PI * i as f64 / NX as f64).cos();
548                    let fy = (PI * j as f64 / NY as f64).cos();
549                    r[i + j * NX + k * NX * NY] = 7.5 * fx + 4.0 * fy;
550                }
551            }
552        }
553        r
554    }
555
556    /// True (unwrapped) phase of echo `e`, built from the model rather than from any
557    /// unwrapper: `phi = receive_phase + offset(slice) + b * TE`.
558    fn true_phase(b: &[f64], offsets: &[f64], te: f64) -> Vec<f64> {
559        let receive = receive_phase();
560        let mut phi = vec![0.0; NX * NY * NZ];
561        for (idx, v) in phi.iter_mut().enumerate() {
562            *v = receive[idx] + offsets[idx / (NX * NY)] + b[idx] * te;
563        }
564        phi
565    }
566
567    #[test]
568    fn the_model_phase_is_demanding_enough_to_be_worth_testing_against() {
569        // Guards the tests below: the receive phase has to span several wraps (so the
570        // consistency pass has a real reference to get right) while staying gentle enough in
571        // plane to be unwrappable, and the echo-to-echo step has to stay inside +/-pi.
572        let b = field();
573        let truth = true_phase(&b, &slice_offsets(false), TES[3]);
574        let span = truth.iter().cloned().fold(f64::MIN, f64::max)
575            - truth.iter().cloned().fold(f64::MAX, f64::min);
576        assert!(span > 6.0 * PI, "phase spans only {span} rad, under three wraps");
577
578        let mut worst_inplane: f64 = 0.0;
579        for k in 0..NZ {
580            for j in 0..NY {
581                for i in 0..NX - 1 {
582                    let a = i + j * NX + k * NX * NY;
583                    worst_inplane = worst_inplane.max((truth[a + 1] - truth[a]).abs());
584                }
585            }
586        }
587        assert!(worst_inplane < PI, "in-plane step reaches {worst_inplane} rad, not unwrappable");
588
589        let step = true_phase(&b, &slice_offsets(false), TES[1]);
590        let first = true_phase(&b, &slice_offsets(false), TES[0]);
591        let worst_step = step.iter().zip(&first).map(|(a, c)| (a - c).abs()).fold(0.0_f64, f64::max);
592        assert!(worst_step < PI, "echo-to-echo step reaches {worst_step} rad");
593    }
594
595    fn wrap_all(phi: &[f64]) -> Vec<f64> {
596        phi.iter().map(|&v| wrap(v)).collect()
597    }
598
599    fn full_mask() -> Vec<u8> {
600        vec![1u8; NX * NY * NZ]
601    }
602
603    /// Largest |in-plane gradient| error against the truth. A per-slice constant - the part a
604    /// 2D multi-slice acquisition genuinely cannot determine - cancels out of this, so it
605    /// measures only whether the structure inside each slice survived.
606    fn max_inplane_gradient_error(u: &[f64], truth: &[f64]) -> f64 {
607        let mut worst: f64 = 0.0;
608        for k in 0..NZ {
609            for j in 0..NY {
610                for i in 0..NX - 1 {
611                    let a = i + j * NX + k * NX * NY;
612                    worst = worst.max(((u[a + 1] - u[a]) - (truth[a + 1] - truth[a])).abs());
613                }
614            }
615            for j in 0..NY - 1 {
616                for i in 0..NX {
617                    let a = i + j * NX + k * NX * NY;
618                    worst = worst.max(((u[a + NX] - u[a]) - (truth[a + NX] - truth[a])).abs());
619                }
620            }
621        }
622        worst
623    }
624
625    /// Per-voxel least-squares slope of phase against TE - the `linear-fit` B0 estimate.
626    fn fit_slope(unwrapped: &[Vec<f64>], tes: &[f64]) -> Vec<f64> {
627        let n = tes.len() as f64;
628        let te_mean = tes.iter().sum::<f64>() / n;
629        let sxx: f64 = tes.iter().map(|t| (t - te_mean).powi(2)).sum();
630        (0..unwrapped[0].len())
631            .map(|i| {
632                let y_mean = unwrapped.iter().map(|u| u[i]).sum::<f64>() / n;
633                let sxy: f64 = tes
634                    .iter()
635                    .zip(unwrapped)
636                    .map(|(t, u)| (t - te_mean) * (u[i] - y_mean))
637                    .sum();
638                sxy / sxx
639            })
640            .collect()
641    }
642
643    fn max_abs_diff(a: &[f64], b: &[f64]) -> f64 {
644        a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0_f64, f64::max)
645    }
646
647    #[test]
648    fn label_components_separates_blobs_and_is_four_connected() {
649        // two 2x2 blobs with a clear gap
650        let (d0, d1) = (8usize, 4usize);
651        let mut mask = vec![0u8; d0 * d1];
652        for (a, b) in [(0, 0), (1, 0), (0, 1), (1, 1), (5, 2), (6, 2), (5, 3), (6, 3)] {
653            mask[a + b * d0] = 1;
654        }
655        let (labels, n) = label_components(&mask, d0, d1);
656        assert_eq!(n, 2);
657        assert_eq!(labels[0], labels[1 + d0]);
658        assert_ne!(labels[0], labels[5 + 2 * d0]);
659        assert_eq!(labels.iter().filter(|&&l| l == 1).count(), 4);
660        assert_eq!(labels.iter().filter(|&&l| l == 0).count(), d0 * d1 - 8);
661
662        // voxels touching only at a corner are two components under 4-connectivity
663        let mut diag = vec![0u8; d0 * d1];
664        diag[1 + d0] = 1;
665        diag[2 + 2 * d0] = 1;
666        assert_eq!(label_components(&diag, d0, d1).1, 2);
667
668        // and are one component once the corner is bridged
669        diag[2 + d0] = 1;
670        assert_eq!(label_components(&diag, d0, d1).1, 1);
671    }
672
673    // ---- the headline behaviour -------------------------------------------------
674
675    fn methods() -> [(UnwrapMethod, &'static str); 3] {
676        [
677            (UnwrapMethod::Romeo, "ROMEO"),
678            (UnwrapMethod::BestPath, "best path"),
679            (UnwrapMethod::Laplacian, "Laplacian"),
680        ]
681    }
682
683    #[test]
684    fn slicewise_recovers_in_plane_structure_through_slice_offsets() {
685        let b = field();
686        let offsets = slice_offsets(false);
687        let mask = full_mask();
688        let params = SliceWiseParams::default();
689
690        for (method, name) in methods() {
691            let truth = true_phase(&b, &offsets, TES[3]);
692            let wrapped = wrap_all(&truth);
693            // the data really does wrap, or the test proves nothing about unwrapping
694            assert!(max_abs_diff(&wrapped, &truth) > 2.0, "{name}: input does not wrap");
695
696            let u = unwrap_slicewise(&wrapped, &[], &mask, method, &params, &grid());
697            let err = max_inplane_gradient_error(&u, &truth);
698            assert!(err < 0.05, "{name}: in-plane structure lost, max gradient error {err}");
699        }
700    }
701
702    /// MEDI's region growing slice by slice, with a magnitude (its quality map): the in-plane
703    /// structure comes back through the slice offsets, to its single precision.
704    #[test]
705    fn slicewise_medi_region_growing_uses_the_magnitude() {
706        let b = field();
707        let truth = true_phase(&b, &slice_offsets(false), TES[3]);
708        let wrapped = wrap_all(&truth);
709        let mag: Vec<f64> = (0..wrapped.len()).map(|i| 1.0 + (i % 3) as f64).collect();
710        let u = unwrap_slicewise(
711            &wrapped, &mag, &full_mask(), UnwrapMethod::MediRegionGrowing, &SliceWiseParams::default(), &grid(),
712        );
713        let err = max_inplane_gradient_error(&u, &truth);
714        assert!(err < 1e-4, "in-plane structure lost, max gradient error {err}");
715    }
716
717    #[test]
718    fn slicewise_leaves_each_slice_a_whole_number_of_wraps_from_the_truth() {
719        let b = field();
720        let offsets = slice_offsets(false);
721        let mask = full_mask();
722        let truth = true_phase(&b, &offsets, TES[3]);
723        let wrapped = wrap_all(&truth);
724
725        // the region-growing methods land exactly on the truth plus 2*pi*k; the Laplacian
726        // solves a Poisson equation and only approaches it, hence the two tolerances
727        for (method, name, tol) in [
728            (UnwrapMethod::Romeo, "ROMEO", 1e-9),
729            (UnwrapMethod::BestPath, "best path", 1e-9),
730            (UnwrapMethod::Laplacian, "Laplacian", 0.1),
731        ] {
732            let u = unwrap_slicewise(
733                &wrapped, &[], &mask, method, &SliceWiseParams::default(), &grid(),
734            );
735            for s in 0..NZ {
736                let base = s * NX * NY;
737                let k = ((u[base] - truth[base]) / TWO_PI).round();
738                for p in 0..NX * NY {
739                    let residual = u[base + p] - truth[base + p] - TWO_PI * k;
740                    assert!(
741                        residual.abs() < tol,
742                        "{name}: slice {s} is not a constant {k} wraps from the truth \
743                         (residual {residual} at {p})"
744                    );
745                }
746            }
747        }
748    }
749
750    /// Counts in-plane neighbour pairs that jump more than pi. A fitted slope cannot see this —
751    /// it is blind to a constant per voxel — so it is the measure on which slice-wise unwrapping
752    /// and the consistency pass come apart.
753    fn inplane_jumps(u: &[f64]) -> usize {
754        let mut n = 0usize;
755        for k in 0..NZ {
756            for j in 0..NY {
757                for i in 0..NX - 1 {
758                    let a = i + j * NX + k * NX * NY;
759                    if (u[a + 1] - u[a]).abs() > PI {
760                        n += 1;
761                    }
762                }
763            }
764        }
765        n
766    }
767
768    #[test]
769    fn the_consistency_pass_is_what_fixes_the_fit_even_without_slice_wise_unwrapping() {
770        // The correction this module's docs used to credit to slice-wise unwrapping. A slope is
771        // blind to a constant per voxel, so the fit needs only that consecutive echoes differ by
772        // their measured evolution - which the pass enforces from any starting point, including
773        // none at all. Pinned because the module docs now say so, and because it tells a caller
774        // who only wants a linear-fit B0 map that they do not need this module.
775        let Inputs { field: b, wrapped, .. } = multi_echo_inputs(true);
776        let mask = full_mask();
777        let refs: Vec<&[f64]> = wrapped.iter().map(|p| p.as_slice()).collect();
778
779        // raw wrapped phase, no spatial unwrapping whatsoever
780        let mut raw = wrapped.clone();
781        let before = max_abs_diff(&fit_slope(&raw, &TES), &b);
782        assert!(before > 50.0, "the raw wrapped fit should be badly wrong: {before}");
783        enforce_inter_echo_consistency(&mut raw, &refs, &mask);
784        let after = max_abs_diff(&fit_slope(&raw, &TES), &b);
785        assert!(after < 1e-6, "the pass alone did not fix the fit: {after}");
786
787        // and the same for 3D ROMEO's output, which fails unaided
788        use crate::unwrap::unwrap_romeo_multi_echo;
789        let params = RomeoParams { individual: true, ..RomeoParams::default() };
790        let mut u =
791            unwrap_romeo_multi_echo(&wrapped, &[] as &[Vec<f64>], &TES, &mask, &params, &grid());
792        enforce_inter_echo_consistency(&mut u, &refs, &mask);
793        let err = max_abs_diff(&fit_slope(&u, &TES), &b);
794        assert!(err < 1e-6, "the pass did not fix 3D ROMEO's output either: {err}");
795    }
796
797    #[test]
798    fn slice_wise_unwrapping_is_what_makes_the_phase_spatially_continuous() {
799        // The job the pass cannot do, and the reason this module exists for any consumer that
800        // reads the phase rather than its TE-slope. The pass propagates echo 0's wrap state
801        // faithfully into every later echo, so on its own it leaves the discontinuities there.
802        let Inputs { wrapped, .. } = multi_echo_inputs(true);
803        let mask = full_mask();
804        let refs: Vec<&[f64]> = wrapped.iter().map(|p| p.as_slice()).collect();
805
806        let mut raw = wrapped.clone();
807        enforce_inter_echo_consistency(&mut raw, &refs, &mask);
808        let slicewise = unwrap_slicewise_multi_echo(
809            &wrapped, &[] as &[Vec<f64>], &TES, &mask, UnwrapMethod::Romeo,
810            &SliceWiseParams::default(), &grid(),
811        );
812
813        // Echo 0 is the clean measurement, and it is clean *by construction* rather than by
814        // comparison: `enforce_inter_echo_consistency` loops `for e in 1..n_echoes` and writes
815        // only `unwrapped[e]`, so it can never touch the first echo. Any difference here is the
816        // spatial unwrapping's and nothing else's - no null needed to rule the pass out, because
817        // the structure already does.
818        let pass_only_first = inplane_jumps(&raw[0]);
819        let slicewise_first = inplane_jumps(&slicewise[0]);
820        assert_eq!(
821            pass_only_first,
822            inplane_jumps(&wrapped[0]),
823            "the pass changed echo 0, which it cannot do if it only writes echoes 1.. - the \
824             structural argument this assertion rests on is broken"
825        );
826        assert_eq!(slicewise_first, 0, "slice-wise left {slicewise_first} jumps in echo 0");
827        assert!(
828            pass_only_first > 0,
829            "the wrapped first echo has no in-plane jumps, so this phantom cannot tell the two \
830             apart and the module docs' claim is untested here"
831        );
832
833        // The last echo is where both mechanisms are in play, so this one does need the null:
834        // the pass propagates echo 0's wrap state forward, leaving more jumps than slice-wise.
835        let last = TES.len() - 1;
836        let (pass_only, with_slicewise) =
837            (inplane_jumps(&raw[last]), inplane_jumps(&slicewise[last]));
838        assert_eq!(with_slicewise, 0, "slice-wise left {with_slicewise} in-plane 2pi jumps");
839        assert!(
840            pass_only > 0,
841            "the consistency pass alone left no in-plane jumps either, so this phantom cannot \
842             tell the two apart and the claim in the module docs is untested here"
843        );
844    }
845
846    #[test]
847    fn no_3d_configuration_survives_both_offset_patterns_unaided() {
848        // What slice-wise unwrapping is for. On clean data the 3D region growers are not
849        // obviously wrong - the quality weighting defers the bad z edges, so each slice tends
850        // to be entered once and picks up a whole number of wraps, which looks harmless.
851        //
852        // It is not harmless, because which whole number depends on the echo: the jump the
853        // grower rounds is `offset_step + field_step * TE`, and TE is in it. The per-slice
854        // wrap then differs between echoes, the slice offset stops being echo-independent,
855        // and the fit across echoes that was supposed to cancel it is corrupted instead.
856        // `correct_multi_echo_wraps` cannot repair that - it applies one correction to the
857        // whole volume, and the error is per slice.
858        //
859        // Neither 3D path survives both offset patterns, and which one each fails on is not
860        // something a caller can know in advance.
861        use crate::unwrap::unwrap_romeo_multi_echo;
862        let b = field();
863        let g = grid();
864        let mask = full_mask();
865
866        for individual in [false, true] {
867            let mut worst: f64 = 0.0;
868            for interleaved in [true, false] {
869                let offsets = slice_offsets(interleaved);
870                let wrapped: Vec<Vec<f64>> = TES
871                    .iter()
872                    .map(|&te| wrap_all(&true_phase(&b, &offsets, te)))
873                    .collect();
874                let params = RomeoParams { individual, ..RomeoParams::default() };
875                let u = unwrap_romeo_multi_echo(
876                    &wrapped, &[] as &[Vec<f64>], &TES, &mask, &params, &g,
877                );
878                worst = worst.max(max_abs_diff(&fit_slope(&u, &TES), &b));
879            }
880            assert!(
881                worst > 100.0,
882                "3D ROMEO (individual={individual}) handled both offset patterns unaided (worst \
883                 fit error {worst} rad/s) - then there was nothing to fix. Note this is 3D \
884                 *without* the consistency pass; with it 3D is fine too, which is what \
885                 the_consistency_pass_is_what_fixes_the_fit_even_without_slice_wise_unwrapping \
886                 pins."
887            );
888        }
889
890        // the slice-wise path gets both, which is the point
891        for interleaved in [true, false] {
892            let offsets = slice_offsets(interleaved);
893            let wrapped: Vec<Vec<f64>> = TES
894                .iter()
895                .map(|&te| wrap_all(&true_phase(&b, &offsets, te)))
896                .collect();
897            let u = unwrap_slicewise_multi_echo(
898                &wrapped, &[] as &[Vec<f64>], &TES, &mask, UnwrapMethod::Romeo,
899                &SliceWiseParams::default(), &g,
900            );
901            let err = max_abs_diff(&fit_slope(&u, &TES), &b);
902            assert!(err < 1e-6, "slice-wise missed interleaved={interleaved}: {err} rad/s");
903        }
904    }
905
906    // ---- inter-echo consistency -------------------------------------------------
907
908    /// Field, true phase per echo and wrapped phase per echo for one offset pattern.
909    struct Inputs {
910        field: Vec<f64>,
911        truth: Vec<Vec<f64>>,
912        wrapped: Vec<Vec<f64>>,
913    }
914
915    fn multi_echo_inputs(interleaved: bool) -> Inputs {
916        let field = field();
917        let offsets = slice_offsets(interleaved);
918        let truth: Vec<Vec<f64>> = TES.iter().map(|&te| true_phase(&field, &offsets, te)).collect();
919        let wrapped: Vec<Vec<f64>> = truth.iter().map(|t| wrap_all(t)).collect();
920        Inputs { field, truth, wrapped }
921    }
922
923    #[test]
924    fn consistency_makes_the_leftover_wrap_the_same_in_every_echo() {
925        let Inputs { truth, wrapped, .. } = multi_echo_inputs(false);
926        let mask = full_mask();
927
928        for (method, name, tol) in [
929            (UnwrapMethod::Romeo, "ROMEO", 1e-9),
930            (UnwrapMethod::BestPath, "best path", 1e-9),
931            (UnwrapMethod::Laplacian, "Laplacian", 0.1),
932        ] {
933            for enforce in [false, true] {
934                let params = SliceWiseParams {
935                    enforce_inter_echo_consistency: enforce,
936                    ..SliceWiseParams::default()
937                };
938                let u = unwrap_slicewise_multi_echo(
939                    &wrapped, &[] as &[Vec<f64>], &TES, &mask, method, &params, &grid(),
940                );
941                // leftover wraps per (slice, echo), measured against the model's own phase
942                let wraps: Vec<Vec<f64>> = (0..NZ)
943                    .map(|s| {
944                        let base = s * NX * NY;
945                        (0..TES.len())
946                            .map(|e| ((u[e][base] - truth[e][base]) / TWO_PI).round())
947                            .collect()
948                    })
949                    .collect();
950                let consistent = wraps.iter().all(|w| w.iter().all(|&v| v == w[0]));
951                if enforce {
952                    assert!(consistent, "{name}: per-slice wraps still differ: {wraps:?}");
953                    // and the result is still the truth plus that one shared multiple
954                    for (e, ue) in u.iter().enumerate() {
955                        for (s, w) in wraps.iter().enumerate() {
956                            let base = s * NX * NY;
957                            let shift = TWO_PI * w[0];
958                            for p in 0..NX * NY {
959                                let r = ue[base + p] - truth[e][base + p] - shift;
960                                assert!(r.abs() < tol, "{name}: echo {e} slice {s} residual {r}");
961                            }
962                        }
963                    }
964                }
965            }
966        }
967    }
968
969    #[test]
970    fn a_linear_fit_across_echoes_then_recovers_the_field() {
971        let Inputs { field: b, wrapped, .. } = multi_echo_inputs(true);
972        let mask = full_mask();
973
974        for (method, name, tol) in [
975            (UnwrapMethod::Romeo, "ROMEO", 1e-6),
976            (UnwrapMethod::BestPath, "best path", 1e-6),
977            (UnwrapMethod::Laplacian, "Laplacian", 5.0),
978        ] {
979            let params = SliceWiseParams::default();
980            let u = unwrap_slicewise_multi_echo(
981                &wrapped, &[] as &[Vec<f64>], &TES, &mask, method, &params, &grid(),
982            );
983            let slope = fit_slope(&u, &TES);
984            let err = max_abs_diff(&slope, &b);
985            assert!(err < tol, "{name}: fitted field is off by {err} rad/s");
986        }
987    }
988
989    #[test]
990    fn without_consistency_the_fit_is_corrupted() {
991        // The companion to the test above: the correction is load-bearing, not decorative.
992        let Inputs { field: b, wrapped, .. } = multi_echo_inputs(true);
993        let mask = full_mask();
994        let params = SliceWiseParams {
995            enforce_inter_echo_consistency: false,
996            ..SliceWiseParams::default()
997        };
998        let u = unwrap_slicewise_multi_echo(
999            &wrapped, &[] as &[Vec<f64>], &TES, &mask, UnwrapMethod::Romeo, &params, &grid(),
1000        );
1001        let err = max_abs_diff(&fit_slope(&u, &TES), &b);
1002        assert!(err > 50.0, "fit came out clean without the correction (error {err} rad/s)");
1003    }
1004
1005    #[test]
1006    fn consistency_survives_a_minority_of_voxels_whose_echo_step_wraps() {
1007        // The wrap count per voxel is `m + relative_wrap`, and only `relative_wrap` is wanted.
1008        // `m` is zero wherever the echo-to-echo evolution stays inside +/-pi, which is the
1009        // usual case but not a guarantee: a strong local source can push a patch of a slice
1010        // past it. The median tolerates that as long as the patch is a minority; a mean would
1011        // be dragged off the integer and shift the slice by something that is not 2*pi.
1012        let mut b = field();
1013        let (cx, cy) = (NX as f64 / 2.0, NY as f64 / 2.0);
1014        let mut over = 0usize;
1015        for k in 0..NZ {
1016            for j in 0..NY {
1017                for i in 0..NX {
1018                    let r2 = (i as f64 - cx).powi(2) + (j as f64 - cy).powi(2);
1019                    let idx = i + j * NX + k * NX * NY;
1020                    b[idx] += 900.0 * (-r2 / 72.0).exp();
1021                    // the step between the closest echoes is what has to stay unwrappable
1022                    if (b[idx] * (TES[1] - TES[0])).abs() > PI {
1023                        over += 1;
1024                    }
1025                }
1026            }
1027        }
1028        let fraction = over as f64 / b.len() as f64;
1029        assert!(
1030            (0.05..0.45).contains(&fraction),
1031            "the bump has to make a minority of voxels wrap between echoes, not none and not              most: {fraction:.3}"
1032        );
1033
1034        let offsets = slice_offsets(false);
1035        let truth: Vec<Vec<f64>> = TES.iter().map(|&te| true_phase(&b, &offsets, te)).collect();
1036        let wrapped: Vec<Vec<f64>> = truth.iter().map(|t| wrap_all(t)).collect();
1037        let u = unwrap_slicewise_multi_echo(
1038            &wrapped, &[] as &[Vec<f64>], &TES, &full_mask(), UnwrapMethod::Romeo,
1039            &SliceWiseParams::default(), &grid(),
1040        );
1041        for s in 0..NZ {
1042            let base = s * NX * NY;
1043            let k0 = ((u[0][base] - truth[0][base]) / TWO_PI).round();
1044            for (e, ue) in u.iter().enumerate() {
1045                let k = (ue[base] - truth[e][base]) / TWO_PI;
1046                assert!(
1047                    (k - k0).abs() < 1e-9,
1048                    "slice {s} echo {e} is {k} wraps from the truth, echo 0 is {k0} - a                      fractional or unequal offset means the correction was not an integer                      number of wraps"
1049                );
1050            }
1051        }
1052    }
1053
1054    #[test]
1055    fn consistency_corrects_each_component_of_a_split_slice_separately() {
1056        // Two blobs in one slice can leave the unwrapper with different wrap counts, and a
1057        // single median over the whole slice would fix one and break the other.
1058        let Inputs { truth, wrapped, .. } = multi_echo_inputs(false);
1059        let mut mask = vec![0u8; NX * NY * NZ];
1060        for k in 0..NZ {
1061            for j in 0..NY {
1062                for i in 0..NX {
1063                    // left and right blobs, separated by a two-voxel gap
1064                    let inside = (2..10).contains(&i) || (14..22).contains(&i);
1065                    if inside && (2..22).contains(&j) {
1066                        mask[i + j * NX + k * NX * NY] = 1;
1067                    }
1068                }
1069            }
1070        }
1071        let u = unwrap_slicewise_multi_echo(
1072            &wrapped, &[] as &[Vec<f64>], &TES, &mask, UnwrapMethod::Romeo,
1073            &SliceWiseParams::default(), &grid(),
1074        );
1075        for s in 0..NZ {
1076            for (label, i0) in [("left", 2usize), ("right", 14usize)] {
1077                let base = i0 + 2 * NX + s * NX * NY;
1078                let k0 = ((u[0][base] - truth[0][base]) / TWO_PI).round();
1079                for e in 0..TES.len() {
1080                    let k = ((u[e][base] - truth[e][base]) / TWO_PI).round();
1081                    assert_eq!(k, k0, "slice {s} {label} blob: echo {e} has {k} wraps, echo 0 has {k0}");
1082                }
1083            }
1084        }
1085    }
1086
1087    // ---- other axes and edge cases ----------------------------------------------
1088
1089    #[test]
1090    fn unwrapping_along_another_axis_matches_the_transposed_problem() {
1091        // Build the same problem with the slices stacked along y, and check it comes out the
1092        // same as the z-stacked one read through the transpose.
1093        let b = field();
1094        let offsets = slice_offsets(false);
1095        let truth_z = true_phase(&b, &offsets, TES[3]);
1096        let g_z = grid();
1097
1098        // transpose (x, y, z) -> (x, z, y), so the slice axis becomes 1
1099        let g_y = Grid::new(NX, NZ, NY, 1.0, 3.0, 1.0);
1100        let mut truth_y = vec![0.0; NX * NY * NZ];
1101        for k in 0..NZ {
1102            for j in 0..NY {
1103                for i in 0..NX {
1104                    truth_y[i + k * NX + j * NX * NZ] = truth_z[i + j * NX + k * NX * NY];
1105                }
1106            }
1107        }
1108
1109        let u_z = unwrap_slicewise(
1110            &wrap_all(&truth_z), &[], &full_mask(), UnwrapMethod::Romeo,
1111            &SliceWiseParams::default(), &g_z,
1112        );
1113        let u_y = unwrap_slicewise(
1114            &wrap_all(&truth_y), &[], &full_mask(), UnwrapMethod::Romeo,
1115            &SliceWiseParams { slice_axis: 1, ..SliceWiseParams::default() }, &g_y,
1116        );
1117        for k in 0..NZ {
1118            for j in 0..NY {
1119                for i in 0..NX {
1120                    let a = u_z[i + j * NX + k * NX * NY];
1121                    let c = u_y[i + k * NX + j * NX * NZ];
1122                    assert!((a - c).abs() < 1e-9, "mismatch at ({i},{j},{k}): {a} vs {c}");
1123                }
1124            }
1125        }
1126    }
1127
1128    #[test]
1129    fn single_echo_input_is_returned_unchanged_by_the_consistency_pass() {
1130        let Inputs { truth, .. } = multi_echo_inputs(false);
1131        let wrapped = vec![wrap_all(&truth[0])];
1132        let mask = full_mask();
1133        let u = unwrap_slicewise_multi_echo(
1134            &wrapped, &[] as &[Vec<f64>], &TES[..1], &mask, UnwrapMethod::Romeo,
1135            &SliceWiseParams::default(), &grid(),
1136        );
1137        assert_eq!(u.len(), 1);
1138        let direct = unwrap_slicewise(
1139            &wrapped[0], &[], &mask, UnwrapMethod::Romeo, &SliceWiseParams::default(), &grid(),
1140        );
1141        assert_eq!(u[0], direct);
1142    }
1143
1144    #[test]
1145    fn magnitude_weighting_is_accepted_and_empty_magnitude_is_allowed() {
1146        let Inputs { truth, .. } = multi_echo_inputs(false);
1147        let wrapped = wrap_all(&truth[0]);
1148        let mask = full_mask();
1149        let mag = vec![1.0; NX * NY * NZ];
1150        let with_mag = unwrap_slicewise(
1151            &wrapped, &mag, &mask, UnwrapMethod::Romeo, &SliceWiseParams::default(), &grid(),
1152        );
1153        let without = unwrap_slicewise(
1154            &wrapped, &[], &mask, UnwrapMethod::Romeo, &SliceWiseParams::default(), &grid(),
1155        );
1156        assert!(max_inplane_gradient_error(&with_mag, &truth[0]) < 0.05);
1157        assert!(max_inplane_gradient_error(&without, &truth[0]) < 0.05);
1158    }
1159
1160    #[test]
1161    fn an_empty_slice_is_skipped_rather_than_breaking_the_correction() {
1162        let Inputs { truth, wrapped, .. } = multi_echo_inputs(false);
1163        let mut mask = full_mask();
1164        for p in 0..NX * NY {
1165            mask[p + 3 * NX * NY] = 0; // slice 3 has no signal at all
1166        }
1167        let u = unwrap_slicewise_multi_echo(
1168            &wrapped, &[] as &[Vec<f64>], &TES, &mask, UnwrapMethod::Romeo,
1169            &SliceWiseParams::default(), &grid(),
1170        );
1171        // the remaining slices are still echo-consistent
1172        for s in [0usize, 1, 2, 4, 5, 6, 7] {
1173            let base = s * NX * NY;
1174            let k0 = ((u[0][base] - truth[0][base]) / TWO_PI).round();
1175            for e in 0..TES.len() {
1176                assert_eq!(((u[e][base] - truth[e][base]) / TWO_PI).round(), k0, "slice {s}");
1177            }
1178        }
1179    }
1180
1181    #[test]
1182    #[should_panic(expected = "phases must have at least one echo")]
1183    fn multi_echo_rejects_no_echoes() {
1184        unwrap_slicewise_multi_echo(
1185            &[] as &[Vec<f64>], &[] as &[Vec<f64>], &[], &full_mask(),
1186            UnwrapMethod::Romeo, &SliceWiseParams::default(), &grid(),
1187        );
1188    }
1189
1190    #[test]
1191    #[should_panic(expected = "phase length must match grid dimensions")]
1192    fn rejects_a_phase_volume_that_does_not_match_the_grid() {
1193        unwrap_slicewise(
1194            &[0.0; 10], &[], &full_mask(), UnwrapMethod::Romeo,
1195            &SliceWiseParams::default(), &grid(),
1196        );
1197    }
1198
1199}