Skip to main content

oxicuda_vision/imgproc/
morphology.rs

1//! Mathematical morphology on single-channel `f32` images.
2//!
3//! Operates on a **single-channel** (grayscale or binary) `[h × w]` row-major
4//! `f32` image, matching the [`crate::imgproc::edges`] convention. Binary inputs
5//! are simply images whose values are restricted to `{0.0, 1.0}`; because the
6//! flat (non-weighted) morphological operators reduce to per-pixel `min` / `max`
7//! over a neighbourhood, the same grayscale implementation handles both the
8//! binary (set) and grayscale (function) cases.
9//!
10//! * [`StructuringElement`] — a binary neighbourhood mask with an anchor, built
11//!   via [`StructuringElement::rect`], [`StructuringElement::square`],
12//!   [`StructuringElement::cross`], or [`StructuringElement::new`].
13//! * [`erode`] — local minimum over the structuring element `B`:
14//!   `(f ⊖ B)(x) = min_{b∈B} f(x + b)`.
15//! * [`dilate`] — local maximum over the *reflected* structuring element:
16//!   `(f ⊕ B)(x) = max_{b∈B} f(x − b)`. A single foreground point therefore
17//!   dilates to a translated copy of `B`.
18//! * [`open`] — [`erode`] followed by [`dilate`]; removes small bright details
19//!   (idempotent).
20//! * [`close`] — [`dilate`] followed by [`erode`]; fills small dark holes
21//!   (idempotent).
22//! * [`morphological_gradient`] — `dilate − erode`; highlights object
23//!   boundaries.
24//!
25//! Out-of-bounds samples use **replicate** (clamp-to-edge) border handling,
26//! consistent with the Sobel / Canny operators in this crate.
27
28use crate::error::{VisionError, VisionResult};
29
30/// A binary structuring element (neighbourhood mask) with an anchor.
31///
32/// The mask is a row-major `[height × width]` boolean grid; `true` cells are
33/// members of the neighbourhood. The anchor (`anchor_y`, `anchor_x`) marks the
34/// cell that aligns with the output pixel — typically the geometric centre.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct StructuringElement {
37    /// Element width (number of columns).
38    pub width: usize,
39    /// Element height (number of rows).
40    pub height: usize,
41    /// Anchor column (origin of the element), `< width`.
42    pub anchor_x: usize,
43    /// Anchor row (origin of the element), `< height`.
44    pub anchor_y: usize,
45    /// Row-major `[height × width]` membership mask (`true` = member).
46    pub mask: Vec<bool>,
47}
48
49impl StructuringElement {
50    /// Build a structuring element from an explicit `[height × width]` mask,
51    /// anchored at its geometric centre (`height / 2`, `width / 2`).
52    ///
53    /// # Errors
54    /// Returns [`VisionError::EmptyInput`] if the element is degenerate (zero
55    /// dimension or no member cells) and [`VisionError::DimensionMismatch`] if
56    /// `mask.len() != width * height`.
57    pub fn new(width: usize, height: usize, mask: Vec<bool>) -> VisionResult<Self> {
58        let se = Self {
59            width,
60            height,
61            anchor_x: width / 2,
62            anchor_y: height / 2,
63            mask,
64        };
65        validate_se(&se)?;
66        Ok(se)
67    }
68
69    /// Build a solid (all-member) `width × height` rectangular element anchored
70    /// at its centre.
71    ///
72    /// # Errors
73    /// Returns [`VisionError::EmptyInput`] if either dimension is zero.
74    pub fn rect(width: usize, height: usize) -> VisionResult<Self> {
75        if width == 0 || height == 0 {
76            return Err(VisionError::EmptyInput("structuring element"));
77        }
78        Ok(Self {
79            width,
80            height,
81            anchor_x: width / 2,
82            anchor_y: height / 2,
83            mask: vec![true; width * height],
84        })
85    }
86
87    /// Build a solid `size × size` square element.
88    ///
89    /// # Errors
90    /// Returns [`VisionError::EmptyInput`] if `size == 0`.
91    pub fn square(size: usize) -> VisionResult<Self> {
92        Self::rect(size, size)
93    }
94
95    /// Build a plus-shaped (4-connected) cross element of the given `radius`.
96    ///
97    /// The element is `(2·radius + 1)²` in size with the central row and column
98    /// set. A `radius` of `0` yields the `1 × 1` identity element.
99    #[must_use]
100    pub fn cross(radius: usize) -> Self {
101        let size = 2 * radius + 1;
102        let mut mask = vec![false; size * size];
103        for i in 0..size {
104            mask[radius * size + i] = true;
105            mask[i * size + radius] = true;
106        }
107        Self {
108            width: size,
109            height: size,
110            anchor_x: radius,
111            anchor_y: radius,
112            mask,
113        }
114    }
115
116    /// Collect the active `(dy, dx)` offsets relative to the anchor.
117    fn active_offsets(&self) -> Vec<(isize, isize)> {
118        let mut offsets = Vec::new();
119        for row in 0..self.height {
120            for col in 0..self.width {
121                if self.mask[row * self.width + col] {
122                    offsets.push((
123                        row as isize - self.anchor_y as isize,
124                        col as isize - self.anchor_x as isize,
125                    ));
126                }
127            }
128        }
129        offsets
130    }
131}
132
133/// Validate a structuring element's shape and non-emptiness.
134fn validate_se(se: &StructuringElement) -> VisionResult<()> {
135    if se.width == 0 || se.height == 0 {
136        return Err(VisionError::EmptyInput("structuring element"));
137    }
138    if se.mask.len() != se.width * se.height {
139        return Err(VisionError::DimensionMismatch {
140            expected: se.width * se.height,
141            got: se.mask.len(),
142        });
143    }
144    if se.anchor_x >= se.width || se.anchor_y >= se.height {
145        return Err(VisionError::Internal(format!(
146            "structuring-element anchor ({}, {}) out of bounds for {}×{}",
147            se.anchor_y, se.anchor_x, se.height, se.width
148        )));
149    }
150    if !se.mask.iter().any(|&m| m) {
151        return Err(VisionError::EmptyInput("structuring element"));
152    }
153    Ok(())
154}
155
156/// Validate a single-channel image buffer.
157#[inline]
158fn validate_gray(img: &[f32], h: usize, w: usize) -> VisionResult<()> {
159    if h == 0 || w == 0 {
160        return Err(VisionError::InvalidImageSize {
161            height: h,
162            width: w,
163            channels: 1,
164        });
165    }
166    if img.len() != h * w {
167        return Err(VisionError::DimensionMismatch {
168            expected: h * w,
169            got: img.len(),
170        });
171    }
172    Ok(())
173}
174
175/// Validate the image and structuring element together for an operation.
176fn validate_op(img: &[f32], h: usize, w: usize, se: &StructuringElement) -> VisionResult<()> {
177    validate_gray(img, h, w)?;
178    validate_se(se)?;
179    if se.height > h || se.width > w {
180        return Err(VisionError::Internal(format!(
181            "structuring element {}×{} larger than image {}×{}",
182            se.height, se.width, h, w
183        )));
184    }
185    Ok(())
186}
187
188/// Clamp a signed coordinate into `[0, n)` (replicate / clamp-to-edge border).
189#[inline]
190fn clamp_idx(v: isize, n: usize) -> usize {
191    if v < 0 {
192        0
193    } else if v as usize >= n {
194        n - 1
195    } else {
196        v as usize
197    }
198}
199
200/// Grayscale / binary **erosion**: per-pixel minimum over the structuring
201/// element, `(f ⊖ B)(x) = min_{b∈B} f(x + b)`.
202///
203/// # Errors
204/// Returns [`VisionError::InvalidImageSize`] / [`VisionError::DimensionMismatch`]
205/// for shape problems, [`VisionError::EmptyInput`] for a degenerate element, and
206/// [`VisionError::Internal`] if the element is larger than the image.
207pub fn erode(img: &[f32], h: usize, w: usize, se: &StructuringElement) -> VisionResult<Vec<f32>> {
208    validate_op(img, h, w, se)?;
209    let offsets = se.active_offsets();
210    let mut out = vec![0.0_f32; h * w];
211    for y in 0..h {
212        for x in 0..w {
213            let mut acc = f32::INFINITY;
214            for &(dy, dx) in &offsets {
215                let sy = clamp_idx(y as isize + dy, h);
216                let sx = clamp_idx(x as isize + dx, w);
217                let v = img[sy * w + sx];
218                if v < acc {
219                    acc = v;
220                }
221            }
222            out[y * w + x] = acc;
223        }
224    }
225    Ok(out)
226}
227
228/// Grayscale / binary **dilation**: per-pixel maximum over the reflected
229/// structuring element, `(f ⊕ B)(x) = max_{b∈B} f(x − b)`.
230///
231/// A single foreground point dilates to a translated copy of `B`, so the
232/// operator "grows" bright regions by the element's shape.
233///
234/// # Errors
235/// Same as [`erode`].
236pub fn dilate(img: &[f32], h: usize, w: usize, se: &StructuringElement) -> VisionResult<Vec<f32>> {
237    validate_op(img, h, w, se)?;
238    let offsets = se.active_offsets();
239    let mut out = vec![0.0_f32; h * w];
240    for y in 0..h {
241        for x in 0..w {
242            let mut acc = f32::NEG_INFINITY;
243            for &(dy, dx) in &offsets {
244                // Reflected element: sample at x − b.
245                let sy = clamp_idx(y as isize - dy, h);
246                let sx = clamp_idx(x as isize - dx, w);
247                let v = img[sy * w + sx];
248                if v > acc {
249                    acc = v;
250                }
251            }
252            out[y * w + x] = acc;
253        }
254    }
255    Ok(out)
256}
257
258/// Morphological **opening**: [`erode`] then [`dilate`]. Removes small bright
259/// structures (smaller than the element) while preserving the shape and size of
260/// larger ones. Opening is idempotent: `open(open(f)) = open(f)`.
261///
262/// # Errors
263/// Same as [`erode`].
264pub fn open(img: &[f32], h: usize, w: usize, se: &StructuringElement) -> VisionResult<Vec<f32>> {
265    let eroded = erode(img, h, w, se)?;
266    dilate(&eroded, h, w, se)
267}
268
269/// Morphological **closing**: [`dilate`] then [`erode`]. Fills small dark holes
270/// and gaps (smaller than the element) while preserving larger structures.
271/// Closing is idempotent: `close(close(f)) = close(f)`.
272///
273/// # Errors
274/// Same as [`erode`].
275pub fn close(img: &[f32], h: usize, w: usize, se: &StructuringElement) -> VisionResult<Vec<f32>> {
276    let dilated = dilate(img, h, w, se)?;
277    erode(&dilated, h, w, se)
278}
279
280/// Morphological **gradient**: `dilate(f) − erode(f)`. Produces a thin response
281/// along object boundaries (the difference between the grown and shrunk image).
282///
283/// # Errors
284/// Same as [`erode`].
285pub fn morphological_gradient(
286    img: &[f32],
287    h: usize,
288    w: usize,
289    se: &StructuringElement,
290) -> VisionResult<Vec<f32>> {
291    let dilated = dilate(img, h, w, se)?;
292    let eroded = erode(img, h, w, se)?;
293    let out = dilated
294        .iter()
295        .zip(eroded.iter())
296        .map(|(&d, &e)| d - e)
297        .collect();
298    Ok(out)
299}
300
301// ─── Tests ───────────────────────────────────────────────────────────────────
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    /// Image with a single foreground pixel at `(y, x)`.
308    fn single_pixel(h: usize, w: usize, y: usize, x: usize) -> Vec<f32> {
309        let mut img = vec![0.0_f32; h * w];
310        img[y * w + x] = 1.0;
311        img
312    }
313
314    /// Image with a solid `[r0..r1] × [c0..c1]` block of foreground.
315    fn block(h: usize, w: usize, r0: usize, r1: usize, c0: usize, c1: usize) -> Vec<f32> {
316        let mut img = vec![0.0_f32; h * w];
317        for y in r0..r1 {
318            for x in c0..c1 {
319                img[y * w + x] = 1.0;
320            }
321        }
322        img
323    }
324
325    #[test]
326    fn erode_removes_isolated_pixel() {
327        let img = single_pixel(7, 7, 3, 3);
328        let se = StructuringElement::square(3).expect("se");
329        let out = erode(&img, 7, 7, &se).expect("erode");
330        let total: f32 = out.iter().sum();
331        assert_eq!(total, 0.0, "erosion must remove an isolated pixel");
332    }
333
334    #[test]
335    fn dilate_grows_single_pixel_to_se() {
336        let img = single_pixel(5, 5, 2, 2);
337        let se = StructuringElement::square(3).expect("se");
338        let out = dilate(&img, 5, 5, &se).expect("dilate");
339        for y in 0..5 {
340            for x in 0..5 {
341                let expected = if (1..=3).contains(&y) && (1..=3).contains(&x) {
342                    1.0
343                } else {
344                    0.0
345                };
346                assert_eq!(
347                    out[y * 5 + x],
348                    expected,
349                    "dilation of a point must reproduce the 3×3 element at ({y},{x})"
350                );
351            }
352        }
353    }
354
355    #[test]
356    fn dilate_constant_image_unchanged() {
357        let img = vec![0.37_f32; 6 * 6];
358        let se = StructuringElement::square(3).expect("se");
359        let out = dilate(&img, 6, 6, &se).expect("dilate");
360        assert!(out.iter().all(|&v| (v - 0.37).abs() < 1e-6));
361    }
362
363    #[test]
364    fn erode_constant_image_unchanged() {
365        let img = vec![0.42_f32; 6 * 6];
366        let se = StructuringElement::square(3).expect("se");
367        let out = erode(&img, 6, 6, &se).expect("erode");
368        assert!(out.iter().all(|&v| (v - 0.42).abs() < 1e-6));
369    }
370
371    #[test]
372    fn cross_se_dilation_shape() {
373        let img = single_pixel(5, 5, 2, 2);
374        let se = StructuringElement::cross(1);
375        let out = dilate(&img, 5, 5, &se).expect("dilate");
376        let total: f32 = out.iter().sum();
377        assert_eq!(total, 5.0, "plus-shaped element has 5 members");
378        // The plus arms.
379        for &(y, x) in &[(2usize, 2usize), (1, 2), (3, 2), (2, 1), (2, 3)] {
380            assert_eq!(out[y * 5 + x], 1.0);
381        }
382        // Diagonal corner must remain background.
383        assert_eq!(out[3 * 5 + 3], 0.0);
384    }
385
386    #[test]
387    fn opening_removes_noise_keeps_blob() {
388        // 8×8: a solid 4×4 block (rows 2..6, cols 2..6) plus a single noise pixel.
389        let mut img = block(8, 8, 2, 6, 2, 6);
390        img[0] = 1.0; // isolated noise at (0, 0)
391        let se = StructuringElement::square(3).expect("se");
392        let out = open(&img, 8, 8, &se).expect("open");
393        // Noise gone.
394        assert_eq!(out[0], 0.0, "opening must remove the isolated noise pixel");
395        // Block restored exactly (16 pixels, rows/cols 2..6).
396        let total: f32 = out.iter().sum();
397        assert_eq!(total, 16.0, "opening must preserve the 4×4 blob");
398        for y in 2..6 {
399            for x in 2..6 {
400                assert_eq!(out[y * 8 + x], 1.0);
401            }
402        }
403    }
404
405    #[test]
406    fn closing_fills_small_hole() {
407        // Solid 4×4 block with a single-pixel hole punched at its centre.
408        let mut img = block(8, 8, 2, 6, 2, 6);
409        img[3 * 8 + 3] = 0.0; // hole
410        let se = StructuringElement::square(3).expect("se");
411        let out = close(&img, 8, 8, &se).expect("close");
412        assert_eq!(out[3 * 8 + 3], 1.0, "closing must fill the interior hole");
413        // The block is fully solid afterwards, no spill outside.
414        let total: f32 = out.iter().sum();
415        assert_eq!(total, 16.0);
416    }
417
418    #[test]
419    fn opening_is_idempotent() {
420        // Interior features with generous margin so border effects vanish.
421        let mut img = block(12, 12, 5, 9, 5, 9);
422        img[3 * 12 + 3] = 1.0; // interior noise, margin ≥ 3 from every border
423        let se = StructuringElement::square(3).expect("se");
424        let once = open(&img, 12, 12, &se).expect("open");
425        let twice = open(&once, 12, 12, &se).expect("open");
426        for (a, b) in once.iter().zip(twice.iter()) {
427            assert!((a - b).abs() < 1e-6, "opening must be idempotent");
428        }
429    }
430
431    #[test]
432    fn closing_is_idempotent() {
433        let mut img = block(12, 12, 4, 9, 4, 9);
434        img[6 * 12 + 6] = 0.0; // interior hole
435        let se = StructuringElement::square(3).expect("se");
436        let once = close(&img, 12, 12, &se).expect("close");
437        let twice = close(&once, 12, 12, &se).expect("close");
438        for (a, b) in once.iter().zip(twice.iter()) {
439            assert!((a - b).abs() < 1e-6, "closing must be idempotent");
440        }
441    }
442
443    #[test]
444    fn morph_gradient_outlines_point() {
445        let img = single_pixel(5, 5, 2, 2);
446        let se = StructuringElement::square(3).expect("se");
447        let grad = morphological_gradient(&img, 5, 5, &se).expect("gradient");
448        // erode → all zero, dilate → 3×3 block ⇒ gradient is the 3×3 block.
449        let total: f32 = grad.iter().sum();
450        assert_eq!(total, 9.0);
451        assert!(grad.iter().all(|&v| v >= 0.0));
452    }
453
454    #[test]
455    fn se_larger_than_image_errors() {
456        let img = vec![0.0_f32; 4 * 4];
457        let se = StructuringElement::square(7).expect("se");
458        assert!(matches!(
459            erode(&img, 4, 4, &se),
460            Err(VisionError::Internal(_))
461        ));
462        assert!(matches!(
463            dilate(&img, 4, 4, &se),
464            Err(VisionError::Internal(_))
465        ));
466    }
467
468    #[test]
469    fn empty_se_errors() {
470        // All-false mask → no members.
471        let r = StructuringElement::new(3, 3, vec![false; 9]);
472        assert!(matches!(r, Err(VisionError::EmptyInput(_))));
473        // Zero dimension.
474        assert!(matches!(
475            StructuringElement::rect(0, 3),
476            Err(VisionError::EmptyInput(_))
477        ));
478    }
479
480    #[test]
481    fn se_mask_length_mismatch_errors() {
482        let r = StructuringElement::new(3, 3, vec![true; 8]);
483        assert!(matches!(r, Err(VisionError::DimensionMismatch { .. })));
484    }
485
486    #[test]
487    fn wrong_image_size_errors() {
488        let img = vec![0.0_f32; 10];
489        let se = StructuringElement::square(3).expect("se");
490        assert!(matches!(
491            erode(&img, 8, 8, &se),
492            Err(VisionError::DimensionMismatch { .. })
493        ));
494    }
495
496    #[test]
497    fn zero_dim_image_errors() {
498        let img: Vec<f32> = vec![];
499        let se = StructuringElement::square(3).expect("se");
500        assert!(matches!(
501            dilate(&img, 0, 8, &se),
502            Err(VisionError::InvalidImageSize { .. })
503        ));
504    }
505
506    #[test]
507    fn erode_is_anti_extensive_dilate_extensive() {
508        // For any input, erosion ≤ f ≤ dilation pointwise (with a centred SE).
509        let img = block(8, 8, 2, 6, 1, 5);
510        let se = StructuringElement::square(3).expect("se");
511        let er = erode(&img, 8, 8, &se).expect("erode");
512        let di = dilate(&img, 8, 8, &se).expect("dilate");
513        for ((&e, &f), &d) in er.iter().zip(img.iter()).zip(di.iter()) {
514            assert!(e <= f + 1e-6, "erosion must be anti-extensive");
515            assert!(d + 1e-6 >= f, "dilation must be extensive");
516        }
517    }
518}