Skip to main content

oxigdal_algorithms/raster/
morphology_compound.rs

1//! Compound morphological operators on flat `f32` rasters.
2//!
3//! This module provides ergonomic, slice-based compound morphological
4//! operators that complement the [`crate::raster::morphology`] module's
5//! [`crate::raster::morphology::dilate`] / [`crate::raster::morphology::erode`]
6//! primitives.
7//!
8//! ## Compound morphological operators
9//! - [`opening`] = erode -> dilate (removes small bright features)
10//! - [`closing`] = dilate -> erode (fills small dark gaps)
11//! - [`top_hat`] = raster - opening (extracts small bright features)
12//! - [`black_hat`] = closing - raster (extracts small dark features)
13//!
14//! The slice-based primitives [`erode_slice`] and [`dilate_slice`] use a
15//! square structuring element of `kernel_size x kernel_size` with edge
16//! replication. They operate on row-major `&[f32]` buffers, which is the
17//! standard layout used elsewhere in the crate for raw raster data.
18//!
19//! The functions are intentionally allocation-friendly and pure: each call
20//! returns a fresh `Vec<f32>` of length `width * height`. For repeated
21//! application (e.g. iteration counts > 1) the option-bag variants
22//! [`opening_with`], [`closing_with`], [`top_hat_with`] and [`black_hat_with`]
23//! avoid re-allocating the option struct and accept an explicit iteration
24//! count.
25//!
26//! NOTE: [`BorderMode`] is currently exposed as part of the options struct
27//! as a forward-compatible knob. The primitives in this module use edge
28//! replication (`Replicate`) unconditionally — the variant is honoured by
29//! the slice-based primitives so users can opt into [`BorderMode::Reflect`]
30//! or [`BorderMode::Constant`].
31
32#[cfg(not(feature = "std"))]
33use alloc::vec::Vec;
34
35/// Boundary handling strategy for slice-based morphology kernels.
36///
37/// The default is [`BorderMode::Replicate`], matching the conventional
38/// behaviour of the buffer-based morphology operators in
39/// [`crate::raster::morphology`].
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub enum BorderMode {
42    /// Replicate edge pixels (default).
43    Replicate,
44    /// Mirror pixels at the border.
45    Reflect,
46    /// Pad with a constant value.
47    Constant(f32),
48}
49
50impl Default for BorderMode {
51    fn default() -> Self {
52        Self::Replicate
53    }
54}
55
56/// Option bag for the compound morphological operators.
57///
58/// # Fields
59///
60/// * `kernel_size` — size of the square structuring element (must be odd and
61///   positive). A `kernel_size` of `3` yields a 3x3 square neighbourhood.
62/// * `iterations` — number of times to repeat the compound operator. Useful
63///   for cascading openings/closings (a.k.a. alternating-sequential filters).
64/// * `border_mode` — boundary handling strategy. See [`BorderMode`].
65#[derive(Debug, Clone, Copy)]
66pub struct MorphologyOptions {
67    /// Size of the square structuring element.
68    pub kernel_size: usize,
69    /// Number of iterations of the compound operator.
70    pub iterations: u32,
71    /// Boundary handling strategy.
72    pub border_mode: BorderMode,
73}
74
75impl Default for MorphologyOptions {
76    fn default() -> Self {
77        Self {
78            kernel_size: 3,
79            iterations: 1,
80            border_mode: BorderMode::default(),
81        }
82    }
83}
84
85impl MorphologyOptions {
86    /// Create a new option bag with the given kernel size, default iterations
87    /// and border mode.
88    pub fn new(kernel_size: usize) -> Self {
89        Self {
90            kernel_size,
91            ..Self::default()
92        }
93    }
94
95    /// Set the iteration count (consuming builder style).
96    pub fn with_iterations(mut self, n: u32) -> Self {
97        self.iterations = n;
98        self
99    }
100
101    /// Set the border mode (consuming builder style).
102    pub fn with_border_mode(mut self, m: BorderMode) -> Self {
103        self.border_mode = m;
104        self
105    }
106}
107
108/// Sample a pixel from `raster` at signed coordinates with the given border
109/// mode. Indices outside `[0, width) x [0, height)` are handled according to
110/// `mode`.
111#[inline]
112fn sample(
113    raster: &[f32],
114    width: usize,
115    height: usize,
116    x: i64,
117    y: i64,
118    mode: BorderMode,
119) -> Option<f32> {
120    let w = width as i64;
121    let h = height as i64;
122
123    let (sx, sy) = match mode {
124        BorderMode::Replicate => (x.clamp(0, w - 1), y.clamp(0, h - 1)),
125        BorderMode::Reflect => {
126            // Reflect such that -1 -> 0, -2 -> 1, w -> w-1, w+1 -> w-2.
127            let rx = reflect_index(x, w);
128            let ry = reflect_index(y, h);
129            (rx, ry)
130        }
131        BorderMode::Constant(c) => {
132            if x < 0 || x >= w || y < 0 || y >= h {
133                return Some(c);
134            }
135            (x, y)
136        }
137    };
138
139    let ux = sx as usize;
140    let uy = sy as usize;
141    Some(raster[uy * width + ux])
142}
143
144#[inline]
145fn reflect_index(i: i64, n: i64) -> i64 {
146    if n <= 1 {
147        return 0;
148    }
149    let period = 2 * (n - 1);
150    let mut m = i % period;
151    if m < 0 {
152        m += period;
153    }
154    if m >= n { period - m } else { m }
155}
156
157/// Slice-based morphological dilation with a `kernel_size x kernel_size`
158/// square structuring element.
159///
160/// Equivalent to applying a max-filter over the neighbourhood. Uses the
161/// boundary strategy from `mode` to handle pixels near the raster edge.
162///
163/// # Panics
164///
165/// Never panics for `raster.len() == width * height` and `kernel_size >= 1`;
166/// inputs that violate these invariants produce well-defined results bounded
167/// by the buffer length (and a debug-mode panic).
168pub fn dilate_slice(
169    raster: &[f32],
170    width: usize,
171    height: usize,
172    kernel_size: usize,
173    mode: BorderMode,
174) -> Vec<f32> {
175    let ks = kernel_size.max(1);
176    let half = (ks / 2) as i64;
177    let mut out = vec![0.0f32; width * height];
178
179    for y in 0..height {
180        for x in 0..width {
181            let mut max_val = f32::NEG_INFINITY;
182            for dy in -half..=half {
183                for dx in -half..=half {
184                    if let Some(v) =
185                        sample(raster, width, height, x as i64 + dx, y as i64 + dy, mode)
186                    {
187                        if v.is_finite() && v > max_val {
188                            max_val = v;
189                        }
190                    }
191                }
192            }
193            out[y * width + x] = if max_val.is_finite() {
194                max_val
195            } else {
196                raster[y * width + x]
197            };
198        }
199    }
200
201    out
202}
203
204/// Slice-based morphological erosion with a `kernel_size x kernel_size`
205/// square structuring element.
206///
207/// Equivalent to applying a min-filter over the neighbourhood. Uses the
208/// boundary strategy from `mode` to handle pixels near the raster edge.
209pub fn erode_slice(
210    raster: &[f32],
211    width: usize,
212    height: usize,
213    kernel_size: usize,
214    mode: BorderMode,
215) -> Vec<f32> {
216    let ks = kernel_size.max(1);
217    let half = (ks / 2) as i64;
218    let mut out = vec![0.0f32; width * height];
219
220    for y in 0..height {
221        for x in 0..width {
222            let mut min_val = f32::INFINITY;
223            for dy in -half..=half {
224                for dx in -half..=half {
225                    if let Some(v) =
226                        sample(raster, width, height, x as i64 + dx, y as i64 + dy, mode)
227                    {
228                        if v.is_finite() && v < min_val {
229                            min_val = v;
230                        }
231                    }
232                }
233            }
234            out[y * width + x] = if min_val.is_finite() {
235                min_val
236            } else {
237                raster[y * width + x]
238            };
239        }
240    }
241
242    out
243}
244
245/// Morphological opening: `erode -> dilate`.
246///
247/// Removes bright features (positive peaks) smaller than the structuring
248/// element. Uses [`BorderMode::Replicate`].
249pub fn opening(raster: &[f32], width: usize, height: usize, kernel_size: usize) -> Vec<f32> {
250    let mode = BorderMode::default();
251    let eroded = erode_slice(raster, width, height, kernel_size, mode);
252    dilate_slice(&eroded, width, height, kernel_size, mode)
253}
254
255/// Morphological closing: `dilate -> erode`.
256///
257/// Fills dark features (negative gaps) smaller than the structuring
258/// element. Uses [`BorderMode::Replicate`].
259pub fn closing(raster: &[f32], width: usize, height: usize, kernel_size: usize) -> Vec<f32> {
260    let mode = BorderMode::default();
261    let dilated = dilate_slice(raster, width, height, kernel_size, mode);
262    erode_slice(&dilated, width, height, kernel_size, mode)
263}
264
265/// Top-hat transform: `raster - opening(raster)`.
266///
267/// Extracts bright features smaller than the structuring element. Output
268/// pixels are non-negative for well-behaved inputs.
269pub fn top_hat(raster: &[f32], width: usize, height: usize, kernel_size: usize) -> Vec<f32> {
270    let opened = opening(raster, width, height, kernel_size);
271    raster
272        .iter()
273        .zip(opened.iter())
274        .map(|(a, b)| a - b)
275        .collect()
276}
277
278/// Black-hat transform: `closing(raster) - raster`.
279///
280/// Extracts dark features smaller than the structuring element. Output
281/// pixels are non-negative for well-behaved inputs.
282pub fn black_hat(raster: &[f32], width: usize, height: usize, kernel_size: usize) -> Vec<f32> {
283    let closed = closing(raster, width, height, kernel_size);
284    closed
285        .iter()
286        .zip(raster.iter())
287        .map(|(a, b)| a - b)
288        .collect()
289}
290
291/// Ergonomic option-bag variant of [`opening`].
292///
293/// Applies opening `opts.iterations` times in succession.
294pub fn opening_with(
295    raster: &[f32],
296    width: usize,
297    height: usize,
298    opts: &MorphologyOptions,
299) -> Vec<f32> {
300    let mut current = raster.to_vec();
301    for _ in 0..opts.iterations {
302        let eroded = erode_slice(&current, width, height, opts.kernel_size, opts.border_mode);
303        current = dilate_slice(&eroded, width, height, opts.kernel_size, opts.border_mode);
304    }
305    current
306}
307
308/// Ergonomic option-bag variant of [`closing`].
309///
310/// Applies closing `opts.iterations` times in succession.
311pub fn closing_with(
312    raster: &[f32],
313    width: usize,
314    height: usize,
315    opts: &MorphologyOptions,
316) -> Vec<f32> {
317    let mut current = raster.to_vec();
318    for _ in 0..opts.iterations {
319        let dilated = dilate_slice(&current, width, height, opts.kernel_size, opts.border_mode);
320        current = erode_slice(&dilated, width, height, opts.kernel_size, opts.border_mode);
321    }
322    current
323}
324
325/// Ergonomic option-bag variant of [`top_hat`].
326pub fn top_hat_with(
327    raster: &[f32],
328    width: usize,
329    height: usize,
330    opts: &MorphologyOptions,
331) -> Vec<f32> {
332    let opened = opening_with(raster, width, height, opts);
333    raster
334        .iter()
335        .zip(opened.iter())
336        .map(|(a, b)| a - b)
337        .collect()
338}
339
340/// Ergonomic option-bag variant of [`black_hat`].
341pub fn black_hat_with(
342    raster: &[f32],
343    width: usize,
344    height: usize,
345    opts: &MorphologyOptions,
346) -> Vec<f32> {
347    let closed = closing_with(raster, width, height, opts);
348    closed
349        .iter()
350        .zip(raster.iter())
351        .map(|(a, b)| a - b)
352        .collect()
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn test_reflect_index_basic() {
361        assert_eq!(reflect_index(-1, 5), 1);
362        assert_eq!(reflect_index(-2, 5), 2);
363        assert_eq!(reflect_index(5, 5), 3);
364        assert_eq!(reflect_index(6, 5), 2);
365    }
366
367    #[test]
368    fn test_default_morphology_options() {
369        let opts = MorphologyOptions::default();
370        assert_eq!(opts.kernel_size, 3);
371        assert_eq!(opts.iterations, 1);
372        matches!(opts.border_mode, BorderMode::Replicate);
373    }
374
375    #[test]
376    fn test_morphology_options_builder() {
377        let opts = MorphologyOptions::new(5)
378            .with_iterations(2)
379            .with_border_mode(BorderMode::Constant(0.0));
380        assert_eq!(opts.kernel_size, 5);
381        assert_eq!(opts.iterations, 2);
382        matches!(opts.border_mode, BorderMode::Constant(_));
383    }
384
385    #[test]
386    fn test_dilate_slice_constant_raster() {
387        let r = vec![5.0f32; 16];
388        let out = dilate_slice(&r, 4, 4, 3, BorderMode::Replicate);
389        assert!(out.iter().all(|&v| (v - 5.0).abs() < 1e-6));
390    }
391
392    #[test]
393    fn test_erode_slice_constant_raster() {
394        let r = vec![5.0f32; 16];
395        let out = erode_slice(&r, 4, 4, 3, BorderMode::Replicate);
396        assert!(out.iter().all(|&v| (v - 5.0).abs() < 1e-6));
397    }
398}