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