qsm_core/bgremove/resharp.rs
1//! RESHARP background field removal
2//!
3//! Regularized SHARP — uses Tikhonov regularization instead of TSVD
4//! truncation for more robust deconvolution of the SMV-filtered field.
5//!
6//! Solves: argmin ||M·ifft(C·fft(x)) - M·ifft(C·fft(y))||² + λ||x||²
7//!
8//! Where C = (1 - S) is the high-pass (delta minus SMV) kernel in k-space,
9//! M is the eroded mask, and λ is the Tikhonov regularization parameter.
10//!
11//! ## Reference pipeline
12//!
13//! The first author's code, `background_field_removal/resharp.m` in github sunhongfu/QSM
14//! (also shipped in QSMART's toolbox), defaults to a 3 mm kernel (`round(r/vox)` per axis, at
15//! least 2 voxels, so ellipsoidal on anisotropic voxels), λ = 1e-4, and MATLAB `cgs` at
16//! tolerance 1e-6 for at most 200 iterations. It zero-pads by the kernel radius and keeps
17//! voxels where the normalised SMV of the mask exceeds 0.999999. Its driver
18//! `Siemens_3T/qsm_r2s_prisma.m` masks with FSL `bet2 -f 0.4 -w 2` on the first-echo
19//! magnitude, unwraps each echo (bestpath by default), takes the total field from
20//! `echofit.m` (magnitude-weighted, zero-intercept), multiplies the mask by the echo-fit
21//! reliability map (`fit_thr` 40), and calls RESHARP with 3 mm, λ = 1e-3 and 500 iterations,
22//! followed by TV (`tvdi`) or iLSQR. [`ResharpParams::default`] follows `resharp.m`'s own
23//! defaults (3 mm, λ = 1e-4, at most 200 iterations), not this driver's λ and iterations.
24//!
25//! # Parity
26//! Against `resharp.m` as the verified QSM-CI `resharp` container runs it (identical to licensed
27//! MATLAB R2023b): the fixture `tests/resharp_sunhongfu_parity.rs` (anisotropic voxels; converged,
28//! another kernel and λ, and a 5-iteration cap) agrees to 1e-14 relative with identical eroded
29//! masks, and the QSM-CI `resharp` pair (QSM-CI's simulated head and the KKI travelling head)
30//! agrees at the container's float32 output (KKI 2.5e-6 % NRMSE, astewartau/QSM.rs#404). Until
31//! #404 the port solved by conjugate gradients, which stops unconverged at 200 iterations where
32//! `cgs` converges (3.06 % on KKI); it also used a ball of radius `r` mm instead of `resharp.m`'s
33//! `round(r/voxel)`-voxel ellipsoid, and did not pad.
34//!
35//! Reference:
36//! Sun, H. and Wilman, A.H. (2013).
37//! "Background field removal using spherical mean value filtering and Tikhonov regularization."
38//! Magn Reson Med, 71(3):1151-1157. https://doi.org/10.1002/mrm.24765
39//!
40//! # Pipeline rules
41//!
42//! The facts above that a configuration can depart from are published as rules in
43//! [`crate::pipeline::rules::RULES`] (each with its strength, basis, reason and source): `resharp.radius`, `resharp.iterations`.
44
45use num_complex::Complex64;
46use crate::Grid;
47use crate::fft::{fft3d, ifft3d, fft_real_kernel};
48use crate::kernels::smv::erode_mask_smv;
49use crate::solvers::cgs_solve_with_progress;
50
51/// RESHARP algorithm parameters.
52///
53/// The defaults are the first author's code: `background_field_removal/resharp.m` in
54/// github.com/sunhongfu/QSM (c75b138), whose own defaults are `ker_rad = 3`, `tik_reg = 1e-4`,
55/// `iter_num = 200` (l. 24-34) and `cgs(@Afun, b, 1e-6, iter_num)` (l. 92). Hongfu Sun's
56/// scanner drivers in the same repository override them inconsistently (3T Siemens drivers
57/// 3 mm / 1e-3 / 500, Philips 3T 3 mm / 1e-4 / 200, 1.5T and 4.7T drivers 4-5 mm / 5e-4, GE
58/// variants 2-4 mm), so the function's defaults are the reference here; the radius, 3 mm, is
59/// also what every 3T driver uses.
60///
61/// Measured against the previous defaults (6 mm, 30 iterations), ROMEO field then RTS, on
62/// the voxels both keep: on the qsm-forward phantom χ XSIM 0.283 → 0.303, NRMSE 80.4 → 79.7 %,
63/// r 0.597 → 0.606; on an in-vivo 3T Philips scan, agreement with the UK Biobank pipeline's
64/// χ (STI Suite V-SHARP + iLSQR) XSIM 0.647 → 0.688, NRMSE 63.2 → 62.2 %, r 0.833 → 0.838. The
65/// 3 mm kernel also keeps ~18 % more voxels at the brain edge, where agreement is lower (in
66/// vivo on its own mask, XSIM 0.653, NRMSE 71.1 %). The radius drives the in-vivo gain
67/// (3 mm / 30 iterations: 0.727) and the iterations the phantom gain (6 mm / 200: 0.302).
68#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
69#[derive(Clone, Debug)]
70pub struct ResharpParams {
71 /// SMV kernel radius in mm (default 3, `resharp.m`'s `ker_rad`); per axis it is
72 /// `round(radius/voxel)` voxels, at least 2 ([`resharp_kernel_radii`]), so the kernel is an
73 /// ellipsoid on anisotropic voxels. QSM.rs previously used 6 mm, which had no source.
74 pub radius: f64,
75 /// Tikhonov regularization parameter (default 1e-4, `resharp.m`'s `tik_reg`)
76 pub tik_reg: f64,
77 /// Relative residual tolerance of the `cgs` solve (default 1e-6, as `resharp.m` passes it)
78 pub tol: f64,
79 /// Maximum `cgs` iterations (default 200, `resharp.m`'s `iter_num`), each making two operator
80 /// calls as MATLAB's `cgs` does. QSM.rs previously defaulted to 30, which had no source and
81 /// stopped well short of the tolerance.
82 pub max_iter: usize,
83}
84
85impl Default for ResharpParams {
86 fn default() -> Self {
87 Self {
88 radius: 3.0,
89 tik_reg: 1e-4,
90 tol: 1e-6,
91 max_iter: 200,
92 }
93 }
94}
95
96/// The kernel radius in voxels per axis, as `resharp.m` (l. 37-42): `round(radius/voxel)`, at
97/// least 2.
98pub fn resharp_kernel_radii(grid: &Grid, radius: f64) -> [usize; 3] {
99 let (vx, vy, vz) = grid.voxel_size;
100 // MATLAB's round: half away from zero, as f64::round.
101 [vx, vy, vz].map(|v| ((radius / v).round() as usize).max(2))
102}
103
104/// `resharp.m`'s kernel (l. 43-45): the voxels of the `rx×ry×rz` ellipsoid
105/// (`X²/rx² + Y²/ry² + Z²/rz² ≤ 1`, in voxels), normalised to sum 1, centred at index (0,0,0)
106/// of an `n` grid for FFT use (Fortran order).
107fn resharp_kernel(n: (usize, usize, usize), r: [usize; 3]) -> Vec<f64> {
108 let (nx, ny, nz) = n;
109 let [rx, ry, rz] = r.map(|v| v as i64);
110 let mut s = vec![0.0; nx * ny * nz];
111 let mut count = 0.0;
112 let wrap = |d: i64, n: usize| if d < 0 { (d + n as i64) as usize } else { d as usize };
113 for z in -rz..=rz {
114 for y in -ry..=ry {
115 for x in -rx..=rx {
116 let e = (x * x) as f64 / (rx * rx) as f64 + (y * y) as f64 / (ry * ry) as f64
117 + (z * z) as f64 / (rz * rz) as f64;
118 if e <= 1.0 {
119 s[wrap(x, nx) + nx * (wrap(y, ny) + ny * wrap(z, nz))] = 1.0;
120 count += 1.0;
121 }
122 }
123 }
124 }
125 s.iter_mut().for_each(|v| *v /= count);
126 s
127}
128
129/// RESHARP background field removal, as `resharp.m` (sunhongfu/QSM c75b138).
130///
131/// Like `resharp.m`, it zero-pads the field and mask by the kernel radius (l. 52-53), erodes
132/// the mask to the voxels whose kernel-weighted mean of the mask exceeds 0.999999 (l. 57-60),
133/// solves `(H'H + λ)x = H'H y` (`H = M·ifft(DKER·fft(·))`, `DKER = δ − kernel`) with MATLAB's
134/// `cgs` at tolerance 1e-6 ([`crate::solvers::cgs_solve_with_progress`]: conjugate gradients
135/// squared with `cgs`'s stopping rules and returned iterate, l. 92), masks the result and crops
136/// the padding. `resharp.m` builds `DKER` from the kernel stored at index `(r+1)` and undoes the
137/// shift with `circshift`; this centres the kernel at index 0, the same operator.
138///
139/// Parity (QSMxT/QSM-CI `resharp` pair, run in astewartau/QSM.rs#404): the port previously ran
140/// conjugate gradients, which a numpy `resharp.m` showed to be exactly the 3.06 % (KKI) /
141/// 0.65 % (sim) gap: CG stops unconverged at 200 iterations where `cgs` converges in 199.
142///
143/// # Arguments
144/// * `field` - Unwrapped total field (nx * ny * nz)
145/// * `mask` - Binary mask (nx * ny * nz), 1 = inside ROI
146/// * `grid` - Volume dimensions and voxel sizes
147/// * `params` - RESHARP algorithm parameters
148/// * `progress` - Progress callback (current_iteration, max_iterations)
149///
150/// # Returns
151/// (local_field, eroded_mask)
152pub fn resharp(
153 field: &[f64],
154 mask: &[u8],
155 grid: &Grid,
156 params: &ResharpParams,
157 mut progress: impl FnMut(usize, usize),
158) -> (Vec<f64>, Vec<u8>) {
159 let (nx, ny, nz) = grid.dims;
160 let r = resharp_kernel_radii(grid, params.radius);
161 let (px, py, pz) = (nx + 2 * r[0], ny + 2 * r[1], nz + 2 * r[2]);
162 let n_pad = px * py * pz;
163 let pidx = |i: usize, j: usize, k: usize| (i + r[0]) + px * ((j + r[1]) + py * (k + r[2]));
164
165 let mut field_p = vec![0.0; n_pad];
166 let mut mask_p = vec![0u8; n_pad];
167 for k in 0..nz {
168 for j in 0..ny {
169 for i in 0..nx {
170 let s = i + nx * (j + ny * k);
171 field_p[pidx(i, j, k)] = field[s];
172 mask_p[pidx(i, j, k)] = mask[s];
173 }
174 }
175 }
176 let pgrid = Grid::new(px, py, pz, grid.voxel_size.0, grid.voxel_size.1, grid.voxel_size.2);
177
178 let s_kernel = resharp_kernel((px, py, pz), r);
179 let s_fft = fft_real_kernel(&s_kernel, px, py, pz);
180 // DKER = 1 - S (delta minus kernel) in k-space
181 let dker: Vec<f64> = s_fft.iter().map(|&s| 1.0 - s).collect();
182
183 // resharp.m: mask_ero(convn(mask, ker, 'same') > 0.999999) = 1
184 let eroded_p = erode_mask_smv(&mask_p, &s_fft, &pgrid, 0.999999);
185 let eroded_f64: Vec<f64> = eroded_p.iter().map(|&m| m as f64).collect();
186
187 // b = H'(H(field))
188 let b = apply_ht_h(&dker, &eroded_f64, &field_p, px, py, pz);
189
190 let tik_reg = params.tik_reg;
191 let x0 = vec![0.0; n_pad];
192 let (x, _report) = cgs_solve_with_progress(
193 |x_vec| {
194 let mut result = apply_ht_h(&dker, &eroded_f64, x_vec, px, py, pz);
195 for (ri, &xi) in result.iter_mut().zip(x_vec) {
196 *ri += tik_reg * xi;
197 }
198 result
199 },
200 &b,
201 &x0,
202 params.tol,
203 params.max_iter,
204 &mut progress,
205 );
206
207 let mut local_field = vec![0.0; nx * ny * nz];
208 let mut eroded_mask = vec![0u8; nx * ny * nz];
209 for k in 0..nz {
210 for j in 0..ny {
211 for i in 0..nx {
212 let p = pidx(i, j, k);
213 let s = i + nx * (j + ny * k);
214 if eroded_p[p] == 1 {
215 local_field[s] = x[p];
216 eroded_mask[s] = 1;
217 }
218 }
219 }
220 }
221 (local_field, eroded_mask)
222}
223
224/// Apply H'H to a vector: ifft(DKER * fft(M * ifft(DKER * fft(x))))
225///
226/// H(x) = M * ifft(DKER * fft(x))
227/// H'(y) = ifft(DKER * fft(M * y))
228fn apply_ht_h(
229 dker: &[f64],
230 mask: &[f64],
231 x: &[f64],
232 nx: usize, ny: usize, nz: usize,
233) -> Vec<f64> {
234 let n_total = nx * ny * nz;
235
236 // Step 1: H(x) = M * ifft(DKER * fft(x))
237 let mut tmp: Vec<Complex64> = x.iter()
238 .map(|&v| Complex64::new(v, 0.0))
239 .collect();
240 fft3d(&mut tmp, nx, ny, nz);
241
242 for i in 0..n_total {
243 tmp[i] *= dker[i];
244 }
245 ifft3d(&mut tmp, nx, ny, nz);
246
247 // Apply mask
248 for i in 0..n_total {
249 tmp[i] *= mask[i];
250 }
251
252 // Step 2: H'(Hx) = ifft(DKER * fft(M * Hx))
253 fft3d(&mut tmp, nx, ny, nz);
254
255 for i in 0..n_total {
256 tmp[i] *= dker[i];
257 }
258 ifft3d(&mut tmp, nx, ny, nz);
259
260 tmp.iter().map(|c| c.re).collect()
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn test_resharp_zero_field() {
269 let n = 16;
270 let field = vec![0.0; n * n * n];
271 let mask = vec![1u8; n * n * n];
272 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
273 let params = ResharpParams { radius: 2.0, tik_reg: 1e-4, tol: 1e-6, max_iter: 50 };
274
275 let (local, _) = resharp(&field, &mask, &grid, ¶ms, |_, _| {});
276
277 for &val in local.iter() {
278 assert!(val.abs() < 1e-8, "Zero field should give zero local field, got {}", val);
279 }
280 }
281
282 #[test]
283 fn test_resharp_finite() {
284 let n = 16;
285 let field: Vec<f64> = (0..n*n*n).map(|i| (i as f64) * 0.01).collect();
286 let mask = vec![1u8; n * n * n];
287 let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
288 let params = ResharpParams { radius: 2.0, tik_reg: 1e-4, tol: 1e-6, max_iter: 50 };
289
290 let (local, eroded) = resharp(&field, &mask, &grid, ¶ms, |_, _| {});
291
292 for (i, &val) in local.iter().enumerate() {
293 assert!(val.is_finite(), "Local field should be finite at index {}", i);
294 }
295
296 let eroded_count: usize = eroded.iter().map(|&m| m as usize).sum();
297 assert!(eroded_count > 0, "Eroded mask should have some voxels");
298 }
299}