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}