Skip to main content

qsm_core/bgremove/
lbv.rs

1//! Laplacian Boundary Value (LBV) background field removal
2//!
3//! LBV removes background fields by solving the Laplace equation inside the mask
4//! with Dirichlet boundary conditions from the total field at the mask boundary.
5//!
6//! The method exploits that background fields satisfy nabla^2 b = 0 inside the ROI.
7//!
8//! Reference:
9//! Zhou, D., Liu, T., Spincemaille, P., Wang, Y. (2014).
10//! "Background field removal by solving the Laplacian boundary value problem."
11//! NMR in Biomedicine, 27(3):312-319. https://doi.org/10.1002/nbm.3064
12//!
13//! Reference implementation: https://github.com/kamesy/QSM.jl
14
15use crate::Grid;
16
17/// LBV algorithm parameters
18#[cfg_attr(feature = "introspection", derive(serde::Serialize))]
19#[derive(Clone, Debug)]
20pub struct LbvParams {
21    /// Convergence tolerance
22    pub tol: f64,
23    /// Maximum iterations. `None` uses an automatic default of
24    /// `min(3 * max_dim, 500)`.
25    pub max_iter: Option<usize>,
26}
27
28impl Default for LbvParams {
29    fn default() -> Self {
30        Self { tol: 1e-6, max_iter: None }
31    }
32}
33
34/// LBV background field removal
35///
36/// Solves nabla^2 b = 0 inside mask with b = f on boundary to find background field,
37/// then computes local field as l = f - b.
38///
39/// # Arguments
40/// * `field` - Total field (nx * ny * nz)
41/// * `mask` - Binary mask (nx * ny * nz), 1 = brain, 0 = background
42/// * `grid` - Volume dimensions and voxel sizes
43/// * `params` - LBV parameters (tolerance, optional max iterations)
44/// * `progress` - Progress callback (iteration, max_iter)
45///
46/// # Returns
47/// Tuple of (local_field, eroded_mask)
48pub fn lbv(
49    field: &[f64],
50    mask: &[u8],
51    grid: &Grid,
52    params: &LbvParams,
53    progress: impl FnMut(usize, usize),
54) -> (Vec<f64>, Vec<u8>) {
55    // Default max iterations to min(3 * max_dim, 500) when unspecified.
56    let max_iter = params.max_iter.unwrap_or_else(|| {
57        let (nx, ny, nz) = grid.dims;
58        (3 * nx.max(ny).max(nz)).min(500)
59    });
60    lbv_core(field, mask, grid, params.tol, max_iter, progress)
61}
62
63/// LBV with an explicit iteration count.
64///
65/// Internal entry point; the public [`lbv`] wrapper supplies the default
66/// iteration count from [`LbvParams`].
67pub(crate) fn lbv_core(
68    field: &[f64],
69    mask: &[u8],
70    grid: &Grid,
71    tol: f64,
72    max_iter: usize,
73    mut progress: impl FnMut(usize, usize),
74) -> (Vec<f64>, Vec<u8>) {
75    let (nx, ny, nz) = grid.dims;
76    let (vsx, vsy, vsz) = grid.voxel_size;
77    let n_total = nx * ny * nz;
78
79    // Compute inverse squared voxel sizes for Laplacian
80    let dx2_inv = 1.0 / (vsx * vsx);
81    let dy2_inv = 1.0 / (vsy * vsy);
82    let dz2_inv = 1.0 / (vsz * vsz);
83    let diag = -2.0 * (dx2_inv + dy2_inv + dz2_inv);
84
85    // Find interior and boundary voxels
86    // Interior: mask=1 and all 6 neighbors have mask=1
87    // Boundary: mask=1 and at least one neighbor has mask=0
88    let mut interior = vec![false; n_total];
89    let mut boundary = vec![false; n_total];
90
91    for z in 1..(nz - 1) {
92        for y in 1..(ny - 1) {
93            for x in 1..(nx - 1) {
94                let idx = x + y * nx + z * nx * ny;
95                if mask[idx] == 0 {
96                    continue;
97                }
98
99                // Check 6-connected neighbors
100                let neighbors = [
101                    idx.wrapping_sub(1),      // x-1
102                    idx + 1,                  // x+1
103                    idx.wrapping_sub(nx),     // y-1
104                    idx + nx,                 // y+1
105                    idx.wrapping_sub(nx * ny), // z-1
106                    idx + nx * ny,            // z+1
107                ];
108
109                let all_inside = neighbors.iter().all(|&n| n < n_total && mask[n] != 0);
110
111                if all_inside {
112                    interior[idx] = true;
113                } else {
114                    boundary[idx] = true;
115                }
116            }
117        }
118    }
119
120    // Edge voxels are boundary by definition
121    for z in 0..nz {
122        for y in 0..ny {
123            for x in 0..nx {
124                if z == 0 || z == nz - 1 || y == 0 || y == ny - 1 || x == 0 || x == nx - 1 {
125                    let idx = x + y * nx + z * nx * ny;
126                    if mask[idx] != 0 {
127                        boundary[idx] = true;
128                        interior[idx] = false;
129                    }
130                }
131            }
132        }
133    }
134
135    // Initialize background field with total field
136    // Background field = total field on boundary, solve for interior
137    let mut bg_field = field.to_vec();
138
139    // Compute field scale for relative convergence criterion
140    // This makes convergence independent of field units (Hz vs ppm)
141    // Matches QSM.jl's rtol behavior
142    let field_scale = field.iter()
143        .map(|&v| v.abs())
144        .fold(0.0f64, f64::max)
145        .max(1.0); // floor at 1.0 to avoid division issues with zero fields
146    let scaled_tol = tol * field_scale;
147
148    // Solve nabla^2 b = 0 on interior voxels using Gauss-Seidel with over-relaxation
149    // The boundary values are fixed (Dirichlet BC)
150    let omega = 1.5; // Over-relaxation parameter
151
152    for iter in 0..max_iter {
153        if iter % 10 == 0 {
154            progress(iter, max_iter);
155        }
156
157        let mut max_change = 0.0f64;
158
159        for z in 1..(nz - 1) {
160            for y in 1..(ny - 1) {
161                for x in 1..(nx - 1) {
162                    let idx = x + y * nx + z * nx * ny;
163
164                    if !interior[idx] {
165                        continue;
166                    }
167
168                    // Compute Laplacian stencil weighted sum
169                    let sum = dx2_inv * (bg_field[idx - 1] + bg_field[idx + 1])
170                            + dy2_inv * (bg_field[idx - nx] + bg_field[idx + nx])
171                            + dz2_inv * (bg_field[idx - nx * ny] + bg_field[idx + nx * ny]);
172
173                    // Gauss-Seidel update: solve diag * b_new = -sum
174                    let new_val = -sum / diag;
175
176                    // SOR update
177                    let old_val = bg_field[idx];
178                    let updated = old_val + omega * (new_val - old_val);
179
180                    max_change = max_change.max((updated - old_val).abs());
181                    bg_field[idx] = updated;
182                }
183            }
184        }
185
186        // Check convergence using relative tolerance (scale-independent)
187        if max_change < scaled_tol {
188            progress(iter + 1, iter + 1);
189            break;
190        }
191    }
192
193    progress(max_iter, max_iter);
194
195    // Compute local field = total field - background field
196    let mut local_field = vec![0.0; n_total];
197    let mut eroded_mask = vec![0u8; n_total];
198
199    for i in 0..n_total {
200        if interior[i] {
201            local_field[i] = field[i] - bg_field[i];
202            eroded_mask[i] = 1;
203        }
204    }
205
206    (local_field, eroded_mask)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_lbv_zero_field() {
215        let n = 8;
216        let field = vec![0.0; n * n * n];
217        let mask = vec![1u8; n * n * n];
218        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
219
220        let (local, _) = lbv_core(&field, &mask, &grid, 1e-6, 100, |_, _| {});
221
222        for &val in local.iter() {
223            assert!(val.abs() < 1e-10, "Zero field should give zero local field");
224        }
225    }
226
227    #[test]
228    fn test_lbv_finite() {
229        let n = 16;
230        let field: Vec<f64> = (0..n*n*n).map(|i| (i as f64) * 0.001).collect();
231
232        // Create spherical mask
233        let mut mask = vec![0u8; n * n * n];
234        let center = n / 2;
235        let radius = n / 3;
236
237        for z in 0..n {
238            for y in 0..n {
239                for x in 0..n {
240                    let dx = (x as i32) - (center as i32);
241                    let dy = (y as i32) - (center as i32);
242                    let dz = (z as i32) - (center as i32);
243                    if dx*dx + dy*dy + dz*dz <= (radius * radius) as i32 {
244                        mask[x + y * n + z * n * n] = 1;
245                    }
246                }
247            }
248        }
249
250        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
251        let (local, eroded_mask) = lbv_core(&field, &mask, &grid, 1e-5, 100, |_, _| {});
252
253        for (i, &val) in local.iter().enumerate() {
254            assert!(val.is_finite(), "Local field should be finite at index {}", i);
255        }
256
257        // Eroded mask should be smaller than original
258        let eroded_count: usize = eroded_mask.iter().map(|&x| x as usize).sum();
259        let mask_count: usize = mask.iter().map(|&x| x as usize).sum();
260        assert!(eroded_count <= mask_count, "Eroded mask should be <= original mask");
261        assert!(eroded_count > 0, "Eroded mask should not be empty for reasonable-sized input");
262    }
263
264    #[test]
265    fn test_lbv_harmonic_removal() {
266        // Create a harmonic background field (satisfies nabla^2 b = 0)
267        // and verify LBV removes it
268        let n = 16;
269        let mut field = vec![0.0; n * n * n];
270
271        // Add linear field (which is harmonic)
272        for z in 0..n {
273            for y in 0..n {
274                for x in 0..n {
275                    let idx = x + y * n + z * n * n;
276                    field[idx] = (z as f64) * 0.1; // Linear in z
277                }
278            }
279        }
280
281        // Create spherical mask
282        let mut mask = vec![0u8; n * n * n];
283        let center = n / 2;
284        let radius = n / 3;
285
286        for z in 0..n {
287            for y in 0..n {
288                for x in 0..n {
289                    let dx = (x as i32) - (center as i32);
290                    let dy = (y as i32) - (center as i32);
291                    let dz = (z as i32) - (center as i32);
292                    if dx*dx + dy*dy + dz*dz <= (radius * radius) as i32 {
293                        mask[x + y * n + z * n * n] = 1;
294                    }
295                }
296            }
297        }
298
299        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
300        let (local, eroded_mask) = lbv_core(&field, &mask, &grid, 1e-6, 500, |_, _| {});
301
302        // Local field should be close to zero for interior voxels
303        // since the input is purely harmonic
304        let mut max_local: f64 = 0.0;
305        for i in 0..n*n*n {
306            if eroded_mask[i] != 0 {
307                max_local = max_local.max(local[i].abs());
308            }
309        }
310
311        // Allow some tolerance due to discrete Laplacian
312        assert!(max_local < 0.5, "Harmonic field should be mostly removed, got max {}", max_local);
313    }
314
315    #[test]
316    fn test_lbv_nonuniform_voxels() {
317        let n = 16;
318
319        // Linear field
320        let mut field = vec![0.0; n * n * n];
321        for z in 0..n {
322            for y in 0..n {
323                for x in 0..n {
324                    let idx = x + y * n + z * n * n;
325                    field[idx] = (z as f64) * 0.1;
326                }
327            }
328        }
329
330        // Spherical mask
331        let mut mask = vec![0u8; n * n * n];
332        let center = n / 2;
333        let radius = n / 3;
334        for z in 0..n {
335            for y in 0..n {
336                for x in 0..n {
337                    let dx = (x as i32) - (center as i32);
338                    let dy = (y as i32) - (center as i32);
339                    let dz = (z as i32) - (center as i32);
340                    if dx*dx + dy*dy + dz*dz <= (radius * radius) as i32 {
341                        mask[x + y * n + z * n * n] = 1;
342                    }
343                }
344            }
345        }
346
347        // Use anisotropic voxel sizes
348        let grid = Grid::new(n, n, n, 0.5, 1.0, 2.0);
349        let (local, eroded_mask) = lbv_core(
350            &field, &mask, &grid, 1e-5, 200, |_, _| {}
351        );
352
353        // All values should be finite
354        for (i, &val) in local.iter().enumerate() {
355            assert!(val.is_finite(), "LBV nonuniform voxels: finite at index {}", i);
356        }
357
358        // Eroded mask should have some voxels
359        let eroded_count: usize = eroded_mask.iter().map(|&x| x as usize).sum();
360        assert!(eroded_count > 0, "LBV nonuniform: eroded mask should not be empty");
361    }
362
363    #[test]
364    fn test_lbv_tolerance() {
365        let n = 16;
366
367        // Linear field (harmonic)
368        let mut field = vec![0.0; n * n * n];
369        for z in 0..n {
370            for y in 0..n {
371                for x in 0..n {
372                    let idx = x + y * n + z * n * n;
373                    field[idx] = (z as f64) * 0.1;
374                }
375            }
376        }
377
378        // Spherical mask
379        let mut mask = vec![0u8; n * n * n];
380        let center = n / 2;
381        let radius = n / 3;
382        for z in 0..n {
383            for y in 0..n {
384                for x in 0..n {
385                    let dx = (x as i32) - (center as i32);
386                    let dy = (y as i32) - (center as i32);
387                    let dz = (z as i32) - (center as i32);
388                    if dx*dx + dy*dy + dz*dz <= (radius * radius) as i32 {
389                        mask[x + y * n + z * n * n] = 1;
390                    }
391                }
392            }
393        }
394
395        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
396
397        // Tight tolerance should produce better harmonic removal
398        let (local_tight, _) = lbv_core(&field, &mask, &grid, 1e-8, 1000, |_, _| {});
399
400        // Loose tolerance
401        let (local_loose, _) = lbv_core(&field, &mask, &grid, 1e-2, 50, |_, _| {});
402
403        // Compute max residual for each
404        let max_tight: f64 = local_tight.iter()
405            .zip(mask.iter())
406            .filter(|(_, &m)| m != 0)
407            .map(|(&v, _)| v.abs())
408            .fold(0.0f64, f64::max);
409
410        let max_loose: f64 = local_loose.iter()
411            .zip(mask.iter())
412            .filter(|(_, &m)| m != 0)
413            .map(|(&v, _)| v.abs())
414            .fold(0.0f64, f64::max);
415
416        // Tight tolerance should give at least as good results as loose
417        assert!(
418            max_tight <= max_loose + 1e-6,
419            "Tight tolerance max={} should be <= loose tolerance max={}",
420            max_tight, max_loose
421        );
422    }
423
424    #[test]
425    fn test_lbv_with_progress() {
426        let n = 16;
427        let mut field = vec![0.0; n * n * n];
428        for z in 0..n {
429            for y in 0..n {
430                for x in 0..n {
431                    field[x + y * n + z * n * n] = (z as f64) * 0.1;
432                }
433            }
434        }
435
436        let mut mask = vec![0u8; n * n * n];
437        let center = n / 2;
438        let radius = n / 3;
439        for z in 0..n {
440            for y in 0..n {
441                for x in 0..n {
442                    let dx = (x as i32) - (center as i32);
443                    let dy = (y as i32) - (center as i32);
444                    let dz = (z as i32) - (center as i32);
445                    if dx * dx + dy * dy + dz * dz <= (radius * radius) as i32 {
446                        mask[x + y * n + z * n * n] = 1;
447                    }
448                }
449            }
450        }
451
452        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
453        let mut progress_calls = Vec::new();
454        let (local, eroded) = lbv_core(
455            &field, &mask, &grid, 1e-5, 200,
456            |iter, max| { progress_calls.push((iter, max)); }
457        );
458
459        assert_eq!(local.len(), n * n * n);
460        assert!(!progress_calls.is_empty(), "Progress callback should be called");
461        for &val in &local {
462            assert!(val.is_finite());
463        }
464        let eroded_count: usize = eroded.iter().map(|&x| x as usize).sum();
465        assert!(eroded_count > 0);
466    }
467
468    #[test]
469    fn test_lbv_non_harmonic_field() {
470        // A non-harmonic field should not be fully removed
471        let n = 16;
472        let mut field = vec![0.0; n * n * n];
473        // Quadratic field: x^2 - not harmonic in 3D (Laplacian = 2)
474        for z in 0..n {
475            for y in 0..n {
476                for x in 0..n {
477                    let xf = (x as f64) / (n as f64);
478                    field[x + y * n + z * n * n] = xf * xf;
479                }
480            }
481        }
482
483        let mut mask = vec![0u8; n * n * n];
484        let center = n / 2;
485        let radius = n / 3;
486        for z in 0..n {
487            for y in 0..n {
488                for x in 0..n {
489                    let dx = (x as i32) - (center as i32);
490                    let dy = (y as i32) - (center as i32);
491                    let dz = (z as i32) - (center as i32);
492                    if dx * dx + dy * dy + dz * dz <= (radius * radius) as i32 {
493                        mask[x + y * n + z * n * n] = 1;
494                    }
495                }
496            }
497        }
498
499        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
500        let (local, eroded_mask) = lbv_core(&field, &mask, &grid, 1e-6, 500, |_, _| {});
501
502        // Local field should have some non-zero values (non-harmonic part remains)
503        let mut has_nonzero = false;
504        for i in 0..n * n * n {
505            assert!(local[i].is_finite());
506            if eroded_mask[i] != 0 && local[i].abs() > 1e-10 {
507                has_nonzero = true;
508            }
509        }
510        assert!(has_nonzero, "Non-harmonic field should leave non-zero local field");
511    }
512
513    #[test]
514    fn test_lbv_small_mask() {
515        // Test with a very small mask (just a few voxels) to exercise boundary logic
516        let n = 8;
517        let field: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.01).collect();
518
519        // Small central mask
520        let mut mask = vec![0u8; n * n * n];
521        for z in 2..6 {
522            for y in 2..6 {
523                for x in 2..6 {
524                    mask[x + y * n + z * n * n] = 1;
525                }
526            }
527        }
528
529        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
530        let (local, eroded) = lbv_core(&field, &mask, &grid, 1e-5, 100, |_, _| {});
531
532        for &val in &local {
533            assert!(val.is_finite());
534        }
535
536        // Eroded mask should be smaller than original
537        let eroded_count: usize = eroded.iter().map(|&x| x as usize).sum();
538        let mask_count: usize = mask.iter().map(|&x| x as usize).sum();
539        assert!(eroded_count <= mask_count);
540    }
541
542    #[test]
543    fn test_lbv_edge_mask_voxels() {
544        // Mask that includes edge voxels of the volume
545        let n = 8;
546        let field = vec![1.0; n * n * n];
547        let mask = vec![1u8; n * n * n]; // Full mask including edges
548        let grid = Grid::new(n, n, n, 1.0, 1.0, 1.0);
549
550        let (local, eroded) = lbv_core(&field, &mask, &grid, 1e-5, 100, |_, _| {});
551
552        for &val in &local {
553            assert!(val.is_finite());
554        }
555
556        // Edge voxels should be boundary, not interior, so they should not be in eroded mask
557        // Check corners
558        assert_eq!(eroded[0], 0, "Corner should not be in eroded mask");
559        assert_eq!(eroded[n - 1], 0, "Corner should not be in eroded mask");
560
561        let eroded_count: usize = eroded.iter().map(|&x| x as usize).sum();
562        assert!(eroded_count > 0, "Should have some interior voxels");
563    }
564}