Skip to main content

qsm_core/utils/
vasculature.rs

1//! Vasculature Mask Generation for QSMART
2//!
3//! This module generates a binary mask identifying blood vessels in the brain.
4//! The vasculature mask is used in QSMART's two-stage processing to separate
5//! tissue from vessel regions, reducing streaking artifacts near veins.
6//!
7//! The algorithm:
8//! 1. Apply morphological bottom-hat filtering to enhance dark tubular structures
9//! 2. Apply Frangi vesselness filter to detect vessels
10//! 3. Threshold: vesselness above 0.2% of maximum = vessel (noise floor filtering)
11//! 4. Return complementary mask (1 = tissue, 0 = vessel)
12
13use crate::utils::frangi::{FrangiParams, frangi_filter_3d};
14
15/// Parameters for vasculature mask generation
16#[derive(Clone, Debug)]
17pub struct VasculatureParams {
18    /// Radius for bottom-hat morphological filter (default 8 voxels)
19    pub sphere_radius: i32,
20    /// Frangi filter scale range [min, max] (default [0.5, 6.0])
21    pub frangi_scale_range: [f64; 2],
22    /// Frangi filter scale ratio (default 0.5)
23    pub frangi_scale_ratio: f64,
24    /// Frangi C parameter (default 500)
25    pub frangi_c: f64,
26}
27
28impl Default for VasculatureParams {
29    fn default() -> Self {
30        Self {
31            sphere_radius: 8,
32            // QSMART Demo defaults: FrangiScaleRange=[0.5,6], FrangiScaleRatio=0.5
33            frangi_scale_range: [0.5, 6.0],
34            frangi_scale_ratio: 0.5,
35            frangi_c: 500.0,
36        }
37    }
38}
39
40/// Generate vasculature mask from magnitude image
41///
42/// # Arguments
43/// * `magnitude` - Average magnitude image (ideally bias-corrected)
44/// * `mask` - Binary brain mask
45/// * `nx`, `ny`, `nz` - Volume dimensions
46/// * `params` - Vasculature detection parameters
47///
48/// # Returns
49/// Complementary vasculature mask (1 = tissue, 0 = vessel)
50pub fn generate_vasculature_mask(
51    magnitude: &[f64],
52    mask: &[u8],
53    nx: usize, ny: usize, nz: usize,
54    params: &VasculatureParams,
55) -> Vec<f64> {
56    generate_vasculature_mask_with_progress(magnitude, mask, nx, ny, nz, params, |_, _| {})
57}
58
59/// Generate vasculature mask with progress callback
60pub fn generate_vasculature_mask_with_progress<F>(
61    magnitude: &[f64],
62    mask: &[u8],
63    nx: usize, ny: usize, nz: usize,
64    params: &VasculatureParams,
65    progress_callback: F,
66) -> Vec<f64>
67where
68    F: Fn(usize, usize),
69{
70    // Step 1: Apply morphological bottom-hat filter to enhance dark structures
71    // Bottom-hat = closing(image) - image
72    progress_callback(0, 4);
73
74    let bottom_hat = morphological_bottom_hat(magnitude, nx, ny, nz, params.sphere_radius);
75
76    // Step 2: Apply mask to bottom-hat result
77    progress_callback(1, 4);
78
79    let masked_bottom_hat: Vec<f64> = bottom_hat.iter()
80        .zip(mask.iter())
81        .map(|(&v, &m)| if m != 0 { v } else { 0.0 })
82        .collect();
83
84    // Step 3: Apply Frangi vesselness filter
85    progress_callback(2, 4);
86
87    let frangi_params = FrangiParams {
88        scale_range: params.frangi_scale_range,
89        scale_ratio: params.frangi_scale_ratio,
90        alpha: 0.5,
91        beta: 0.5,
92        c: params.frangi_c,
93        black_white: false, // Detect bright structures (after bottom-hat)
94    };
95
96    let frangi_result = frangi_filter_3d(&masked_bottom_hat, nx, ny, nz, &frangi_params);
97    let enhanced = frangi_result.vesselness;
98
99    // Step 4: Threshold vesselness
100    // Apply a relative noise floor before thresholding. Our Hessian eigenvalue
101    // computation (Cardano analytical) produces more near-zero vesselness values
102    // than MATLAB's implementation due to numerical differences in eigenvalue signs
103    // for near-degenerate matrices at the mask boundary. A noise floor of 0.2% of
104    // the max vesselness filters these out, matching MATLAB's effective vessel count.
105    progress_callback(3, 4);
106
107    let max_enhanced = enhanced.iter()
108        .zip(mask.iter())
109        .filter(|(_, &m)| m != 0)
110        .map(|(&v, _)| v)
111        .fold(0.0f64, f64::max);
112
113    let noise_floor = max_enhanced * 2e-3;
114
115    // Return complementary mask (1 = tissue, 0 = vessel)
116    let vasc_only: Vec<f64> = enhanced.iter()
117        .zip(mask.iter())
118        .map(|(&v, &m)| {
119            if m == 0 { 1.0 } // outside brain → tissue (excluded)
120            else if v > noise_floor { 0.0 } // significant vesselness → vessel
121            else { 1.0 } // below noise floor → tissue
122        })
123        .collect();
124
125    progress_callback(4, 4);
126
127    vasc_only
128}
129
130/// Spherical structuring element with pre-computed linear offsets for fast access
131struct SphereKernel {
132    /// Linear offsets for interior voxels (no bounds checking needed)
133    linear_offsets: Vec<isize>,
134    /// Coordinate offsets for boundary voxels (need bounds checking)
135    coord_offsets: Vec<(i32, i32, i32)>,
136    /// Radius of the sphere
137    radius: usize,
138}
139
140impl SphereKernel {
141    /// Create a spherical kernel with pre-computed offsets
142    fn new(radius: i32, nx: usize, ny: usize) -> Self {
143        let mut linear_offsets = Vec::new();
144        let mut coord_offsets = Vec::new();
145        let r2 = (radius * radius) as f64;
146        let stride_y = nx as isize;
147        let stride_z = (nx * ny) as isize;
148
149        for dk in -radius..=radius {
150            for dj in -radius..=radius {
151                for di in -radius..=radius {
152                    let dist2 = (di * di + dj * dj + dk * dk) as f64;
153                    if dist2 <= r2 {
154                        // Pre-compute linear offset: di + dj*nx + dk*nx*ny
155                        let linear = di as isize + dj as isize * stride_y + dk as isize * stride_z;
156                        linear_offsets.push(linear);
157                        coord_offsets.push((di, dj, dk));
158                    }
159                }
160            }
161        }
162
163        Self {
164            linear_offsets,
165            coord_offsets,
166            radius: radius as usize,
167        }
168    }
169}
170
171/// Morphological bottom-hat filter (closing - original)
172///
173/// Enhances dark features smaller than the structuring element.
174/// Uses true spherical structuring element for accuracy.
175/// Optimized with pre-computed linear offsets and interior/boundary separation.
176fn morphological_bottom_hat(
177    data: &[f64],
178    nx: usize, ny: usize, nz: usize,
179    radius: i32,
180) -> Vec<f64> {
181    // Pre-compute spherical kernel with linear offsets
182    let kernel = SphereKernel::new(radius, nx, ny);
183
184    // Closing = dilation followed by erosion with spherical SE
185    let dilated = dilate_grayscale_optimized(data, nx, ny, nz, &kernel);
186    let closed = erode_grayscale_optimized(&dilated, nx, ny, nz, &kernel);
187
188    // Bottom-hat = closed - original
189    closed.iter()
190        .zip(data.iter())
191        .map(|(&c, &d)| (c - d).max(0.0))
192        .collect()
193}
194
195/// Optimized grayscale dilation with spherical structuring element
196/// Uses pre-computed linear offsets and separates interior from boundary processing
197fn dilate_grayscale_optimized(
198    data: &[f64],
199    nx: usize, ny: usize, nz: usize,
200    kernel: &SphereKernel,
201) -> Vec<f64> {
202    let n_total = nx * ny * nz;
203    let mut result = vec![0.0f64; n_total];
204    let r = kernel.radius;
205    let stride_z = nx * ny;
206
207    // Process interior voxels (no bounds checking needed) - this is the fast path
208    if nx > 2 * r && ny > 2 * r && nz > 2 * r {
209        for k in r..(nz - r) {
210            let k_offset = k * stride_z;
211            for j in r..(ny - r) {
212                let jk_offset = j * nx + k_offset;
213                for i in r..(nx - r) {
214                    let idx = i + jk_offset;
215                    let mut max_val = f64::NEG_INFINITY;
216
217                    // Fast path: use pre-computed linear offsets, no bounds checking
218                    for &offset in &kernel.linear_offsets {
219                        let neighbor_idx = (idx as isize + offset) as usize;
220                        // Use unsafe for additional speed in inner loop
221                        let val = unsafe { *data.get_unchecked(neighbor_idx) };
222                        if val > max_val {
223                            max_val = val;
224                        }
225                    }
226
227                    unsafe { *result.get_unchecked_mut(idx) = max_val };
228                }
229            }
230        }
231    }
232
233    // Process boundary voxels (need bounds checking) - slower but less frequent
234    let nx_i32 = nx as i32;
235    let ny_i32 = ny as i32;
236    let nz_i32 = nz as i32;
237
238    for k in 0..nz {
239        let k_offset = k * stride_z;
240        let k_is_boundary = k < r || k >= nz - r;
241
242        for j in 0..ny {
243            let jk_offset = j * nx + k_offset;
244            let j_is_boundary = j < r || j >= ny - r;
245
246            for i in 0..nx {
247                let i_is_boundary = i < r || i >= nx - r;
248
249                // Skip interior voxels (already processed)
250                if !k_is_boundary && !j_is_boundary && !i_is_boundary {
251                    continue;
252                }
253
254                let idx = i + jk_offset;
255                let mut max_val = f64::NEG_INFINITY;
256
257                // Slow path: need to check bounds for each neighbor
258                for &(di, dj, dk) in &kernel.coord_offsets {
259                    let ni = i as i32 + di;
260                    let nj = j as i32 + dj;
261                    let nk = k as i32 + dk;
262
263                    if ni >= 0 && ni < nx_i32 &&
264                       nj >= 0 && nj < ny_i32 &&
265                       nk >= 0 && nk < nz_i32 {
266                        let neighbor_idx = ni as usize + nj as usize * nx + nk as usize * stride_z;
267                        max_val = max_val.max(data[neighbor_idx]);
268                    }
269                }
270
271                result[idx] = if max_val.is_finite() { max_val } else { data[idx] };
272            }
273        }
274    }
275
276    result
277}
278
279/// Optimized grayscale erosion with spherical structuring element
280fn erode_grayscale_optimized(
281    data: &[f64],
282    nx: usize, ny: usize, nz: usize,
283    kernel: &SphereKernel,
284) -> Vec<f64> {
285    let n_total = nx * ny * nz;
286    let mut result = vec![0.0f64; n_total];
287    let r = kernel.radius;
288    let stride_z = nx * ny;
289
290    // Process interior voxels (no bounds checking needed) - this is the fast path
291    if nx > 2 * r && ny > 2 * r && nz > 2 * r {
292        for k in r..(nz - r) {
293            let k_offset = k * stride_z;
294            for j in r..(ny - r) {
295                let jk_offset = j * nx + k_offset;
296                for i in r..(nx - r) {
297                    let idx = i + jk_offset;
298                    let mut min_val = f64::INFINITY;
299
300                    // Fast path: use pre-computed linear offsets, no bounds checking
301                    for &offset in &kernel.linear_offsets {
302                        let neighbor_idx = (idx as isize + offset) as usize;
303                        let val = unsafe { *data.get_unchecked(neighbor_idx) };
304                        if val < min_val {
305                            min_val = val;
306                        }
307                    }
308
309                    unsafe { *result.get_unchecked_mut(idx) = min_val };
310                }
311            }
312        }
313    }
314
315    // Process boundary voxels (need bounds checking)
316    let nx_i32 = nx as i32;
317    let ny_i32 = ny as i32;
318    let nz_i32 = nz as i32;
319
320    for k in 0..nz {
321        let k_offset = k * stride_z;
322        let k_is_boundary = k < r || k >= nz - r;
323
324        for j in 0..ny {
325            let jk_offset = j * nx + k_offset;
326            let j_is_boundary = j < r || j >= ny - r;
327
328            for i in 0..nx {
329                let i_is_boundary = i < r || i >= nx - r;
330
331                // Skip interior voxels (already processed)
332                if !k_is_boundary && !j_is_boundary && !i_is_boundary {
333                    continue;
334                }
335
336                let idx = i + jk_offset;
337                let mut min_val = f64::INFINITY;
338
339                for &(di, dj, dk) in &kernel.coord_offsets {
340                    let ni = i as i32 + di;
341                    let nj = j as i32 + dj;
342                    let nk = k as i32 + dk;
343
344                    if ni >= 0 && ni < nx_i32 &&
345                       nj >= 0 && nj < ny_i32 &&
346                       nk >= 0 && nk < nz_i32 {
347                        let neighbor_idx = ni as usize + nj as usize * nx + nk as usize * stride_z;
348                        min_val = min_val.min(data[neighbor_idx]);
349                    }
350                }
351
352                result[idx] = if min_val.is_finite() { min_val } else { data[idx] };
353            }
354        }
355    }
356
357    result
358}
359
360
361/// Simple wrapper with default parameters
362pub fn generate_vasculature_mask_default(
363    magnitude: &[f64],
364    mask: &[u8],
365    nx: usize, ny: usize, nz: usize,
366) -> Vec<f64> {
367    generate_vasculature_mask(magnitude, mask, nx, ny, nz, &VasculatureParams::default())
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn test_bottom_hat_basic() {
376        // Constant image should give zero bottom-hat
377        let data = vec![1.0f64; 27];
378        let result = morphological_bottom_hat(&data, 3, 3, 3, 1);
379
380        for &v in &result {
381            assert!(v.abs() < 0.01, "Bottom-hat of constant should be ~0");
382        }
383    }
384
385    #[test]
386    fn test_vasculature_mask_basic() {
387        // Basic test: should run without panic
388        let magnitude = vec![1.0f64; 1000];
389        let mask = vec![1u8; 1000];
390
391        let params = VasculatureParams {
392            sphere_radius: 2,
393            frangi_scale_range: [1.0, 2.0],
394            frangi_scale_ratio: 1.0,
395            frangi_c: 100.0,
396        };
397
398        let result = generate_vasculature_mask(&magnitude, &mask, 10, 10, 10, &params);
399        assert_eq!(result.len(), 1000);
400    }
401}