Skip to main content

qsm_core/unwrap/
medi_region_growing.rs

1//! MEDI's region-growing phase unwrapping: `unwrapPhase` / `mexUnwrap.c` (Tian Liu), a
2//! magnitude-guided, quality-ordered flood fill.
3//!
4//! Liu T, Wisnieff C, Lou M, Chen W, Spincemaille P, Wang Y. *Nonlinear formulation of the
5//! magnetic field to source relationship for robust quantitative susceptibility mapping.*
6//! Magn Reson Med 2013;69(2):467–476, <https://doi.org/10.1002/mrm.24272>. The MEDI toolbox's
7//! pipeline script unwraps `Fit_ppm_complex`'s field with it (`README.m:80-95`):
8//!
9//! ```text
10//! iMag = sqrt(sum(abs(iField).^2,4));
11//! [iFreq_raw, N_std] = Fit_ppm_complex(iField);
12//! iFreq = unwrapPhase(iMag, iFreq_raw, matrix_size);
13//! ```
14//!
15//! # Reference code
16//!
17//! The original is the MEDI toolbox 2024.11.26's `functions/unwrapPhase.m` and the C it calls,
18//! `functions/mexUnwrap.c` ("written by Tian Liu", mex interface by Dong Zhou, last update
19//! 7.11.13). This module is a line-by-line port of that C, and reproduces it **bit for bit**:
20//! `tests/medi_unwrap_cornell_parity.rs` checks every voxel against the verified QSM-CI
21//! `fit-ppm-complex-cornell` container (`ghcr.io/astewartau/qsm-ci/matlab-medi@sha256:7efa4bd6…`,
22//! `mexUnwrap` compiled from that `mexUnwrap.c` with R2023b's `mex` and identical to licensed
23//! MATLAB; QSMxT/QSM-CI#300), on phantoms built to exercise heap ties, the noise level, a region
24//! cut off from the seed and even and odd matrix sizes. From the echoes, the field-mapping stage
25//! with [`B0EstimationMethod::ComplexFit`](crate::pipeline::B0EstimationMethod::ComplexFit) and
26//! this unwrapper gives that container's total field to 8.9e-16 ppm (the units' rounding). The
27//! fixture is sensitive to the heap: making `heap_pop` take the left child on a tie moves 541 of
28//! its 2970 voxels, and making `heap_add` sift up on a tie 1017. Every decision below is the C's:
29//!
30//! - **Precision: single.** `mexUnwrap` copies the magnitude and the phase from MATLAB's doubles
31//!   into `float` arrays (`mexFunction`), and every comparison and update is done on those floats.
32//!   The output is the float phase written back as double, so every output value, including the
33//!   voxels it never touches, is the input rounded to `f32`. This is kept (not double precision,
34//!   unlike other ports here) because the growth order is decided by comparing `f32` magnitudes:
35//!   two magnitudes that differ in double but round to the same float are a tie in the original,
36//!   and the tie decides which neighbour a voxel is unwrapped against.
37//! - **Quality map: the magnitude, nothing else.** The heap is ordered by the magnitude value
38//!   alone (`heap_add` / `heap_pop` compare `.mag`). MEDI passes `iMag`, the root-sum-of-squares
39//!   of the echo magnitudes; the caller passes whatever magnitude it wants used (see
40//!   [`UnwrappingAlgorithm::MediRegionGrowing`](crate::pipeline::UnwrappingAlgorithm::MediRegionGrowing)
41//!   for what the pipeline passes).
42//! - **Seed: the middle of the field of view**, voxel `(nx/2, ny/2, nz/2)` in integer division
43//!   (`mexFunction`: `dim[2]/2*dim[1]*dim[0] + dim[1]/2*dim[0] + dim[0]/2`, "choose the middle of
44//!   the FOV as the seeding point"). Not the brightest voxel: the brightest voxel only sets the
45//!   noise level. The seed is unwrapped (left as it is) whatever its magnitude, even below the
46//!   noise level; if none of its neighbours is above the noise level, nothing else is unwrapped.
47//! - **Noise level: `0.005 ×` the largest magnitude**, in single precision (`noise_ratio = 5e-3`,
48//!   a `float`; `noise_level = MRimage[max_ind].mag * noise_ratio`). The largest magnitude is
49//!   found with a strict `>` from voxel 0, so the first of equal maxima is taken (it only matters
50//!   through its value). Only voxels with magnitude strictly above the noise level are ever
51//!   planned; the rest keep their wrapped phase. [`MEDI_NOISE_RATIO`]. MEDI's `unwrapPhase` has no
52//!   way to change it, and neither does this function.
53//! - **No mask.** `unwrapPhase(iMag, iFreq_raw, matrix_size)` takes none: the growth runs over the
54//!   whole array, limited only by the noise level, so the phase outside the brain can decide the
55//!   path into it. Voxels that the growth never reaches (below the noise level, or above it but
56//!   cut off from the seed by voxels below it) are returned as they came in (rounded to `f32`).
57//! - **Neighbours: the 6-neighbourhood, in the order `x−1, x+1, y−1, y+1, z−1, z+1`**
58//!   (`get_neighbors`), with x fastest in memory (MATLAB's column-major order; QSM.rs's layout).
59//!   Neighbours outside the array are skipped.
60//! - **Growth.** A binary max-heap of voxel indices keyed on magnitude (`heap_create`, `heap_add`,
61//!   `heap_pop`). Each step takes the top voxel, unwraps it against the voxel it was planned from
62//!   (its `pre`; the seed has none), pops it, and plans each neighbour that is unplanned and above
63//!   the noise level, in neighbour order: its `pre` becomes the popped voxel, it is pushed, and it
64//!   is marked planned. So a voxel is unwrapped against the first unwrapped neighbour that found
65//!   it, not the best one, and `pre` is fixed when the voxel is planned, not when it is popped.
66//! - **Heap order and ties, exactly.** `heap_add` sifts up while the parent's magnitude is
67//!   strictly less than the child's (equal magnitudes stay below their parent). `heap_pop` moves
68//!   the last element to the root, clears the vacated slot, and sifts down while the parent is
69//!   strictly less than the chosen child, choosing the left child when its magnitude is strictly
70//!   greater than the right's and the right child otherwise (so the right one on a tie), and the
71//!   left one when the right is past the end. Ties therefore resolve by heap position, not by
72//!   voxel index or insertion time; reproducing them needs this exact heap, so no standard
73//!   priority queue is used.
74//! - **The unwrap step.** `phase = phase − round((phase − phase[pre]) / (2π)) · 2π` with the C's
75//!   types: the difference of the two floats is a float; it is divided by the double `2*PI`
76//!   (`PI` = 3.1415926535897931, the same double as [`std::f64::consts::PI`]); the quotient is
77//!   passed to the file's own `static float round(float)`, i.e. converted to float, then
78//!   `floor(num + 0.5)` in double (round half up, not half away from zero), returned as float;
79//!   that float times the int 2 is a float, times `PI` a double, subtracted from the float phase
80//!   in double, and the result stored as float.
81//!
82//! The C also prints `"%d voxels are unwrapped"`; that is not reproduced.
83//!
84//! # Pipeline rules
85//!
86//! `complex-fit.unwrap-region-growing`: with the complex fit
87//! ([`B0EstimationMethod::ComplexFit`](crate::pipeline::B0EstimationMethod::ComplexFit), MEDI's
88//! `Fit_ppm_complex`), the unwrapper MEDI's pipeline pairs it with is this one (`README.m:80-95`).
89//! It is a [`Kind::Reference`](crate::pipeline::rules::Kind::Reference) rule, the authors'
90//! setting, not a recommendation: nobody has shown it unwraps better than ROMEO, so a host shows
91//! it when the complex fit is chosen but never preselects it, and ROMEO stays the default
92//! unwrapper (decided 2026-11-04). It was left out when the port landed (2026-11-03) because
93//! QSMxT then preselected Apply on every reference value, which would have switched the
94//! unwrapper from ROMEO on one keypress. It is triggered by the complex fit rather than by MEDI,
95//! because the pairing is the field map's (`medi.field-complex-fit` leads from MEDI to the fit).
96//!
97//! # Use
98//!
99//! `mexUnwrap.c` assumes the input is wrapped; in MEDI it is `Fit_ppm_complex`'s field per echo
100//! spacing. The pipeline offers it as
101//! [`UnwrappingAlgorithm::MediRegionGrowing`](crate::pipeline::UnwrappingAlgorithm::MediRegionGrowing),
102//! on every field-mapping path; with [`B0EstimationMethod::ComplexFit`](crate::pipeline::B0EstimationMethod::ComplexFit)
103//! it gives MEDI's field map (QSM-CI's `fit-ppm-complex-cornell` pair).
104
105use crate::grid::Grid;
106
107/// `mexUnwrap.c`'s `noise_ratio`: voxels with magnitude at or below this fraction of the
108/// largest magnitude are never unwrapped. A `float` in the C, as here.
109pub const MEDI_NOISE_RATIO: f32 = 5e-3;
110
111/// `mexUnwrap.c`'s `#define PI 3.1415926535897931`: that literal is this same double.
112const PI: f64 = std::f64::consts::PI;
113
114/// Unwrap `phase` (radians) by MEDI's region growing, ordered by `magnitude`, as MEDI's
115/// `unwrapPhase(magnitude, phase, matrix_size)` does. See the [module docs](self) for every
116/// detail of the original that is reproduced.
117///
118/// Both arrays are column-major over `grid.dims` (x fastest). There is no mask: the growth runs
119/// over the whole array, above the noise level ([`MEDI_NOISE_RATIO`] of the largest magnitude).
120/// The result is computed in single precision, as in the original, so every value (also those
121/// the growth does not reach, which keep their wrapped phase) is an `f32` widened to `f64`.
122///
123/// # Panics
124///
125/// If `phase` or `magnitude` does not have `nx·ny·nz` elements, or the array is empty.
126pub fn unwrap_medi_region_growing(phase: &[f64], magnitude: &[f64], grid: &Grid) -> Vec<f64> {
127    let (nx, ny, nz) = grid.dims;
128    let n = nx * ny * nz;
129    assert!(n > 0, "unwrap_medi_region_growing: empty array");
130    assert_eq!(phase.len(), n, "unwrap_medi_region_growing: phase has the wrong length");
131    assert_eq!(magnitude.len(), n, "unwrap_medi_region_growing: magnitude has the wrong length");
132
133    // mexFunction: `mag[i] = p[i]` and `phase[i] = p[i]`, double to float.
134    let mag: Vec<f32> = magnitude.iter().map(|&m| m as f32).collect();
135    let mut ph: Vec<f32> = phase.iter().map(|&p| p as f32).collect();
136
137    // assemble_data: `if (mag[ind] > mag[*max_ind]) *max_ind = ind;` from max_ind = 0.
138    let mut max_ind = 0usize;
139    for i in 0..n {
140        if mag[i] > mag[max_ind] {
141            max_ind = i;
142        }
143    }
144    let noise = mag[max_ind] * MEDI_NOISE_RATIO;
145
146    // "choose the middle of the FOV as the seeding point"
147    let seed = nz / 2 * ny * nx + ny / 2 * nx + nx / 2;
148
149    let mut planned = vec![false; n];
150    // `pre`: the voxel each one was planned from; None for the seed (the C's -1).
151    let mut pre: Vec<Option<usize>> = vec![None; n];
152    let mut heap = Heap { idx: vec![0usize; n], len: 0 };
153
154    // unwrap(): heap_create(seed); heap_length++; pre = -1; planned = 1.
155    heap.idx[0] = seed;
156    heap.len = 1;
157    planned[seed] = true;
158
159    let mut nei = [None; 6];
160    loop {
161        let top = heap.idx[0];
162        if let Some(p) = pre[top] {
163            ph[top] = unwrap_against(ph[top], ph[p]);
164        }
165        neighbours(&mut nei, top, nx, ny, nz);
166        let curr = heap.pop(&mag);
167        debug_assert_eq!(curr, top);
168        for &nb in nei.iter().flatten() {
169            if !planned[nb] && mag[nb] > noise {
170                pre[nb] = Some(curr);
171                heap.add(&mag, nb);
172                planned[nb] = true;
173            }
174        }
175        if heap.len == 0 {
176            break;
177        }
178    }
179
180    ph.into_iter().map(f64::from).collect()
181}
182
183/// `phase - round((phase - reference)/(2*PI))*2*PI` with `mexUnwrap.c`'s types (module docs,
184/// *The unwrap step*).
185#[inline]
186fn unwrap_against(phase: f32, reference: f32) -> f32 {
187    let diff: f32 = phase - reference;
188    let turns = round_c(((diff as f64) / (2.0 * PI)) as f32);
189    ((phase as f64) - ((turns * 2.0) as f64) * PI) as f32
190}
191
192/// The file's `static float round(float num) { return floor(num+0.5); }`: `num + 0.5` and the
193/// floor in double, the result converted to float.
194#[inline]
195fn round_c(num: f32) -> f32 {
196    ((num as f64) + 0.5).floor() as f32
197}
198
199/// `get_neighbors`: x−1, x+1, y−1, y+1, z−1, z+1, `None` past the array's edge (the C's −1).
200#[inline]
201fn neighbours(nei: &mut [Option<usize>; 6], ind: usize, nx: usize, ny: usize, nz: usize) {
202    let z = ind / (nx * ny);
203    let y = (ind - z * nx * ny) / nx;
204    let x = ind - y * nx - z * nx * ny;
205    let at = |x: usize, y: usize, z: usize| x + y * nx + z * nx * ny;
206    nei[0] = (x > 0).then(|| at(x - 1, y, z));
207    nei[1] = (x + 1 < nx).then(|| at(x + 1, y, z));
208    nei[2] = (y > 0).then(|| at(x, y - 1, z));
209    nei[3] = (y + 1 < ny).then(|| at(x, y + 1, z));
210    nei[4] = (z > 0).then(|| at(x, y, z - 1));
211    nei[5] = (z + 1 < nz).then(|| at(x, y, z + 1));
212}
213
214/// `mexUnwrap.c`'s binary max-heap of voxel indices, keyed on the magnitude. Kept as the C writes
215/// it (module docs, *Heap order and ties*): its tie behaviour decides the growth order.
216struct Heap {
217    /// `heap`: `calloc(numel, sizeof(int))`, so one slot per voxel, zero-initialised.
218    idx: Vec<usize>,
219    /// `heap_length`
220    len: usize,
221}
222
223impl Heap {
224    /// `heap_add`: append, then sift up while the parent's magnitude is strictly less.
225    fn add(&mut self, mag: &[f32], index: usize) {
226        self.idx[self.len] = index;
227        let mut ind = self.len;
228        while ind > 0 {
229            let parent = (ind - 1) / 2;
230            if mag[self.idx[parent]] < mag[self.idx[ind]] {
231                self.idx.swap(parent, ind);
232                ind = parent;
233            } else {
234                break;
235            }
236        }
237        self.len += 1;
238    }
239
240    /// `heap_pop`: take the root, move the last element there (and zero its old slot), then sift
241    /// down: the left child if its magnitude is strictly greater than the right's, else the
242    /// right, else the left when the right is past the end; swap while the parent's magnitude is
243    /// strictly less than that child's.
244    fn pop(&mut self, mag: &[f32]) -> usize {
245        self.len -= 1;
246        let len = self.len;
247        let ret = self.idx[0];
248        self.idx[0] = self.idx[len];
249        self.idx[len] = 0;
250        let mut ind = 0usize;
251        // `while ((ind*2+1 <= *length - 1) && goon)`: with `len` 0 the C's `*length - 1` is -1 in
252        // int arithmetic, so the loop does not run.
253        while len > 0 && ind * 2 + 1 < len {
254            let left = ind * 2 + 1;
255            let right = ind * 2 + 2;
256            // The C compares heap[right] even when right == len (the slot just zeroed, still in
257            // the array); the choice is then overridden to `left`, so only in-range rights matter.
258            let mut child = if mag[self.idx[left]] > mag[self.idx[right]] {
259                left
260            } else {
261                right
262            };
263            if child > len - 1 {
264                child = left;
265            }
266            if mag[self.idx[ind]] < mag[self.idx[child]] {
267                self.idx.swap(ind, child);
268                ind = child;
269            } else {
270                break;
271            }
272        }
273        ret
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use std::f64::consts::TAU;
281
282    fn wrap(x: f64) -> f64 {
283        x.sin().atan2(x.cos())
284    }
285
286    /// A smooth field that wraps several times comes back unwrapped, up to the seed's own turn
287    /// (the seed keeps its wrapped value), to f32 rounding.
288    #[test]
289    fn unwraps_a_smooth_field() {
290        let (nx, ny, nz) = (20, 17, 9);
291        let g = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
292        let n = nx * ny * nz;
293        let truth: Vec<f64> = (0..n)
294            .map(|i| {
295                let (x, y, z) = ((i % nx) as f64, (i / nx % ny) as f64, (i / (nx * ny)) as f64);
296                0.9 * x - 0.6 * y + 0.4 * z + 0.02 * x * y
297            })
298            .collect();
299        let wrapped: Vec<f64> = truth.iter().map(|&t| wrap(t)).collect();
300        let mag: Vec<f64> = (0..n).map(|i| 1.0 + (i % 7) as f64).collect();
301        let u = unwrap_medi_region_growing(&wrapped, &mag, &g);
302        let seed = nz / 2 * ny * nx + ny / 2 * nx + nx / 2;
303        let offset = truth[seed] - u[seed];
304        assert!((offset / TAU - (offset / TAU).round()).abs() < 1e-6);
305        for i in 0..n {
306            assert!((u[i] + offset - truth[i]).abs() < 1e-4, "voxel {i}: {} vs {}", u[i] + offset, truth[i]);
307        }
308    }
309
310    /// Voxels at or below 0.5 % of the largest magnitude are never unwrapped, nor is a bright
311    /// region they cut off from the seed; both keep their wrapped phase (as f32).
312    #[test]
313    fn noise_and_unreachable_voxels_keep_their_phase() {
314        let (nx, ny, nz) = (12, 5, 1);
315        let g = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
316        let n = nx * ny * nz;
317        let truth: Vec<f64> = (0..n).map(|i| 1.1 * (i % nx) as f64).collect();
318        let wrapped: Vec<f64> = truth.iter().map(|&t| wrap(t)).collect();
319        // Columns 0..=6 bright (seed at x = 6), column 7 at exactly the noise level, 8.. bright.
320        let mag: Vec<f64> = (0..n).map(|i| if i % nx == 7 { 0.005 } else { 1.0 }).collect();
321        let u = unwrap_medi_region_growing(&wrapped, &mag, &g);
322        for i in 0..n {
323            let x = i % nx;
324            if x >= 7 {
325                assert_eq!(u[i], wrapped[i] as f32 as f64, "x = {x} was touched");
326            }
327        }
328        assert!((u[6] - u[0] - (truth[6] - truth[0])).abs() < 1e-4);
329    }
330
331    /// The seed is the middle of the field of view, even when it is below the noise level: then
332    /// nothing else is unwrapped.
333    #[test]
334    fn a_dark_seed_unwraps_nothing() {
335        let (nx, ny, nz) = (5, 5, 5);
336        let g = Grid::new(nx, ny, nz, 1.0, 1.0, 1.0);
337        let n = nx * ny * nz;
338        let wrapped: Vec<f64> = (0..n).map(|i| wrap(0.9 * i as f64)).collect();
339        let mut mag = vec![1.0; n];
340        let seed = 2 * 25 + 2 * 5 + 2;
341        for d in [1usize, 5, 25] {
342            mag[seed - d] = 0.0;
343            mag[seed + d] = 0.0;
344        }
345        let u = unwrap_medi_region_growing(&wrapped, &mag, &g);
346        for i in 0..n {
347            assert_eq!(u[i], wrapped[i] as f32 as f64);
348        }
349    }
350
351    /// `round` is the C's `floor(x + 0.5)`: halves round up, also below zero.
352    #[test]
353    fn round_is_floor_of_x_plus_a_half() {
354        assert_eq!(round_c(0.5), 1.0);
355        assert_eq!(round_c(-0.5), 0.0);
356        assert_eq!(round_c(-1.5), -1.0);
357        assert_eq!(round_c(2.4999), 2.0);
358    }
359}