Skip to main content

qsm_core/inversion/
admm.rs

1//! Shared ADMM iteration infrastructure for TV-based inversion methods.
2//!
3//! Provides pre-allocated buffers and a generic iteration step used by
4//! TV-ADMM, NLTV, and RTS algorithms.
5
6use num_complex::Complex64;
7use crate::fft::Fft3dWorkspace;
8use crate::Grid;
9use crate::kernels::dipole::dipole_kernel;
10use crate::kernels::laplacian::laplacian_kernel;
11use crate::utils::gradient::{bdiv_inplace, fgrad_inplace};
12use crate::utils::relative_change;
13
14/// Pre-allocated buffers for ADMM-based inversion algorithms.
15///
16/// All buffers are sized to `n_total` voxels. Zero allocations occur
17/// during iteration when using these buffers.
18pub struct AdmmBuffers {
19    /// Current solution
20    pub x: Vec<f64>,
21    /// Previous solution (for convergence)
22    pub x_prev: Vec<f64>,
23    /// Dual variables (x/y/z components)
24    pub ux: Vec<f64>,
25    pub uy: Vec<f64>,
26    pub uz: Vec<f64>,
27    /// Gradient / (z-u) buffers (dual-purpose)
28    pub gx: Vec<f64>,
29    pub gy: Vec<f64>,
30    pub gz: Vec<f64>,
31    /// Divergence buffer
32    pub div_buf: Vec<f64>,
33    /// Complex FFT work buffer
34    pub work_complex: Vec<Complex64>,
35}
36
37impl AdmmBuffers {
38    /// Allocate all ADMM buffers for a given volume size.
39    pub fn new(n_total: usize) -> Self {
40        Self {
41            x: vec![0.0; n_total],
42            x_prev: vec![0.0; n_total],
43            ux: vec![0.0; n_total],
44            uy: vec![0.0; n_total],
45            uz: vec![0.0; n_total],
46            gx: vec![0.0; n_total],
47            gy: vec![0.0; n_total],
48            gz: vec![0.0; n_total],
49            div_buf: vec![0.0; n_total],
50            work_complex: vec![Complex64::new(0.0, 0.0); n_total],
51        }
52    }
53}
54
55/// Run one ADMM iteration step.
56///
57/// Performs the x-subproblem (spectral solve), convergence check,
58/// and fused z-subproblem + dual variable update.
59///
60/// The `shrink_fn(vx, vy, vz, index) -> (zx, zy, zz)` closure captures
61/// per-algorithm differences in the proximal operator.
62///
63/// # Arguments
64/// * `buf` - Pre-allocated buffers (modified in place)
65/// * `fft_ws` - FFT workspace
66/// * `f_hat` - Pre-computed constant RHS in frequency domain
67/// * `inv_a` - Pre-computed inverse operator
68/// * `rho` - ADMM penalty multiplier for divergence term
69/// * `grid` - Volume grid
70/// * `tol` - Convergence tolerance
71/// * `shrink_fn` - Proximal operator for z-subproblem
72///
73/// # Returns
74/// `true` if converged (relative change < tol)
75#[inline]
76pub fn admm_step<S>(
77    buf: &mut AdmmBuffers,
78    fft_ws: &mut Fft3dWorkspace,
79    f_hat: &[Complex64],
80    inv_a: &[f64],
81    rho: f64,
82    grid: &Grid,
83    tol: f64,
84    shrink_fn: S,
85) -> bool
86where
87    S: Fn(f64, f64, f64, usize) -> (f64, f64, f64),
88{
89    let n_total = grid.n_total();
90
91    // Swap x and x_prev (no allocation, just pointer swap)
92    std::mem::swap(&mut buf.x, &mut buf.x_prev);
93
94    // === x-subproblem: solve in frequency domain ===
95
96    // Compute div(z - u) — gx/gy/gz hold (z-u) from previous step
97    bdiv_inplace(&mut buf.div_buf, &buf.gx, &buf.gy, &buf.gz, grid);
98
99    // FFT of divergence
100    for i in 0..n_total {
101        buf.work_complex[i] = Complex64::new(buf.div_buf[i], 0.0);
102    }
103    fft_ws.fft3d(&mut buf.work_complex);
104
105    // x_hat = f_hat - rho * FFT(div) * inv_a
106    for i in 0..n_total {
107        buf.work_complex[i] = f_hat[i] - rho * buf.work_complex[i] * inv_a[i];
108    }
109
110    // IFFT to spatial domain
111    fft_ws.ifft3d(&mut buf.work_complex);
112    for i in 0..n_total {
113        buf.x[i] = buf.work_complex[i].re;
114    }
115
116    // === Convergence check ===
117    if relative_change(&buf.x, &buf.x_prev) < tol {
118        return true;
119    }
120
121    // === Fused z-subproblem + u-update ===
122
123    // Compute gradient of x
124    fgrad_inplace(&mut buf.gx, &mut buf.gy, &mut buf.gz, &buf.x, grid);
125
126    // Apply proximal operator and update duals
127    for i in 0..n_total {
128        let vx = buf.gx[i] + buf.ux[i];
129        let vy = buf.gy[i] + buf.uy[i];
130        let vz = buf.gz[i] + buf.uz[i];
131
132        let (zx, zy, zz) = shrink_fn(vx, vy, vz, i);
133
134        buf.ux[i] = vx - zx;
135        buf.uy[i] = vy - zy;
136        buf.uz[i] = vz - zz;
137
138        // Store (2z - v) = (z - u_new) for next iteration's divergence
139        buf.gx[i] = 2.0 * zx - vx;
140        buf.gy[i] = 2.0 * zy - vy;
141        buf.gz[i] = 2.0 * zz - vz;
142    }
143
144    false
145}
146
147/// Pre-compute ADMM spectral operators (dipole kernel, inverse operator, and RHS).
148///
149/// For TV-ADMM (QSM.jl's `tv`): both are the kernels QSM.jl's real-to-complex FFT implies
150/// ([`crate::fft::c2r_kernel`]). Returns (fft_workspace, inv_a, f_hat).
151pub fn prepare_admm_spectral(
152    local_field: &[f64],
153    grid: &Grid,
154    bdir: (f64, f64, f64),
155    rho: f64,
156) -> (Fft3dWorkspace, Vec<f64>, Vec<Complex64>) {
157    let n_total = grid.n_total();
158    let mut fft_ws = Fft3dWorkspace::new(grid.nx(), grid.ny(), grid.nz());
159    let d_kernel = dipole_kernel(grid, bdir);
160    let l_kernel = laplacian_kernel(grid, true);
161    let mut l_complex: Vec<Complex64> = l_kernel.iter()
162        .map(|&x| Complex64::new(x, 0.0))
163        .collect();
164    fft_ws.fft3d(&mut l_complex);
165    // The Laplacian's DC term is 0 (its stencil sums to zero), but its FFT is only zero to
166    // rounding on anisotropic voxels (e.g. 4.4e-16 at 0.9 × 1.0 × 1.3 mm), and inverting that
167    // puts a rounding-dependent constant into χ. Zero it, so `a` at DC is exactly 0 and χ's DC is
168    // 0, as it is on isotropic voxels. QSM.jl's `tv` keeps its rounded value (see `tv`'s docs).
169    l_complex[0] = Complex64::new(0.0, 0.0);
170    let mut inv_a: Vec<f64> = vec![0.0; n_total];
171    for i in 0..n_total {
172        let a = d_kernel[i] * d_kernel[i] + rho * l_complex[i].re;
173        inv_a[i] = if a.abs() > 1e-20 { 1.0 / a } else { 0.0 };
174    }
175    // QSM.jl's `tv` applies `iA` and `iA·D` on its real-to-complex half spectrum; give the full
176    // FFT the kernels `irfft` implies (astewartau/QSM.rs#329), so tilted B0 matches it too.
177    let (nx, ny, nz) = grid.dims;
178    let rhs: Vec<f64> = (0..n_total).map(|i| d_kernel[i] * inv_a[i]).collect();
179    let rhs = crate::fft::c2r_kernel(&rhs, nx, ny, nz);
180    let inv_a = crate::fft::c2r_kernel(&inv_a, nx, ny, nz);
181    let f_hat = &mut l_complex;
182    for i in 0..n_total {
183        f_hat[i] = Complex64::new(local_field[i], 0.0);
184    }
185    fft_ws.fft3d(f_hat);
186    for i in 0..n_total {
187        f_hat[i] *= rhs[i];
188    }
189    (fft_ws, inv_a, l_complex)
190}
191
192/// Pre-compute the continuous-kernel spectral operators of the nonlinear solvers that are
193/// not FANSI's (QSM.jl's NLTV, AMP-PE). The FANSI-family ports take their kernel from
194/// [`prepare_fansi_kernel`] instead.
195///
196/// Unlike [`prepare_admm_spectral`], this does not bake the data term into an RHS,
197/// because those solvers carry a separately-updated data auxiliary variable. It
198/// returns the reusable primitives instead:
199///
200/// * `Fft3dWorkspace` — reusable FFT plans (unnormalized forward `fft3d`,
201///   normalized inverse `ifft3d`, matching MATLAB `fftn`/`ifftn`).
202/// * `k_kernel` — the real k-space dipole kernel `D(k)` (same convention as the
203///   rest of the crate: continuous kernel, includes voxel-size scaling, DC = 0).
204/// * `ee2` — the real spectral Laplacian `EE2(k) = |E1|² + |E2|² + |E3|²`, i.e.
205///   the frequency response of `bdiv ∘ fgrad`. Use as the regularization term in
206///   the x-subproblem denominator so it stays consistent with real-space
207///   `fgrad_inplace`/`bdiv_inplace` (which share the same voxel-size scaling).
208///
209/// The x-subproblem denominator for these solvers is therefore
210/// `mu2 * k_kernel² + mu * ee2` (all real), matching FANSI's
211/// `mu2*abs(Kernel).^2 + mu*EE2`.
212pub fn prepare_fansi_spectral(
213    grid: &Grid,
214    bdir: (f64, f64, f64),
215) -> (Fft3dWorkspace, Vec<f64>, Vec<f64>) {
216    let mut fft_ws = Fft3dWorkspace::new(grid.nx(), grid.ny(), grid.nz());
217    let k_kernel = dipole_kernel(grid, bdir);
218    let l_kernel = laplacian_kernel(grid, true);
219    let mut l_complex: Vec<Complex64> = l_kernel.iter()
220        .map(|&x| Complex64::new(x, 0.0))
221        .collect();
222    fft_ws.fft3d(&mut l_complex);
223    let ee2: Vec<f64> = l_complex.iter().map(|c| c.re).collect();
224    (fft_ws, k_kernel, ee2)
225}
226
227/// The dipole kernel a FANSI-family solver (NDI, nlTV, nlTGV, L1-QSM, WH-QSM, HD-QSM) uses.
228///
229/// FANSI's solvers take the kernel as an input (`params.K`), and every FANSI script, like the
230/// QSM-CI wrappers of the verified originals (FANSI-toolbox 93044a3, HD-QSM c456a94), builds it
231/// with `dipole_kernel_angulated(N, voxel_size, B0_dir)`, whose default `mode = 1` is the
232/// discrete kernel [`FansiKernel::Discrete`]. HDQSM.m ships no kernel and is "based on" FANSI;
233/// its wrapper uses the same function. The discrete kernel is consistent with the unit
234/// forward differences these solvers regularise with (both come from the same `E_j`).
235///
236/// Decision (Ashley, 2026-11-02; QSM.rs#399–#403): the FANSI-family ports use FANSI's own
237/// discrete kernel by default, as the originals do; the continuous kernel the rest of the crate
238/// uses is kept as an option. Measured with the continuous kernel against the verified originals
239/// (QSM-CI parity run 41384710780, sim / KKI χ NRMSE): NDI 10.6 / 9.5 %, nlTV 11.0 / 9.5 %,
240/// nlTGV 11.0 / 9.6 %, L1-QSM 14.4 / 15.5 %, WH-QSM 21.4 / 50.7 %, HD-QSM 9.1 / 7.1 %; a numpy
241/// transcription of `ndi.m` on the original's own inputs is 0.0001 % from it with the discrete
242/// kernel and 10.6 % with the continuous one.
243/// With the discrete kernel every pair is at rounding (each method's module docs, "Parity"). The
244/// phantom measurements quoted in the methods' `te` docs were re-measured with the discrete kernel
245/// (QSM.rs#418); the echo-time choices they support did not change.
246#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
247#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
248pub enum FansiKernel {
249    /// FANSI's `dipole_kernel_angulated` mode 1 (FANSI-toolbox 93044a3): the kernel of unit
250    /// forward differences, `E_j = (1 - exp(2*pi*i*k_j/N_j)) / r_j` with the voxel size `r`
251    /// normalised by its maximum, `D = 1/3 - |sum_j E_j*b_j|^2 / sum_j |E_j|^2`, `D(0) = 0`
252    /// ([`fansi_dipole_kernel`]).
253    #[default]
254    Discrete,
255    /// The continuous kernel `1/3 - (k.b)^2/|k|^2` of [`crate::kernels::dipole::dipole_kernel`]
256    /// (`dipole_kernel_angulated` mode 0 up to its `k` normalisation).
257    Continuous,
258}
259
260impl FansiKernel {
261    /// The kernel in FFT order, `i + nx*(j + ny*k)`.
262    pub fn build(self, grid: &Grid, bdir: (f64, f64, f64)) -> Vec<f64> {
263        match self {
264            FansiKernel::Discrete => fansi_dipole_kernel(grid, bdir),
265            FansiKernel::Continuous => dipole_kernel(grid, bdir),
266        }
267    }
268}
269
270/// FANSI's discrete dipole kernel, `dipole_kernel_angulated(N, spatial_res, B0_dir)` with its
271/// default `mode = 1` (FANSI-toolbox 93044a3):
272///
273/// ```matlab
274/// spatial_res = spatial_res/max(spatial_res(:));
275/// E_j = (1 - exp(2i*pi*k_j/N_j)) / spatial_res(j);    % k_j = 0..N_j-1
276/// kernel = 1/3 - |E1*B0(1) + E2*B0(2) + E3*B0(3)|^2 ./ (|E1|^2 + |E2|^2 + |E3|^2);
277/// kernel(1,1,1) = 0;
278/// ```
279///
280/// `bdir` is normalised first (the QSM-CI wrappers pass `B0_dir/norm(B0_dir)`). MATLAB builds
281/// it in single precision (`N = single(N)`); this is double, as the crate's other kernels.
282/// FFT order, `i + nx*(j + ny*k)`.
283pub fn fansi_dipole_kernel(grid: &Grid, bdir: (f64, f64, f64)) -> Vec<f64> {
284    let (nx, ny, nz) = grid.dims;
285    let (vx, vy, vz) = grid.voxel_size;
286    let vmax = vx.max(vy).max(vz);
287    let (bx, by, bz) = bdir;
288    let bn = (bx * bx + by * by + bz * bz).sqrt();
289    let b = [bx / bn, by / bn, bz / bn];
290    let e = |k: usize, n: usize, r: f64| {
291        let a = 2.0 * std::f64::consts::PI * k as f64 / n as f64;
292        Complex64::new(1.0 - a.cos(), -a.sin()) / (r / vmax)
293    };
294    let ex: Vec<Complex64> = (0..nx).map(|i| e(i, nx, vx)).collect();
295    let ey: Vec<Complex64> = (0..ny).map(|j| e(j, ny, vy)).collect();
296    let ez: Vec<Complex64> = (0..nz).map(|k| e(k, nz, vz)).collect();
297    let mut d = Vec::with_capacity(nx * ny * nz);
298    for e3 in &ez {
299        for e2 in &ey {
300            for e1 in &ex {
301                let lap = e1.norm_sqr() + e2.norm_sqr() + e3.norm_sqr();
302                let proj = (e1 * b[0] + e2 * b[1] + e3 * b[2]).norm_sqr();
303                d.push(if lap > 0.0 { 1.0 / 3.0 - proj / lap } else { 0.0 });
304            }
305        }
306    }
307    d
308}
309
310/// [`Fft3dWorkspace`] and the FANSI-family dipole kernel `kernel` selects (see [`FansiKernel`]).
311pub fn prepare_fansi_kernel(
312    grid: &Grid,
313    bdir: (f64, f64, f64),
314    kernel: FansiKernel,
315) -> (Fft3dWorkspace, Vec<f64>) {
316    (Fft3dWorkspace::new(grid.nx(), grid.ny(), grid.nz()), kernel.build(grid, bdir))
317}
318
319/// FANSI's `EE2 = |E1|^2 + |E2|^2 + |E3|^2` with `E_j = 1 - exp(2*pi*i*k_j/N_j)`: the
320/// spectral Laplacian of unit-spacing forward differences, as FANSI's nlTV.m, nlTGV.m and
321/// nlL1TV.m build it (whatever the voxel size). Pair it with `fgrad_inplace`/`bdiv_inplace`
322/// on a unit-voxel grid. FFT order, `i + nx*(j + ny*k)`.
323pub fn fansi_unit_ee2(dims: (usize, usize, usize)) -> Vec<f64> {
324    let (nx, ny, nz) = dims;
325    let e = |k: usize, n: usize| 2.0 - 2.0 * (2.0 * std::f64::consts::PI * k as f64 / n as f64).cos();
326    let mut ee2 = Vec::with_capacity(nx * ny * nz);
327    for k in 0..nz {
328        for j in 0..ny {
329            for i in 0..nx {
330                ee2.push(e(i, nx) + e(j, ny) + e(k, nz));
331            }
332        }
333    }
334    ee2
335}
336
337/// ppm -> radians factor `2*pi*gamma*b0*te` (gamma = [`GAMMA_BAR_MHZ_PER_T`](crate::constants::GAMMA_BAR_MHZ_PER_T)),
338/// for the FANSI solvers, which work on the phase in radians while the crate's inversions take
339/// ppm. FANSI's scripts use `gyro = 2*pi*42.58`; QSM.rs uses the shared constant, which is
340/// closer to the CODATA shielded-proton value (see [`crate::constants`]).
341pub fn fansi_ppm_to_rad(b0: f64, te: f64) -> f64 {
342    2.0 * std::f64::consts::PI * crate::constants::GAMMA_BAR_MHZ_PER_T * b0 * te
343}
344
345/// FANSI's data weight from the magnitude, as in its in vivo example (script_invivo.m):
346/// `min(2*mask*mag/max(mag), 1)`.
347pub fn fansi_magnitude_weight(mask: &[u8], magnitude: &[f64]) -> Vec<f64> {
348    let mag_max = magnitude.iter().cloned().fold(0.0f64, f64::max);
349    (0..mask.len())
350        .map(|i| {
351            if mask[i] == 0 || mag_max <= 0.0 {
352                0.0
353            } else {
354                (2.0 * magnitude[i] / mag_max).min(1.0)
355            }
356        })
357        .collect()
358}
359
360#[cfg(test)]
361mod fansi_kernel_tests {
362    use super::*;
363
364    /// `dipole_kernel_angulated` mode 1, written out for one bin: B0 oblique, anisotropic voxels
365    /// (normalised by the largest), DC 0, and the continuous kernel differs.
366    #[test]
367    fn discrete_kernel_matches_dipole_kernel_angulated() {
368        let grid = Grid::new(6, 5, 4, 0.5, 1.0, 2.0);
369        let b = (0.3, 0.1, 0.9);
370        let d = fansi_dipole_kernel(&grid, b);
371        assert_eq!(d[0], 0.0);
372        let bn = (0.3f64 * 0.3 + 0.1 * 0.1 + 0.9 * 0.9).sqrt();
373        let (i, j, k) = (2usize, 3usize, 1usize);
374        let ee = |kk: usize, n: usize, r: f64| {
375            let a = 2.0 * std::f64::consts::PI * kk as f64 / n as f64;
376            (Complex64::new(1.0, 0.0) - Complex64::new(0.0, a).exp()) / (r / 2.0)
377        };
378        let (e1, e2, e3) = (ee(i, 6, 0.5), ee(j, 5, 1.0), ee(k, 4, 2.0));
379        let lap = e1.norm_sqr() + e2.norm_sqr() + e3.norm_sqr();
380        let proj = (e1 * (0.3 / bn) + e2 * (0.1 / bn) + e3 * (0.9 / bn)).norm_sqr();
381        let want = 1.0 / 3.0 - proj / lap;
382        let got = d[i + 6 * (j + 5 * k)];
383        assert!((got - want).abs() < 1e-14, "{got} vs {want}");
384        let c = FansiKernel::Continuous.build(&grid, b);
385        assert!((c[i + 6 * (j + 5 * k)] - got).abs() > 1e-3);
386        assert_eq!(FansiKernel::default(), FansiKernel::Discrete);
387    }
388}
389