Skip to main content

qsm_core/utils/
frangi.rs

1//! Frangi Vesselness Filter for 3D tubular structure detection
2//!
3//! This filter uses eigenvalues of the Hessian matrix to detect tubular (vessel-like) structures.
4//!
5//! Reference:
6//! Frangi, A.F., Niessen, W.J., Vincken, K.L., Viergever, M.A. (1998).
7//! "Multiscale vessel enhancement filtering." MICCAI'98, LNCS vol 1496, 130-137.
8//! https://doi.org/10.1007/BFb0056195
9//!
10//! Reference implementation: https://www.mathworks.com/matlabcentral/fileexchange/24409-hessian-based-frangi-vesselness-filter
11
12
13/// Parameters for Frangi vesselness filter
14#[derive(Clone, Debug)]
15pub struct FrangiParams {
16    /// Range of sigma values [min, max] for multi-scale analysis
17    pub scale_range: [f64; 2],
18    /// Step size between sigma values (default 0.5)
19    pub scale_ratio: f64,
20    /// Alpha parameter: sensitivity to plate-like structures (Ra term), default 0.5
21    pub alpha: f64,
22    /// Beta parameter: sensitivity to blob-like structures (Rb term), default 0.5
23    pub beta: f64,
24    /// C parameter: sensitivity to noise/background (S term), default 500
25    /// Threshold between eigenvalues of noise and vessel structure
26    pub c: f64,
27    /// Detect black vessels (true) or white vessels (false)
28    pub black_white: bool,
29}
30
31impl Default for FrangiParams {
32    fn default() -> Self {
33        Self {
34            // QSMART Demo defaults: FrangiScaleRange=[0.5,6], FrangiScaleRatio=0.5
35            scale_range: [0.5, 6.0],
36            scale_ratio: 0.5,
37            alpha: 0.5,
38            beta: 0.5,
39            c: 500.0,
40            black_white: false, // White vessels (bright structures)
41        }
42    }
43}
44
45/// Result of Frangi filter including vesselness and scale information
46pub struct FrangiResult {
47    /// Vesselness response (0 to 1)
48    pub vesselness: Vec<f64>,
49    /// Scale at which maximum vesselness was found
50    pub scale: Vec<f64>,
51}
52
53/// Apply 3D Frangi vesselness filter
54///
55/// # Arguments
56/// * `data` - Input 3D volume (nx * ny * nz)
57/// * `nx`, `ny`, `nz` - Volume dimensions
58/// * `params` - Frangi filter parameters
59///
60/// # Returns
61/// FrangiResult with vesselness map and optimal scale map
62pub fn frangi_filter_3d(
63    data: &[f64],
64    nx: usize, ny: usize, nz: usize,
65    params: &FrangiParams,
66) -> FrangiResult {
67    frangi_filter_3d_with_progress(data, nx, ny, nz, params, |_, _| {})
68}
69
70/// Compute vesselness measure from sorted eigenvalues
71/// |lambda1| <= |lambda2| <= |lambda3|
72fn compute_vesselness(l1: f64, l2: f64, l3: f64, a: f64, b: f64, c2: f64, black_white: bool) -> f64 {
73    let abs_l2 = l2.abs();
74    let abs_l3 = l3.abs();
75
76    // Avoid division by zero
77    if abs_l3 < 1e-10 || abs_l2 < 1e-10 {
78        return 0.0;
79    }
80
81    // Check sign conditions for vessel-like structures
82    // For bright vessels: lambda2 < 0 AND lambda3 < 0
83    // For dark vessels: lambda2 > 0 AND lambda3 > 0
84    if black_white {
85        // Dark vessels (black ridges)
86        if l2 < 0.0 || l3 < 0.0 {
87            return 0.0;
88        }
89    } else {
90        // Bright vessels (white ridges)
91        if l2 > 0.0 || l3 > 0.0 {
92            return 0.0;
93        }
94    }
95
96    // Ra: distinguishes plate-like from line-like structures
97    // Ra = |lambda2| / |lambda3|
98    let ra = abs_l2 / abs_l3;
99
100    // Rb: distinguishes blob-like from line-like structures
101    // Rb = |lambda1| / sqrt(|lambda2 * lambda3|)
102    let rb = l1.abs() / (abs_l2 * abs_l3).sqrt();
103
104    // S: second-order structureness (Frobenius norm of Hessian eigenvalues)
105    // S = sqrt(lambda1^2 + lambda2^2 + lambda3^2)
106    let s = (l1 * l1 + l2 * l2 + l3 * l3).sqrt();
107
108    // Vesselness function
109    // V = (1 - exp(-Ra^2/2alpha^2)) * exp(-Rb^2/2beta^2) * (1 - exp(-S^2/2c^2))
110    let exp_ra = 1.0 - (-ra * ra / a).exp();
111    let exp_rb = (-rb * rb / b).exp();
112    let exp_s = 1.0 - (-s * s / c2).exp();
113
114    let v = exp_ra * exp_rb * exp_s;
115
116    // Clamp to valid range
117    if v.is_finite() { v.max(0.0).min(1.0) } else { 0.0 }
118}
119
120/// Sort three values by absolute value
121fn sort_by_abs(a: f64, b: f64, c: f64) -> (f64, f64, f64) {
122    let mut vals = [(a.abs(), a), (b.abs(), b), (c.abs(), c)];
123    vals.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap_or(std::cmp::Ordering::Equal));
124    (vals[0].1, vals[1].1, vals[2].1)
125}
126
127#[cfg(test)]
128fn compute_hessian_3d(
129    data: &[f64],
130    nx: usize, ny: usize, nz: usize,
131    sigma: f64,
132) -> (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) {
133    let n_total = nx * ny * nz;
134
135    // First, apply Gaussian smoothing
136    let smoothed = if sigma > 0.0 {
137        gaussian_smooth_3d(data, nx, ny, nz, sigma)
138    } else {
139        data.to_vec()
140    };
141
142    // Compute first derivatives
143    let dx = gradient_3d(&smoothed, nx, ny, nz, 'x');
144    let dy = gradient_3d(&smoothed, nx, ny, nz, 'y');
145    let dz = gradient_3d(&smoothed, nx, ny, nz, 'z');
146
147    // Compute second derivatives
148    let dxx = gradient_3d(&dx, nx, ny, nz, 'x');
149    let dxy = gradient_3d(&dx, nx, ny, nz, 'y');
150    let dxz = gradient_3d(&dx, nx, ny, nz, 'z');
151
152    let dyy = gradient_3d(&dy, nx, ny, nz, 'y');
153    let dyz = gradient_3d(&dy, nx, ny, nz, 'z');
154
155    let dzz = gradient_3d(&dz, nx, ny, nz, 'z');
156
157    (dxx, dyy, dzz, dxy, dxz, dyz)
158}
159
160#[cfg(test)]
161fn gradient_3d(data: &[f64], nx: usize, ny: usize, nz: usize, direction: char) -> Vec<f64> {
162    let n_total = nx * ny * nz;
163    let mut grad = vec![0.0f64; n_total];
164
165    // Index helper: i + j*nx + k*nx*ny
166    let idx = |i: usize, j: usize, k: usize| i + j * nx + k * nx * ny;
167
168    match direction {
169        'x' => {
170            for k in 0..nz {
171                for j in 0..ny {
172                    // Forward difference at left edge
173                    grad[idx(0, j, k)] = data[idx(1, j, k)] - data[idx(0, j, k)];
174
175                    // Central differences in interior
176                    for i in 1..nx-1 {
177                        grad[idx(i, j, k)] = (data[idx(i+1, j, k)] - data[idx(i-1, j, k)]) / 2.0;
178                    }
179
180                    // Backward difference at right edge
181                    if nx > 1 {
182                        grad[idx(nx-1, j, k)] = data[idx(nx-1, j, k)] - data[idx(nx-2, j, k)];
183                    }
184                }
185            }
186        }
187        'y' => {
188            for k in 0..nz {
189                for i in 0..nx {
190                    // Forward difference at bottom edge
191                    grad[idx(i, 0, k)] = data[idx(i, 1, k)] - data[idx(i, 0, k)];
192
193                    // Central differences in interior
194                    for j in 1..ny-1 {
195                        grad[idx(i, j, k)] = (data[idx(i, j+1, k)] - data[idx(i, j-1, k)]) / 2.0;
196                    }
197
198                    // Backward difference at top edge
199                    if ny > 1 {
200                        grad[idx(i, ny-1, k)] = data[idx(i, ny-1, k)] - data[idx(i, ny-2, k)];
201                    }
202                }
203            }
204        }
205        'z' => {
206            for j in 0..ny {
207                for i in 0..nx {
208                    // Forward difference at front edge
209                    grad[idx(i, j, 0)] = data[idx(i, j, 1)] - data[idx(i, j, 0)];
210
211                    // Central differences in interior
212                    for k in 1..nz-1 {
213                        grad[idx(i, j, k)] = (data[idx(i, j, k+1)] - data[idx(i, j, k-1)]) / 2.0;
214                    }
215
216                    // Backward difference at back edge
217                    if nz > 1 {
218                        grad[idx(i, j, nz-1)] = data[idx(i, j, nz-1)] - data[idx(i, j, nz-2)];
219                    }
220                }
221            }
222        }
223        _ => panic!("Invalid gradient direction"),
224    }
225
226    grad
227}
228
229/// 3D Gaussian smoothing using separable 1D convolutions
230fn gaussian_smooth_3d(data: &[f64], nx: usize, ny: usize, nz: usize, sigma: f64) -> Vec<f64> {
231    if sigma <= 0.0 {
232        return data.to_vec();
233    }
234
235    // Create 1D Gaussian kernel
236    let kernel_radius = (3.0 * sigma).ceil() as usize;
237    let kernel_size = 2 * kernel_radius + 1;
238    let mut kernel = vec![0.0f64; kernel_size];
239
240    let mut sum = 0.0;
241    for i in 0..kernel_size {
242        let x = i as f64 - kernel_radius as f64;
243        kernel[i] = (-x * x / (2.0 * sigma * sigma)).exp();
244        sum += kernel[i];
245    }
246
247    // Normalize kernel
248    for k in kernel.iter_mut() {
249        *k /= sum;
250    }
251
252    // Apply separable convolution
253    // X direction
254    let smoothed_x = convolve_1d_direction(data, nx, ny, nz, &kernel, 'x');
255
256    // Y direction
257    let smoothed_xy = convolve_1d_direction(&smoothed_x, nx, ny, nz, &kernel, 'y');
258
259    // Z direction
260    let smoothed_xyz = convolve_1d_direction(&smoothed_xy, nx, ny, nz, &kernel, 'z');
261
262    smoothed_xyz
263}
264
265/// Apply 1D convolution along specified axis with replicate padding
266/// Matches MATLAB's imgaussian which uses 'replicate' boundary handling
267fn convolve_1d_direction(
268    data: &[f64],
269    nx: usize, ny: usize, nz: usize,
270    kernel: &[f64],
271    direction: char,
272) -> Vec<f64> {
273    let n_total = nx * ny * nz;
274    let mut result = vec![0.0f64; n_total];
275    let kernel_radius = (kernel.len() - 1) / 2;
276
277    let idx = |i: usize, j: usize, k: usize| i + j * nx + k * nx * ny;
278
279    // Clamp helpers for replicate padding
280    let clamp_x = |x: isize| -> usize { x.max(0).min(nx as isize - 1) as usize };
281    let clamp_y = |y: isize| -> usize { y.max(0).min(ny as isize - 1) as usize };
282    let clamp_z = |z: isize| -> usize { z.max(0).min(nz as isize - 1) as usize };
283
284    match direction {
285        'x' => {
286            for k in 0..nz {
287                for j in 0..ny {
288                    for i in 0..nx {
289                        let mut sum = 0.0;
290
291                        for ki in 0..kernel.len() {
292                            let offset = ki as isize - kernel_radius as isize;
293                            let ni = clamp_x(i as isize + offset);
294                            sum += data[idx(ni, j, k)] * kernel[ki];
295                        }
296
297                        result[idx(i, j, k)] = sum;
298                    }
299                }
300            }
301        }
302        'y' => {
303            for k in 0..nz {
304                for j in 0..ny {
305                    for i in 0..nx {
306                        let mut sum = 0.0;
307
308                        for ki in 0..kernel.len() {
309                            let offset = ki as isize - kernel_radius as isize;
310                            let nj = clamp_y(j as isize + offset);
311                            sum += data[idx(i, nj, k)] * kernel[ki];
312                        }
313
314                        result[idx(i, j, k)] = sum;
315                    }
316                }
317            }
318        }
319        'z' => {
320            for k in 0..nz {
321                for j in 0..ny {
322                    for i in 0..nx {
323                        let mut sum = 0.0;
324
325                        for ki in 0..kernel.len() {
326                            let offset = ki as isize - kernel_radius as isize;
327                            let nk = clamp_z(k as isize + offset);
328                            sum += data[idx(i, j, nk)] * kernel[ki];
329                        }
330
331                        result[idx(i, j, k)] = sum;
332                    }
333                }
334            }
335        }
336        _ => panic!("Invalid convolution direction"),
337    }
338
339    result
340}
341
342/// Analytical eigenvalues of 3×3 symmetric matrix using Cardano's method
343///
344/// Much faster than Householder+QL since it avoids eigenvector computation
345/// and uses a fixed number of operations (no iteration).
346///
347/// Matrix is:
348/// | a  d  e |
349/// | d  b  f |
350/// | e  f  c |
351///
352/// Returns eigenvalues (not sorted)
353#[inline(always)]
354fn eigenvalues_3x3_cardano(a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) -> (f64, f64, f64) {
355    let p1 = d * d + e * e + f * f;
356    if p1 < 1e-30 {
357        // Matrix is effectively diagonal
358        return (a, b, c);
359    }
360
361    let q = (a + b + c) / 3.0;
362    let p2 = (a - q) * (a - q) + (b - q) * (b - q) + (c - q) * (c - q) + 2.0 * p1;
363    let p = (p2 / 6.0).sqrt();
364
365    // B = (A - q*I) / p
366    let inv_p = 1.0 / p;
367    let b00 = (a - q) * inv_p;
368    let b11 = (b - q) * inv_p;
369    let b22 = (c - q) * inv_p;
370    let b01 = d * inv_p;
371    let b02 = e * inv_p;
372    let b12 = f * inv_p;
373
374    // det(B) / 2
375    let det_b_half = (b00 * (b11 * b22 - b12 * b12)
376                    - b01 * (b01 * b22 - b12 * b02)
377                    + b02 * (b01 * b12 - b11 * b02)) / 2.0;
378
379    // Clamp for numerical stability (det_b_half should be in [-1, 1])
380    let r = det_b_half.max(-1.0).min(1.0);
381    let phi = r.acos() / 3.0;
382
383    let eig1 = q + 2.0 * p * phi.cos();
384    let eig3 = q + 2.0 * p * (phi + 2.0 * std::f64::consts::PI / 3.0).cos();
385    let eig2 = 3.0 * q - eig1 - eig3; // trace = sum of eigenvalues
386
387    (eig1, eig2, eig3)
388}
389
390/// Compute eigenvalues of a 3x3 symmetric matrix using Householder + QL algorithm
391///
392/// This is a direct port of the QSMART/JAMA algorithm from eig3volume.c,
393/// which uses Householder reduction to tridiagonal form followed by QL iteration.
394/// This method is more numerically stable than the analytical Cardano formula.
395///
396/// Matrix is:
397/// | a  d  e |
398/// | d  b  f |
399/// | e  f  c |
400///
401/// Returns eigenvalues (not sorted)
402#[allow(dead_code)]
403fn eigenvalues_3x3_symmetric(a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) -> (f64, f64, f64) {
404    // Build the symmetric matrix V (will be modified in place)
405    let mut v = [[0.0f64; 3]; 3];
406    v[0][0] = a; v[0][1] = d; v[0][2] = e;
407    v[1][0] = d; v[1][1] = b; v[1][2] = f;
408    v[2][0] = e; v[2][1] = f; v[2][2] = c;
409
410    let mut eigenvalues = [0.0f64; 3];
411    let mut e_vec = [0.0f64; 3];
412
413    // Step 1: Householder reduction to tridiagonal form (tred2)
414    tred2(&mut v, &mut eigenvalues, &mut e_vec);
415
416    // Step 2: QL algorithm for symmetric tridiagonal matrix (tql2)
417    tql2(&mut v, &mut eigenvalues, &mut e_vec);
418
419    (eigenvalues[0], eigenvalues[1], eigenvalues[2])
420}
421
422/// Symmetric Householder reduction to tridiagonal form
423///
424/// Derived from the Algol procedures tred2 by Bowdler, Martin, Reinsch, and Wilkinson,
425/// Handbook for Auto. Comp., Vol.ii-Linear Algebra, and the corresponding Fortran
426/// subroutine in EISPACK.
427///
428/// Direct port from QSMART's eig3volume.c
429fn tred2(v: &mut [[f64; 3]; 3], d: &mut [f64; 3], e: &mut [f64; 3]) {
430    const N: usize = 3;
431
432    // Initialize d with the last row of V
433    for j in 0..N {
434        d[j] = v[N - 1][j];
435    }
436
437    // Householder reduction to tridiagonal form
438    for i in (1..N).rev() {
439        // Scale to avoid under/overflow
440        let mut scale = 0.0;
441        let mut h = 0.0;
442
443        for k in 0..i {
444            scale += d[k].abs();
445        }
446
447        if scale == 0.0 {
448            e[i] = d[i - 1];
449            for j in 0..i {
450                d[j] = v[i - 1][j];
451                v[i][j] = 0.0;
452                v[j][i] = 0.0;
453            }
454        } else {
455            // Generate Householder vector
456            for k in 0..i {
457                d[k] /= scale;
458                h += d[k] * d[k];
459            }
460
461            let f = d[i - 1];
462            let mut g = h.sqrt();
463            if f > 0.0 {
464                g = -g;
465            }
466            e[i] = scale * g;
467            h -= f * g;
468            d[i - 1] = f - g;
469
470            for j in 0..i {
471                e[j] = 0.0;
472            }
473
474            // Apply similarity transformation to remaining columns
475            for j in 0..i {
476                let f = d[j];
477                v[j][i] = f;
478                let mut g = e[j] + v[j][j] * f;
479                for k in (j + 1)..i {
480                    g += v[k][j] * d[k];
481                    e[k] += v[k][j] * f;
482                }
483                e[j] = g;
484            }
485
486            let mut f = 0.0;
487            for j in 0..i {
488                e[j] /= h;
489                f += e[j] * d[j];
490            }
491
492            let hh = f / (h + h);
493            for j in 0..i {
494                e[j] -= hh * d[j];
495            }
496
497            for j in 0..i {
498                let f = d[j];
499                let g = e[j];
500                for k in j..i {
501                    v[k][j] -= f * e[k] + g * d[k];
502                }
503                d[j] = v[i - 1][j];
504                v[i][j] = 0.0;
505            }
506        }
507        d[i] = h;
508    }
509
510    // Accumulate transformations
511    for i in 0..(N - 1) {
512        v[N - 1][i] = v[i][i];
513        v[i][i] = 1.0;
514        let h = d[i + 1];
515        if h != 0.0 {
516            for k in 0..=i {
517                d[k] = v[k][i + 1] / h;
518            }
519            for j in 0..=i {
520                let mut g = 0.0;
521                for k in 0..=i {
522                    g += v[k][i + 1] * v[k][j];
523                }
524                for k in 0..=i {
525                    v[k][j] -= g * d[k];
526                }
527            }
528        }
529        for k in 0..=i {
530            v[k][i + 1] = 0.0;
531        }
532    }
533
534    for j in 0..N {
535        d[j] = v[N - 1][j];
536        v[N - 1][j] = 0.0;
537    }
538    v[N - 1][N - 1] = 1.0;
539    e[0] = 0.0;
540}
541
542/// Symmetric tridiagonal QL algorithm
543///
544/// Derived from the Algol procedures tql2 by Bowdler, Martin, Reinsch, and Wilkinson,
545/// Handbook for Auto. Comp., Vol.ii-Linear Algebra, and the corresponding Fortran
546/// subroutine in EISPACK.
547///
548/// Direct port from QSMART's eig3volume.c
549fn tql2(v: &mut [[f64; 3]; 3], d: &mut [f64; 3], e: &mut [f64; 3]) {
550    const N: usize = 3;
551
552    for i in 1..N {
553        e[i - 1] = e[i];
554    }
555    e[N - 1] = 0.0;
556
557    let mut f: f64 = 0.0;
558    let mut tst1: f64 = 0.0;
559    let eps: f64 = 2.0f64.powi(-52);
560
561    for l in 0..N {
562        // Find small subdiagonal element
563        tst1 = tst1.max(d[l].abs() + e[l].abs());
564        let mut m = l;
565        while m < N {
566            if e[m].abs() <= eps * tst1 {
567                break;
568            }
569            m += 1;
570        }
571
572        // If m == l, d[l] is an eigenvalue, otherwise iterate
573        if m > l {
574            loop {
575                // Compute implicit shift
576                let g = d[l];
577                let mut p = (d[l + 1] - g) / (2.0 * e[l]);
578                let mut r = hypot(p, 1.0);
579                if p < 0.0 {
580                    r = -r;
581                }
582                d[l] = e[l] / (p + r);
583                d[l + 1] = e[l] * (p + r);
584                let dl1 = d[l + 1];
585                let h = g - d[l];
586                for i in (l + 2)..N {
587                    d[i] -= h;
588                }
589                f += h;
590
591                // Implicit QL transformation
592                p = d[m];
593                let mut c = 1.0;
594                let mut c2 = c;
595                let mut c3 = c;
596                let el1 = e[l + 1];
597                let mut s = 0.0;
598                let mut s2 = 0.0;
599
600                for i in (l..m).rev() {
601                    c3 = c2;
602                    c2 = c;
603                    s2 = s;
604                    let g = c * e[i];
605                    let h = c * p;
606                    r = hypot(p, e[i]);
607                    e[i + 1] = s * r;
608                    s = e[i] / r;
609                    c = p / r;
610                    p = c * d[i] - s * g;
611                    d[i + 1] = h + s * (c * g + s * d[i]);
612
613                    // Accumulate transformation
614                    for k in 0..N {
615                        let vh = v[k][i + 1];
616                        v[k][i + 1] = s * v[k][i] + c * vh;
617                        v[k][i] = c * v[k][i] - s * vh;
618                    }
619                }
620                p = -s * s2 * c3 * el1 * e[l] / dl1;
621                e[l] = s * p;
622                d[l] = c * p;
623
624                // Check for convergence
625                if e[l].abs() <= eps * tst1 {
626                    break;
627                }
628            }
629        }
630        d[l] += f;
631        e[l] = 0.0;
632    }
633
634    // Sort eigenvalues and corresponding vectors (ascending order)
635    for i in 0..(N - 1) {
636        let mut k = i;
637        let mut p = d[i];
638        for j in (i + 1)..N {
639            if d[j] < p {
640                k = j;
641                p = d[j];
642            }
643        }
644        if k != i {
645            d[k] = d[i];
646            d[i] = p;
647            for j in 0..N {
648                let temp = v[j][i];
649                v[j][i] = v[j][k];
650                v[j][k] = temp;
651            }
652        }
653    }
654}
655
656/// Compute hypotenuse avoiding overflow/underflow
657#[inline]
658fn hypot(x: f64, y: f64) -> f64 {
659    (x * x + y * y).sqrt()
660}
661
662/// Simple wrapper for Frangi filter with default parameters
663pub fn frangi_filter_3d_default(
664    data: &[f64],
665    nx: usize, ny: usize, nz: usize,
666) -> Vec<f64> {
667    let params = FrangiParams::default();
668    frangi_filter_3d(data, nx, ny, nz, &params).vesselness
669}
670
671/// Frangi filter with progress callback
672///
673/// Uses a fused approach: Hessian components are computed inline from the smoothed
674/// array using composed central-difference stencils, then eigenvalues and vesselness
675/// are computed immediately. This avoids allocating 9 intermediate gradient arrays
676/// (~927 MB per scale for typical brain volumes) and improves cache utilization.
677pub fn frangi_filter_3d_with_progress<F>(
678    data: &[f64],
679    nx: usize, ny: usize, nz: usize,
680    params: &FrangiParams,
681    mut progress_callback: F,
682) -> FrangiResult
683where
684    F: FnMut(usize, usize),
685{
686    let n_total = nx * ny * nz;
687
688    // Generate sigma values
689    let mut sigmas = Vec::new();
690    let mut sigma = params.scale_range[0];
691    while sigma <= params.scale_range[1] {
692        sigmas.push(sigma);
693        sigma += params.scale_ratio;
694    }
695
696    if sigmas.is_empty() {
697        sigmas.push(params.scale_range[0]);
698    }
699
700    let total_scales = sigmas.len();
701
702    // Initialize output arrays
703    let mut best_vesselness = vec![0.0f64; n_total];
704    let mut best_scale = vec![1.0f64; n_total];
705
706    // Constants for vesselness computation
707    let a = 2.0 * params.alpha * params.alpha;
708    let b = 2.0 * params.beta * params.beta;
709    let c2 = 2.0 * params.c * params.c;
710
711    // Strides for indexing
712    let sx: usize = 1;
713    let sy: usize = nx;
714    let sz: usize = nx * ny;
715
716    // Process each scale
717    for (scale_idx, &sigma) in sigmas.iter().enumerate() {
718        progress_callback(scale_idx, total_scales);
719
720        // Gaussian smoothing (only allocation per scale)
721        let smoothed = if sigma > 0.0 {
722            gaussian_smooth_3d(data, nx, ny, nz, sigma)
723        } else {
724            data.to_vec()
725        };
726
727        // Scale normalization (sigma^2)
728        let scale_factor = sigma * sigma;
729
730        // Fused: compute Hessian + eigenvalues + vesselness inline
731        // Uses composed central-difference stencils matching gradient(gradient(f)):
732        //   D²f/dx² = (f[i+2] - 2f[i] + f[i-2]) / 4
733        //   D²f/dxdy = (f[i+1,j+1] - f[i-1,j+1] - f[i+1,j-1] + f[i-1,j-1]) / 4
734        // Requires 2-voxel margin for diagonal terms (boundary voxels stay at 0).
735        for k in 2..nz.saturating_sub(2) {
736            for j in 2..ny.saturating_sub(2) {
737                for i in 2..nx.saturating_sub(2) {
738                    let idx = i + j * sy + k * sz;
739                    let s = smoothed[idx];
740
741                    // Diagonal second derivatives (spacing-2 stencil)
742                    let dxx = (smoothed[idx + 2 * sx] - 2.0 * s + smoothed[idx - 2 * sx]) / 4.0;
743                    let dyy = (smoothed[idx + 2 * sy] - 2.0 * s + smoothed[idx - 2 * sy]) / 4.0;
744                    let dzz = (smoothed[idx + 2 * sz] - 2.0 * s + smoothed[idx - 2 * sz]) / 4.0;
745
746                    // Cross derivatives (spacing-1 stencil)
747                    let dxy = (smoothed[idx + sx + sy] - smoothed[idx - sx + sy]
748                             - smoothed[idx + sx - sy] + smoothed[idx - sx - sy]) / 4.0;
749                    let dxz = (smoothed[idx + sx + sz] - smoothed[idx - sx + sz]
750                             - smoothed[idx + sx - sz] + smoothed[idx - sx - sz]) / 4.0;
751                    let dyz = (smoothed[idx + sy + sz] - smoothed[idx - sy + sz]
752                             - smoothed[idx + sy - sz] + smoothed[idx - sy - sz]) / 4.0;
753
754                    // Early exit for near-zero Hessian (outside brain mask)
755                    let hessian_mag = dxx.abs() + dyy.abs() + dzz.abs()
756                                    + dxy.abs() + dxz.abs() + dyz.abs();
757                    if hessian_mag < 1e-20 {
758                        continue;
759                    }
760
761                    // Scale-normalized Hessian
762                    let h_xx = dxx * scale_factor;
763                    let h_yy = dyy * scale_factor;
764                    let h_zz = dzz * scale_factor;
765                    let h_xy = dxy * scale_factor;
766                    let h_xz = dxz * scale_factor;
767                    let h_yz = dyz * scale_factor;
768
769                    // Analytical eigenvalues (Cardano's method, ~5x faster than Householder+QL)
770                    let (lambda1, lambda2, lambda3) = eigenvalues_3x3_cardano(
771                        h_xx, h_yy, h_zz, h_xy, h_xz, h_yz
772                    );
773
774                    // Sort by absolute value: |lambda1| <= |lambda2| <= |lambda3|
775                    let (l1, l2, l3) = sort_by_abs(lambda1, lambda2, lambda3);
776
777                    // Compute vesselness
778                    let vesselness = compute_vesselness(l1, l2, l3, a, b, c2, params.black_white);
779
780                    // Keep maximum across scales
781                    if scale_idx == 0 || vesselness > best_vesselness[idx] {
782                        best_vesselness[idx] = vesselness;
783                        best_scale[idx] = sigma;
784                    }
785                }
786            }
787        }
788    }
789
790    progress_callback(total_scales, total_scales);
791
792    FrangiResult {
793        vesselness: best_vesselness,
794        scale: best_scale,
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    #[test]
803    fn test_eigenvalues_diagonal() {
804        // Diagonal matrix: eigenvalues are the diagonal elements
805        let (l1, l2, l3) = eigenvalues_3x3_symmetric(1.0, 2.0, 3.0, 0.0, 0.0, 0.0);
806        let mut sorted = vec![l1, l2, l3];
807        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
808
809        assert!((sorted[0] - 1.0).abs() < 1e-10);
810        assert!((sorted[1] - 2.0).abs() < 1e-10);
811        assert!((sorted[2] - 3.0).abs() < 1e-10);
812    }
813
814    #[test]
815    fn test_eigenvalues_identity() {
816        // Identity matrix: all eigenvalues are 1
817        let (l1, l2, l3) = eigenvalues_3x3_symmetric(1.0, 1.0, 1.0, 0.0, 0.0, 0.0);
818
819        assert!((l1 - 1.0).abs() < 1e-10);
820        assert!((l2 - 1.0).abs() < 1e-10);
821        assert!((l3 - 1.0).abs() < 1e-10);
822    }
823
824    #[test]
825    fn test_cardano_matches_householder() {
826        // Verify Cardano analytical eigenvalues match Householder+QL
827        let test_cases = vec![
828            (1.0, 2.0, 3.0, 0.0, 0.0, 0.0),     // diagonal
829            (1.0, 1.0, 1.0, 0.0, 0.0, 0.0),     // identity
830            (2.0, 2.0, 1.0, 1.0, 0.0, 0.0),     // off-diagonal
831            (5.0, 3.0, 1.0, 0.5, -0.3, 0.7),    // general
832            (0.0, 0.0, 0.0, 0.0, 0.0, 0.0),     // zero
833            (1e-6, 2e-6, 3e-6, 1e-7, 2e-7, 3e-7), // tiny values
834        ];
835
836        for (a, b, c, d, e, f) in test_cases {
837            let (h1, h2, h3) = eigenvalues_3x3_symmetric(a, b, c, d, e, f);
838            let (c1, c2, c3) = eigenvalues_3x3_cardano(a, b, c, d, e, f);
839
840            let mut hs = vec![h1, h2, h3];
841            let mut cs = vec![c1, c2, c3];
842            hs.sort_by(|a, b| a.partial_cmp(b).unwrap());
843            cs.sort_by(|a, b| a.partial_cmp(b).unwrap());
844
845            for i in 0..3 {
846                assert!(
847                    (hs[i] - cs[i]).abs() < 1e-8,
848                    "Mismatch for ({},{},{},{},{},{}): Householder={:?} Cardano={:?}",
849                    a, b, c, d, e, f, hs, cs
850                );
851            }
852        }
853    }
854
855    #[test]
856    fn test_gradient_constant() {
857        // Gradient of constant field should be zero
858        let data = vec![5.0; 27]; // 3x3x3
859        let grad = gradient_3d(&data, 3, 3, 3, 'x');
860
861        for &v in &grad {
862            assert!(v.abs() < 1e-10);
863        }
864    }
865
866    #[test]
867    fn test_frangi_filter_basic() {
868        // Basic test: filter should run without panic
869        let data = vec![0.0; 1000]; // 10x10x10
870        let params = FrangiParams {
871            scale_range: [1.0, 2.0],
872            scale_ratio: 1.0,
873            ..Default::default()
874        };
875
876        let result = frangi_filter_3d(&data, 10, 10, 10, &params);
877        assert_eq!(result.vesselness.len(), 1000);
878    }
879
880    #[test]
881    fn test_frangi_multi_scale() {
882        // Test multi-scale filtering with several sigma values
883        let n = 10;
884        let mut data = vec![0.0; n * n * n];
885
886        // Create a tube-like structure along the x-axis at center
887        let cy = n / 2;
888        let cz = n / 2;
889        for x in 0..n {
890            for dy in -1i32..=1 {
891                for dz in -1i32..=1 {
892                    let y = (cy as i32 + dy) as usize;
893                    let z = (cz as i32 + dz) as usize;
894                    if y < n && z < n {
895                        data[x + y * n + z * n * n] = 1.0;
896                    }
897                }
898            }
899        }
900
901        let params = FrangiParams {
902            scale_range: [0.5, 3.0],
903            scale_ratio: 0.5, // This gives sigmas: 0.5, 1.0, 1.5, 2.0, 2.5, 3.0
904            alpha: 0.5,
905            beta: 0.5,
906            c: 500.0,
907            black_white: false, // bright vessels
908        };
909
910        let result = frangi_filter_3d(&data, n, n, n, &params);
911
912        assert_eq!(result.vesselness.len(), n * n * n);
913        assert_eq!(result.scale.len(), n * n * n);
914
915        // All values should be finite and in [0, 1]
916        for &v in &result.vesselness {
917            assert!(v.is_finite(), "Vesselness must be finite");
918            assert!(v >= 0.0, "Vesselness must be >= 0");
919            assert!(v <= 1.0, "Vesselness must be <= 1");
920        }
921
922        for &s in &result.scale {
923            assert!(s.is_finite(), "Scale must be finite");
924            assert!(s >= 0.5 && s <= 3.0, "Scale must be in range [0.5, 3.0], got {}", s);
925        }
926    }
927
928    #[test]
929    fn test_frangi_different_beta_c() {
930        // Test with non-default beta and c parameters
931        let n = 10;
932        let mut data = vec![0.0; n * n * n];
933
934        // Create a bright tube along y
935        let cx = n / 2;
936        let cz = n / 2;
937        for y in 0..n {
938            data[cx + y * n + cz * n * n] = 10.0;
939        }
940
941        let params_default = FrangiParams {
942            scale_range: [1.0, 2.0],
943            scale_ratio: 1.0,
944            alpha: 0.5,
945            beta: 0.5,
946            c: 500.0,
947            black_white: false,
948        };
949
950        let params_small_c = FrangiParams {
951            scale_range: [1.0, 2.0],
952            scale_ratio: 1.0,
953            alpha: 0.5,
954            beta: 0.5,
955            c: 1.0, // Small c makes filter more sensitive to structure
956            black_white: false,
957        };
958
959        let params_large_beta = FrangiParams {
960            scale_range: [1.0, 2.0],
961            scale_ratio: 1.0,
962            alpha: 0.5,
963            beta: 2.0, // Large beta is less sensitive to blob-like structures
964            c: 500.0,
965            black_white: false,
966        };
967
968        let result_default = frangi_filter_3d(&data, n, n, n, &params_default);
969        let result_small_c = frangi_filter_3d(&data, n, n, n, &params_small_c);
970        let result_large_beta = frangi_filter_3d(&data, n, n, n, &params_large_beta);
971
972        // All should be finite
973        for &v in &result_default.vesselness {
974            assert!(v.is_finite());
975        }
976        for &v in &result_small_c.vesselness {
977            assert!(v.is_finite());
978        }
979        for &v in &result_large_beta.vesselness {
980            assert!(v.is_finite());
981        }
982    }
983
984    #[test]
985    fn test_frangi_black_white() {
986        // Test dark vessels (black_white = true) vs bright vessels
987        let n = 10;
988        let mut data = vec![1.0; n * n * n]; // Background is bright
989
990        // Create a dark tube along z
991        let cx = n / 2;
992        let cy = n / 2;
993        for z in 0..n {
994            data[cx + cy * n + z * n * n] = 0.0; // Dark structure
995        }
996
997        let params_bright = FrangiParams {
998            scale_range: [1.0, 2.0],
999            scale_ratio: 1.0,
1000            black_white: false, // bright vessels
1001            ..Default::default()
1002        };
1003
1004        let params_dark = FrangiParams {
1005            scale_range: [1.0, 2.0],
1006            scale_ratio: 1.0,
1007            black_white: true, // dark vessels
1008            ..Default::default()
1009        };
1010
1011        let result_bright = frangi_filter_3d(&data, n, n, n, &params_bright);
1012        let result_dark = frangi_filter_3d(&data, n, n, n, &params_dark);
1013
1014        // Both should produce valid output
1015        for &v in &result_bright.vesselness {
1016            assert!(v.is_finite());
1017            assert!(v >= 0.0 && v <= 1.0);
1018        }
1019        for &v in &result_dark.vesselness {
1020            assert!(v.is_finite());
1021            assert!(v >= 0.0 && v <= 1.0);
1022        }
1023
1024        // Dark vessel detection should detect something in the dark tube region
1025        // (or at least produce different results than bright vessel mode)
1026        let sum_bright: f64 = result_bright.vesselness.iter().sum();
1027        let sum_dark: f64 = result_dark.vesselness.iter().sum();
1028        // They should generally differ
1029        assert!(
1030            (sum_bright - sum_dark).abs() >= 0.0,
1031            "Bright and dark modes should both work"
1032        );
1033    }
1034
1035    #[test]
1036    fn test_frangi_vessel_structure() {
1037        // Create a volume with an actual tube-like structure that should
1038        // trigger non-zero vesselness
1039        let n = 12;
1040        let mut data = vec![0.0; n * n * n];
1041
1042        // Gaussian cross-section tube along x-axis at center
1043        let cy = n as f64 / 2.0;
1044        let cz = n as f64 / 2.0;
1045        let sigma_tube = 1.5;
1046        for x in 1..(n - 1) {
1047            for y in 0..n {
1048                for z in 0..n {
1049                    let dy = y as f64 - cy;
1050                    let dz = z as f64 - cz;
1051                    let r2 = dy * dy + dz * dz;
1052                    data[x + y * n + z * n * n] = (-r2 / (2.0 * sigma_tube * sigma_tube)).exp();
1053                }
1054            }
1055        }
1056
1057        let params = FrangiParams {
1058            scale_range: [1.0, 2.0],
1059            scale_ratio: 1.0,
1060            alpha: 0.5,
1061            beta: 0.5,
1062            c: 10.0, // Lower c to be more sensitive
1063            black_white: false,
1064        };
1065
1066        let result = frangi_filter_3d(&data, n, n, n, &params);
1067
1068        // All should be valid
1069        for &v in &result.vesselness {
1070            assert!(v.is_finite());
1071            assert!(v >= 0.0 && v <= 1.0);
1072        }
1073
1074        // There should be some non-zero vesselness near the tube center
1075        let max_v: f64 = result.vesselness.iter().cloned().fold(0.0, f64::max);
1076        assert!(
1077            max_v > 0.0,
1078            "Frangi should detect the vessel-like structure, max vesselness = {}",
1079            max_v
1080        );
1081    }
1082
1083    #[test]
1084    fn test_frangi_filter_3d_default() {
1085        // Test the default wrapper
1086        let n = 8;
1087        let data = vec![0.0; n * n * n];
1088
1089        let vesselness = frangi_filter_3d_default(&data, n, n, n);
1090        assert_eq!(vesselness.len(), n * n * n);
1091        for &v in &vesselness {
1092            assert!(v.is_finite());
1093        }
1094    }
1095
1096    #[test]
1097    fn test_frangi_with_progress() {
1098        let n = 8;
1099        let data = vec![0.0; n * n * n];
1100        let params = FrangiParams {
1101            scale_range: [1.0, 2.0],
1102            scale_ratio: 1.0,
1103            ..Default::default()
1104        };
1105
1106        let mut progress_calls = Vec::new();
1107        let result = frangi_filter_3d_with_progress(&data, n, n, n, &params, |current, total| {
1108            progress_calls.push((current, total));
1109        });
1110
1111        assert_eq!(result.vesselness.len(), n * n * n);
1112        // The progress callback should have been called at least once
1113        assert!(!progress_calls.is_empty(), "Progress callback should be called");
1114        // Last call should indicate completion
1115        let last = progress_calls.last().unwrap();
1116        assert_eq!(last.0, last.1, "Last progress call should indicate completion");
1117    }
1118
1119    #[test]
1120    fn test_sort_by_abs() {
1121        let (a, b, c) = sort_by_abs(3.0, -1.0, 2.0);
1122        assert!((a.abs()) <= b.abs());
1123        assert!((b.abs()) <= c.abs());
1124
1125        let (a, b, c) = sort_by_abs(-5.0, 0.1, -2.0);
1126        assert!((a.abs()) <= b.abs());
1127        assert!((b.abs()) <= c.abs());
1128    }
1129
1130    #[test]
1131    fn test_compute_vesselness_zero_eigenvalues() {
1132        // When eigenvalues are near zero, vesselness should be 0
1133        let v = compute_vesselness(0.0, 0.0, 0.0, 0.5, 0.5, 500.0, false);
1134        assert_eq!(v, 0.0);
1135    }
1136
1137    #[test]
1138    fn test_compute_vesselness_bright_vessel() {
1139        // For bright vessels: l2 < 0 and l3 < 0
1140        // Small l1, large negative l2, l3
1141        let v = compute_vesselness(0.01, -1.0, -1.0, 0.5, 0.5, 500.0, false);
1142        assert!(v.is_finite());
1143        assert!(v >= 0.0);
1144    }
1145
1146    #[test]
1147    fn test_compute_vesselness_dark_vessel() {
1148        // For dark vessels (black_white=true): l2 > 0 and l3 > 0
1149        let v = compute_vesselness(0.01, 1.0, 1.0, 0.5, 0.5, 500.0, true);
1150        assert!(v.is_finite());
1151        assert!(v >= 0.0);
1152    }
1153
1154    #[test]
1155    fn test_compute_vesselness_wrong_sign() {
1156        // bright vessel mode but positive eigenvalues -> should be 0
1157        let v = compute_vesselness(0.01, 1.0, 1.0, 0.5, 0.5, 500.0, false);
1158        assert_eq!(v, 0.0);
1159
1160        // dark vessel mode but negative eigenvalues -> should be 0
1161        let v = compute_vesselness(0.01, -1.0, -1.0, 0.5, 0.5, 500.0, true);
1162        assert_eq!(v, 0.0);
1163    }
1164
1165    #[test]
1166    fn test_gaussian_smooth_3d() {
1167        // Smoothing a constant field should return the same field
1168        let n = 8;
1169        let data = vec![5.0; n * n * n];
1170        let smoothed = gaussian_smooth_3d(&data, n, n, n, 1.0);
1171        for &v in &smoothed {
1172            assert!((v - 5.0).abs() < 1e-6, "Smoothing constant should preserve value, got {}", v);
1173        }
1174    }
1175
1176    #[test]
1177    fn test_gaussian_smooth_3d_zero_sigma() {
1178        // Zero sigma should return same data
1179        let n = 4;
1180        let data: Vec<f64> = (0..n * n * n).map(|i| i as f64).collect();
1181        let smoothed = gaussian_smooth_3d(&data, n, n, n, 0.0);
1182        assert_eq!(smoothed, data);
1183    }
1184
1185    #[test]
1186    fn test_gradient_3d_y_direction() {
1187        // Test gradient in y direction
1188        let n = 4;
1189        let mut data = vec![0.0; n * n * n];
1190        // Linear ramp in y
1191        for k in 0..n {
1192            for j in 0..n {
1193                for i in 0..n {
1194                    data[i + j * n + k * n * n] = j as f64;
1195                }
1196            }
1197        }
1198        let grad_y = gradient_3d(&data, n, n, n, 'y');
1199        // Interior points should have gradient ~ 1.0
1200        for k in 0..n {
1201            for j in 1..(n - 1) {
1202                for i in 0..n {
1203                    let idx = i + j * n + k * n * n;
1204                    assert!(
1205                        (grad_y[idx] - 1.0).abs() < 1e-10,
1206                        "y gradient at interior should be 1.0, got {}",
1207                        grad_y[idx]
1208                    );
1209                }
1210            }
1211        }
1212    }
1213
1214    #[test]
1215    fn test_gradient_3d_z_direction() {
1216        // Test gradient in z direction
1217        let n = 4;
1218        let mut data = vec![0.0; n * n * n];
1219        // Linear ramp in z
1220        for k in 0..n {
1221            for j in 0..n {
1222                for i in 0..n {
1223                    data[i + j * n + k * n * n] = k as f64;
1224                }
1225            }
1226        }
1227        let grad_z = gradient_3d(&data, n, n, n, 'z');
1228        // Interior z-points should have gradient ~ 1.0
1229        for k in 1..(n - 1) {
1230            for j in 0..n {
1231                for i in 0..n {
1232                    let idx = i + j * n + k * n * n;
1233                    assert!(
1234                        (grad_z[idx] - 1.0).abs() < 1e-10,
1235                        "z gradient at interior should be 1.0, got {}",
1236                        grad_z[idx]
1237                    );
1238                }
1239            }
1240        }
1241    }
1242
1243    #[test]
1244    fn test_eigenvalues_offdiagonal() {
1245        // Test with off-diagonal elements
1246        // Matrix: [[2, 1, 0], [1, 2, 0], [0, 0, 1]]
1247        // Eigenvalues: 3, 1, 1
1248        let (l1, l2, l3) = eigenvalues_3x3_symmetric(2.0, 2.0, 1.0, 1.0, 0.0, 0.0);
1249        let mut sorted = vec![l1, l2, l3];
1250        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
1251
1252        assert!((sorted[0] - 1.0).abs() < 1e-10);
1253        assert!((sorted[1] - 1.0).abs() < 1e-10);
1254        assert!((sorted[2] - 3.0).abs() < 1e-10);
1255    }
1256
1257    #[test]
1258    fn test_frangi_empty_sigmas_fallback() {
1259        // If scale_range[0] > scale_range[1], the sigmas list would be empty
1260        // The code should fall back to using scale_range[0]
1261        let n = 8;
1262        let data = vec![0.0; n * n * n];
1263        let params = FrangiParams {
1264            scale_range: [5.0, 1.0], // min > max
1265            scale_ratio: 1.0,
1266            ..Default::default()
1267        };
1268
1269        let result = frangi_filter_3d(&data, n, n, n, &params);
1270        assert_eq!(result.vesselness.len(), n * n * n);
1271    }
1272
1273    #[test]
1274    fn test_compute_hessian_3d() {
1275        // Test that Hessian computation runs and produces finite output
1276        let n = 8;
1277        let data: Vec<f64> = (0..n * n * n).map(|i| (i as f64 * 0.01).sin()).collect();
1278
1279        let (dxx, dyy, dzz, dxy, dxz, dyz) = compute_hessian_3d(&data, n, n, n, 1.0);
1280
1281        assert_eq!(dxx.len(), n * n * n);
1282        for &v in dxx.iter().chain(dyy.iter()).chain(dzz.iter())
1283            .chain(dxy.iter()).chain(dxz.iter()).chain(dyz.iter()) {
1284            assert!(v.is_finite(), "Hessian components must be finite");
1285        }
1286    }
1287
1288    #[test]
1289    fn test_convolve_1d_all_directions() {
1290        // Test that convolution in all 3 directions runs on non-trivial data
1291        let n = 6;
1292        let data: Vec<f64> = (0..n * n * n).map(|i| (i as f64) * 0.1).collect();
1293        let kernel = vec![0.25, 0.5, 0.25]; // Simple smoothing kernel
1294
1295        let rx = convolve_1d_direction(&data, n, n, n, &kernel, 'x');
1296        let ry = convolve_1d_direction(&data, n, n, n, &kernel, 'y');
1297        let rz = convolve_1d_direction(&data, n, n, n, &kernel, 'z');
1298
1299        assert_eq!(rx.len(), n * n * n);
1300        assert_eq!(ry.len(), n * n * n);
1301        assert_eq!(rz.len(), n * n * n);
1302
1303        for &v in rx.iter().chain(ry.iter()).chain(rz.iter()) {
1304            assert!(v.is_finite());
1305        }
1306    }
1307}