Skip to main content

qsm_core/utils/
complex.rs

1//! Real/imaginary input: conversion to magnitude and phase, with slice-chopping correction.
2//!
3//! Some scanners (GE in particular) export the complex image as separate real and imaginary
4//! volumes instead of magnitude and phase. Converting them is `|z|` and `arg z`, except for one
5//! artefact: when the reconstruction centres k-space without the matching image-domain shift,
6//! every other slice comes out multiplied by −1 — a π phase jump between neighbouring slices
7//! ("chopping"). Magnitude does not see it; phase does, and nothing downstream (unwrapping, field
8//! fitting, background removal) can tell a π jump per slice from a field.
9//!
10//! [`complex_to_mag_phase`] detects that per axis and undoes it with a `(−1)^k` factor along the
11//! affected axis before taking the phase. The factor is the same for every echo: the chop comes
12//! from the reconstruction, not the signal, so all echoes share it. Flipping the same slices in
13//! every echo leaves at most a common π offset, which phase-offset removal absorbs; flipping
14//! different slices per echo would put a slice-alternating offset into the field.
15//!
16//! The sign of the phase is a vendor convention too: GE's real/imaginary pair gives phase of the
17//! opposite sign to Siemens and Philips phase images (on the same subject, high-pass field maps
18//! anti-correlate). Nothing in the data says which convention it follows, so the caller states
19//! it ([`ComplexToMagPhaseParams::negate_phase`]); conjugating `z` brings it to the convention
20//! the rest of the crate assumes.
21
22use std::f64::consts::PI;
23
24use crate::utils::threshold::otsu_threshold;
25use crate::Grid;
26
27/// Coherence below which an axis's chop decision is uncertain; see
28/// [`SliceChopping::ambiguous_axes`].
29///
30/// Smooth phase gives a neighbour coherence near +1, chopped phase near −1. Values close to zero
31/// come from data with too little signal, or phase varying too fast between voxels, for the
32/// sign to be trusted either way.
33pub const CHOP_AMBIGUOUS_COHERENCE: f64 = 0.5;
34
35/// How [`complex_to_mag_phase`] treats alternate-slice π chopping.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
37pub enum ChopCorrection {
38    /// Detect chopping along each axis from the data and correct the axes where it is found.
39    #[default]
40    Auto,
41    /// Correct exactly these axes (`[x, y, z]`) whatever the data says. `[false; 3]` converts
42    /// without any correction.
43    Axes([bool; 3]),
44}
45
46/// Options for [`complex_to_mag_phase`].
47#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
48pub struct ComplexToMagPhaseParams {
49    /// How alternate-slice π chopping is handled.
50    pub chop: ChopCorrection,
51    /// Negate the phase (take `arg z̄`). Set it for data whose phase runs opposite to the
52    /// Siemens/Philips convention the rest of the crate assumes, such as GE real/imaginary
53    /// exports; it cannot be detected from the data.
54    pub negate_phase: bool,
55}
56
57/// What [`complex_to_mag_phase`] found and did about slice chopping.
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub struct SliceChopping {
60    /// Magnitude-weighted coherence of neighbouring voxels along each axis (`[x, y, z]`), in
61    /// `[-1, 1]`, pooled over all echoes and measured on the input as given: about +1 for smooth
62    /// phase, about −1 for an axis chopped by π. See [`slice_chopping_coherence`].
63    pub coherence: [f64; 3],
64    /// Axes the `(−1)^k` correction was applied along.
65    pub corrected: [bool; 3],
66    /// Whether `corrected` came from detection ([`ChopCorrection::Auto`]) or was imposed.
67    pub detected: bool,
68}
69
70impl SliceChopping {
71    /// Axes whose coherence is too close to zero for the chop decision to be trusted
72    /// (`|coherence| < CHOP_AMBIGUOUS_COHERENCE`).
73    pub fn ambiguous_axes(&self) -> Vec<usize> {
74        (0..3).filter(|&a| self.coherence[a].abs() < CHOP_AMBIGUOUS_COHERENCE).collect()
75    }
76}
77
78/// Multi-echo magnitude and phase converted from real/imaginary input.
79#[derive(Clone, Debug)]
80pub struct MagPhaseEchoes {
81    /// Per-echo magnitude `|z|`.
82    pub magnitudes: Vec<Vec<f64>>,
83    /// Per-echo phase `arg z` in `[-π, π]`, after the chop correction (and negated when
84    /// `phase_negated`).
85    pub phases: Vec<Vec<f64>>,
86    pub chopping: SliceChopping,
87    /// Whether the phase was negated ([`ComplexToMagPhaseParams::negate_phase`]).
88    pub phase_negated: bool,
89}
90
91/// Magnitude-weighted neighbour coherence along each axis, pooled over echoes.
92///
93/// For each axis this is `Re Σ z_v z̄_{v+1} / Σ |z_v| |z_{v+1}|` over neighbouring pairs that both
94/// lie in the signal mask, summed over echoes: the magnitude-weighted mean of `cos Δφ` between
95/// neighbours. Weighting by magnitude lets the high-signal echoes and voxels dominate, which is
96/// where the neighbour phase difference is small and the sign of the result unambiguous.
97///
98/// The mask is an Otsu threshold on the first echo's magnitude, so background noise (whose
99/// neighbour phase differences are uniform and average to zero, diluting the result) does not
100/// count. If nothing clears it, every voxel counts.
101///
102/// # Panics
103/// If there are no echoes, `real` and `imag` hold different numbers of echoes, or a volume does
104/// not have `grid.n_total()` voxels.
105pub fn slice_chopping_coherence(real: &[&[f64]], imag: &[&[f64]], grid: &Grid) -> [f64; 3] {
106    check_echoes(real, imag, grid);
107    let (nx, ny, nz) = grid.dims;
108    let n = grid.n_total();
109
110    let mag0: Vec<f64> = (0..n).map(|v| finite(real[0][v]).hypot(finite(imag[0][v]))).collect();
111    let threshold = otsu_threshold(&mag0, 256);
112    let mut mask: Vec<bool> = mag0.iter().map(|&m| m > threshold).collect();
113    if !mask.iter().any(|&m| m) {
114        mask.fill(true);
115    }
116
117    let strides = [1, nx, nx * ny];
118    let mut num = [0.0f64; 3];
119    let mut den = [0.0f64; 3];
120    for (re, im) in real.iter().zip(imag) {
121        for k in 0..nz {
122            for j in 0..ny {
123                for i in 0..nx {
124                    let v = i + j * nx + k * nx * ny;
125                    if !mask[v] {
126                        continue;
127                    }
128                    let (a_re, a_im) = (finite(re[v]), finite(im[v]));
129                    for (axis, &inside) in [i + 1 < nx, j + 1 < ny, k + 1 < nz].iter().enumerate() {
130                        let w = v + strides[axis];
131                        if !inside || !mask[w] {
132                            continue;
133                        }
134                        let (b_re, b_im) = (finite(re[w]), finite(im[w]));
135                        // Re(a · conj(b)) and |a| |b|
136                        num[axis] += a_re * b_re + a_im * b_im;
137                        den[axis] += a_re.hypot(a_im) * b_re.hypot(b_im);
138                    }
139                }
140            }
141        }
142    }
143    [0, 1, 2].map(|a| if den[a] > 0.0 { num[a] / den[a] } else { 0.0 })
144}
145
146/// Convert multi-echo real/imaginary volumes to magnitude and phase, correcting alternate-slice
147/// π chopping.
148///
149/// With [`ChopCorrection::Auto`] an axis is corrected when its
150/// [`slice_chopping_coherence`] is negative, i.e. neighbours along it are, on balance, closer
151/// to anti-phase than in-phase. One decision covers all echoes. Check
152/// [`SliceChopping::ambiguous_axes`] on the result for axes where that decision was close.
153///
154/// The correction negates `z` on odd-indexed planes along each corrected axis before taking
155/// `arg z`; magnitude is unchanged by it. With `negate_phase` the phase is `arg z̄` instead,
156/// which leaves the chop detection unchanged. Non-finite inputs are treated as zero.
157///
158/// # Panics
159/// As [`slice_chopping_coherence`].
160pub fn complex_to_mag_phase(
161    real: &[&[f64]],
162    imag: &[&[f64]],
163    grid: &Grid,
164    params: &ComplexToMagPhaseParams,
165) -> MagPhaseEchoes {
166    let coherence = slice_chopping_coherence(real, imag, grid);
167    let (corrected, detected) = match params.chop {
168        ChopCorrection::Auto => (coherence.map(|c| c < 0.0), true),
169        ChopCorrection::Axes(axes) => (axes, false),
170    };
171
172    let (nx, ny, nz) = grid.dims;
173    let n = grid.n_total();
174    let mut magnitudes = Vec::with_capacity(real.len());
175    let mut phases = Vec::with_capacity(real.len());
176    for (re, im) in real.iter().zip(imag) {
177        let mut mag = vec![0.0f64; n];
178        let mut phase = vec![0.0f64; n];
179        for k in 0..nz {
180            for j in 0..ny {
181                for i in 0..nx {
182                    let v = i + j * nx + k * nx * ny;
183                    let (r, q) = (finite(re[v]), finite(im[v]));
184                    let q = if params.negate_phase { -q } else { q };
185                    let odd = (corrected[0] && i % 2 == 1) as u8
186                        + (corrected[1] && j % 2 == 1) as u8
187                        + (corrected[2] && k % 2 == 1) as u8;
188                    let (r, q) = if odd % 2 == 1 { (-r, -q) } else { (r, q) };
189                    mag[v] = r.hypot(q);
190                    phase[v] = q.atan2(r);
191                }
192            }
193        }
194        magnitudes.push(mag);
195        phases.push(phase);
196    }
197    debug_assert!(phases.iter().flatten().all(|p| p.abs() <= PI));
198
199    MagPhaseEchoes {
200        magnitudes,
201        phases,
202        chopping: SliceChopping { coherence, corrected, detected },
203        phase_negated: params.negate_phase,
204    }
205}
206
207#[inline]
208fn finite(x: f64) -> f64 {
209    if x.is_finite() { x } else { 0.0 }
210}
211
212fn check_echoes(real: &[&[f64]], imag: &[&[f64]], grid: &Grid) {
213    assert!(!real.is_empty(), "complex_to_mag_phase: no echoes");
214    assert_eq!(
215        real.len(), imag.len(),
216        "complex_to_mag_phase: {} real echoes but {} imaginary",
217        real.len(), imag.len()
218    );
219    let n = grid.n_total();
220    for (e, (re, im)) in real.iter().zip(imag).enumerate() {
221        assert!(
222            re.len() == n && im.len() == n,
223            "complex_to_mag_phase: echo {e} has {} real and {} imaginary voxels, the grid has {n}",
224            re.len(), im.len()
225        );
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    /// A smooth multi-echo complex image on a 16³ grid: an ellipsoid of signal in noise-free
234    /// background, phase a smooth field times TE plus a constant offset.
235    /// Per-echo real, imaginary and phase volumes.
236    type Echoes = (Vec<Vec<f64>>, Vec<Vec<f64>>, Vec<Vec<f64>>);
237
238    fn smooth_echoes(grid: &Grid, n_echoes: usize) -> Echoes {
239        let (nx, ny, nz) = grid.dims;
240        let (mut re, mut im, mut ph) = (Vec::new(), Vec::new(), Vec::new());
241        for e in 0..n_echoes {
242            let te = (e + 1) as f64;
243            let (mut r, mut q, mut p) = (vec![0.0; grid.n_total()], vec![0.0; grid.n_total()], vec![0.0; grid.n_total()]);
244            for k in 0..nz {
245                for j in 0..ny {
246                    for i in 0..nx {
247                        let (x, y, z) = (i as f64 / nx as f64 - 0.5, j as f64 / ny as f64 - 0.5, k as f64 / nz as f64 - 0.5);
248                        let v = i + j * nx + k * nx * ny;
249                        let inside = x * x / 0.16 + y * y / 0.12 + z * z / 0.14 < 1.0;
250                        let m = if inside { 100.0 * (-0.1 * te).exp() } else { 0.5 };
251                        let phase = wrap(0.3 + te * (1.5 * x + 0.8 * y * y - 1.2 * z));
252                        r[v] = m * phase.cos();
253                        q[v] = m * phase.sin();
254                        p[v] = phase;
255                    }
256                }
257            }
258            re.push(r);
259            im.push(q);
260            ph.push(p);
261        }
262        (re, im, ph)
263    }
264
265    fn wrap(p: f64) -> f64 {
266        (p + PI).rem_euclid(2.0 * PI) - PI
267    }
268
269    /// Negate odd-indexed planes along `axis`, as a chopped reconstruction does.
270    fn chop(data: &mut [Vec<f64>], grid: &Grid, axis: usize) {
271        let (nx, ny, nz) = grid.dims;
272        for vol in data.iter_mut() {
273            for k in 0..nz {
274                for j in 0..ny {
275                    for i in 0..nx {
276                        if [i, j, k][axis] % 2 == 1 {
277                            vol[i + j * nx + k * nx * ny] *= -1.0;
278                        }
279                    }
280                }
281            }
282        }
283    }
284
285    fn auto() -> ComplexToMagPhaseParams {
286        ComplexToMagPhaseParams::default()
287    }
288
289    fn axes(a: [bool; 3]) -> ComplexToMagPhaseParams {
290        ComplexToMagPhaseParams { chop: ChopCorrection::Axes(a), ..Default::default() }
291    }
292
293    fn refs(v: &[Vec<f64>]) -> Vec<&[f64]> {
294        v.iter().map(|x| x.as_slice()).collect()
295    }
296
297    /// Largest wrapped difference between two phase volumes.
298    fn max_phase_err(a: &[f64], b: &[f64]) -> f64 {
299        a.iter().zip(b).map(|(x, y)| wrap(x - y).abs()).fold(0.0, f64::max)
300    }
301
302    #[test]
303    fn unchopped_input_is_left_alone() {
304        let grid = Grid::new(16, 16, 16, 1.0, 1.0, 1.0);
305        let (re, im, ph) = smooth_echoes(&grid, 3);
306        let out = complex_to_mag_phase(&refs(&re), &refs(&im), &grid, &auto());
307        assert_eq!(out.chopping.corrected, [false; 3]);
308        assert!(out.chopping.detected);
309        assert!(out.chopping.coherence.iter().all(|&c| c > 0.8), "{:?}", out.chopping.coherence);
310        assert!(out.chopping.ambiguous_axes().is_empty());
311        for e in 0..3 {
312            assert!(max_phase_err(&out.phases[e], &ph[e]) < 1e-12);
313            for v in 0..grid.n_total() {
314                assert!((out.magnitudes[e][v] - re[e][v].hypot(im[e][v])).abs() < 1e-12);
315            }
316        }
317    }
318
319    #[test]
320    fn chopping_is_detected_and_undone_on_each_axis() {
321        let grid = Grid::new(16, 14, 12, 1.0, 1.0, 2.0);
322        for axis in 0..3 {
323            let (mut re, mut im, ph) = smooth_echoes(&grid, 3);
324            chop(&mut re, &grid, axis);
325            chop(&mut im, &grid, axis);
326            let out = complex_to_mag_phase(&refs(&re), &refs(&im), &grid, &auto());
327            let mut expected = [false; 3];
328            expected[axis] = true;
329            assert_eq!(out.chopping.corrected, expected, "axis {axis}: {:?}", out.chopping.coherence);
330            assert!(out.chopping.coherence[axis] < -0.8, "axis {axis}: {:?}", out.chopping.coherence);
331            // Even planes are untouched, so the phase is recovered exactly, with no global offset.
332            for e in 0..3 {
333                assert!(max_phase_err(&out.phases[e], &ph[e]) < 1e-12, "axis {axis} echo {e}");
334            }
335        }
336    }
337
338    #[test]
339    fn chopping_on_two_axes_is_undone() {
340        let grid = Grid::new(16, 16, 16, 1.0, 1.0, 1.0);
341        let (mut re, mut im, ph) = smooth_echoes(&grid, 2);
342        for axis in [0, 2] {
343            chop(&mut re, &grid, axis);
344            chop(&mut im, &grid, axis);
345        }
346        let out = complex_to_mag_phase(&refs(&re), &refs(&im), &grid, &auto());
347        assert_eq!(out.chopping.corrected, [true, false, true]);
348        assert!(max_phase_err(&out.phases[1], &ph[1]) < 1e-12);
349    }
350
351    // One decision for all echoes: a late echo whose own evidence is weak still gets the same
352    // planes flipped as the first, so no echo-dependent π offset appears between slices.
353    #[test]
354    fn one_decision_covers_every_echo() {
355        let grid = Grid::new(16, 16, 16, 1.0, 1.0, 1.0);
356        let (mut re, mut im, ph) = smooth_echoes(&grid, 2);
357        chop(&mut re, &grid, 2);
358        chop(&mut im, &grid, 2);
359        // Scramble the second echo's phase so it carries no chop evidence of its own.
360        let mut seed = 12345u64;
361        for v in 0..grid.n_total() {
362            seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
363            let p = (seed >> 11) as f64 / (1u64 << 53) as f64 * 2.0 * PI;
364            let m = re[1][v].hypot(im[1][v]) * 0.01;
365            re[1][v] = m * p.cos();
366            im[1][v] = m * p.sin();
367        }
368        let out = complex_to_mag_phase(&refs(&re), &refs(&im), &grid, &auto());
369        assert_eq!(out.chopping.corrected, [false, false, true]);
370        assert!(max_phase_err(&out.phases[0], &ph[0]) < 1e-12);
371    }
372
373    #[test]
374    fn override_is_obeyed_against_the_data() {
375        let grid = Grid::new(16, 16, 16, 1.0, 1.0, 1.0);
376        let (mut re, mut im, ph) = smooth_echoes(&grid, 2);
377        chop(&mut re, &grid, 1);
378        chop(&mut im, &grid, 1);
379
380        let off = complex_to_mag_phase(&refs(&re), &refs(&im), &grid, &axes([false; 3]));
381        assert_eq!(off.chopping.corrected, [false; 3]);
382        assert!(!off.chopping.detected);
383        // The coherence is still reported, so a caller can see the override disagreed.
384        assert!(off.chopping.coherence[1] < -0.8);
385        assert!(max_phase_err(&off.phases[0], &ph[0]) > 3.0);
386
387        let forced = complex_to_mag_phase(&refs(&re), &refs(&im), &grid, &axes([false, true, false]));
388        assert_eq!(forced.chopping.corrected, [false, true, false]);
389        assert!(max_phase_err(&forced.phases[0], &ph[0]) < 1e-12);
390    }
391
392    #[test]
393    fn negate_phase_conjugates_and_keeps_the_chop_decision() {
394        let grid = Grid::new(16, 16, 16, 1.0, 1.0, 1.0);
395        let (mut re, mut im, ph) = smooth_echoes(&grid, 2);
396        chop(&mut re, &grid, 2);
397        chop(&mut im, &grid, 2);
398        let plain = complex_to_mag_phase(&refs(&re), &refs(&im), &grid, &auto());
399        let params = ComplexToMagPhaseParams { negate_phase: true, ..Default::default() };
400        let negated = complex_to_mag_phase(&refs(&re), &refs(&im), &grid, &params);
401        assert!(!plain.phase_negated && negated.phase_negated);
402        assert_eq!(negated.chopping, plain.chopping);
403        for e in 0..2 {
404            let flipped: Vec<f64> = ph[e].iter().map(|p| -p).collect();
405            assert!(max_phase_err(&negated.phases[e], &flipped) < 1e-12);
406            assert_eq!(negated.magnitudes[e], plain.magnitudes[e]);
407        }
408    }
409
410    #[test]
411    fn noise_only_input_is_ambiguous() {
412        let grid = Grid::new(16, 16, 16, 1.0, 1.0, 1.0);
413        let mut seed = 7u64;
414        let mut next = || {
415            seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
416            (seed >> 11) as f64 / (1u64 << 53) as f64 - 0.5
417        };
418        let re: Vec<f64> = (0..grid.n_total()).map(|_| next()).collect();
419        let im: Vec<f64> = (0..grid.n_total()).map(|_| next()).collect();
420        let out = complex_to_mag_phase(&[&re], &[&im], &grid, &auto());
421        assert_eq!(out.chopping.ambiguous_axes(), vec![0, 1, 2]);
422    }
423
424    #[test]
425    fn non_finite_input_becomes_zero() {
426        let grid = Grid::new(4, 4, 4, 1.0, 1.0, 1.0);
427        let mut re = vec![1.0; 64];
428        let im = vec![0.0; 64];
429        re[5] = f64::NAN;
430        let out = complex_to_mag_phase(&[&re], &[&im], &grid, &axes([false; 3]));
431        assert_eq!(out.magnitudes[0][5], 0.0);
432        assert_eq!(out.phases[0][5], 0.0);
433    }
434
435    #[test]
436    #[should_panic(expected = "complex_to_mag_phase: echo 0 has 64 real and 63 imaginary voxels")]
437    fn mismatched_volumes_are_refused() {
438        let grid = Grid::new(4, 4, 4, 1.0, 1.0, 1.0);
439        complex_to_mag_phase(&[&[0.0; 64]], &[&[0.0; 63]], &grid, &auto());
440    }
441}