Skip to main content

qsm_core/utils/
gibbs.rs

1//! Gibbs-ringing removal via local subvoxel shifts.
2//!
3//! Truncating k-space (finite acquisition matrix) produces oscillatory ringing near sharp edges.
4//! For multi-echo data the ringing pattern differs per echo (image contrast changes with TE), so
5//! it perturbs the mono-exponential decay and biases R2*/R2 maps — hence unringing is
6//! recommended before relaxometry.
7//!
8//! Method (Kellner et al. 2016): resample the image at a set of subvoxel shifts using the Fourier
9//! shift theorem; at each voxel pick the shift that minimises local total variation (the
10//! "ring-free" sampling), then interpolate back to the grid. The 1-D correction runs along each
11//! axis, each axis weighted where its ringing dominates (low spatial frequency in the other axes).
12//!
13//! # Which mode for which acquisition
14//!
15//! | [`GibbsMode`] | method | for |
16//! |---|---|---|
17//! | [`Bautista3d`](GibbsMode::Bautista3d) (**default**) | Bautista et al. 2021, MRtrix3 `mrdegibbs -dimensionality 3` | 3-D acquisitions (k-space truncated along all three axes) |
18//! | [`Mrtrix2d`](GibbsMode::Mrtrix2d) | Kellner et al. 2016, MRtrix3 `mrdegibbs` (2-D) | 2-D multi-slice acquisitions (truncated in-plane only) |
19//! | [`Dipy2d`](GibbsMode::Dipy2d) | Kellner et al. 2016, DIPY `gibbs_removal` | parity with DIPY, the reference QSM-CI runs |
20//!
21//! The default is the 3-D method because QSM's gradient-echo scans are typically 3-D. Kellner's
22//! slice-wise method assumes the ringing runs only in-plane; on a 3-D acquisition it leaves the
23//! ringing along the slice axis (measured below). On a 2-D multi-slice acquisition use
24//! `Mrtrix2d` with `slice_axis` normal to the slices. The MRtrix modes are ported in
25//! [`crate::utils::gibbs_mrtrix`]; DIPY's is in this module. Decided 2026-10-31
26//! (astewartau/QSM.rs#327): MRtrix's implementation of Kellner's method for 2-D, Bautista's for
27//! 3-D, DIPY's kept as a parity option.
28//!
29//! # Measured (2026-10-31, astewartau/QSM.rs#327)
30//!
31//! Phantoms: 96×96×64 voxels of 14 random rotated boxes and ellipsoids inside a head-like shell,
32//! built on a 3× finer grid; ground truth its 3×3×3 voxel averages. A 3-D acquisition keeps the
33//! central 96×96×64 of its k-space; a 2-D one averages each slice's 3 fine slices (the slice
34//! profile) and keeps the central 96×96 in-plane. NRMSE to the ground truth (%), and the RMS
35//! error over the flat voxels 2–8 voxels from any edge (pure ringing); mean of three phantoms
36//! (seeds 0–2, no noise). The MRtrix rows were run with `mrdegibbs` itself (MRtrix3 `dev`
37//! 9b0a2f1), which these ports reproduce to ~1e-16; the 3-D row has negatives set to 0, as here.
38//!
39//! | method | 3-D acq. NRMSE | 3-D acq. flat RMS | 2-D acq. NRMSE | 2-D acq. flat RMS |
40//! |---|---|---|---|---|
41//! | none | 2.81 | 0.0061 | 1.92 | 0.0044 |
42//! | `Dipy2d` | 2.34 | 0.0037 | 1.61 | 0.0021 |
43//! | `Mrtrix2d` | 2.05 | 0.0033 | **1.17** | **0.0009** |
44//! | QSM.rs's former own 3-D generalisation (removed) | 2.03 | 0.0032 | 1.74 | 0.0022 |
45//! | `Bautista3d` | **1.58** | **0.0013** | 1.37 | 0.0010 |
46//!
47//! - On 3-D-truncated data `Bautista3d` removes ~79 % of the flat-region ringing; the slice-wise
48//!   methods remove ~40–45 %.
49//! - On 2-D-truncated data `Mrtrix2d` is best, as expected.
50//! - With noise (σ 0.01, seed 0) the order is the same (3-D acquisition NRMSE: none 3.28,
51//!   `Dipy2d` 2.71, `Mrtrix2d` 2.49, `Bautista3d` 1.92).
52//! - MRtrix's 2-D implementation beats DIPY's on both: it splits the slice into the two weighted
53//!   parts before unringing each (Kellner's order), where DIPY unrings first and weights after.
54//!
55//! QSM.rs used to unring with its own 3-D generalisation of DIPY's steps (each axis unrung over
56//! the whole volume, then combined with the 3-D cosine weights). It was not a published method,
57//! and was no better than the slice-wise methods on 3-D data, so it was removed.
58//!
59//! # Sources and licences
60//!
61//! - Kellner, Dhital, Kiselev & Reisert, "Gibbs-ringing artifact removal based on local
62//!   subvoxel-shifts", MRM 2016, 76, 1574–1581.
63//! - Bautista, O'Muircheartaigh, Hajnal & Tournier, "Removal of Gibbs ringing artefacts for 3D
64//!   acquisitions using subvoxel shifts", Proc. ISMRM 2021, 29, 3535.
65//! - MRtrix3 `mrdegibbs` (`cpp/core/degibbs/unring{1d,2d,3d}.h`, `dev` 9b0a2f1), MPL-2.0:
66//!   ported anew in Rust (no source copied), credited here and in `THIRD_PARTY_NOTICES.md`.
67//!   Tournier, an author of the 3-D method, is an MRtrix3 author.
68//! - DIPY 1.12.1 `dipy.denoise.gibbs.gibbs_removal`, BSD-3-Clause: ported anew in Rust.
69//! - Kellner's own code (`unring`, bitbucket.org/reisert/unring) forbids reproduction or
70//!   modification without the University Medical Center Freiburg's permission, so it is not
71//!   ported; MRtrix3's 2-D implementation follows its structure (split before unringing, exact
72//!   FFT-frequency cosines, a running TV over the complex line, linear interpolation).
73//!
74//! # `Dipy2d`
75//!
76//! DIPY's `gibbs_removal`, ported step for step: DIPY's 45 shifts in [0.02, 0.9], `n_points` 3,
77//! each shift direction its own inverse transform and magnitude (`|ifft(c·exp(±ks))|`), the 2-D
78//! combination `|ifft2(fftshift(C1)·G1 + fftshift(C0)·G0)|` with DIPY's `linspace(−π, π)` cosine
79//! weights and edge rows. Its output is a magnitude, so it is never negative.
80//!
81//! **Agreement with DIPY 1.12.1** (verified QSM-CI `py-originals` image, QSMxT/QSM-CI#302;
82//! measured 2026-10-31): NRMSE 2.6e-6 % on a 64×64×32 crop of a travelling head's five echoes
83//! (`prisma-bridge-run1`; DIPY changes the input by 3.4 %), and to the fixture's float32 rounding
84//! on the 24×24×16 phantom of `tests/gibbs_dipy_parity.rs`.
85
86use crate::utils::gibbs_mrtrix::{gibbs_unring_2d, gibbs_unring_3d, MrtrixUnringParams};
87use num_complex::Complex64;
88use rustfft::{Fft, FftPlanner};
89use std::sync::Arc;
90
91#[cfg(feature = "parallel")]
92use rayon::prelude::*;
93
94const N_SHIFTS: usize = 45;
95const PI: f64 = std::f64::consts::PI;
96
97/// Local total variation along a 1-D line, minimum of the right- and left-side
98/// TV over `n_points` neighbours, with periodic boundaries (matches DIPY
99/// `_image_tv` reduced with `np.minimum`).
100///
101/// The neighbour-difference magnitudes are staged in `diff` (`diff[j] =
102/// |row[j] - row[j+1 mod n]|`) so the sliding sums index it directly — the
103/// interior needs no modulo at all, which matters in the per-shift hot loop.
104fn tv_min_line(row: &[f64], n: usize, n_points: usize, out: &mut [f64], diff: &mut [f64]) {
105    for j in 0..n - 1 {
106        diff[j] = (row[j] - row[j + 1]).abs();
107    }
108    diff[n - 1] = (row[n - 1] - row[0]).abs();
109    for i in 0..n {
110        let mut ptv = 0.0;
111        let mut ntv = 0.0;
112        if i >= n_points && i + n_points <= n {
113            // Interior: both windows lie within the array.
114            for o in 0..n_points {
115                ptv += diff[i + o];
116                ntv += diff[i - 1 - o];
117            }
118        } else {
119            for o in 0..n_points {
120                ptv += diff[(i + o) % n];
121                ntv += diff[(i + n - 1 - o) % n];
122            }
123        }
124        out[i] = ptv.min(ntv);
125    }
126}
127
128/// Which unringing [`gibbs_unring_volume`] and the functions built on it run. See the
129/// [module docs](self) for which suits which acquisition.
130#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
131pub enum GibbsMode {
132    /// Bautista et al.'s 3-D unringing (MRtrix3 `mrdegibbs -dimensionality 3`), for 3-D
133    /// acquisitions, which truncate k-space along all three axes. The default: QSM's
134    /// gradient-echo scans are typically 3-D. See [`gibbs_unring_3d`].
135    #[default]
136    Bautista3d,
137    /// Kellner et al.'s 2-D unringing as MRtrix3's `mrdegibbs` implements it, for 2-D multi-slice
138    /// acquisitions (k-space truncated in-plane only): each slice normal to `slice_axis`.
139    /// See [`gibbs_unring_2d`].
140    Mrtrix2d {
141        /// The axis the slices are normal to (0, 1 or 2; MRtrix's default is 2).
142        slice_axis: usize,
143    },
144    /// DIPY's `gibbs_removal` (Kellner et al.'s 2-D method as DIPY implements it): each slice
145    /// normal to `slice_axis` is unrung along both of its axes, and the two results are combined
146    /// in its 2-D Fourier domain with DIPY's cosine weights; the output is the magnitude of that
147    /// combination. A parity option: DIPY is the reference QSM-CI runs.
148    Dipy2d {
149        /// The axis the slices are normal to (0, 1 or 2; DIPY's default is 2).
150        slice_axis: usize,
151    },
152}
153
154
155/// DIPY's TV window (`n_points`, its default).
156const N_POINTS: usize = 3;
157
158/// numpy's `linspace(start, stop, num)`: `i·step + start`, the last value exactly `stop`.
159fn linspace(start: f64, stop: f64, num: usize) -> Vec<f64> {
160    if num == 1 {
161        return vec![start];
162    }
163    let step = (stop - start) / (num - 1) as f64;
164    let mut y: Vec<f64> = (0..num).map(|i| i as f64 * step + start).collect();
165    y[num - 1] = stop;
166    y
167}
168
169/// Everything a line of length `n` needs: DIPY's 45 shifts `ssamp`, the shift factors
170/// `exp(k·s)` for every shift (`k = fftfreq(n, 1/(2πi))`, computed as numpy does), and the FFT
171/// plans.
172struct LineCtx {
173    n: usize,
174    ssamp: Vec<f64>,
175    /// `phases[s*n + m] = exp(i·θ)`, `θ = (m'·Im(1/(n·d)))·ssamp[s]` with `d = 1/(2πi)`.
176    phases: Vec<Complex64>,
177    fft: Arc<dyn Fft<f64>>,
178    ifft: Arc<dyn Fft<f64>>,
179}
180
181impl LineCtx {
182    fn new(n: usize, planner: &mut FftPlanner<f64>) -> Self {
183        let ssamp = linspace(0.02, 0.9, N_SHIFTS);
184        // numpy: d = 1/(2j·π) = −i/(2π); val = 1/(n·d) = i·(−1/(n·(−1/(2π)))) (Python complex
185        // division: real parts 0); k[m] = m'·val with m' the fftfreq integers.
186        let inv_2pi = 1.0 / (2.0 * PI);
187        let b = n as f64 * -inv_2pi;
188        let val_im = -1.0 / b;
189        let mut phases = vec![Complex64::new(0.0, 0.0); ssamp.len() * n];
190        for (s_i, &s) in ssamp.iter().enumerate() {
191            for m in 0..n {
192                let mi = if m < n.div_ceil(2) { m as f64 } else { m as f64 - n as f64 };
193                let theta = (mi * val_im) * s;
194                phases[s_i * n + m] = Complex64::new(theta.cos(), theta.sin());
195            }
196        }
197        LineCtx { n, ssamp, phases, fft: planner.plan_fft_forward(n), ifft: planner.plan_fft_inverse(n) }
198    }
199}
200
201/// Reusable per-thread scratch for [`unring_line`], sized to a line of length `n`.
202struct LineBufs {
203    c: Vec<Complex64>,
204    buf: Vec<Complex64>,
205    tvp: Vec<f64>,
206    tvn: Vec<f64>,
207    isp: Vec<f64>,
208    isn: Vec<f64>,
209    sp: Vec<f64>,
210    sn: Vec<f64>,
211    img: Vec<f64>,
212    tvs: Vec<f64>,
213    diff: Vec<f64>,
214    scratch_f: Vec<Complex64>,
215    scratch_i: Vec<Complex64>,
216}
217
218impl LineBufs {
219    fn new(ctx: &LineCtx) -> Self {
220        let (n, z) = (ctx.n, Complex64::new(0.0, 0.0));
221        LineBufs {
222            c: vec![z; n],
223            buf: vec![z; n],
224            tvp: vec![0.0; n],
225            tvn: vec![0.0; n],
226            isp: vec![0.0; n],
227            isn: vec![0.0; n],
228            sp: vec![0.0; n],
229            sn: vec![0.0; n],
230            img: vec![0.0; n],
231            tvs: vec![0.0; n],
232            diff: vec![0.0; n],
233            scratch_f: vec![z; ctx.fft.get_inplace_scratch_len()],
234            scratch_i: vec![z; ctx.ifft.get_inplace_scratch_len()],
235        }
236    }
237}
238
239/// DIPY's `_gibbs_removal_1d` on one line, in place: for each shift `s` in `ssamp`, the line
240/// shifted by `+s` and by `−s` (`|ifft(c·exp(±k·s))|`, each its own inverse transform), and at
241/// every voxel the shift of least local TV in each direction; the result interpolates the two.
242fn unring_line(row: &mut [f64], b: &mut LineBufs, ctx: &LineCtx) {
243    let n = ctx.n;
244    let LineBufs { c, buf, tvp, tvn, isp, isn, sp, sn, img, tvs, diff, scratch_f, scratch_i } = b;
245    for m in 0..n {
246        c[m] = Complex64::new(row[m], 0.0);
247    }
248    ctx.fft.process_with_scratch(c, scratch_f);
249    tv_min_line(row, n, N_POINTS, tvp, diff);
250    tvn.copy_from_slice(tvp);
251    isp.copy_from_slice(row);
252    isn.copy_from_slice(row);
253    sp.iter_mut().for_each(|x| *x = 0.0);
254    sn.iter_mut().for_each(|x| *x = 0.0);
255    let inv_n = 1.0 / n as f64;
256    for (s_i, &s) in ctx.ssamp.iter().enumerate() {
257        let ph = &ctx.phases[s_i * n..s_i * n + n];
258        for (positive, (best_tv, best_img, best_s)) in [(true, (&mut *tvp, &mut *isp, &mut *sp)), (false, (&mut *tvn, &mut *isn, &mut *sn))] {
259            for m in 0..n {
260                buf[m] = if positive { c[m] * ph[m] } else { c[m] * ph[m].conj() };
261            }
262            ctx.ifft.process_with_scratch(buf, scratch_i);
263            for m in 0..n {
264                img[m] = (buf[m] * inv_n).norm();
265            }
266            tv_min_line(img, n, N_POINTS, tvs, diff);
267            for i in 0..n {
268                if best_tv[i] > tvs[i] {
269                    best_img[i] = img[i];
270                    best_s[i] = s;
271                    best_tv[i] = tvs[i];
272                }
273            }
274        }
275    }
276    for i in 0..n {
277        let d = sp[i] + sn[i];
278        if d != 0.0 {
279            row[i] = (isp[i] - isn[i]) / d * sn[i] + isn[i];
280        }
281    }
282}
283
284#[cfg(test)]
285fn fftfreq(n: usize) -> Vec<f64> {
286    (0..n)
287        .map(|m| {
288            if m < n.div_ceil(2) {
289                m as f64 / n as f64
290            } else {
291                (m as f64 - n as f64) / n as f64
292            }
293        })
294        .collect()
295}
296
297/// Un-ring `vol` along a single axis (0=x, 1=y, 2=z) over the whole volume, returning a new
298/// volume: DIPY's 1-D step, tested against DIPY's `_gibbs_removal_1d`.
299#[cfg(test)]
300fn unring_axis(vol: &[f64], (nx, ny, nz): (usize, usize, usize), axis: usize) -> Vec<f64> {
301    let n = [nx, ny, nz][axis];
302    if n < 2 {
303        return vol.to_vec();
304    }
305    let mut planner = FftPlanner::<f64>::new();
306    let ctx = LineCtx::new(n, &mut planner);
307    let (stride, lines): (usize, Vec<usize>) = match axis {
308        0 => (1, (0..ny * nz).map(|l| l * nx).collect()),
309        1 => (nx, (0..nz).flat_map(|z| (0..nx).map(move |x| z * nx * ny + x)).collect()),
310        _ => (nx * ny, (0..ny).flat_map(|y| (0..nx).map(move |x| y * nx + x)).collect()),
311    };
312    let mut mat = vec![0.0_f64; lines.len() * n];
313    for (l, &start) in lines.iter().enumerate() {
314        for (i, d) in mat[l * n..l * n + n].iter_mut().enumerate() {
315            *d = vol[start + i * stride];
316        }
317    }
318    #[cfg(feature = "parallel")]
319    mat.par_chunks_mut(n).for_each_init(|| LineBufs::new(&ctx), |b, row| unring_line(row, b, &ctx));
320    #[cfg(not(feature = "parallel"))]
321    {
322        let mut b = LineBufs::new(&ctx);
323        for row in mat.chunks_mut(n) {
324            unring_line(row, &mut b, &ctx);
325        }
326    }
327    let mut out = vol.to_vec();
328    for (l, &start) in lines.iter().enumerate() {
329        for (i, &s) in mat[l * n..l * n + n].iter().enumerate() {
330            out[start + i * stride] = s;
331        }
332    }
333    out
334}
335
336/// DIPY's `_weights(shape)`: `G0`, `G1` (row-major `n0 × n1`) on the fft-shifted grid, from
337/// `linspace(−π, π)` frequencies, with DIPY's edge rows and columns.
338fn dipy_weights(n0: usize, n1: usize) -> (Vec<f64>, Vec<f64>) {
339    let mut g0 = vec![0.0_f64; n0 * n1];
340    let mut g1 = vec![0.0_f64; n0 * n1];
341    let k0 = linspace(-PI, PI, n0);
342    let k1 = linspace(-PI, PI, n1);
343    for i in 1..n0.saturating_sub(1) {
344        for j in 1..n1.saturating_sub(1) {
345            let c0 = 1.0 + k0[i].cos();
346            let c1 = 1.0 + k1[j].cos();
347            g1[i * n1 + j] = c0 / (c0 + c1);
348            g0[i * n1 + j] = c1 / (c0 + c1);
349        }
350    }
351    if n0 >= 2 && n1 >= 1 {
352        for i in 1..n0 - 1 {
353            g1[i * n1] = 1.0;
354            g1[i * n1 + n1 - 1] = 1.0;
355        }
356    }
357    if n1 >= 2 && n0 >= 1 {
358        for j in 1..n1 - 1 {
359            g0[j] = 1.0;
360            g0[(n0 - 1) * n1 + j] = 1.0;
361        }
362    }
363    for &(i, j) in &[(0, 0), (n0 - 1, n1 - 1), (0, n1 - 1), (n0 - 1, 0)] {
364        g1[i * n1 + j] = 0.5;
365        g0[i * n1 + j] = 0.5;
366    }
367    (g0, g1)
368}
369
370/// Everything a slice of `n0 × n1` needs (DIPY's `_gibbs_removal_2d`).
371struct SliceCtx {
372    n0: usize,
373    n1: usize,
374    line0: LineCtx,
375    line1: LineCtx,
376    g0: Vec<f64>,
377    g1: Vec<f64>,
378}
379
380/// In-place 2-D FFT of a row-major `n0 × n1` array with the line plans.
381fn fft2_inplace(a: &mut [Complex64], n0: usize, n1: usize, along1: &Arc<dyn Fft<f64>>, along0: &Arc<dyn Fft<f64>>) {
382    for row in a.chunks_mut(n1) {
383        along1.process(row);
384    }
385    let mut col = vec![Complex64::new(0.0, 0.0); n0];
386    for j in 0..n1 {
387        for i in 0..n0 {
388            col[i] = a[i * n1 + j];
389        }
390        along0.process(&mut col);
391        for i in 0..n0 {
392            a[i * n1 + j] = col[i];
393        }
394    }
395}
396
397/// DIPY's `_gibbs_removal_2d` on a row-major `n0 × n1` slice: unring along axis 1 (`img_c1`) and
398/// axis 0 (`img_c0`), then `|ifft2(fftshift(fft2(img_c1))·G1 + fftshift(fft2(img_c0))·G0)|`.
399fn unring_slice(img: &[f64], ctx: &SliceCtx) -> Vec<f64> {
400    let (n0, n1) = (ctx.n0, ctx.n1);
401    // Along axis 1: rows of length n1.
402    let mut c1 = img.to_vec();
403    let mut b1 = LineBufs::new(&ctx.line1);
404    for row in c1.chunks_mut(n1) {
405        unring_line(row, &mut b1, &ctx.line1);
406    }
407    // Along axis 0: columns of length n0.
408    let mut c0 = img.to_vec();
409    let mut b0 = LineBufs::new(&ctx.line0);
410    let mut col = vec![0.0_f64; n0];
411    for j in 0..n1 {
412        for i in 0..n0 {
413            col[i] = c0[i * n1 + j];
414        }
415        unring_line(&mut col, &mut b0, &ctx.line0);
416        for i in 0..n0 {
417            c0[i * n1 + j] = col[i];
418        }
419    }
420    let mut s1: Vec<Complex64> = c1.iter().map(|&v| Complex64::new(v, 0.0)).collect();
421    let mut s0: Vec<Complex64> = c0.iter().map(|&v| Complex64::new(v, 0.0)).collect();
422    fft2_inplace(&mut s1, n0, n1, &ctx.line1.fft, &ctx.line0.fft);
423    fft2_inplace(&mut s0, n0, n1, &ctx.line1.fft, &ctx.line0.fft);
424    // fftshift (roll by n//2 on each axis), weight, sum.
425    let (h0, h1) = (n0 / 2, n1 / 2);
426    let mut sum = vec![Complex64::new(0.0, 0.0); n0 * n1];
427    for i in 0..n0 {
428        let si = (i + n0 - h0) % n0;
429        for j in 0..n1 {
430            let sj = (j + n1 - h1) % n1;
431            let w = i * n1 + j;
432            sum[w] = s1[si * n1 + sj] * ctx.g1[w] + s0[si * n1 + sj] * ctx.g0[w];
433        }
434    }
435    fft2_inplace(&mut sum, n0, n1, &ctx.line1.ifft, &ctx.line0.ifft);
436    let inv = 1.0 / (n0 * n1) as f64;
437    sum.iter().map(|z| (z * inv).norm()).collect()
438}
439
440/// DIPY's `gibbs_removal(vol, slice_axis)` on one 3-D volume.
441fn unring_slices(vol: &[f64], (nx, ny, nz): (usize, usize, usize), slice_axis: usize) -> Vec<f64> {
442    // The slice's axes in order (DIPY moves the slice axis first and keeps the others' order).
443    let (ns, n0, n1) = match slice_axis {
444        0 => (nx, ny, nz),
445        1 => (ny, nx, nz),
446        _ => (nz, nx, ny),
447    };
448    let index = |s: usize, i: usize, j: usize| match slice_axis {
449        0 => s + i * nx + j * nx * ny,
450        1 => i + s * nx + j * nx * ny,
451        _ => i + j * nx + s * nx * ny,
452    };
453    let mut planner = FftPlanner::<f64>::new();
454    let (g0, g1) = dipy_weights(n0, n1);
455    let ctx = SliceCtx { n0, n1, line0: LineCtx::new(n0, &mut planner), line1: LineCtx::new(n1, &mut planner), g0, g1 };
456    let slice_out = |s: usize| -> Vec<f64> {
457        let mut img = vec![0.0_f64; n0 * n1];
458        for i in 0..n0 {
459            for j in 0..n1 {
460                img[i * n1 + j] = vol[index(s, i, j)];
461            }
462        }
463        unring_slice(&img, &ctx)
464    };
465    #[cfg(feature = "parallel")]
466    let slices: Vec<Vec<f64>> = (0..ns).into_par_iter().map(slice_out).collect();
467    #[cfg(not(feature = "parallel"))]
468    let slices: Vec<Vec<f64>> = (0..ns).map(slice_out).collect();
469    let mut out = vec![0.0_f64; vol.len()];
470    for (s, img) in slices.iter().enumerate() {
471        for i in 0..n0 {
472            for j in 0..n1 {
473                out[index(s, i, j)] = img[i * n1 + j];
474            }
475        }
476    }
477    out
478}
479
480/// Gibbs-unring a single 3-D volume with `mode` ([`GibbsMode::default`] is Bautista et al.'s
481/// 3-D unringing).
482///
483/// The output is never negative: DIPY's 2-D combination is a magnitude, and the MRtrix modes'
484/// negative values are set to 0.
485///
486/// # Panics
487/// If `vol` is not `nx·ny·nz` long or a slice axis is not 0, 1 or 2.
488pub fn gibbs_unring_volume(vol: &[f64], dims: (usize, usize, usize), mode: GibbsMode) -> Vec<f64> {
489    let (nx, ny, nz) = dims;
490    assert_eq!(vol.len(), nx * ny * nz, "vol length must be nx*ny*nz");
491    match mode {
492        GibbsMode::Bautista3d => gibbs_unring_3d(vol, dims, MrtrixUnringParams::default()),
493        GibbsMode::Mrtrix2d { slice_axis } => gibbs_unring_2d(vol, dims, slice_axis, MrtrixUnringParams::default()),
494        GibbsMode::Dipy2d { slice_axis } => {
495            assert!(slice_axis < 3, "slice_axis must be 0, 1 or 2");
496            unring_slices(vol, dims, slice_axis)
497        }
498    }
499}
500
501/// Gibbs-unring multi-volume data (e.g. multi-echo magnitude), each volume in turn, as DIPY and
502/// MRtrix do for a 4-D array.
503///
504/// # Arguments
505/// * `data` - Interleaved `[voxel0_vol0, voxel0_vol1, ..., voxel1_vol0, ...]`
506///   (row-major `(n_voxels, n_vols)`).
507/// * `dims` - Volume dimensions `(nx, ny, nz)`.
508/// * `n_vols` - Number of volumes.
509/// * `mode` - See [`GibbsMode`]; the default is Bautista et al.'s 3-D unringing.
510///
511/// # Returns
512/// Unrung data in the same layout (never negative).
513pub fn gibbs_unring(data: &[f64], dims: (usize, usize, usize), n_vols: usize, mode: GibbsMode) -> Vec<f64> {
514    let (nx, ny, nz) = dims;
515    let n_vox = nx * ny * nz;
516    assert_eq!(data.len(), n_vox * n_vols, "data length must be n_voxels * n_vols");
517    let mut out = data.to_vec();
518    let mut vol = vec![0.0_f64; n_vox];
519    for e in 0..n_vols {
520        for v in 0..n_vox {
521            vol[v] = data[v * n_vols + e];
522        }
523        let un = gibbs_unring_volume(&vol, dims, mode);
524        for v in 0..n_vox {
525            out[v * n_vols + e] = un[v];
526        }
527    }
528    out
529}
530
531/// Gibbs-unring multi-volume data within a mask bounding box (fast path).
532///
533/// Only the axis-aligned bounding box of `mask`, expanded by `margin` voxels, is
534/// unrung; voxels outside are copied through unchanged. This skips air/background
535/// lines and shrinks the per-line FFT length, which is much faster on data where
536/// the region of interest fills a fraction of the FOV.
537///
538/// Because the FFT is taken over the cropped extent rather than the full FOV, the
539/// result differs slightly from [`gibbs_unring`] near the crop border, but with a
540/// sufficient `margin` the interior of the mask is essentially identical (the
541/// ringing there is driven by edges inside the box). Intended for relaxometry
542/// where downstream fitting is masked to the brain anyway.
543///
544/// # Arguments
545/// * `data` - Interleaved `(n_voxels, n_vols)`, same layout as [`gibbs_unring`].
546/// * `dims` - Volume dimensions `(nx, ny, nz)`.
547/// * `n_vols` - Number of volumes.
548/// * `mask` - Binary mask `[nx*ny*nz]`; the bounding box of `mask != 0` is unrung.
549/// * `margin` - Voxels to expand the bounding box on every side.
550/// * `mode` - See [`GibbsMode`].
551pub fn gibbs_unring_masked(
552    data: &[f64],
553    dims: (usize, usize, usize),
554    n_vols: usize,
555    mask: &[u8],
556    margin: usize,
557    mode: GibbsMode,
558) -> Vec<f64> {
559    let (nx, ny, nz) = dims;
560    let n_vox = nx * ny * nz;
561    assert_eq!(data.len(), n_vox * n_vols, "data length must be n_voxels * n_vols");
562    assert_eq!(mask.len(), n_vox, "mask length must be n_voxels");
563
564    // Bounding box of the mask.
565    let (mut x0, mut y0, mut z0) = (nx, ny, nz);
566    let (mut x1, mut y1, mut z1) = (0usize, 0usize, 0usize);
567    let mut any = false;
568    for z in 0..nz {
569        for y in 0..ny {
570            for x in 0..nx {
571                if mask[x + y * nx + z * nx * ny] != 0 {
572                    any = true;
573                    x0 = x0.min(x);
574                    y0 = y0.min(y);
575                    z0 = z0.min(z);
576                    x1 = x1.max(x);
577                    y1 = y1.max(y);
578                    z1 = z1.max(z);
579                }
580            }
581        }
582    }
583    if !any {
584        return data.to_vec();
585    }
586    // Expand by margin, clamp to volume.
587    x0 = x0.saturating_sub(margin);
588    y0 = y0.saturating_sub(margin);
589    z0 = z0.saturating_sub(margin);
590    x1 = (x1 + margin + 1).min(nx);
591    y1 = (y1 + margin + 1).min(ny);
592    z1 = (z1 + margin + 1).min(nz);
593    let (cx, cy, cz) = (x1 - x0, y1 - y0, z1 - z0);
594
595    // If the crop is (almost) the whole volume, just do the full transform.
596    if cx * cy * cz >= n_vox * 9 / 10 {
597        return gibbs_unring(data, dims, n_vols, mode);
598    }
599
600    let n_crop = cx * cy * cz;
601    let mut out = data.to_vec();
602    let mut sub = vec![0.0_f64; n_crop];
603    for e in 0..n_vols {
604        // Extract the cropped sub-volume for this echo.
605        for zz in 0..cz {
606            for yy in 0..cy {
607                for xx in 0..cx {
608                    let full = (x0 + xx) + (y0 + yy) * nx + (z0 + zz) * nx * ny;
609                    sub[xx + yy * cx + zz * cx * cy] = data[full * n_vols + e];
610                }
611            }
612        }
613        let un = gibbs_unring_volume(&sub, (cx, cy, cz), mode);
614        // Write the corrected crop back.
615        for zz in 0..cz {
616            for yy in 0..cy {
617                for xx in 0..cx {
618                    let full = (x0 + xx) + (y0 + yy) * nx + (z0 + zz) * nx * ny;
619                    out[full * n_vols + e] = un[xx + yy * cx + zz * cx * cy];
620                }
621            }
622        }
623    }
624    out
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use crate::fft::{fft3d_real, ifft3d_real};
631
632    #[test]
633    fn test_unring_reduces_edge_overshoot() {
634        // 1-D step, band-limited by zeroing high k-space → Gibbs ringing. Unring
635        // along x should reduce the overshoot just past the edge.
636        let (nx, ny, nz) = (64usize, 3usize, 3usize);
637        let mut clean = vec![0.0_f64; nx];
638        for x in 0..nx {
639            clean[x] = if x >= nx / 2 { 1.0 } else { 0.0 };
640        }
641        // Band-limit via FFT truncation.
642        let mut planner = FftPlanner::<f64>::new();
643        let fft = planner.plan_fft_forward(nx);
644        let ifft = planner.plan_fft_inverse(nx);
645        let mut c: Vec<Complex64> = clean.iter().map(|&v| Complex64::new(v, 0.0)).collect();
646        fft.process(&mut c);
647        let keep = nx / 4;
648        for m in 0..nx {
649            let fm = if m < nx / 2 { m } else { nx - m };
650            if fm > keep {
651                c[m] = Complex64::new(0.0, 0.0);
652            }
653        }
654        ifft.process(&mut c);
655        let rung: Vec<f64> = c.iter().map(|z| z.re / nx as f64).collect();
656
657        // Broadcast the ringing line across a small volume.
658        let mut vol = vec![0.0_f64; nx * ny * nz];
659        for z in 0..nz {
660            for y in 0..ny {
661                for x in 0..nx {
662                    vol[x + y * nx + z * nx * ny] = rung[x];
663                }
664            }
665        }
666        // Test the 1-D core directly (the 3-D Fourier combine assumes every axis
667        // carries structure; a ring broadcast flat across y,z is a degenerate
668        // case for it — the full 3-D path is validated on the phantom).
669        let un = unring_axis(&vol, (nx, ny, nz), 0);
670        // Extract the corrected centre line.
671        let line: Vec<f64> = (0..nx).map(|x| un[x + nx + nx * ny]).collect();
672
673        // Port check: values must match DIPY's `_gibbs_removal_1d` on the
674        // identical ringing line (indices 30..=40).
675        let dipy_ref = [
676            0.06875, 0.25484, 0.67489, 1.06882, 1.02528, 0.96117, 0.98865, 1.02746, 1.00689,
677            0.97820, 0.97820,
678        ];
679        for (k, &r) in dipy_ref.iter().enumerate() {
680            let got = line[30 + k];
681            assert!(
682                (got - r).abs() < 5e-3,
683                "index {}: Rust {:.5} vs DIPY {:.5}",
684                30 + k,
685                got,
686                r
687            );
688        }
689        // Total variation should drop like DIPY's (3.23 -> ~2.41).
690        let tv = |a: &[f64]| a.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f64>();
691        let tv_rung: f64 = rung.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
692        assert!(tv(&line) < 0.85 * tv_rung, "unring should cut TV: {:.3} -> {:.3}", tv_rung, tv(&line));
693    }
694
695    /// Ringing around a block on a zero background undershoots below 0; the output never does,
696    /// and the clamp leaves the block's interior and the far background untouched.
697    #[test]
698    fn test_unring_volume_is_never_negative() {
699        let (nx, ny, nz) = (24usize, 24usize, 24usize);
700        let n = nx * ny * nz;
701        let mut block = vec![0.0_f64; n];
702        for z in 8..16 {
703            for y in 8..16 {
704                for x in 8..16 {
705                    block[x + y * nx + z * nx * ny] = 1.0;
706                }
707            }
708        }
709        // Band-limit in 3-D to make it ring.
710        let mut spec = fft3d_real(&block, nx, ny, nz);
711        let f = fftfreq(nx);
712        for z in 0..nz {
713            for y in 0..ny {
714                for x in 0..nx {
715                    if f[x].abs() > 0.2 || f[y].abs() > 0.2 || f[z].abs() > 0.2 {
716                        spec[x + y * nx + z * nx * ny] = Complex64::new(0.0, 0.0);
717                    }
718                }
719            }
720        }
721        let rung = ifft3d_real(&spec, nx, ny, nz);
722        assert!(rung.iter().any(|&v| v < -1e-3), "the test input should undershoot");
723        let un = gibbs_unring_volume(&rung, (nx, ny, nz), GibbsMode::default());
724        assert!(un.iter().all(|&v| v >= 0.0), "unrung magnitude must not be negative");
725        assert!(un.contains(&0.0));
726        let c = 12 + 12 * nx + 12 * nx * ny;
727        assert!(un[c] > 0.5, "block interior kept: {}", un[c]);
728        for mode in [
729            GibbsMode::Bautista3d,
730            GibbsMode::Mrtrix2d { slice_axis: 2 },
731            GibbsMode::Mrtrix2d { slice_axis: 0 },
732            GibbsMode::Dipy2d { slice_axis: 2 },
733            GibbsMode::Dipy2d { slice_axis: 0 },
734        ] {
735            let multi = gibbs_unring(&rung, (nx, ny, nz), 1, mode);
736            assert!(multi.iter().all(|&v| v >= 0.0), "{mode:?}");
737            assert!(multi[c] > 0.5, "{mode:?}: block interior kept: {}", multi[c]);
738        }
739    }
740
741    /// Timing + numeric fingerprint on a synthetic volume (sphere + deterministic
742    /// noise). Run manually with `--ignored --nocapture` to compare performance
743    /// work against the baseline.
744    #[test]
745    #[ignore]
746    fn bench_unring_volume() {
747        let (nx, ny, nz) = (128usize, 128usize, 128usize);
748        let mut vol = vec![0.0_f64; nx * ny * nz];
749        let mut lcg: u64 = 42;
750        for z in 0..nz {
751            for y in 0..ny {
752                for x in 0..nx {
753                    let dx = x as f64 - nx as f64 / 2.0;
754                    let dy = y as f64 - ny as f64 / 2.0;
755                    let dz = z as f64 - nz as f64 / 2.0;
756                    let r = (dx * dx + dy * dy + dz * dz).sqrt();
757                    let base = if r < nx as f64 / 3.0 { 1.0 } else { 0.1 };
758                    lcg = lcg.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
759                    let noise = (lcg >> 33) as f64 / (1u64 << 31) as f64 * 0.02;
760                    vol[x + y * nx + z * nx * ny] = base + noise;
761                }
762            }
763        }
764        let t = std::time::Instant::now();
765        let out = gibbs_unring_volume(&vol, (nx, ny, nz), GibbsMode::default());
766        let dt = t.elapsed();
767        let sum: f64 = out.iter().sum();
768        let sq: f64 = out.iter().map(|v| v * v).sum();
769        let mx = out.iter().cloned().fold(f64::MIN, f64::max);
770        println!(
771            "gibbs_unring_volume 128^3: {:?}  sum {:.6}  sumsq {:.6}  max {:.6}",
772            dt, sum, sq, mx
773        );
774    }
775
776    #[test]
777    fn test_fftfreq_matches_numpy() {
778        // numpy fftfreq(4) = [0, .25, -.5, -.25]
779        let f = fftfreq(4);
780        assert!((f[0] - 0.0).abs() < 1e-12);
781        assert!((f[1] - 0.25).abs() < 1e-12);
782        assert!((f[2] + 0.5).abs() < 1e-12);
783        assert!((f[3] + 0.25).abs() < 1e-12);
784    }
785}