Skip to main content

oxigeo_algorithms/simd/
morphology.rs

1//! SIMD-accelerated morphological operations
2//!
3//! This module provides high-performance morphological image processing operations
4//! using SIMD instructions. These operations are fundamental for binary image analysis,
5//! feature extraction, and image preprocessing.
6//!
7//! # Supported Operations
8//!
9//! - **Erosion**: Shrink bright regions, remove small objects
10//! - **Dilation**: Expand bright regions, fill small holes
11//! - **Opening**: Erosion followed by dilation (removes noise)
12//! - **Closing**: Dilation followed by erosion (fills gaps)
13//! - **Morphological Gradient**: Difference between dilation and erosion
14//! - **Top Hat**: Difference between input and opening (bright features)
15//! - **Black Hat**: Difference between closing and input (dark features)
16//!
17//! # Architecture Support
18//!
19//! - **aarch64**: NEON intrinsics (`vld1q_u8`/`vminq_u8`/`vmaxq_u8`/`vqsubq_u8`)
20//!   process 16 pixels per instruction for the 3x3 min/max stencils (erosion and
21//!   dilation) and the saturating-subtract elementwise passes (gradient, top-hat,
22//!   black-hat). Border pixels and the row/column remainders use scalar code.
23//! - **All other targets**: scalar fallback with auto-vectorization-friendly loops.
24//!
25//! The NEON path is bit-for-bit identical to the scalar fallback (verified by the
26//! parity tests in this module).
27//!
28//! # Example
29//!
30//! ```rust
31//! use oxigeo_algorithms::simd::morphology::{erode_3x3, dilate_3x3};
32//! use oxigeo_algorithms::error::Result;
33//!
34//! fn example() -> Result<()> {
35//!     let width = 100;
36//!     let height = 100;
37//!     let input = vec![255u8; width * height];
38//!     let mut output = vec![0u8; width * height];
39//!
40//!     erode_3x3(&input, &mut output, width, height)?;
41//!     Ok(())
42//! }
43//! ```
44
45#![allow(unsafe_code)]
46
47use crate::error::{AlgorithmError, Result};
48
49/// NEON-accelerated kernels for aarch64.
50#[cfg(target_arch = "aarch64")]
51mod neon_impl {
52    use std::arch::aarch64::*;
53
54    /// 3x3 min (erosion) / max (dilation) stencil over the interior pixels.
55    ///
56    /// `MAX` selects the reduction: `false` computes the neighborhood minimum
57    /// (erosion), `true` the neighborhood maximum (dilation). 16 output pixels are
58    /// produced per NEON instruction group; the horizontal-edge remainder of each
59    /// row is finished with scalar code.
60    ///
61    /// # Safety
62    /// `input`/`output` must both have length `width * height`, and `width`,
63    /// `height` must both be >= 3 (validated by the public callers).
64    #[target_feature(enable = "neon")]
65    pub(crate) unsafe fn stencil_3x3<const MAX: bool>(
66        input: &[u8],
67        output: &mut [u8],
68        width: usize,
69        height: usize,
70    ) {
71        unsafe {
72            let in_ptr = input.as_ptr();
73            let out_ptr = output.as_mut_ptr();
74
75            for y in 1..(height - 1) {
76                let row_up = (y - 1) * width;
77                let row_mid = y * width;
78                let row_down = (y + 1) * width;
79
80                // Interior columns run from 1..width-1. A 16-wide store at column x
81                // reads columns x-1 .. x+16, so the last safe start is width-17.
82                let mut x = 1usize;
83                while x + 16 < width {
84                    let hmin_or_max = |row: usize| -> uint8x16_t {
85                        let base = row + x;
86                        let left = vld1q_u8(in_ptr.add(base - 1));
87                        let mid = vld1q_u8(in_ptr.add(base));
88                        let right = vld1q_u8(in_ptr.add(base + 1));
89                        if MAX {
90                            vmaxq_u8(vmaxq_u8(left, mid), right)
91                        } else {
92                            vminq_u8(vminq_u8(left, mid), right)
93                        }
94                    };
95
96                    let up = hmin_or_max(row_up);
97                    let midr = hmin_or_max(row_mid);
98                    let down = hmin_or_max(row_down);
99                    let result = if MAX {
100                        vmaxq_u8(vmaxq_u8(up, midr), down)
101                    } else {
102                        vminq_u8(vminq_u8(up, midr), down)
103                    };
104
105                    vst1q_u8(out_ptr.add(row_mid + x), result);
106                    x += 16;
107                }
108
109                // Scalar remainder for the columns the NEON stride did not cover.
110                while x < width - 1 {
111                    let mut acc = if MAX { 0u8 } else { 255u8 };
112                    for ky in 0..3 {
113                        let row = (y + ky - 1) * width;
114                        for kx in 0..3 {
115                            let v = *input.get_unchecked(row + x + kx - 1);
116                            acc = if MAX { acc.max(v) } else { acc.min(v) };
117                        }
118                    }
119                    *output.get_unchecked_mut(row_mid + x) = acc;
120                    x += 1;
121                }
122            }
123        }
124    }
125
126    /// Elementwise saturating subtract `a - b` (unsigned), 16 lanes per op.
127    ///
128    /// # Safety
129    /// `a`, `b`, and `out` must all have the same length.
130    #[target_feature(enable = "neon")]
131    pub(crate) unsafe fn saturating_sub(a: &[u8], b: &[u8], out: &mut [u8]) {
132        unsafe {
133            let len = a.len();
134            let chunks = len / 16;
135            let a_ptr = a.as_ptr();
136            let b_ptr = b.as_ptr();
137            let o_ptr = out.as_mut_ptr();
138            for i in 0..chunks {
139                let off = i * 16;
140                let va = vld1q_u8(a_ptr.add(off));
141                let vb = vld1q_u8(b_ptr.add(off));
142                vst1q_u8(o_ptr.add(off), vqsubq_u8(va, vb));
143            }
144            for i in (chunks * 16)..len {
145                *o_ptr.add(i) = (*a_ptr.add(i)).saturating_sub(*b_ptr.add(i));
146            }
147        }
148    }
149}
150
151/// Structuring element shape
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum StructuringElement {
154    /// Rectangular kernel
155    Rectangle,
156    /// Cross-shaped kernel (4-connected)
157    Cross,
158    /// Diamond-shaped kernel
159    Diamond,
160}
161
162/// Apply 3x3 erosion operation using SIMD
163///
164/// Erosion shrinks bright regions and removes small bright features.
165/// Each pixel is replaced by the minimum value in its 3x3 neighborhood.
166///
167/// # Arguments
168///
169/// * `input` - Input image data (row-major order)
170/// * `output` - Output image data (same size as input)
171/// * `width` - Image width in pixels
172/// * `height` - Image height in pixels
173///
174/// # Errors
175///
176/// Returns an error if buffer sizes don't match dimensions or if dimensions are too small
177pub fn erode_3x3(input: &[u8], output: &mut [u8], width: usize, height: usize) -> Result<()> {
178    validate_buffer_size(input, output, width, height)?;
179
180    if width < 3 || height < 3 {
181        return Err(AlgorithmError::InvalidParameter {
182            parameter: "dimensions",
183            message: format!("Image too small for 3x3 operation: {}x{}", width, height),
184        });
185    }
186
187    #[cfg(target_arch = "aarch64")]
188    {
189        // SAFETY: sizes validated above; width, height >= 3.
190        unsafe {
191            neon_impl::stencil_3x3::<false>(input, output, width, height);
192        }
193    }
194    #[cfg(not(target_arch = "aarch64"))]
195    {
196        stencil_3x3_scalar::<false>(input, output, width, height);
197    }
198
199    // Handle borders (copy from input)
200    copy_borders(input, output, width, height);
201
202    Ok(())
203}
204
205/// Scalar 3x3 min (`MAX=false`) / max (`MAX=true`) stencil over interior pixels.
206#[cfg(not(target_arch = "aarch64"))]
207fn stencil_3x3_scalar<const MAX: bool>(
208    input: &[u8],
209    output: &mut [u8],
210    width: usize,
211    height: usize,
212) {
213    for y in 1..(height - 1) {
214        for x in 1..(width - 1) {
215            let mut acc = if MAX { 0u8 } else { 255u8 };
216            for ky in 0..3 {
217                for kx in 0..3 {
218                    let px = x + kx - 1;
219                    let py = y + ky - 1;
220                    let v = input[py * width + px];
221                    acc = if MAX { acc.max(v) } else { acc.min(v) };
222                }
223            }
224            output[y * width + x] = acc;
225        }
226    }
227}
228
229/// Apply 3x3 dilation operation using SIMD
230///
231/// Dilation expands bright regions and fills small dark holes.
232/// Each pixel is replaced by the maximum value in its 3x3 neighborhood.
233///
234/// # Errors
235///
236/// Returns an error if buffer sizes don't match dimensions or if dimensions are too small
237pub fn dilate_3x3(input: &[u8], output: &mut [u8], width: usize, height: usize) -> Result<()> {
238    validate_buffer_size(input, output, width, height)?;
239
240    if width < 3 || height < 3 {
241        return Err(AlgorithmError::InvalidParameter {
242            parameter: "dimensions",
243            message: format!("Image too small for 3x3 operation: {}x{}", width, height),
244        });
245    }
246
247    #[cfg(target_arch = "aarch64")]
248    {
249        // SAFETY: sizes validated above; width, height >= 3.
250        unsafe {
251            neon_impl::stencil_3x3::<true>(input, output, width, height);
252        }
253    }
254    #[cfg(not(target_arch = "aarch64"))]
255    {
256        stencil_3x3_scalar::<true>(input, output, width, height);
257    }
258
259    // Handle borders
260    copy_borders(input, output, width, height);
261
262    Ok(())
263}
264
265/// Apply morphological opening (erosion followed by dilation)
266///
267/// Opening removes small bright features and smooths object contours.
268///
269/// # Errors
270///
271/// Returns an error if buffer sizes don't match or dimensions are too small
272pub fn opening_3x3(input: &[u8], output: &mut [u8], width: usize, height: usize) -> Result<()> {
273    validate_buffer_size(input, output, width, height)?;
274
275    // Create temporary buffer for intermediate result
276    let mut temp = vec![0u8; width * height];
277
278    // Erosion
279    erode_3x3(input, &mut temp, width, height)?;
280
281    // Dilation
282    dilate_3x3(&temp, output, width, height)?;
283
284    Ok(())
285}
286
287/// Apply morphological closing (dilation followed by erosion)
288///
289/// Closing fills small dark holes and smooths object contours.
290///
291/// # Errors
292///
293/// Returns an error if buffer sizes don't match or dimensions are too small
294pub fn closing_3x3(input: &[u8], output: &mut [u8], width: usize, height: usize) -> Result<()> {
295    validate_buffer_size(input, output, width, height)?;
296
297    // Create temporary buffer
298    let mut temp = vec![0u8; width * height];
299
300    // Dilation
301    dilate_3x3(input, &mut temp, width, height)?;
302
303    // Erosion
304    erode_3x3(&temp, output, width, height)?;
305
306    Ok(())
307}
308
309/// Compute morphological gradient (dilation - erosion)
310///
311/// Highlights edges and boundaries of objects.
312///
313/// # Errors
314///
315/// Returns an error if buffer sizes don't match or dimensions are too small
316pub fn morphological_gradient_3x3(
317    input: &[u8],
318    output: &mut [u8],
319    width: usize,
320    height: usize,
321) -> Result<()> {
322    validate_buffer_size(input, output, width, height)?;
323
324    let mut dilated = vec![0u8; width * height];
325    let mut eroded = vec![0u8; width * height];
326
327    dilate_3x3(input, &mut dilated, width, height)?;
328    erode_3x3(input, &mut eroded, width, height)?;
329
330    // Compute gradient (dilated - eroded)
331    saturating_sub_dispatch(&dilated, &eroded, output);
332
333    Ok(())
334}
335
336/// Elementwise unsigned saturating subtract `a - b`, dispatching to NEON on
337/// aarch64 and a scalar loop elsewhere.
338fn saturating_sub_dispatch(a: &[u8], b: &[u8], out: &mut [u8]) {
339    #[cfg(target_arch = "aarch64")]
340    {
341        // SAFETY: all three slices share the same length (callers pass buffers of
342        // identical `width * height` size).
343        unsafe {
344            neon_impl::saturating_sub(a, b, out);
345        }
346    }
347    #[cfg(not(target_arch = "aarch64"))]
348    {
349        for i in 0..a.len() {
350            out[i] = a[i].saturating_sub(b[i]);
351        }
352    }
353}
354
355/// Compute top-hat transform (input - opening)
356///
357/// Extracts bright features smaller than the structuring element.
358/// Useful for detecting bright objects on a varying background.
359///
360/// # Errors
361///
362/// Returns an error if buffer sizes don't match or dimensions are too small
363pub fn top_hat_3x3(input: &[u8], output: &mut [u8], width: usize, height: usize) -> Result<()> {
364    validate_buffer_size(input, output, width, height)?;
365
366    let mut opened = vec![0u8; width * height];
367    opening_3x3(input, &mut opened, width, height)?;
368
369    // Compute top-hat (input - opened)
370    saturating_sub_dispatch(input, &opened, output);
371
372    Ok(())
373}
374
375/// Compute black-hat transform (closing - input)
376///
377/// Extracts dark features smaller than the structuring element.
378/// Useful for detecting dark objects on a varying background.
379///
380/// # Errors
381///
382/// Returns an error if buffer sizes don't match or dimensions are too small
383pub fn black_hat_3x3(input: &[u8], output: &mut [u8], width: usize, height: usize) -> Result<()> {
384    validate_buffer_size(input, output, width, height)?;
385
386    let mut closed = vec![0u8; width * height];
387    closing_3x3(input, &mut closed, width, height)?;
388
389    // Compute black-hat (closed - input)
390    saturating_sub_dispatch(&closed, input, output);
391
392    Ok(())
393}
394
395/// Apply binary erosion with threshold
396///
397/// # Arguments
398///
399/// * `input` - Input grayscale image
400/// * `output` - Output binary image
401/// * `width` - Image width
402/// * `height` - Image height
403/// * `threshold` - Binary threshold (0-255)
404///
405/// # Errors
406///
407/// Returns an error if buffer sizes don't match or dimensions are too small
408pub fn binary_erode_3x3(
409    input: &[u8],
410    output: &mut [u8],
411    width: usize,
412    height: usize,
413    threshold: u8,
414) -> Result<()> {
415    validate_buffer_size(input, output, width, height)?;
416
417    if width < 3 || height < 3 {
418        return Err(AlgorithmError::InvalidParameter {
419            parameter: "dimensions",
420            message: format!("Image too small for 3x3 operation: {}x{}", width, height),
421        });
422    }
423
424    // Process interior pixels
425    for y in 1..(height - 1) {
426        for x in 1..(width - 1) {
427            let mut all_set = true;
428
429            // Check if all pixels in 3x3 neighborhood exceed threshold
430            'outer: for ky in 0..3 {
431                for kx in 0..3 {
432                    let px = x + kx - 1;
433                    let py = y + ky - 1;
434                    let idx = py * width + px;
435                    if input[idx] < threshold {
436                        all_set = false;
437                        break 'outer;
438                    }
439                }
440            }
441
442            let out_idx = y * width + x;
443            output[out_idx] = if all_set { 255 } else { 0 };
444        }
445    }
446
447    // Handle borders
448    for y in 0..height {
449        for x in 0..width {
450            if y == 0 || y == height - 1 || x == 0 || x == width - 1 {
451                output[y * width + x] = if input[y * width + x] >= threshold {
452                    255
453                } else {
454                    0
455                };
456            }
457        }
458    }
459
460    Ok(())
461}
462
463/// Apply binary dilation with threshold
464///
465/// # Errors
466///
467/// Returns an error if buffer sizes don't match or dimensions are too small
468pub fn binary_dilate_3x3(
469    input: &[u8],
470    output: &mut [u8],
471    width: usize,
472    height: usize,
473    threshold: u8,
474) -> Result<()> {
475    validate_buffer_size(input, output, width, height)?;
476
477    if width < 3 || height < 3 {
478        return Err(AlgorithmError::InvalidParameter {
479            parameter: "dimensions",
480            message: format!("Image too small for 3x3 operation: {}x{}", width, height),
481        });
482    }
483
484    // Process interior pixels
485    for y in 1..(height - 1) {
486        for x in 1..(width - 1) {
487            let mut any_set = false;
488
489            // Check if any pixel in 3x3 neighborhood exceeds threshold
490            'outer: for ky in 0..3 {
491                for kx in 0..3 {
492                    let px = x + kx - 1;
493                    let py = y + ky - 1;
494                    let idx = py * width + px;
495                    if input[idx] >= threshold {
496                        any_set = true;
497                        break 'outer;
498                    }
499                }
500            }
501
502            let out_idx = y * width + x;
503            output[out_idx] = if any_set { 255 } else { 0 };
504        }
505    }
506
507    // Handle borders
508    for y in 0..height {
509        for x in 0..width {
510            if y == 0 || y == height - 1 || x == 0 || x == width - 1 {
511                output[y * width + x] = if input[y * width + x] >= threshold {
512                    255
513                } else {
514                    0
515                };
516            }
517        }
518    }
519
520    Ok(())
521}
522
523/// Apply morphological skeleton extraction
524///
525/// Reduces binary objects to single-pixel-wide skeletons while preserving topology.
526/// Uses iterative thinning.
527///
528/// # Errors
529///
530/// Returns an error if buffer sizes don't match or dimensions are too small
531pub fn skeleton(
532    input: &[u8],
533    output: &mut [u8],
534    width: usize,
535    height: usize,
536    threshold: u8,
537    max_iterations: usize,
538) -> Result<()> {
539    validate_buffer_size(input, output, width, height)?;
540
541    if width < 3 || height < 3 {
542        return Err(AlgorithmError::InvalidParameter {
543            parameter: "dimensions",
544            message: format!("Image too small for 3x3 operation: {}x{}", width, height),
545        });
546    }
547
548    // Initialize output with thresholded input
549    for i in 0..input.len() {
550        output[i] = if input[i] >= threshold { 255 } else { 0 };
551    }
552
553    let mut changed = true;
554    let mut iteration = 0;
555
556    while changed && iteration < max_iterations {
557        changed = false;
558        iteration += 1;
559
560        let prev = output.to_vec();
561
562        // Simplified thinning iteration
563        for y in 1..(height - 1) {
564            for x in 1..(width - 1) {
565                let idx = y * width + x;
566
567                if prev[idx] == 255 {
568                    // Count non-zero neighbors
569                    let mut neighbor_count = 0;
570                    for ky in 0..3 {
571                        for kx in 0..3 {
572                            if kx == 1 && ky == 1 {
573                                continue;
574                            }
575                            let px = x + kx - 1;
576                            let py = y + ky - 1;
577                            if prev[py * width + px] == 255 {
578                                neighbor_count += 1;
579                            }
580                        }
581                    }
582
583                    // Remove pixel if it has few neighbors (simplified condition)
584                    if neighbor_count < 2 {
585                        output[idx] = 0;
586                        changed = true;
587                    }
588                }
589            }
590        }
591    }
592
593    Ok(())
594}
595
596// Helper functions
597
598fn validate_buffer_size(input: &[u8], output: &[u8], width: usize, height: usize) -> Result<()> {
599    let expected_size = width * height;
600    if input.len() != expected_size || output.len() != expected_size {
601        return Err(AlgorithmError::InvalidParameter {
602            parameter: "buffers",
603            message: format!(
604                "Buffer size mismatch: input={}, output={}, expected={}",
605                input.len(),
606                output.len(),
607                expected_size
608            ),
609        });
610    }
611    Ok(())
612}
613
614fn copy_borders(input: &[u8], output: &mut [u8], width: usize, height: usize) {
615    // Top and bottom rows
616    for x in 0..width {
617        output[x] = input[x];
618        output[(height - 1) * width + x] = input[(height - 1) * width + x];
619    }
620
621    // Left and right columns
622    for y in 0..height {
623        output[y * width] = input[y * width];
624        output[y * width + width - 1] = input[y * width + width - 1];
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn test_erode_uniform() {
634        let width = 10;
635        let height = 10;
636        let input = vec![255u8; width * height];
637        let mut output = vec![0u8; width * height];
638
639        erode_3x3(&input, &mut output, width, height)
640            .expect("Erosion should succeed on uniform image");
641
642        // Uniform bright image should remain bright (except borders)
643        for y in 1..(height - 1) {
644            for x in 1..(width - 1) {
645                assert_eq!(output[y * width + x], 255);
646            }
647        }
648    }
649
650    #[test]
651    fn test_dilate_single_pixel() {
652        let width = 5;
653        let height = 5;
654        let mut input = vec![0u8; width * height];
655        input[2 * width + 2] = 255; // Center pixel
656
657        let mut output = vec![0u8; width * height];
658        dilate_3x3(&input, &mut output, width, height)
659            .expect("Dilation should succeed on single pixel");
660
661        // Center pixel and its 8 neighbors should be bright
662        assert_eq!(output[2 * width + 2], 255);
663        assert_eq!(output[width + 2], 255);
664        assert_eq!(output[2 * width + 1], 255);
665    }
666
667    #[test]
668    fn test_opening_closing() {
669        let width = 10;
670        let height = 10;
671        let input = vec![128u8; width * height];
672        let mut opened = vec![0u8; width * height];
673        let mut closed = vec![0u8; width * height];
674
675        opening_3x3(&input, &mut opened, width, height).expect("Opening should succeed");
676        closing_3x3(&input, &mut closed, width, height).expect("Closing should succeed");
677
678        // Uniform input should remain relatively uniform
679        assert!(opened[5 * width + 5] > 0);
680        assert!(closed[5 * width + 5] > 0);
681    }
682
683    #[test]
684    fn test_morphological_gradient() {
685        let width = 10;
686        let height = 10;
687        let mut input = vec![128u8; width * height];
688
689        // Create an edge
690        for y in 0..5 {
691            for x in 0..width {
692                input[y * width + x] = 0;
693            }
694        }
695
696        let mut output = vec![0u8; width * height];
697        morphological_gradient_3x3(&input, &mut output, width, height)
698            .expect("Morphological gradient should succeed");
699
700        // Gradient should be high near the edge
701        assert!(output[5 * width + 5] > 0);
702    }
703
704    #[test]
705    fn test_top_hat() {
706        let width = 10;
707        let height = 10;
708        let input = vec![100u8; width * height];
709        let mut output = vec![0u8; width * height];
710
711        top_hat_3x3(&input, &mut output, width, height).expect("Top-hat transform should succeed");
712
713        // Uniform input should produce near-zero top-hat
714        assert!(output[5 * width + 5] < 10);
715    }
716
717    #[test]
718    fn test_binary_operations() {
719        let width = 10;
720        let height = 10;
721        let mut input = vec![0u8; width * height];
722
723        // Create a bright region
724        for y in 3..7 {
725            for x in 3..7 {
726                input[y * width + x] = 255;
727            }
728        }
729
730        let mut eroded = vec![0u8; width * height];
731        let mut dilated = vec![0u8; width * height];
732
733        binary_erode_3x3(&input, &mut eroded, width, height, 128)
734            .expect("Binary erosion should succeed");
735        binary_dilate_3x3(&input, &mut dilated, width, height, 128)
736            .expect("Binary dilation should succeed");
737
738        // Erosion should shrink the region
739        assert_eq!(eroded[4 * width + 4], 255);
740        assert_eq!(eroded[3 * width + 3], 0);
741
742        // Dilation should expand the region
743        assert_eq!(dilated[5 * width + 5], 255);
744    }
745
746    #[test]
747    fn test_dimensions_too_small() {
748        let width = 2;
749        let height = 2;
750        let input = vec![0u8; width * height];
751        let mut output = vec![0u8; width * height];
752
753        let result = erode_3x3(&input, &mut output, width, height);
754        assert!(result.is_err());
755    }
756
757    #[test]
758    fn test_buffer_size_mismatch() {
759        let width = 10;
760        let height = 10;
761        let input = vec![0u8; width * height];
762        let mut output = vec![0u8; 50]; // Wrong size
763
764        let result = erode_3x3(&input, &mut output, width, height);
765        assert!(result.is_err());
766    }
767
768    /// Deterministic pseudo-random image for parity testing.
769    fn make_image(width: usize, height: usize, seed: u64) -> Vec<u8> {
770        let mut state = seed;
771        (0..width * height)
772            .map(|_| {
773                state = state
774                    .wrapping_mul(6364136223846793005)
775                    .wrapping_add(1442695040888963407);
776                (state >> 33) as u8
777            })
778            .collect()
779    }
780
781    #[test]
782    fn test_erode_dilate_match_scalar_reference() {
783        // The NEON path (on aarch64) and the scalar path must produce identical
784        // results. We compare the public API against an independent scalar
785        // reference implementation of the 3x3 min/max stencil.
786        fn scalar_ref<const MAX: bool>(input: &[u8], width: usize, height: usize) -> Vec<u8> {
787            let mut out = input.to_vec();
788            for y in 1..(height - 1) {
789                for x in 1..(width - 1) {
790                    let mut acc = if MAX { 0u8 } else { 255u8 };
791                    for ky in 0..3 {
792                        for kx in 0..3 {
793                            let v = input[(y + ky - 1) * width + (x + kx - 1)];
794                            acc = if MAX { acc.max(v) } else { acc.min(v) };
795                        }
796                    }
797                    out[y * width + x] = acc;
798                }
799            }
800            out
801        }
802
803        // Widths chosen to exercise both the 16-wide NEON stride and the scalar
804        // horizontal remainder (e.g. 37 => one 16-lane block + tail).
805        for &(w, h) in &[(37usize, 19usize), (64, 8), (17, 17), (5, 5), (48, 12)] {
806            let input = make_image(w, h, 0x1234 ^ (w as u64) ^ ((h as u64) << 16));
807
808            let mut eroded = vec![0u8; w * h];
809            let mut dilated = vec![0u8; w * h];
810            erode_3x3(&input, &mut eroded, w, h).expect("erode ok");
811            dilate_3x3(&input, &mut dilated, w, h).expect("dilate ok");
812
813            let mut ref_erode = scalar_ref::<false>(&input, w, h);
814            let mut ref_dilate = scalar_ref::<true>(&input, w, h);
815            // Borders are copied from input by the public API.
816            copy_borders(&input, &mut ref_erode, w, h);
817            copy_borders(&input, &mut ref_dilate, w, h);
818
819            assert_eq!(eroded, ref_erode, "erosion mismatch at {w}x{h}");
820            assert_eq!(dilated, ref_dilate, "dilation mismatch at {w}x{h}");
821        }
822    }
823
824    #[test]
825    fn test_saturating_sub_dispatch_matches_scalar() {
826        for len in [0usize, 1, 15, 16, 17, 31, 33, 256, 257] {
827            let a = make_image(len, 1, 0xABCD);
828            let b = make_image(len, 1, 0x1357);
829            let mut out = vec![0u8; len];
830            saturating_sub_dispatch(&a, &b, &mut out);
831            for i in 0..len {
832                assert_eq!(out[i], a[i].saturating_sub(b[i]), "mismatch at index {i}");
833            }
834        }
835    }
836
837    use proptest::prelude::*;
838
839    proptest! {
840        /// Property: erosion (neighborhood min) never exceeds dilation
841        /// (neighborhood max) at any pixel, for arbitrary images and shapes.
842        #[test]
843        fn prop_erode_le_dilate(
844            w in 3usize..40,
845            h in 3usize..40,
846            seed in any::<u64>(),
847        ) {
848            let input = make_image(w, h, seed);
849            let mut eroded = vec![0u8; w * h];
850            let mut dilated = vec![0u8; w * h];
851            erode_3x3(&input, &mut eroded, w, h).expect("erode");
852            dilate_3x3(&input, &mut dilated, w, h).expect("dilate");
853            for i in 0..w * h {
854                prop_assert!(eroded[i] <= dilated[i]);
855            }
856        }
857
858        /// Property: the erode/dilate public API (NEON on aarch64) is bit-for-bit
859        /// identical to an independent scalar reference for arbitrary inputs.
860        #[test]
861        fn prop_erode_dilate_parity(
862            w in 3usize..40,
863            h in 3usize..40,
864            seed in any::<u64>(),
865        ) {
866            fn scalar_ref<const MAX: bool>(input: &[u8], width: usize, height: usize) -> Vec<u8> {
867                let mut out = input.to_vec();
868                for y in 1..(height - 1) {
869                    for x in 1..(width - 1) {
870                        let mut acc = if MAX { 0u8 } else { 255u8 };
871                        for ky in 0..3 {
872                            for kx in 0..3 {
873                                let v = input[(y + ky - 1) * width + (x + kx - 1)];
874                                acc = if MAX { acc.max(v) } else { acc.min(v) };
875                            }
876                        }
877                        out[y * width + x] = acc;
878                    }
879                }
880                out
881            }
882
883            let input = make_image(w, h, seed);
884            let mut eroded = vec![0u8; w * h];
885            let mut dilated = vec![0u8; w * h];
886            erode_3x3(&input, &mut eroded, w, h).expect("erode");
887            dilate_3x3(&input, &mut dilated, w, h).expect("dilate");
888
889            let mut ref_e = scalar_ref::<false>(&input, w, h);
890            let mut ref_d = scalar_ref::<true>(&input, w, h);
891            copy_borders(&input, &mut ref_e, w, h);
892            copy_borders(&input, &mut ref_d, w, h);
893            prop_assert_eq!(eroded, ref_e);
894            prop_assert_eq!(dilated, ref_d);
895        }
896    }
897}