Skip to main content

qsm_core/utils/
gibbs.rs

1//! Gibbs-ringing removal via local subvoxel shifts (Kellner et al. 2016).
2//!
3//! Truncating k-space (finite acquisition matrix) produces oscillatory ringing
4//! near sharp edges. For multi-echo data the ringing pattern differs per echo
5//! (image contrast changes with TE), so it perturbs the mono-exponential decay
6//! and biases R2*/R2 maps — hence unringing is recommended before relaxometry.
7//!
8//! Method (Kellner 2016): resample the image at a set of subvoxel shifts using
9//! the Fourier shift theorem; at each voxel pick the shift that minimises local
10//! total variation (the "ring-free" sampling), then interpolate back to the
11//! grid. The 1-D correction is applied along each axis and the axis-corrected
12//! volumes are combined in the Fourier domain, weighting each axis where its
13//! ringing dominates (low spatial frequency in the other axes).
14//!
15//! This is a faithful port of DIPY's `gibbs_removal` (Kellner 2016; Neto
16//! Henriques 2018), generalised from 2-D slice-wise to full 3-D: the cosine
17//! weight for axis *i* is `∏_{j≠i}(1+cos k_j) / Σ_m ∏_{j≠m}(1+cos k_j)`, which
18//! reduces exactly to DIPY's 2-D weights.
19//!
20//! One deliberate deviation for speed: both shift directions share a single
21//! complex IFFT (see [`unring_row_inplace`]), which requires symmetrising the
22//! unpaired Nyquist bin on even-length lines. This differs from DIPY only in
23//! that one bin — measured max |diff| ~4e-3 on unit-scale data, r > 0.9999999 —
24//! and halves the per-line FFT work (~2× overall).
25
26use crate::fft::{fft3d_real, ifft3d_real};
27use num_complex::Complex64;
28use rustfft::{Fft, FftPlanner};
29use std::sync::Arc;
30
31#[cfg(feature = "parallel")]
32use rayon::prelude::*;
33
34const N_SHIFTS: usize = 45;
35const PI: f64 = std::f64::consts::PI;
36
37/// Local total variation along a 1-D line, minimum of the right- and left-side
38/// TV over `n_points` neighbours, with periodic boundaries (matches DIPY
39/// `_image_tv` reduced with `np.minimum`).
40///
41/// The neighbour-difference magnitudes are staged in `diff` (`diff[j] =
42/// |row[j] - row[j+1 mod n]|`) so the sliding sums index it directly — the
43/// interior needs no modulo at all, which matters in the per-shift hot loop.
44fn tv_min_line(row: &[f64], n: usize, n_points: usize, out: &mut [f64], diff: &mut [f64]) {
45    for j in 0..n - 1 {
46        diff[j] = (row[j] - row[j + 1]).abs();
47    }
48    diff[n - 1] = (row[n - 1] - row[0]).abs();
49    for i in 0..n {
50        let mut ptv = 0.0;
51        let mut ntv = 0.0;
52        if i >= n_points && i + n_points <= n {
53            // Interior: both windows lie within the array.
54            for o in 0..n_points {
55                ptv += diff[i + o];
56                ntv += diff[i - 1 - o];
57            }
58        } else {
59            for o in 0..n_points {
60                ptv += diff[(i + o) % n];
61                ntv += diff[(i + n - 1 - o) % n];
62            }
63        }
64        out[i] = ptv.min(ntv);
65    }
66}
67
68/// Reusable per-thread scratch for [`unring_row_inplace`], sized to a line of
69/// length `n`. Allocated once per worker thread (via rayon `for_each_init`) so the
70/// hot loop performs no allocation.
71struct LineBufs {
72    c: Vec<Complex64>,
73    buf: Vec<Complex64>,
74    tvp: Vec<f64>,
75    tvn: Vec<f64>,
76    isp: Vec<f64>,
77    isn: Vec<f64>,
78    sp: Vec<f64>,
79    sn: Vec<f64>,
80    img: Vec<f64>,
81    imgn: Vec<f64>,
82    tvs: Vec<f64>,
83    diff: Vec<f64>,
84    scratch_f: Vec<Complex64>,
85    scratch_i: Vec<Complex64>,
86}
87
88impl LineBufs {
89    fn new(n: usize, sf: usize, si: usize) -> Self {
90        let z = Complex64::new(0.0, 0.0);
91        LineBufs {
92            c: vec![z; n],
93            buf: vec![z; n],
94            tvp: vec![0.0; n],
95            tvn: vec![0.0; n],
96            isp: vec![0.0; n],
97            isn: vec![0.0; n],
98            sp: vec![0.0; n],
99            sn: vec![0.0; n],
100            img: vec![0.0; n],
101            imgn: vec![0.0; n],
102            tvs: vec![0.0; n],
103            diff: vec![0.0; n],
104            scratch_f: vec![z; sf],
105            scratch_i: vec![z; si],
106        }
107    }
108}
109
110/// Un-ring a single 1-D line in place, reusing `b`'s buffers.
111///
112/// Both shift directions of a magnitude `ssamp[s]` are evaluated with a single
113/// complex IFFT: for a real line the shifted spectra `c·ph` and `c·conj(ph)`
114/// are each Hermitian (the even-`n` Nyquist bin is symmetrised — see
115/// [`unring_axis`]), so their inverse transforms are real and can share one
116/// transform as `IFFT(c·(ph + i·conj(ph))) = img₊ + i·img₋`. `phases[s*n + m]`
117/// holds the packed factor `ph + i·conj(ph)`.
118#[allow(clippy::too_many_arguments)]
119fn unring_row_inplace(
120    row: &mut [f64],
121    b: &mut LineBufs,
122    n: usize,
123    n_points: usize,
124    ssamp: &[f64],
125    phases: &[Complex64],
126    fft: &Arc<dyn Fft<f64>>,
127    ifft: &Arc<dyn Fft<f64>>,
128) {
129    // Skip near-constant/empty lines (nothing to unring).
130    let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
131    for &v in row.iter() {
132        lo = lo.min(v);
133        hi = hi.max(v);
134    }
135    if hi - lo < 1e-12 {
136        return;
137    }
138
139    let LineBufs {
140        c,
141        buf,
142        tvp,
143        tvn,
144        isp,
145        isn,
146        sp,
147        sn,
148        img,
149        imgn,
150        tvs,
151        diff,
152        scratch_f,
153        scratch_i,
154    } = b;
155
156    for m in 0..n {
157        c[m] = Complex64::new(row[m], 0.0);
158    }
159    fft.process_with_scratch(c, scratch_f);
160
161    tv_min_line(row, n, n_points, tvp, diff);
162    tvn.copy_from_slice(tvp);
163    isp.copy_from_slice(row);
164    isn.copy_from_slice(row);
165    sp.iter_mut().for_each(|x| *x = 0.0);
166    sn.iter_mut().for_each(|x| *x = 0.0);
167    let inv_n = 1.0 / n as f64;
168
169    for (s_i, &s) in ssamp.iter().enumerate() {
170        let ph = &phases[s_i * n..s_i * n + n];
171
172        // Both shift directions in one IFFT: real part = positive shift,
173        // imaginary part = negative shift.
174        for m in 0..n {
175            buf[m] = c[m] * ph[m];
176        }
177        ifft.process_with_scratch(buf, scratch_i);
178        for m in 0..n {
179            img[m] = (buf[m].re * inv_n).abs();
180            imgn[m] = (buf[m].im * inv_n).abs();
181        }
182
183        tv_min_line(img, n, n_points, tvs, diff);
184        for i in 0..n {
185            if tvp[i] > tvs[i] {
186                isp[i] = img[i];
187                sp[i] = s;
188                tvp[i] = tvs[i];
189            }
190        }
191
192        tv_min_line(imgn, n, n_points, tvs, diff);
193        for i in 0..n {
194            if tvn[i] > tvs[i] {
195                isn[i] = imgn[i];
196                sn[i] = s;
197                tvn[i] = tvs[i];
198            }
199        }
200    }
201
202    for i in 0..n {
203        let d = sp[i] + sn[i];
204        if d != 0.0 {
205            row[i] = (isp[i] - isn[i]) / d * sn[i] + isn[i];
206        }
207    }
208}
209
210fn fftfreq(n: usize) -> Vec<f64> {
211    (0..n)
212        .map(|m| {
213            if m < n.div_ceil(2) {
214                m as f64 / n as f64
215            } else {
216                (m as f64 - n as f64) / n as f64
217            }
218        })
219        .collect()
220}
221
222/// Un-ring `vol` along a single axis (0=x, 1=y, 2=z), returning a new volume.
223///
224/// Lines are gathered into a contiguous `(num_lines × n)` matrix so they can be
225/// processed in place with `par_chunks_mut` — each worker thread reuses one
226/// [`LineBufs`] (no per-line allocation) and the shift phase factors are
227/// precomputed once for the whole axis.
228fn unring_axis(vol: &[f64], (nx, ny, nz): (usize, usize, usize), axis: usize) -> Vec<f64> {
229    let n = [nx, ny, nz][axis];
230    if n < 4 {
231        return vol.to_vec(); // too short to unring
232    }
233    let freq = fftfreq(n);
234    let ssamp: Vec<f64> = (0..N_SHIFTS)
235        .map(|i| 0.02 + (0.9 - 0.02) * i as f64 / (N_SHIFTS - 1) as f64)
236        .collect();
237    // Precompute the packed shift factors `ph + i·conj(ph)` once
238    // (line-independent), where `ph[m] = exp(i·2π·freq[m]·ssamp[s])` is the
239    // positive-shift factor. For even `n` the unpaired Nyquist bin is
240    // symmetrised to the real factor `cos(π·s)` — the trigonometric-
241    // interpolation convention — so both shifted spectra stay Hermitian and
242    // their (then real) inverse transforms can share one complex IFFT. This
243    // deviates from DIPY only in that one bin, an O(|c[n/2]|/n) difference.
244    let mut phases = vec![Complex64::new(0.0, 0.0); ssamp.len() * n];
245    for (s_i, &s) in ssamp.iter().enumerate() {
246        for m in 0..n {
247            let ph = if 2 * m == n {
248                Complex64::new((PI * s).cos(), 0.0)
249            } else {
250                Complex64::from_polar(1.0, 2.0 * PI * freq[m] * s)
251            };
252            phases[s_i * n + m] = ph + Complex64::i() * ph.conj();
253        }
254    }
255    let mut planner = FftPlanner::<f64>::new();
256    let fft = planner.plan_fft_forward(n);
257    let ifft = planner.plan_fft_inverse(n);
258    let sf = fft.get_inplace_scratch_len();
259    let si = ifft.get_inplace_scratch_len();
260
261    // Line start offsets + stride for this axis.
262    let (stride, lines): (usize, Vec<usize>) = match axis {
263        0 => (1, (0..ny * nz).map(|l| l * nx).collect()),
264        1 => {
265            let mut v = Vec::with_capacity(nx * nz);
266            for z in 0..nz {
267                for x in 0..nx {
268                    v.push(z * nx * ny + x);
269                }
270            }
271            (nx, v)
272        }
273        _ => {
274            let mut v = Vec::with_capacity(nx * ny);
275            for y in 0..ny {
276                for x in 0..nx {
277                    v.push(y * nx + x);
278                }
279            }
280            (nx * ny, v)
281        }
282    };
283
284    // Gather strided lines into a contiguous matrix (num_lines × n).
285    let num_lines = lines.len();
286    let mut mat = vec![0.0_f64; num_lines * n];
287    for (l, &start) in lines.iter().enumerate() {
288        let dst = &mut mat[l * n..l * n + n];
289        for (i, d) in dst.iter_mut().enumerate() {
290            *d = vol[start + i * stride];
291        }
292    }
293
294    // Process each row in place (buffers reused per worker thread).
295    #[cfg(feature = "parallel")]
296    mat.par_chunks_mut(n).for_each_init(
297        || LineBufs::new(n, sf, si),
298        |b, row| unring_row_inplace(row, b, n, 3, &ssamp, &phases, &fft, &ifft),
299    );
300    #[cfg(not(feature = "parallel"))]
301    {
302        let mut b = LineBufs::new(n, sf, si);
303        for row in mat.chunks_mut(n) {
304            unring_row_inplace(row, &mut b, n, 3, &ssamp, &phases, &fft, &ifft);
305        }
306    }
307
308    // Scatter back to a new volume.
309    let mut out = vol.to_vec();
310    for (l, &start) in lines.iter().enumerate() {
311        let src = &mat[l * n..l * n + n];
312        for (i, &s) in src.iter().enumerate() {
313            out[start + i * stride] = s;
314        }
315    }
316    out
317}
318
319/// Gibbs-unring a single 3-D volume.
320pub fn gibbs_unring_volume(vol: &[f64], dims: (usize, usize, usize)) -> Vec<f64> {
321    let (nx, ny, nz) = dims;
322    let n_vox = nx * ny * nz;
323    assert_eq!(vol.len(), n_vox, "vol length must be nx*ny*nz");
324
325    let cx = unring_axis(vol, dims, 0);
326    let cy = unring_axis(vol, dims, 1);
327    let cz = unring_axis(vol, dims, 2);
328
329    // Fourier-domain weighted combine (Kellner cosine weights, 3-D form).
330    let fx = fftfreq(nx);
331    let fy = fftfreq(ny);
332    let fz = fftfreq(nz);
333    let cosk = |f: f64| 1.0 + (2.0 * PI * f).cos();
334    let ckx: Vec<f64> = fx.iter().map(|&f| cosk(f)).collect();
335    let cky: Vec<f64> = fy.iter().map(|&f| cosk(f)).collect();
336    let ckz: Vec<f64> = fz.iter().map(|&f| cosk(f)).collect();
337
338    let sx = fft3d_real(&cx, nx, ny, nz);
339    let sy = fft3d_real(&cy, nx, ny, nz);
340    let sz = fft3d_real(&cz, nx, ny, nz);
341    let mut spec = vec![Complex64::new(0.0, 0.0); n_vox];
342    for z in 0..nz {
343        for y in 0..ny {
344            for x in 0..nx {
345                let idx = x + y * nx + z * nx * ny;
346                let wx = cky[y] * ckz[z]; // axis-x correction weight
347                let wy = ckx[x] * ckz[z];
348                let wz = ckx[x] * cky[y];
349                let den = wx + wy + wz;
350                spec[idx] = if den > 1e-12 {
351                    (sx[idx] * wx + sy[idx] * wy + sz[idx] * wz) / den
352                } else {
353                    (sx[idx] + sy[idx] + sz[idx]) / 3.0
354                };
355            }
356        }
357    }
358    let mut out = ifft3d_real(&spec, nx, ny, nz);
359    for v in out.iter_mut() {
360        if *v < 0.0 {
361            *v = 0.0;
362        }
363    }
364    out
365}
366
367/// Gibbs-unring multi-volume data (e.g. multi-echo magnitude).
368///
369/// # Arguments
370/// * `data` - Interleaved `[voxel0_vol0, voxel0_vol1, ..., voxel1_vol0, ...]`
371///   (row-major `(n_voxels, n_vols)`), same layout as [`r2star_arlo`].
372/// * `dims` - Volume dimensions `(nx, ny, nz)`.
373/// * `n_vols` - Number of volumes.
374///
375/// # Returns
376/// Unrung data in the same layout.
377pub fn gibbs_unring(data: &[f64], dims: (usize, usize, usize), n_vols: usize) -> Vec<f64> {
378    let (nx, ny, nz) = dims;
379    let n_vox = nx * ny * nz;
380    assert_eq!(data.len(), n_vox * n_vols, "data length must be n_voxels * n_vols");
381    let mut out = data.to_vec();
382    let mut vol = vec![0.0_f64; n_vox];
383    for e in 0..n_vols {
384        for v in 0..n_vox {
385            vol[v] = data[v * n_vols + e];
386        }
387        let un = gibbs_unring_volume(&vol, dims);
388        for v in 0..n_vox {
389            out[v * n_vols + e] = un[v];
390        }
391    }
392    out
393}
394
395/// Gibbs-unring multi-volume data within a mask bounding box (fast path).
396///
397/// Only the axis-aligned bounding box of `mask`, expanded by `margin` voxels, is
398/// unrung; voxels outside are copied through unchanged. This skips air/background
399/// lines and shrinks the per-line FFT length, which is much faster on data where
400/// the region of interest fills a fraction of the FOV.
401///
402/// Because the FFT is taken over the cropped extent rather than the full FOV, the
403/// result differs slightly from [`gibbs_unring`] near the crop border, but with a
404/// sufficient `margin` the interior of the mask is essentially identical (the
405/// ringing there is driven by edges inside the box). Intended for relaxometry
406/// where downstream fitting is masked to the brain anyway.
407///
408/// # Arguments
409/// * `data` - Interleaved `(n_voxels, n_vols)`, same layout as [`gibbs_unring`].
410/// * `dims` - Volume dimensions `(nx, ny, nz)`.
411/// * `n_vols` - Number of volumes.
412/// * `mask` - Binary mask `[nx*ny*nz]`; the bounding box of `mask != 0` is unrung.
413/// * `margin` - Voxels to expand the bounding box on every side.
414pub fn gibbs_unring_masked(
415    data: &[f64],
416    dims: (usize, usize, usize),
417    n_vols: usize,
418    mask: &[u8],
419    margin: usize,
420) -> Vec<f64> {
421    let (nx, ny, nz) = dims;
422    let n_vox = nx * ny * nz;
423    assert_eq!(data.len(), n_vox * n_vols, "data length must be n_voxels * n_vols");
424    assert_eq!(mask.len(), n_vox, "mask length must be n_voxels");
425
426    // Bounding box of the mask.
427    let (mut x0, mut y0, mut z0) = (nx, ny, nz);
428    let (mut x1, mut y1, mut z1) = (0usize, 0usize, 0usize);
429    let mut any = false;
430    for z in 0..nz {
431        for y in 0..ny {
432            for x in 0..nx {
433                if mask[x + y * nx + z * nx * ny] != 0 {
434                    any = true;
435                    x0 = x0.min(x);
436                    y0 = y0.min(y);
437                    z0 = z0.min(z);
438                    x1 = x1.max(x);
439                    y1 = y1.max(y);
440                    z1 = z1.max(z);
441                }
442            }
443        }
444    }
445    if !any {
446        return data.to_vec();
447    }
448    // Expand by margin, clamp to volume.
449    x0 = x0.saturating_sub(margin);
450    y0 = y0.saturating_sub(margin);
451    z0 = z0.saturating_sub(margin);
452    x1 = (x1 + margin + 1).min(nx);
453    y1 = (y1 + margin + 1).min(ny);
454    z1 = (z1 + margin + 1).min(nz);
455    let (cx, cy, cz) = (x1 - x0, y1 - y0, z1 - z0);
456
457    // If the crop is (almost) the whole volume, just do the full transform.
458    if cx * cy * cz >= n_vox * 9 / 10 {
459        return gibbs_unring(data, dims, n_vols);
460    }
461
462    let n_crop = cx * cy * cz;
463    let mut out = data.to_vec();
464    let mut sub = vec![0.0_f64; n_crop];
465    for e in 0..n_vols {
466        // Extract the cropped sub-volume for this echo.
467        for zz in 0..cz {
468            for yy in 0..cy {
469                for xx in 0..cx {
470                    let full = (x0 + xx) + (y0 + yy) * nx + (z0 + zz) * nx * ny;
471                    sub[xx + yy * cx + zz * cx * cy] = data[full * n_vols + e];
472                }
473            }
474        }
475        let un = gibbs_unring_volume(&sub, (cx, cy, cz));
476        // Write the corrected crop back.
477        for zz in 0..cz {
478            for yy in 0..cy {
479                for xx in 0..cx {
480                    let full = (x0 + xx) + (y0 + yy) * nx + (z0 + zz) * nx * ny;
481                    out[full * n_vols + e] = un[xx + yy * cx + zz * cx * cy];
482                }
483            }
484        }
485    }
486    out
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    #[test]
494    fn test_unring_reduces_edge_overshoot() {
495        // 1-D step, band-limited by zeroing high k-space → Gibbs ringing. Unring
496        // along x should reduce the overshoot just past the edge.
497        let (nx, ny, nz) = (64usize, 3usize, 3usize);
498        let mut clean = vec![0.0_f64; nx];
499        for x in 0..nx {
500            clean[x] = if x >= nx / 2 { 1.0 } else { 0.0 };
501        }
502        // Band-limit via FFT truncation.
503        let mut planner = FftPlanner::<f64>::new();
504        let fft = planner.plan_fft_forward(nx);
505        let ifft = planner.plan_fft_inverse(nx);
506        let mut c: Vec<Complex64> = clean.iter().map(|&v| Complex64::new(v, 0.0)).collect();
507        fft.process(&mut c);
508        let keep = nx / 4;
509        for m in 0..nx {
510            let fm = if m < nx / 2 { m } else { nx - m };
511            if fm > keep {
512                c[m] = Complex64::new(0.0, 0.0);
513            }
514        }
515        ifft.process(&mut c);
516        let rung: Vec<f64> = c.iter().map(|z| z.re / nx as f64).collect();
517
518        // Broadcast the ringing line across a small volume.
519        let mut vol = vec![0.0_f64; nx * ny * nz];
520        for z in 0..nz {
521            for y in 0..ny {
522                for x in 0..nx {
523                    vol[x + y * nx + z * nx * ny] = rung[x];
524                }
525            }
526        }
527        // Test the 1-D core directly (the 3-D Fourier combine assumes every axis
528        // carries structure; a ring broadcast flat across y,z is a degenerate
529        // case for it — the full 3-D path is validated on the phantom).
530        let un = unring_axis(&vol, (nx, ny, nz), 0);
531        // Extract the corrected centre line.
532        let line: Vec<f64> = (0..nx).map(|x| un[x + nx + nx * ny]).collect();
533
534        // Faithful-port check: values must match DIPY's `_gibbs_removal_1d` on the
535        // identical ringing line (indices 30..=40).
536        let dipy_ref = [
537            0.06875, 0.25484, 0.67489, 1.06882, 1.02528, 0.96117, 0.98865, 1.02746, 1.00689,
538            0.97820, 0.97820,
539        ];
540        for (k, &r) in dipy_ref.iter().enumerate() {
541            let got = line[30 + k];
542            assert!(
543                (got - r).abs() < 5e-3,
544                "index {}: Rust {:.5} vs DIPY {:.5}",
545                30 + k,
546                got,
547                r
548            );
549        }
550        // Total variation should drop like DIPY's (3.23 -> ~2.41).
551        let tv = |a: &[f64]| a.windows(2).map(|w| (w[1] - w[0]).abs()).sum::<f64>();
552        let tv_rung: f64 = rung.windows(2).map(|w| (w[1] - w[0]).abs()).sum();
553        assert!(tv(&line) < 0.85 * tv_rung, "unring should cut TV: {:.3} -> {:.3}", tv_rung, tv(&line));
554    }
555
556    /// Timing + numeric fingerprint on a synthetic volume (sphere + deterministic
557    /// noise). Run manually with `--ignored --nocapture` to compare performance
558    /// work against the baseline.
559    #[test]
560    #[ignore]
561    fn bench_unring_volume() {
562        let (nx, ny, nz) = (128usize, 128usize, 128usize);
563        let mut vol = vec![0.0_f64; nx * ny * nz];
564        let mut lcg: u64 = 42;
565        for z in 0..nz {
566            for y in 0..ny {
567                for x in 0..nx {
568                    let dx = x as f64 - nx as f64 / 2.0;
569                    let dy = y as f64 - ny as f64 / 2.0;
570                    let dz = z as f64 - nz as f64 / 2.0;
571                    let r = (dx * dx + dy * dy + dz * dz).sqrt();
572                    let base = if r < nx as f64 / 3.0 { 1.0 } else { 0.1 };
573                    lcg = lcg.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
574                    let noise = (lcg >> 33) as f64 / (1u64 << 31) as f64 * 0.02;
575                    vol[x + y * nx + z * nx * ny] = base + noise;
576                }
577            }
578        }
579        let t = std::time::Instant::now();
580        let out = gibbs_unring_volume(&vol, (nx, ny, nz));
581        let dt = t.elapsed();
582        let sum: f64 = out.iter().sum();
583        let sq: f64 = out.iter().map(|v| v * v).sum();
584        let mx = out.iter().cloned().fold(f64::MIN, f64::max);
585        println!(
586            "gibbs_unring_volume 128^3: {:?}  sum {:.6}  sumsq {:.6}  max {:.6}",
587            dt, sum, sq, mx
588        );
589    }
590
591    #[test]
592    fn test_fftfreq_matches_numpy() {
593        // numpy fftfreq(4) = [0, .25, -.5, -.25]
594        let f = fftfreq(4);
595        assert!((f[0] - 0.0).abs() < 1e-12);
596        assert!((f[1] - 0.25).abs() < 1e-12);
597        assert!((f[2] + 0.5).abs() < 1e-12);
598        assert!((f[3] + 0.25).abs() < 1e-12);
599    }
600}