Skip to main content

qsm_core/inversion/
tfi.rs

1//! TFI (Preconditioned Total Field Inversion)
2//!
3//! Single-step QSM: inverts the TOTAL field (before background removal)
4//! directly to susceptibility over the whole field-of-view, jointly handling
5//! background removal and dipole inversion.
6//!
7//! It reuses MEDI's nonlinear Gauss-Newton + CG + IRLS(L1) machinery, with
8//! three differences from MEDI (local-field inversion):
9//!
10//! 1. Input is the **total field** (not background-removed), solved over the
11//!    **whole FOV** (not just the brain mask).
12//! 2. A **preconditioner** change-of-variables χ = P⊙y is used, where P[i] = 1
13//!    inside the brain mask and P[i] = `precond` (default 30) outside. Solving
14//!    for y is better conditioned because background susceptibility (e.g. air
15//!    ≈ 9 ppm) is far larger than tissue. With χ = P⊙y the Gauss-Newton normal
16//!    equation operator on δy is `A_tfi(δy) = P ⊙ A_medi(P ⊙ δy)` and the RHS
17//!    is `b_tfi = P ⊙ b_medi(χ = P⊙y)`.
18//! 3. Regularization (L1 morphology) is applied over the whole FOV: the
19//!    magnitude edge mask is used inside the brain and set to 1 (regularize)
20//!    OUTSIDE the brain. The data-fidelity weight is SNR/brain-based (≈0 outside
21//!    the brain), so outside-brain χ is constrained by regularization + the
22//!    preconditioner.
23//!
24//! Reference:
25//! Liu, Z., Kee, Y., Zhou, D., Wang, Y., Spincemaille, P. (2017).
26//! "Preconditioned total field inversion (TFI) algorithm for quantitative
27//! susceptibility mapping." Magnetic Resonance in Medicine, 78(1):303-315.
28//! https://doi.org/10.1002/mrm.26946
29
30use num_complex::Complex32;
31use crate::kernels::dipole::dipole_kernel_f32;
32use crate::utils::simd_ops::{
33    dot_product_f32, norm_squared_f32, axpy_f32, xpby_f32,
34    compute_p_weights_f32, negate_f32,
35};
36use crate::Grid;
37
38use super::medi::{
39    MediWorkspace, MediOpBuffers,
40    apply_medi_operator_core, apply_dipole_conv, compute_rhs_inplace,
41    dataterm_mask_f32, gradient_mask_f32, fgrad_periodic_inplace_f32,
42};
43
44/// TFI algorithm parameters
45#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
46#[derive(Clone, Debug)]
47pub struct TfiParams {
48    /// Regularization weight
49    pub lambda: f64,
50    /// Preconditioner value outside the brain mask (1.0 inside)
51    pub precond: f64,
52    /// Enable MERIT (outlier adjustment) — currently a no-op placeholder
53    pub merit: bool,
54    /// Data weighting mode (1 = SNR)
55    pub data_weighting: i32,
56    /// Fraction of voxels considered edges (0.0-1.0)
57    pub percentage: f64,
58    /// CG convergence tolerance
59    pub cg_tol: f64,
60    /// Maximum CG iterations
61    pub cg_max_iter: usize,
62    /// Maximum outer (Gauss-Newton) iterations
63    pub max_iter: usize,
64    /// Outer convergence tolerance
65    pub tol: f64,
66}
67
68impl Default for TfiParams {
69    fn default() -> Self {
70        Self {
71            // TFI shares MEDI's L1 machinery and λ convention, so we use MEDI's default λ
72            // (7.5e-5) rather than a benchmark-fitted value. precond=30 is the standard TFI
73            // preconditioner (Liu 2017). Neither is tuned to any specific dataset.
74            lambda: 7.5e-5,
75            precond: 30.0,
76            merit: false,
77            data_weighting: 1,
78            percentage: 0.9,
79            cg_tol: 0.01,
80            cg_max_iter: 100,
81            max_iter: 10,
82            tol: 0.1,
83        }
84    }
85}
86
87/// Conjugate gradient solver for the preconditioned TFI operator.
88///
89/// Solves `A_tfi(y) = b` where `A_tfi(dy) = P ⊙ A_medi(P ⊙ dy)`.
90/// The MEDI operator itself is reused unchanged.
91#[allow(clippy::too_many_arguments)]
92fn cg_solve_tfi<F>(
93    ws: &mut MediWorkspace,
94    precond: &[f32],
95    w: &[Complex32],
96    d_kernel: &[f32],
97    mx: &[f32],
98    my: &[f32],
99    mz: &[f32],
100    vr: &[f32],
101    lambda: f32,
102    b: &[f32],
103    x: &mut [f32],
104    tol: f32,
105    max_iter: usize,
106    mut progress_callback: F,
107) where
108    F: FnMut(usize, usize),
109{
110    let n = ws.n_total;
111    let (nx, ny, nz) = (ws.nx, ws.ny, ws.nz);
112    let (vsx, vsy, vsz) = (ws.vsx, ws.vsy, ws.vsz);
113
114    x.fill(0.0);
115    ws.cg_r.copy_from_slice(b);
116    ws.cg_p.copy_from_slice(&ws.cg_r);
117
118    let mut rsold: f32 = norm_squared_f32(&ws.cg_r);
119    let b_norm: f32 = norm_squared_f32(b).sqrt();
120    if b_norm < 1e-10 {
121        return;
122    }
123
124    // Scratch buffers: p_scaled = P ⊙ p; ap holds A_medi(p_scaled) then P ⊙ ap
125    let mut p_scaled = vec![0.0f32; n];
126
127    for cg_iter in 0..max_iter {
128        progress_callback(cg_iter + 1, max_iter);
129
130        // p_scaled = P ⊙ p
131        for i in 0..n {
132            p_scaled[i] = precond[i] * ws.cg_p[i];
133        }
134
135        // ap = A_medi(p_scaled)
136        {
137            let mut bufs = MediOpBuffers {
138                gx: &mut ws.gx,
139                gy: &mut ws.gy,
140                gz: &mut ws.gz,
141                reg_x: &mut ws.reg_x,
142                reg_y: &mut ws.reg_y,
143                reg_z: &mut ws.reg_z,
144                div_buf: &mut ws.div_buf,
145                dipole_buf: &mut ws.dipole_buf,
146                complex_buf: &mut ws.complex_buf,
147                complex_buf2: &mut ws.complex_buf2,
148            };
149            apply_medi_operator_core(
150                &mut ws.fft_ws, &mut bufs, n, nx, ny, nz, vsx, vsy, vsz,
151                &p_scaled, w, d_kernel, mx, my, mz, vr, lambda, &mut ws.cg_ap,
152            );
153        }
154
155        // ap = P ⊙ ap
156        for i in 0..n {
157            ws.cg_ap[i] *= precond[i];
158        }
159
160        let pap: f32 = dot_product_f32(&ws.cg_p, &ws.cg_ap);
161        if pap.abs() < 1e-15 {
162            break;
163        }
164
165        let alpha = rsold / pap;
166        axpy_f32(x, alpha, &ws.cg_p);
167        axpy_f32(&mut ws.cg_r, -alpha, &ws.cg_ap);
168
169        let rsnew: f32 = norm_squared_f32(&ws.cg_r);
170        let residual = rsnew.sqrt();
171        if residual < tol * b_norm {
172            break;
173        }
174
175        let beta = rsnew / rsold;
176        xpby_f32(&mut ws.cg_p, &ws.cg_r, beta);
177        rsold = rsnew;
178    }
179}
180
181/// Preconditioned Total Field Inversion (TFI).
182///
183/// # Arguments
184/// * `total_field` - Total field (before background removal) in **ppm** — same units convention as
185///   NDI and the other inversions (NOT MEDI's radians). Do not pre-scale to radians: the total field
186///   is large and would wrap in the `exp(i·field)` data term.
187/// * `n_std` - Noise standard deviation map (same size as total_field)
188/// * `magnitude` - Magnitude image for gradient weighting
189/// * `mask` - Binary brain mask (1 = brain)
190/// * `grid` - Volume grid (dimensions and voxel sizes)
191/// * `bdir` - B0 field direction
192/// * `params` - TFI parameters
193/// * `progress` - Progress callback `(current_step, total_steps)`
194///
195/// # Returns
196/// Susceptibility map χ (same units as input field), zeroed outside the brain mask.
197/// The solve itself runs over the whole FOV (to absorb the background into out-of-brain
198/// susceptibility), but that region is unconstrained/artefacty, so only the brain is returned.
199#[allow(clippy::too_many_arguments)]
200pub fn tfi(
201    total_field: &[f64],
202    n_std: &[f64],
203    magnitude: &[f64],
204    mask: &[u8],
205    grid: &Grid,
206    bdir: (f64, f64, f64),
207    params: &TfiParams,
208    mut progress: impl FnMut(usize, usize),
209) -> Vec<f64> {
210    let (nx, ny, nz) = grid.dims;
211    let n_total = grid.n_total();
212
213    let vsx_f32 = grid.vsx() as f32;
214    let vsy_f32 = grid.vsy() as f32;
215    let vsz_f32 = grid.vsz() as f32;
216    let lambda_f32 = params.lambda as f32;
217    let bdir_f32 = (bdir.0 as f32, bdir.1 as f32, bdir.2 as f32);
218    let percentage_f32 = params.percentage as f32;
219    let cg_tol_f32 = params.cg_tol as f32;
220    let tol_f32 = params.tol as f32;
221    let max_iter = params.max_iter;
222    let cg_max_iter = params.cg_max_iter;
223    let data_weighting = params.data_weighting;
224
225    let field_f32: Vec<f32> = total_field.iter().map(|&v| v as f32).collect();
226    let n_std_f32: Vec<f32> = n_std.iter().map(|&v| v as f32).collect();
227    let magnitude_f32: Vec<f32> = magnitude.iter().map(|&v| v as f32).collect();
228
229    let mut ws = MediWorkspace::new(grid);
230
231    // Preconditioner: P[i] = 1 inside brain, precond outside.
232    let precond_out = params.precond as f32;
233    let precond: Vec<f32> = mask.iter()
234        .map(|&m| if m != 0 { 1.0 } else { precond_out })
235        .collect();
236
237    // Dipole kernel (no SMV — TFI operates on the total field over whole FOV).
238    let d_kernel = dipole_kernel_f32(grid, bdir_f32);
239
240    // Data weighting: SNR inside brain, ~0 outside (dataterm_mask behavior).
241    // n_std zeroed outside brain so m = 0 there.
242    let mut tempn: Vec<f32> = n_std_f32.clone();
243    for i in 0..n_total {
244        if mask[i] == 0 {
245            tempn[i] = 0.0;
246        }
247    }
248    let m = dataterm_mask_f32(data_weighting, &tempn, mask);
249
250    // b0 = m * exp(i * total_field)  (only nonzero inside the brain via m)
251    let b0: Vec<Complex32> = field_f32.iter()
252        .zip(m.iter())
253        .map(|(&f, &mi)| {
254            let phase = Complex32::new(0.0, f);
255            mi * phase.exp()
256        })
257        .collect();
258
259    // Gradient (morphology) mask: magnitude edges inside brain, then 1 OUTSIDE
260    // the brain so the whole FOV is regularized (smoothed).
261    let (mut w_gx, mut w_gy, mut w_gz) = gradient_mask_f32(
262        &magnitude_f32, mask, nx, ny, nz, vsx_f32, vsy_f32, vsz_f32, percentage_f32,
263    );
264    // Fallback: if a mask is all zeros, use the magnitude image (matching MEDI).
265    if !w_gx.iter().any(|&v| v != 0.0) { w_gx = magnitude_f32.clone(); }
266    if !w_gy.iter().any(|&v| v != 0.0) { w_gy = magnitude_f32.clone(); }
267    if !w_gz.iter().any(|&v| v != 0.0) { w_gz = magnitude_f32.clone(); }
268    // Set to 1 OUTSIDE the brain: regularize/smooth the whole FOV.
269    for i in 0..n_total {
270        if mask[i] == 0 {
271            w_gx[i] = 1.0;
272            w_gy[i] = 1.0;
273            w_gz[i] = 1.0;
274        }
275    }
276
277    // State in the preconditioned variable y, where χ = P ⊙ y.
278    let mut y = vec![0.0f32; n_total];
279    let mut chi = vec![0.0f32; n_total];
280    let mut dy = vec![0.0f32; n_total];
281    let mut rhs = vec![0.0f32; n_total];
282    let mut vr = vec![0.0f32; n_total];
283    let mut w: Vec<Complex32> = vec![Complex32::new(0.0, 0.0); n_total];
284    let mut y_prev = vec![0.0f32; n_total];
285
286    let beta = 1.49e-8_f32;
287    let total_steps = max_iter * cg_max_iter;
288
289    for iter in 0..max_iter {
290        y_prev.copy_from_slice(&y);
291
292        // χ = P ⊙ y
293        for i in 0..n_total {
294            chi[i] = precond[i] * y[i];
295        }
296
297        // P weights (IRLS): P_irls = 1 / sqrt(|m_grad · grad(χ)|^2 + beta)
298        fgrad_periodic_inplace_f32(
299            &mut ws.gx, &mut ws.gy, &mut ws.gz,
300            &chi, nx, ny, nz, vsx_f32, vsy_f32, vsz_f32,
301        );
302        compute_p_weights_f32(&mut vr, &w_gx, &w_gy, &w_gz, &ws.gx, &ws.gy, &ws.gz, beta);
303
304        // w = m * exp(i * D*χ)
305        apply_dipole_conv(&mut ws.fft_ws, &chi, &d_kernel, &mut ws.dipole_buf, &mut ws.complex_buf);
306        for i in 0..n_total {
307            let phase = Complex32::new(0.0, ws.dipole_buf[i]);
308            w[i] = m[i] * phase.exp();
309        }
310
311        // b_medi(χ = P⊙y)
312        compute_rhs_inplace(&chi, &w, &b0, &d_kernel, &w_gx, &w_gy, &w_gz, &vr, lambda_f32, &mut rhs, &mut ws);
313
314        // b_tfi = P ⊙ b_medi
315        for i in 0..n_total {
316            rhs[i] *= precond[i];
317        }
318
319        // Solve A_tfi δy = -b_tfi
320        negate_f32(&mut rhs);
321
322        let gn_iter = iter;
323        cg_solve_tfi(
324            &mut ws, &precond, &w, &d_kernel, &w_gx, &w_gy, &w_gz, &vr, lambda_f32,
325            &rhs, &mut dy, cg_tol_f32, cg_max_iter,
326            |cg_iter, cg_total| {
327                let current = gn_iter * cg_total + cg_iter;
328                progress(current, total_steps);
329            },
330        );
331
332        // y = y + dy
333        axpy_f32(&mut y, 1.0, &dy);
334
335        // Convergence check in y
336        let norm_dy = norm_squared_f32(&dy).sqrt();
337        let norm_y = norm_squared_f32(&y_prev).sqrt();
338        let rel_change = norm_dy / (norm_y + 1e-6);
339        if rel_change < tol_f32 {
340            progress(total_steps, total_steps);
341            break;
342        }
343    }
344
345    // Final χ = P ⊙ y, zeroed outside the brain mask. The whole-FOV solve is only needed to
346    // absorb the background field into out-of-brain susceptibility; that region is unconstrained
347    // (artefacty), so the returned map keeps only the brain — matching MEDI/NDI/etc.
348    let _ = params.merit;
349    y.iter()
350        .zip(precond.iter())
351        .zip(mask.iter())
352        .map(|((&yi, &pi), &m)| if m == 0 { 0.0 } else { (pi * yi) as f64 })
353        .collect()
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn test_tfi_params() -> TfiParams {
361        TfiParams {
362            lambda: 1e-3, percentage: 0.9, cg_tol: 0.1,
363            cg_max_iter: 10, max_iter: 3, tol: 0.1,
364            ..TfiParams::default()
365        }
366    }
367
368    #[test]
369    fn test_tfi_zero_field() {
370        let n = 8;
371        let field = vec![0.0; n * n * n];
372        let mask = vec![1u8; n * n * n];
373        let mag = vec![1.0; n * n * n];
374        let n_std = vec![1.0; n * n * n];
375        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
376
377        let chi = tfi(
378            &field, &n_std, &mag, &mask, &grid,
379            (0.0, 0.0, 1.0), &test_tfi_params(), |_, _| {},
380        );
381
382        for &val in chi.iter() {
383            assert!(val.abs() < 1e-4, "Zero field should give near-zero chi, got {}", val);
384        }
385    }
386
387    #[test]
388    fn test_tfi_finite() {
389        let n = 8;
390        let field: Vec<f64> = (0..n*n*n).map(|i| (i as f64) * 0.001).collect();
391        let mask = vec![1u8; n * n * n];
392        let mag = vec![1.0; n * n * n];
393        let n_std = vec![1.0; n * n * n];
394        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
395
396        let chi = tfi(
397            &field, &n_std, &mag, &mask, &grid,
398            (0.0, 0.0, 1.0), &test_tfi_params(), |_, _| {},
399        );
400
401        for (i, &val) in chi.iter().enumerate() {
402            assert!(val.is_finite(), "Chi should be finite at index {}", i);
403        }
404    }
405
406    #[test]
407    fn test_tfi_finite_with_background() {
408        // Partial mask: exercise the preconditioner + whole-FOV regularization.
409        let n = 8;
410        let field: Vec<f64> = (0..n*n*n).map(|i| (i as f64) * 0.001).collect();
411        let mut mask = vec![0u8; n * n * n];
412        // Central 4x4x4 region is "brain".
413        for z in 2..6 { for y in 2..6 { for x in 2..6 {
414            mask[x + y*n + z*n*n] = 1;
415        }}}
416        let mag = vec![1.0; n * n * n];
417        let n_std = vec![1.0; n * n * n];
418        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
419
420        let chi = tfi(
421            &field, &n_std, &mag, &mask, &grid,
422            (0.0, 0.0, 1.0), &test_tfi_params(), |_, _| {},
423        );
424
425        assert_eq!(chi.len(), n * n * n);
426        for (i, &val) in chi.iter().enumerate() {
427            assert!(val.is_finite(), "Chi should be finite at index {} (whole FOV)", i);
428        }
429    }
430
431    /// Numerical validation on the real dev phantom.
432    /// Run with: cargo test --release test_tfi_phantom -- --ignored --nocapture
433    #[test]
434    #[ignore]
435    fn test_tfi_phantom() {
436
437        let base = "/home/ashley/repos/qsm/qsmci/qsmci/data/sim/dev";
438        let field_path = format!("{}/groundtruth/totalfield.nii.gz", base);
439        let mask_path = format!("{}/inputs/mask.nii.gz", base);
440        let mag_path = format!("{}/inputs/magnitude.nii.gz", base);
441        let chi_path = format!("{}/groundtruth/chimap.nii.gz", base);
442
443        if !std::path::Path::new(&field_path).exists() {
444            eprintln!("Skipping: {} not found", field_path);
445            return;
446        }
447
448        // Load total field (ppm)
449        let field_nii = crate::io::load_nifti(&std::fs::read(&field_path).unwrap()).unwrap();
450        let (nx, ny, nz) = field_nii.dims;
451        let (vsx, vsy, vsz) = field_nii.voxel_size;
452        let n_total = nx * ny * nz;
453        eprintln!("Dims: {}x{}x{}  voxel: {}x{}x{}", nx, ny, nz, vsx, vsy, vsz);
454
455        let field_ppm = field_nii.data;
456
457        // Mask
458        let mask_nii = crate::io::load_nifti(&std::fs::read(&mask_path).unwrap()).unwrap();
459        let mask: Vec<u8> = mask_nii.data.iter().map(|&v| if v > 0.5 { 1 } else { 0 }).collect();
460        let mask_count = mask.iter().filter(|&&m| m != 0).count();
461        eprintln!("Mask voxels: {} / {}", mask_count, n_total);
462
463        // Magnitude (4D) — combine echoes as sqrt(sum of squares)
464        let (mag4d, mag_dims, _, _) = crate::io::load_nifti_4d(&std::fs::read(&mag_path).unwrap()).unwrap();
465        let (mnx, mny, mnz, nt) = mag_dims;
466        assert_eq!(mnx * mny * mnz, n_total, "magnitude spatial dims mismatch");
467        let mut magnitude = vec![0.0f64; n_total];
468        for i in 0..n_total {
469            let mut ss = 0.0;
470            for t in 0..nt {
471                let v = mag4d[i + t * n_total];
472                ss += v * v;
473            }
474            magnitude[i] = ss.sqrt();
475        }
476
477        // Ground truth chi (ppm)
478        let chi_gt_nii = crate::io::load_nifti(&std::fs::read(&chi_path).unwrap()).unwrap();
479        let chi_gt_ppm = chi_gt_nii.data;
480
481        // params.json
482        let bdir = (0.0, 0.0, 1.0);
483
484        // TFI takes the total field in ppm (no rad conversion — see fn docs).
485        let n_std = vec![1.0f64; n_total];
486        let grid = Grid::new(nx, ny, nz, vsx, vsy, vsz);
487
488        // On this dev phantom the background outside the brain is tiny (±0.01 ppm), so a small
489        // preconditioner (2-3) is best here; the default of 30 targets in-vivo air (~9 ppm).
490        let lambda = std::env::var("TFI_LAMBDA").ok()
491            .and_then(|s| s.parse::<f64>().ok()).unwrap_or(1e-4);
492        let precond = std::env::var("TFI_PRECOND").ok()
493            .and_then(|s| s.parse::<f64>().ok()).unwrap_or(3.0);
494        let params = TfiParams {
495            lambda,
496            precond,
497            percentage: 0.9,
498            cg_tol: 0.01,
499            cg_max_iter: 100,
500            max_iter: 20,
501            tol: 0.005,
502            ..TfiParams::default()
503        };
504        eprintln!("params: lambda={} precond={}", lambda, precond);
505
506        eprintln!("Running TFI...");
507        let chi_ppm = tfi(
508            &field_ppm, &n_std, &magnitude, &mask, &grid, bdir, &params,
509            |c, t| { if c % 200 == 0 || c == t { eprintln!("  progress {}/{}", c, t); } },
510        );
511
512        // Metrics within the mask
513        let idx: Vec<usize> = (0..n_total).filter(|&i| mask[i] != 0).collect();
514        let a: Vec<f64> = idx.iter().map(|&i| chi_ppm[i]).collect();
515        let b: Vec<f64> = idx.iter().map(|&i| chi_gt_ppm[i]).collect();
516        let nm = a.len() as f64;
517
518        let mean_a = a.iter().sum::<f64>() / nm;
519        let mean_b = b.iter().sum::<f64>() / nm;
520
521        // Pearson correlation
522        let mut cov = 0.0; let mut va = 0.0; let mut vb = 0.0;
523        for k in 0..a.len() {
524            let da = a[k] - mean_a; let db = b[k] - mean_b;
525            cov += da * db; va += da * da; vb += db * db;
526        }
527        let corr = cov / (va.sqrt() * vb.sqrt());
528
529        // NRMSE = 100 * ||a - b|| / ||b||
530        let mut num = 0.0; let mut den = 0.0;
531        for k in 0..a.len() {
532            num += (a[k] - b[k]).powi(2);
533            den += b[k].powi(2);
534        }
535        let nrmse = 100.0 * (num / den).sqrt();
536
537        // Detrended NRMSE (subtract per-map mean)
538        let mut num_d = 0.0; let mut den_d = 0.0;
539        for k in 0..a.len() {
540            num_d += ((a[k] - mean_a) - (b[k] - mean_b)).powi(2);
541            den_d += (b[k] - mean_b).powi(2);
542        }
543        let nrmse_d = 100.0 * (num_d / den_d).sqrt();
544
545        eprintln!("=== TFI dev phantom results (within mask) ===");
546        eprintln!("  Pearson correlation : {:.5}", corr);
547        eprintln!("  NRMSE               : {:.3}", nrmse);
548        eprintln!("  detrended NRMSE     : {:.3}", nrmse_d);
549        eprintln!("  chi_out mean={:.4} min={:.4} max={:.4}",
550            mean_a,
551            a.iter().cloned().fold(f64::MAX, f64::min),
552            a.iter().cloned().fold(f64::MIN, f64::max));
553        eprintln!("  chi_gt  mean={:.4} min={:.4} max={:.4}",
554            mean_b,
555            b.iter().cloned().fold(f64::MAX, f64::min),
556            b.iter().cloned().fold(f64::MIN, f64::max));
557
558        assert!(corr > 0.95, "TFI correlation {:.5} below acceptance 0.95", corr);
559    }
560
561    /// Background-removal stress test on the REAL CI phantom (~/bids), which has a
562    /// genuine in-brain background field (unlike the qsm-ci dev phantom). Sweeps
563    /// `precond`/`lambda` on the TOTAL field and reports corr vs GT χ, plus a
564    /// local-field run as the ceiling. Fields are fed raw (ppm), matching how the
565    /// CI harness scores MEDI/NDI/etc.
566    /// Run: cargo test --release --lib test_tfi_background -- --ignored --nocapture
567    #[test]
568    #[ignore]
569    fn test_tfi_background() {
570        let base = "/home/ashley/bids/derivatives/qsm-forward/sub-1/anat";
571        let total_path = format!("{}/sub-1_fieldmap.nii", base);
572        if !std::path::Path::new(&total_path).exists() {
573            eprintln!("Skipping: {} not found", total_path);
574            return;
575        }
576        let load = |p: &str| crate::io::load_nifti(&std::fs::read(p).unwrap()).unwrap();
577        let total_nii = load(&total_path);
578        let (nx, ny, nz) = total_nii.dims;
579        let (vsx, vsy, vsz) = total_nii.voxel_size;
580        let n = nx * ny * nz;
581        let total_field = total_nii.data;                                   // ppm
582        let local_field = load(&format!("{}/sub-1_fieldmap-local.nii", base)).data;
583        let chi_gt = load(&format!("{}/sub-1_Chimap.nii", base)).data;
584        let mask: Vec<u8> = load(&format!("{}/sub-1_mask.nii", base)).data
585            .iter().map(|&v| if v > 0.5 { 1 } else { 0 }).collect();
586
587        let grid = Grid::new(nx, ny, nz, vsx, vsy, vsz);
588        let bdir = (0.0, 0.0, 1.0);
589        let n_std = vec![1.0f64; n];
590        let magnitude = vec![1.0f64; n];
591
592        let std_in = |v: &[f64]| {
593            let m: Vec<f64> = (0..n).filter(|&i| mask[i] != 0).map(|i| v[i]).collect();
594            let mean = m.iter().sum::<f64>() / m.len() as f64;
595            (m.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / m.len() as f64).sqrt()
596        };
597        eprintln!("dims {}x{}x{}  in-brain field std: total={:.5} local={:.5}",
598            nx, ny, nz, std_in(&total_field), std_in(&local_field));
599
600        let corr_in = |a: &[f64], b: &[f64]| {
601            let idx: Vec<usize> = (0..n).filter(|&i| mask[i] != 0).collect();
602            let (mut sa, mut sb) = (0.0, 0.0);
603            for &i in &idx { sa += a[i]; sb += b[i]; }
604            let (ma, mb) = (sa / idx.len() as f64, sb / idx.len() as f64);
605            let (mut num, mut da, mut db) = (0.0, 0.0, 0.0);
606            for &i in &idx {
607                num += (a[i] - ma) * (b[i] - mb);
608                da += (a[i] - ma).powi(2); db += (b[i] - mb).powi(2);
609            }
610            num / (da.sqrt() * db.sqrt())
611        };
612
613        // Ceiling: TFI on the LOCAL field (no background to remove, precond irrelevant).
614        let p_local = TfiParams { lambda: 1e-4, precond: 1.0, ..TfiParams::default() };
615        let chi_local = tfi(&local_field, &n_std, &magnitude, &mask, &grid, bdir, &p_local, |_, _| {});
616        eprintln!("TFI on LOCAL field (ceiling): corr={:.4}", corr_in(&chi_local, &chi_gt));
617
618        eprintln!("=== TFI on TOTAL field — precond × lambda sweep (corr vs GT χ, within mask) ===");
619        for &lambda in &[5e-5f64, 7.5e-5, 1e-4] {
620            for &precond in &[30.0f64] {
621                let params = TfiParams { lambda, precond, ..TfiParams::default() };
622                let chi = tfi(&total_field, &n_std, &magnitude, &mask, &grid, bdir, &params, |_, _| {});
623                eprintln!("  lambda={:>6.0e} precond={:>5}  corr={:.4}", lambda, precond, corr_in(&chi, &chi_gt));
624            }
625        }
626    }
627}