Skip to main content

scirs2_ndimage/
morphology_advanced.rs

1//! Advanced mathematical morphology transforms
2//!
3//! This module provides extended morphological operations beyond the basics:
4//! - Rolling-ball background subtraction
5//! - White and black top-hat transforms
6//! - Morphological gradient
7//! - Toggle contrast mapping
8//! - Hit-or-miss transform
9
10use crate::error::{NdimageError, NdimageResult};
11use scirs2_core::ndarray::Array2;
12use std::collections::VecDeque;
13
14// ─── Structuring element helpers ─────────────────────────────────────────────
15
16/// A flat 2-D structuring element (SE), represented as a boolean mask.
17///
18/// The anchor is the centre pixel `(rows/2, cols/2)`.
19#[derive(Debug, Clone)]
20pub struct StructuringElement {
21    /// 2-D boolean mask (true = SE member)
22    pub mask: Array2<bool>,
23}
24
25impl StructuringElement {
26    /// Create a disk (filled circle) structuring element with given `radius`.
27    pub fn disk(radius: usize) -> Self {
28        let side = 2 * radius + 1;
29        let c = radius as f64;
30        let mask = Array2::from_shape_fn((side, side), |(r, col)| {
31            let dr = r as f64 - c;
32            let dc = col as f64 - c;
33            dr * dr + dc * dc <= (radius as f64).powi(2) + 1e-9
34        });
35        StructuringElement { mask }
36    }
37
38    /// Create a square (all-ones) structuring element with side length `2*half+1`.
39    pub fn square(half: usize) -> Self {
40        let side = 2 * half + 1;
41        let mask = Array2::from_elem((side, side), true);
42        StructuringElement { mask }
43    }
44
45    /// Create a cross (plus) structuring element with given `radius`.
46    pub fn cross(radius: usize) -> Self {
47        let side = 2 * radius + 1;
48        let cr = radius;
49        let mask = Array2::from_shape_fn((side, side), |(r, c)| r == cr || c == cr);
50        StructuringElement { mask }
51    }
52
53    /// Number of rows in the SE mask.
54    pub fn rows(&self) -> usize {
55        self.mask.nrows()
56    }
57
58    /// Number of columns in the SE mask.
59    pub fn cols(&self) -> usize {
60        self.mask.ncols()
61    }
62
63    /// Row index of the anchor (centre).
64    pub fn anchor_row(&self) -> usize {
65        self.rows() / 2
66    }
67
68    /// Column index of the anchor (centre).
69    pub fn anchor_col(&self) -> usize {
70        self.cols() / 2
71    }
72}
73
74// ─── Core erosion / dilation helpers ────────────────────────────────────────
75
76/// Grayscale erosion: replace each pixel by the minimum value in its SE neighbourhood.
77pub fn erode(image: &Array2<f64>, se: &StructuringElement) -> NdimageResult<Array2<f64>> {
78    let rows = image.nrows();
79    let cols = image.ncols();
80    if rows == 0 || cols == 0 {
81        return Err(NdimageError::InvalidInput("Image must not be empty".into()));
82    }
83    let ar = se.anchor_row() as isize;
84    let ac = se.anchor_col() as isize;
85    let mut out = Array2::<f64>::from_elem((rows, cols), f64::INFINITY);
86    for r in 0..rows {
87        for c in 0..cols {
88            let mut min_val = f64::INFINITY;
89            for sr in 0..se.rows() {
90                for sc in 0..se.cols() {
91                    if !se.mask[[sr, sc]] {
92                        continue;
93                    }
94                    let nr = r as isize + sr as isize - ar;
95                    let nc = c as isize + sc as isize - ac;
96                    if nr < 0 || nc < 0 || nr >= rows as isize || nc >= cols as isize {
97                        // Reflect border: clamp
98                        let nr = nr.max(0).min(rows as isize - 1) as usize;
99                        let nc = nc.max(0).min(cols as isize - 1) as usize;
100                        let v = image[[nr, nc]];
101                        if v < min_val {
102                            min_val = v;
103                        }
104                    } else {
105                        let v = image[[nr as usize, nc as usize]];
106                        if v < min_val {
107                            min_val = v;
108                        }
109                    }
110                }
111            }
112            out[[r, c]] = min_val;
113        }
114    }
115    Ok(out)
116}
117
118/// Grayscale dilation: replace each pixel by the maximum value in its SE neighbourhood.
119pub fn dilate(image: &Array2<f64>, se: &StructuringElement) -> NdimageResult<Array2<f64>> {
120    let rows = image.nrows();
121    let cols = image.ncols();
122    if rows == 0 || cols == 0 {
123        return Err(NdimageError::InvalidInput("Image must not be empty".into()));
124    }
125    let ar = se.anchor_row() as isize;
126    let ac = se.anchor_col() as isize;
127    let mut out = Array2::<f64>::from_elem((rows, cols), f64::NEG_INFINITY);
128    for r in 0..rows {
129        for c in 0..cols {
130            let mut max_val = f64::NEG_INFINITY;
131            for sr in 0..se.rows() {
132                for sc in 0..se.cols() {
133                    if !se.mask[[sr, sc]] {
134                        continue;
135                    }
136                    let nr = r as isize + sr as isize - ar;
137                    let nc = c as isize + sc as isize - ac;
138                    let nr = nr.max(0).min(rows as isize - 1) as usize;
139                    let nc = nc.max(0).min(cols as isize - 1) as usize;
140                    let v = image[[nr, nc]];
141                    if v > max_val {
142                        max_val = v;
143                    }
144                }
145            }
146            out[[r, c]] = max_val;
147        }
148    }
149    Ok(out)
150}
151
152// ─── Rolling-Ball Background Subtraction ────────────────────────────────────
153
154/// Rolling-ball background estimation for fluorescence microscopy images.
155///
156/// Estimates a smoothly varying background by rolling a spherical ball of
157/// the given `radius` under the image intensity surface. The background is
158/// the minimum of the image in a disk neighbourhood, additionally
159/// flattened by an opening with a disk of the same radius.
160///
161/// # Arguments
162/// * `image`  – 2-D grayscale image (should be non-negative)
163/// * `radius` – ball radius in pixels
164///
165/// # Returns
166/// Background image of same shape; subtract from original to obtain
167/// foreground objects.
168pub fn rolling_ball_background(image: &Array2<f64>, radius: f64) -> NdimageResult<Array2<f64>> {
169    if radius <= 0.0 {
170        return Err(NdimageError::InvalidInput("radius must be positive".into()));
171    }
172    let rows = image.nrows();
173    let cols = image.ncols();
174    if rows == 0 || cols == 0 {
175        return Err(NdimageError::InvalidInput("Image must not be empty".into()));
176    }
177
178    let r_int = radius.ceil() as usize;
179    let se = StructuringElement::disk(r_int);
180
181    // Opening = dilation of erosion
182    let eroded = erode(image, &se)?;
183    let background = dilate(&eroded, &se)?;
184    Ok(background)
185}
186
187// ─── Top-Hat Transforms ─────────────────────────────────────────────────────
188
189/// White top-hat transform: image minus its morphological opening.
190///
191/// Extracts bright structures smaller than the SE (spots, thin lines).
192///
193/// # Arguments
194/// * `image` – 2-D grayscale image
195/// * `se`    – flat structuring element
196pub fn top_hat(image: &Array2<f64>, se: &StructuringElement) -> NdimageResult<Array2<f64>> {
197    // Opening = dilation(erosion(image))
198    let eroded = erode(image, se)?;
199    let opened = dilate(&eroded, se)?;
200    let rows = image.nrows();
201    let cols = image.ncols();
202    let mut result = Array2::<f64>::zeros((rows, cols));
203    for r in 0..rows {
204        for c in 0..cols {
205            result[[r, c]] = (image[[r, c]] - opened[[r, c]]).max(0.0);
206        }
207    }
208    Ok(result)
209}
210
211/// Black top-hat transform (bottom-hat): morphological closing minus image.
212///
213/// Extracts dark structures smaller than the SE (holes, dark spots).
214///
215/// # Arguments
216/// * `image` – 2-D grayscale image
217/// * `se`    – flat structuring element
218pub fn black_hat(image: &Array2<f64>, se: &StructuringElement) -> NdimageResult<Array2<f64>> {
219    // Closing = erosion(dilation(image))
220    let dilated = dilate(image, se)?;
221    let closed = erode(&dilated, se)?;
222    let rows = image.nrows();
223    let cols = image.ncols();
224    let mut result = Array2::<f64>::zeros((rows, cols));
225    for r in 0..rows {
226        for c in 0..cols {
227            result[[r, c]] = (closed[[r, c]] - image[[r, c]]).max(0.0);
228        }
229    }
230    Ok(result)
231}
232
233// ─── Morphological Gradient ──────────────────────────────────────────────────
234
235/// Morphological gradient: dilation minus erosion.
236///
237/// Highlights object boundaries and contours in grayscale images.
238///
239/// # Arguments
240/// * `image` – 2-D grayscale image
241/// * `se`    – flat structuring element
242pub fn morphological_gradient(
243    image: &Array2<f64>,
244    se: &StructuringElement,
245) -> NdimageResult<Array2<f64>> {
246    let dil = dilate(image, se)?;
247    let ero = erode(image, se)?;
248    let rows = image.nrows();
249    let cols = image.ncols();
250    let mut result = Array2::<f64>::zeros((rows, cols));
251    for r in 0..rows {
252        for c in 0..cols {
253            result[[r, c]] = dil[[r, c]] - ero[[r, c]];
254        }
255    }
256    Ok(result)
257}
258
259// ─── Toggle Contrast ─────────────────────────────────────────────────────────
260
261/// Toggle mapping (contrast enhancement via morphological toggle).
262///
263/// For each pixel x, assigns `dilation(x)` if the pixel is closer to its
264/// dilated value, else `erosion(x)`. Sharpens edges while preserving
265/// extremal regions.
266///
267/// # Arguments
268/// * `image`    – 2-D grayscale image
269/// * `se_inner` – inner (smaller) structuring element for erosion
270/// * `se_outer` – outer (larger) structuring element for dilation
271pub fn toggle_contrast(
272    image: &Array2<f64>,
273    se_inner: &StructuringElement,
274    se_outer: &StructuringElement,
275) -> NdimageResult<Array2<f64>> {
276    let dil = dilate(image, se_outer)?;
277    let ero = erode(image, se_inner)?;
278    let rows = image.nrows();
279    let cols = image.ncols();
280    let mut result = Array2::<f64>::zeros((rows, cols));
281    for r in 0..rows {
282        for c in 0..cols {
283            let v = image[[r, c]];
284            let d = dil[[r, c]];
285            let e = ero[[r, c]];
286            result[[r, c]] = if (v - d).abs() <= (v - e).abs() { d } else { e };
287        }
288    }
289    Ok(result)
290}
291
292// ─── Hit-or-Miss Transform ───────────────────────────────────────────────────
293
294/// Hit-or-miss transform for template matching in grayscale images.
295///
296/// Detects pixels where the foreground SE (`fg_se`) fits within the image
297/// AND the background SE (`bg_se`) fits within the complement (regions
298/// below the image values).
299///
300/// For binary images, this reduces to the classical hit-or-miss transform.
301/// For grayscale images, a pixel at position p hits if:
302///   `erosion(image, fg_se)[p] > dilation(image, bg_se)[p]`
303///
304/// # Arguments
305/// * `image` – 2-D grayscale image (values in [0, 1] typical)
306/// * `fg_se` – foreground structuring element (fit to bright regions)
307/// * `bg_se` – background structuring element (fit to dark regions)
308///
309/// # Returns
310/// Boolean hit mask of same shape.
311pub fn hit_or_miss(
312    image: &Array2<f64>,
313    fg_se: &StructuringElement,
314    bg_se: &StructuringElement,
315) -> NdimageResult<Array2<bool>> {
316    let rows = image.nrows();
317    let cols = image.ncols();
318    if rows == 0 || cols == 0 {
319        return Err(NdimageError::InvalidInput("Image must not be empty".into()));
320    }
321
322    let ero_fg = erode(image, fg_se)?;
323    let dil_bg = dilate(image, bg_se)?;
324
325    let mut result = Array2::<bool>::from_elem((rows, cols), false);
326    for r in 0..rows {
327        for c in 0..cols {
328            result[[r, c]] = ero_fg[[r, c]] > dil_bg[[r, c]];
329        }
330    }
331    Ok(result)
332}
333
334// ─── Tests ───────────────────────────────────────────────────────────────────
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use scirs2_core::ndarray::Array2;
340
341    /// Create a simple test image with a bright spot in the centre.
342    fn bright_spot_image(rows: usize, cols: usize) -> Array2<f64> {
343        let cr = rows / 2;
344        let cc = cols / 2;
345        Array2::from_shape_fn((rows, cols), |(r, c)| {
346            let dr = r as f64 - cr as f64;
347            let dc = c as f64 - cc as f64;
348            if dr * dr + dc * dc < 4.0 {
349                1.0
350            } else {
351                0.1
352            }
353        })
354    }
355
356    fn step_image(rows: usize, cols: usize) -> Array2<f64> {
357        Array2::from_shape_fn((rows, cols), |(_, c)| if c < cols / 2 { 0.0 } else { 1.0 })
358    }
359
360    // ── StructuringElement tests ─────────────────────────────────────────────
361
362    #[test]
363    fn test_disk_se_centre_true() {
364        let se = StructuringElement::disk(2);
365        let r = se.anchor_row();
366        let c = se.anchor_col();
367        assert!(se.mask[[r, c]]);
368    }
369
370    #[test]
371    fn test_square_se_all_true() {
372        let se = StructuringElement::square(1);
373        assert_eq!(se.rows(), 3);
374        assert_eq!(se.cols(), 3);
375        assert!(se.mask.iter().all(|&v| v));
376    }
377
378    // ── Rolling-ball tests ───────────────────────────────────────────────────
379
380    #[test]
381    fn test_rolling_ball_background_shape() {
382        let img = bright_spot_image(16, 16);
383        let bg = rolling_ball_background(&img, 3.0).expect("rolling ball failed");
384        assert_eq!(bg.shape(), img.shape());
385    }
386
387    #[test]
388    fn test_rolling_ball_background_lte_image() {
389        // Background must be <= original image (opening property)
390        let img = bright_spot_image(12, 12);
391        let bg = rolling_ball_background(&img, 2.0).expect("rolling ball");
392        for r in 0..img.nrows() {
393            for c in 0..img.ncols() {
394                assert!(
395                    bg[[r, c]] <= img[[r, c]] + 1e-9,
396                    "bg > img at ({r},{c}): {} > {}",
397                    bg[[r, c]],
398                    img[[r, c]]
399                );
400            }
401        }
402    }
403
404    #[test]
405    fn test_rolling_ball_invalid_radius() {
406        let img = Array2::<f64>::zeros((4, 4));
407        assert!(rolling_ball_background(&img, 0.0).is_err());
408        assert!(rolling_ball_background(&img, -1.0).is_err());
409    }
410
411    // ── Top-hat tests ────────────────────────────────────────────────────────
412
413    #[test]
414    fn test_top_hat_bright_spot_detected() {
415        let img = bright_spot_image(16, 16);
416        let se = StructuringElement::disk(3);
417        let th = top_hat(&img, &se).expect("top hat failed");
418        assert_eq!(th.shape(), img.shape());
419        // Bright spot should survive top-hat
420        let cr = img.nrows() / 2;
421        let cc = img.ncols() / 2;
422        assert!(th[[cr, cc]] > 0.0, "Centre should be > 0 after top-hat");
423    }
424
425    #[test]
426    fn test_top_hat_uniform_image_zero() {
427        let img = Array2::<f64>::from_elem((8, 8), 0.5);
428        let se = StructuringElement::square(1);
429        let th = top_hat(&img, &se).expect("top hat uniform");
430        // Uniform image: opening = image, so top-hat = 0
431        assert!(th.iter().all(|&v| v.abs() < 1e-10));
432    }
433
434    // ── Black-hat tests ──────────────────────────────────────────────────────
435
436    #[test]
437    fn test_black_hat_dark_hole() {
438        // Image with a dark hole in the centre
439        let img = Array2::from_shape_fn((16, 16), |(r, c)| {
440            let cr = 8usize;
441            let cc = 8usize;
442            let dr = r as f64 - cr as f64;
443            let dc = c as f64 - cc as f64;
444            if dr * dr + dc * dc < 4.0 {
445                0.0
446            } else {
447                0.9
448            }
449        });
450        let se = StructuringElement::disk(3);
451        let bh = black_hat(&img, &se).expect("black hat failed");
452        assert_eq!(bh.shape(), img.shape());
453        let cr = img.nrows() / 2;
454        let cc = img.ncols() / 2;
455        assert!(
456            bh[[cr, cc]] > 0.0,
457            "Dark hole should be detected by black-hat"
458        );
459    }
460
461    #[test]
462    fn test_black_hat_uniform_zero() {
463        let img = Array2::<f64>::from_elem((8, 8), 0.5);
464        let se = StructuringElement::square(1);
465        let bh = black_hat(&img, &se).expect("black hat uniform");
466        assert!(bh.iter().all(|&v| v.abs() < 1e-10));
467    }
468
469    // ── Morphological gradient tests ─────────────────────────────────────────
470
471    #[test]
472    fn test_morphological_gradient_step_edge() {
473        let img = step_image(8, 8);
474        let se = StructuringElement::square(1);
475        let grad = morphological_gradient(&img, &se).expect("morphological gradient failed");
476        assert_eq!(grad.shape(), img.shape());
477        // Gradient should be non-zero near the step
478        let col = 4; // edge column
479        assert!(grad[[4, col]] > 0.0 || grad[[4, col - 1]] > 0.0);
480    }
481
482    #[test]
483    fn test_morphological_gradient_uniform_zero() {
484        let img = Array2::<f64>::from_elem((8, 8), 0.5);
485        let se = StructuringElement::square(1);
486        let grad = morphological_gradient(&img, &se).expect("gradient uniform");
487        // Uniform: dilation == erosion == image, gradient == 0
488        assert!(grad.iter().all(|&v| v.abs() < 1e-10));
489    }
490
491    // ── Toggle contrast tests ────────────────────────────────────────────────
492
493    #[test]
494    fn test_toggle_contrast_shape() {
495        let img = bright_spot_image(12, 12);
496        let se_inner = StructuringElement::cross(1);
497        let se_outer = StructuringElement::disk(2);
498        let tc = toggle_contrast(&img, &se_inner, &se_outer).expect("toggle contrast failed");
499        assert_eq!(tc.shape(), img.shape());
500    }
501
502    #[test]
503    fn test_toggle_contrast_extreme_values() {
504        // Constant image: toggle should leave it unchanged or produce valid values
505        let img = Array2::<f64>::from_elem((6, 6), 0.5);
506        let se = StructuringElement::square(1);
507        let tc = toggle_contrast(&img, &se, &se).expect("toggle contrast const");
508        assert!(tc.iter().all(|&v| v.is_finite()));
509    }
510
511    // ── Hit-or-miss tests ────────────────────────────────────────────────────
512
513    #[test]
514    fn test_hit_or_miss_bright_peak() {
515        // Bright isolated pixel surrounded by dark
516        let mut img = Array2::<f64>::from_elem((8, 8), 0.0);
517        img[[4, 4]] = 1.0;
518        let fg_se = StructuringElement::disk(0); // 1×1 foreground SE
519        let bg_se = StructuringElement::disk(0); // 1×1 background SE (will not overlap with same anchor)
520                                                 // Use a cross: centre is fg, arms are bg
521        let fg_se = StructuringElement {
522            mask: {
523                let mut m = Array2::from_elem((3, 3), false);
524                m[[1, 1]] = true;
525                m
526            },
527        };
528        let bg_se = StructuringElement {
529            mask: {
530                let mut m = Array2::from_elem((3, 3), false);
531                m[[0, 1]] = true;
532                m[[2, 1]] = true;
533                m[[1, 0]] = true;
534                m[[1, 2]] = true;
535                m
536            },
537        };
538        let hom = hit_or_miss(&img, &fg_se, &bg_se).expect("hit or miss failed");
539        assert_eq!(hom.shape(), img.shape());
540        // The bright peak at (4,4) surrounded by 0s should hit
541        assert!(hom[[4, 4]], "Bright isolated pixel should produce a hit");
542    }
543
544    #[test]
545    fn test_hit_or_miss_no_hit_uniform() {
546        // Uniform image: erosion == dilation, so no hits
547        let img = Array2::<f64>::from_elem((8, 8), 0.5);
548        let se = StructuringElement::square(1);
549        let hom = hit_or_miss(&img, &se, &se).expect("hit or miss uniform");
550        // On a uniform image, erosion == dilation, so erosion(fg) <= dilation(bg): no hits
551        assert!(!hom.iter().any(|&v| v), "Uniform image should have no hits");
552    }
553}