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/// Shared by TV-ADMM and NLTV. Returns (fft_workspace, inv_a, f_hat).
150pub fn prepare_admm_spectral(
151 local_field: &[f64],
152 grid: &Grid,
153 bdir: (f64, f64, f64),
154 rho: f64,
155) -> (Fft3dWorkspace, Vec<f64>, Vec<Complex64>) {
156 let n_total = grid.n_total();
157 let mut fft_ws = Fft3dWorkspace::new(grid.nx(), grid.ny(), grid.nz());
158 let d_kernel = dipole_kernel(grid, bdir);
159 let l_kernel = laplacian_kernel(grid, true);
160 let mut l_complex: Vec<Complex64> = l_kernel.iter()
161 .map(|&x| Complex64::new(x, 0.0))
162 .collect();
163 fft_ws.fft3d(&mut l_complex);
164 let mut inv_a: Vec<f64> = vec![0.0; n_total];
165 for i in 0..n_total {
166 let a = d_kernel[i] * d_kernel[i] + rho * l_complex[i].re;
167 inv_a[i] = if a.abs() > 1e-20 { 1.0 / a } else { 0.0 };
168 }
169 let f_hat = &mut l_complex;
170 for i in 0..n_total {
171 f_hat[i] = Complex64::new(local_field[i], 0.0);
172 }
173 fft_ws.fft3d(f_hat);
174 for i in 0..n_total {
175 f_hat[i] = f_hat[i] * d_kernel[i] * inv_a[i];
176 }
177 (fft_ws, inv_a, l_complex)
178}
179
180/// Pre-compute spectral operators shared by the FANSI-family nonlinear solvers
181/// (NDI, nlTV, nlTGV, L1-QSM, WH-QSM, HD-QSM).
182///
183/// Unlike [`prepare_admm_spectral`], this does not bake the data term into an RHS,
184/// because those solvers carry a separately-updated data auxiliary variable. It
185/// returns the reusable primitives instead:
186///
187/// * `Fft3dWorkspace` — reusable FFT plans (unnormalized forward `fft3d`,
188/// normalized inverse `ifft3d`, matching MATLAB `fftn`/`ifftn`).
189/// * `k_kernel` — the real k-space dipole kernel `D(k)` (same convention as the
190/// rest of the crate: continuous kernel, includes voxel-size scaling, DC = 0).
191/// * `ee2` — the real spectral Laplacian `EE2(k) = |E1|² + |E2|² + |E3|²`, i.e.
192/// the frequency response of `bdiv ∘ fgrad`. Use as the regularization term in
193/// the x-subproblem denominator so it stays consistent with real-space
194/// `fgrad_inplace`/`bdiv_inplace` (which share the same voxel-size scaling).
195///
196/// The x-subproblem denominator for these solvers is therefore
197/// `mu2 * k_kernel² + mu * ee2` (all real), matching FANSI's
198/// `mu2*abs(Kernel).^2 + mu*EE2`.
199pub fn prepare_fansi_spectral(
200 grid: &Grid,
201 bdir: (f64, f64, f64),
202) -> (Fft3dWorkspace, Vec<f64>, Vec<f64>) {
203 let mut fft_ws = Fft3dWorkspace::new(grid.nx(), grid.ny(), grid.nz());
204 let k_kernel = dipole_kernel(grid, bdir);
205 let l_kernel = laplacian_kernel(grid, true);
206 let mut l_complex: Vec<Complex64> = l_kernel.iter()
207 .map(|&x| Complex64::new(x, 0.0))
208 .collect();
209 fft_ws.fft3d(&mut l_complex);
210 let ee2: Vec<f64> = l_complex.iter().map(|c| c.re).collect();
211 (fft_ws, k_kernel, ee2)
212}