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