Skip to main content

oxigeo_algorithms/simd/
threshold.rs

1//! SIMD-accelerated thresholding operations
2//!
3//! This module provides high-performance image thresholding and binarization
4//! using SIMD instructions. Thresholding is fundamental for segmentation,
5//! feature extraction, and image preprocessing.
6//!
7//! # Supported Operations
8//!
9//! - **Binary Thresholding**: Simple threshold with two output values
10//! - **Adaptive Thresholding**: Local threshold based on neighborhood
11//! - **Otsu's Method**: Automatic threshold selection
12//! - **Multi-level Thresholding**: Multiple threshold values
13//! - **Range Thresholding**: Keep values within a range
14//! - **Hysteresis Thresholding**: Two-level threshold with connectivity
15//!
16//! # Architecture Support
17//!
18//! The pointwise thresholds (`binary_threshold`, `threshold_to_zero`,
19//! `threshold_truncate`, `threshold_range`) use NEON intrinsics
20//! (`vcgeq_u8`/`vcleq_u8`/`vbslq_u8`/`vminq_u8`) on aarch64, processing 16 pixels
21//! per instruction, with a scalar remainder. The neighborhood/histogram-based
22//! operations (Otsu, adaptive, hysteresis, multi-level) remain scalar. NEON
23//! output is bit-for-bit identical to the scalar fallback (see the parity tests).
24//!
25//! # Example
26//!
27//! ```rust
28//! use oxigeo_algorithms::simd::threshold::{binary_threshold, otsu_threshold};
29//! use oxigeo_algorithms::error::Result;
30//!
31//! fn example() -> Result<()> {
32//!     let data = vec![128u8; 1000];
33//!     let mut output = vec![0u8; 1000];
34//!
35//!     binary_threshold(&data, &mut output, 100, 255, 0)?;
36//!     Ok(())
37//! }
38//! # example().expect("example failed");
39//! ```
40
41#![allow(unsafe_code)]
42
43use crate::error::{AlgorithmError, Result};
44
45/// NEON-accelerated pointwise threshold kernels for aarch64. Each kernel loops
46/// over 16-byte chunks with intrinsics and finishes the tail with scalar code.
47#[cfg(target_arch = "aarch64")]
48mod neon_impl {
49    use std::arch::aarch64::*;
50
51    /// # Safety
52    /// `input.len() == output.len()`.
53    #[target_feature(enable = "neon")]
54    pub(crate) unsafe fn binary(
55        input: &[u8],
56        output: &mut [u8],
57        threshold: u8,
58        max_value: u8,
59        min_value: u8,
60    ) {
61        unsafe {
62            let len = input.len();
63            let chunks = len / 16;
64            let ip = input.as_ptr();
65            let op = output.as_mut_ptr();
66            let vt = vdupq_n_u8(threshold);
67            let vmax = vdupq_n_u8(max_value);
68            let vmin = vdupq_n_u8(min_value);
69            for i in 0..chunks {
70                let off = i * 16;
71                let v = vld1q_u8(ip.add(off));
72                let mask = vcgeq_u8(v, vt); // 0xFF where v >= threshold
73                vst1q_u8(op.add(off), vbslq_u8(mask, vmax, vmin));
74            }
75            for i in (chunks * 16)..len {
76                *op.add(i) = if *ip.add(i) >= threshold {
77                    max_value
78                } else {
79                    min_value
80                };
81            }
82        }
83    }
84
85    /// # Safety
86    /// `input.len() == output.len()`.
87    #[target_feature(enable = "neon")]
88    pub(crate) unsafe fn to_zero(input: &[u8], output: &mut [u8], threshold: u8) {
89        unsafe {
90            let len = input.len();
91            let chunks = len / 16;
92            let ip = input.as_ptr();
93            let op = output.as_mut_ptr();
94            let vt = vdupq_n_u8(threshold);
95            for i in 0..chunks {
96                let off = i * 16;
97                let v = vld1q_u8(ip.add(off));
98                let mask = vcgeq_u8(v, vt);
99                vst1q_u8(op.add(off), vandq_u8(mask, v));
100            }
101            for i in (chunks * 16)..len {
102                let x = *ip.add(i);
103                *op.add(i) = if x >= threshold { x } else { 0 };
104            }
105        }
106    }
107
108    /// # Safety
109    /// `input.len() == output.len()`.
110    #[target_feature(enable = "neon")]
111    pub(crate) unsafe fn truncate(input: &[u8], output: &mut [u8], threshold: u8) {
112        unsafe {
113            let len = input.len();
114            let chunks = len / 16;
115            let ip = input.as_ptr();
116            let op = output.as_mut_ptr();
117            let vt = vdupq_n_u8(threshold);
118            for i in 0..chunks {
119                let off = i * 16;
120                let v = vld1q_u8(ip.add(off));
121                vst1q_u8(op.add(off), vminq_u8(v, vt));
122            }
123            for i in (chunks * 16)..len {
124                *op.add(i) = (*ip.add(i)).min(threshold);
125            }
126        }
127    }
128
129    /// # Safety
130    /// `input.len() == output.len()`.
131    #[target_feature(enable = "neon")]
132    pub(crate) unsafe fn range(input: &[u8], output: &mut [u8], low: u8, high: u8) {
133        unsafe {
134            let len = input.len();
135            let chunks = len / 16;
136            let ip = input.as_ptr();
137            let op = output.as_mut_ptr();
138            let vlo = vdupq_n_u8(low);
139            let vhi = vdupq_n_u8(high);
140            for i in 0..chunks {
141                let off = i * 16;
142                let v = vld1q_u8(ip.add(off));
143                let mask = vandq_u8(vcgeq_u8(v, vlo), vcleq_u8(v, vhi));
144                vst1q_u8(op.add(off), vandq_u8(mask, v));
145            }
146            for i in (chunks * 16)..len {
147                let x = *ip.add(i);
148                *op.add(i) = if x >= low && x <= high { x } else { 0 };
149            }
150        }
151    }
152}
153
154/// Binary threshold with custom output values
155///
156/// # Arguments
157///
158/// * `input` - Input data
159/// * `output` - Output data
160/// * `threshold` - Threshold value
161/// * `max_value` - Value to use when input >= threshold
162/// * `min_value` - Value to use when input < threshold
163///
164/// # Errors
165///
166/// Returns an error if buffer sizes don't match
167pub fn binary_threshold(
168    input: &[u8],
169    output: &mut [u8],
170    threshold: u8,
171    max_value: u8,
172    min_value: u8,
173) -> Result<()> {
174    if input.len() != output.len() {
175        return Err(AlgorithmError::InvalidParameter {
176            parameter: "buffers",
177            message: format!(
178                "Buffer size mismatch: input={}, output={}",
179                input.len(),
180                output.len()
181            ),
182        });
183    }
184
185    #[cfg(target_arch = "aarch64")]
186    {
187        // SAFETY: lengths validated equal above.
188        unsafe {
189            neon_impl::binary(input, output, threshold, max_value, min_value);
190        }
191    }
192    #[cfg(not(target_arch = "aarch64"))]
193    {
194        for i in 0..input.len() {
195            output[i] = if input[i] >= threshold {
196                max_value
197            } else {
198                min_value
199            };
200        }
201    }
202
203    Ok(())
204}
205
206/// Binary threshold to zero
207///
208/// Values below threshold are set to zero, others remain unchanged.
209///
210/// # Errors
211///
212/// Returns an error if buffer sizes don't match
213pub fn threshold_to_zero(input: &[u8], output: &mut [u8], threshold: u8) -> Result<()> {
214    if input.len() != output.len() {
215        return Err(AlgorithmError::InvalidParameter {
216            parameter: "buffers",
217            message: format!(
218                "Buffer size mismatch: input={}, output={}",
219                input.len(),
220                output.len()
221            ),
222        });
223    }
224
225    #[cfg(target_arch = "aarch64")]
226    {
227        // SAFETY: lengths validated equal above.
228        unsafe {
229            neon_impl::to_zero(input, output, threshold);
230        }
231    }
232    #[cfg(not(target_arch = "aarch64"))]
233    {
234        for i in 0..input.len() {
235            output[i] = if input[i] >= threshold { input[i] } else { 0 };
236        }
237    }
238
239    Ok(())
240}
241
242/// Truncate threshold - cap values at threshold
243///
244/// Values above threshold are set to threshold, others remain unchanged.
245///
246/// # Errors
247///
248/// Returns an error if buffer sizes don't match
249pub fn threshold_truncate(input: &[u8], output: &mut [u8], threshold: u8) -> Result<()> {
250    if input.len() != output.len() {
251        return Err(AlgorithmError::InvalidParameter {
252            parameter: "buffers",
253            message: format!(
254                "Buffer size mismatch: input={}, output={}",
255                input.len(),
256                output.len()
257            ),
258        });
259    }
260
261    #[cfg(target_arch = "aarch64")]
262    {
263        // SAFETY: lengths validated equal above.
264        unsafe {
265            neon_impl::truncate(input, output, threshold);
266        }
267    }
268    #[cfg(not(target_arch = "aarch64"))]
269    {
270        for i in 0..input.len() {
271            output[i] = input[i].min(threshold);
272        }
273    }
274
275    Ok(())
276}
277
278/// Range threshold - keep values within [low, high]
279///
280/// Values outside range are set to zero.
281///
282/// # Errors
283///
284/// Returns an error if buffer sizes don't match or if low > high
285pub fn threshold_range(
286    input: &[u8],
287    output: &mut [u8],
288    low_threshold: u8,
289    high_threshold: u8,
290) -> Result<()> {
291    if input.len() != output.len() {
292        return Err(AlgorithmError::InvalidParameter {
293            parameter: "buffers",
294            message: format!(
295                "Buffer size mismatch: input={}, output={}",
296                input.len(),
297                output.len()
298            ),
299        });
300    }
301
302    if low_threshold > high_threshold {
303        return Err(AlgorithmError::InvalidParameter {
304            parameter: "thresholds",
305            message: format!("Invalid range: low={low_threshold}, high={high_threshold}"),
306        });
307    }
308
309    #[cfg(target_arch = "aarch64")]
310    {
311        // SAFETY: lengths validated equal above.
312        unsafe {
313            neon_impl::range(input, output, low_threshold, high_threshold);
314        }
315    }
316    #[cfg(not(target_arch = "aarch64"))]
317    {
318        for i in 0..input.len() {
319            let val = input[i];
320            output[i] = if val >= low_threshold && val <= high_threshold {
321                val
322            } else {
323                0
324            };
325        }
326    }
327
328    Ok(())
329}
330
331/// Calculate optimal threshold using Otsu's method
332///
333/// Finds threshold that minimizes intra-class variance (maximizes inter-class variance).
334/// This is optimal for bimodal distributions.
335///
336/// # Errors
337///
338/// Returns an error if data is empty
339pub fn otsu_threshold(data: &[u8]) -> Result<u8> {
340    if data.is_empty() {
341        return Err(AlgorithmError::EmptyInput {
342            operation: "otsu_threshold",
343        });
344    }
345
346    // Compute histogram
347    let mut histogram = [0u32; 256];
348    for &value in data {
349        histogram[value as usize] += 1;
350    }
351
352    let total_pixels = data.len() as f64;
353
354    // Compute total mean
355    let mut total_mean = 0.0;
356    for (i, &count) in histogram.iter().enumerate() {
357        total_mean += i as f64 * f64::from(count);
358    }
359    total_mean /= total_pixels;
360
361    let mut max_variance = 0.0;
362    let mut optimal_threshold = 0u8;
363
364    let mut weight_background = 0.0;
365    let mut sum_background = 0.0;
366
367    for (t, &count) in histogram.iter().enumerate() {
368        weight_background += f64::from(count) / total_pixels;
369        sum_background += t as f64 * f64::from(count);
370
371        if weight_background < 1e-10 || (1.0 - weight_background) < 1e-10 {
372            continue;
373        }
374
375        let mean_background = sum_background / (weight_background * total_pixels);
376        let mean_foreground = (total_mean * total_pixels - sum_background)
377            / ((1.0 - weight_background) * total_pixels);
378
379        let variance = weight_background
380            * (1.0 - weight_background)
381            * (mean_background - mean_foreground).powi(2);
382
383        // Use >= to prefer later thresholds in case of ties (finds midpoint)
384        if variance >= max_variance {
385            max_variance = variance;
386            optimal_threshold = t as u8;
387        }
388    }
389
390    Ok(optimal_threshold)
391}
392
393/// Apply adaptive threshold using local mean
394///
395/// # Arguments
396///
397/// * `input` - Input image data
398/// * `output` - Output binary image
399/// * `width` - Image width
400/// * `height` - Image height
401/// * `window_size` - Size of local window (must be odd)
402/// * `c` - Constant subtracted from mean
403///
404/// # Errors
405///
406/// Returns an error if parameters are invalid
407pub fn adaptive_threshold_mean(
408    input: &[u8],
409    output: &mut [u8],
410    width: usize,
411    height: usize,
412    window_size: usize,
413    c: i16,
414) -> Result<()> {
415    if input.len() != width * height || output.len() != width * height {
416        return Err(AlgorithmError::InvalidParameter {
417            parameter: "buffers",
418            message: format!(
419                "Buffer size mismatch: input={}, output={}, expected={}",
420                input.len(),
421                output.len(),
422                width * height
423            ),
424        });
425    }
426
427    if window_size == 0 || window_size.is_multiple_of(2) {
428        return Err(AlgorithmError::InvalidParameter {
429            parameter: "window_size",
430            message: format!("Window size must be odd and positive, got {window_size}"),
431        });
432    }
433
434    let half_window = window_size / 2;
435
436    for y in 0..height {
437        for x in 0..width {
438            // Compute local mean
439            let mut sum = 0u32;
440            let mut count = 0u32;
441
442            let y_start = y.saturating_sub(half_window);
443            let y_end = (y + half_window + 1).min(height);
444            let x_start = x.saturating_sub(half_window);
445            let x_end = (x + half_window + 1).min(width);
446
447            for py in y_start..y_end {
448                for px in x_start..x_end {
449                    sum += u32::from(input[py * width + px]);
450                    count += 1;
451                }
452            }
453
454            let mean = sum.checked_div(count).unwrap_or(0);
455            let threshold = (mean as i32 - i32::from(c)).max(0) as u8;
456
457            let idx = y * width + x;
458            output[idx] = if input[idx] >= threshold { 255 } else { 0 };
459        }
460    }
461
462    Ok(())
463}
464
465/// Apply adaptive threshold using Gaussian-weighted mean
466///
467/// Similar to adaptive_threshold_mean but uses Gaussian weights.
468///
469/// # Errors
470///
471/// Returns an error if parameters are invalid
472pub fn adaptive_threshold_gaussian(
473    input: &[u8],
474    output: &mut [u8],
475    width: usize,
476    height: usize,
477    window_size: usize,
478    c: i16,
479) -> Result<()> {
480    if input.len() != width * height || output.len() != width * height {
481        return Err(AlgorithmError::InvalidParameter {
482            parameter: "buffers",
483            message: format!(
484                "Buffer size mismatch: input={}, output={}, expected={}",
485                input.len(),
486                output.len(),
487                width * height
488            ),
489        });
490    }
491
492    if window_size == 0 || window_size.is_multiple_of(2) {
493        return Err(AlgorithmError::InvalidParameter {
494            parameter: "window_size",
495            message: format!("Window size must be odd and positive, got {window_size}"),
496        });
497    }
498
499    let half_window = window_size / 2;
500    let sigma = window_size as f32 / 6.0;
501
502    // Precompute Gaussian weights
503    let mut weights = vec![vec![0.0f32; window_size]; window_size];
504    let mut weight_sum = 0.0f32;
505
506    for wy in 0..window_size {
507        for wx in 0..window_size {
508            let dy = wy as f32 - half_window as f32;
509            let dx = wx as f32 - half_window as f32;
510            let weight = (-((dx * dx + dy * dy) / (2.0 * sigma * sigma))).exp();
511            weights[wy][wx] = weight;
512            weight_sum += weight;
513        }
514    }
515
516    // Normalize weights
517    for row in &mut weights {
518        for w in row {
519            *w /= weight_sum;
520        }
521    }
522
523    for y in 0..height {
524        for x in 0..width {
525            let mut weighted_sum = 0.0f32;
526
527            let y_start = y.saturating_sub(half_window);
528            let y_end = (y + half_window + 1).min(height);
529            let x_start = x.saturating_sub(half_window);
530            let x_end = (x + half_window + 1).min(width);
531
532            for py in y_start..y_end {
533                for px in x_start..x_end {
534                    let wy = py - y + half_window;
535                    let wx = px - x + half_window;
536                    if wy < window_size && wx < window_size {
537                        weighted_sum += f32::from(input[py * width + px]) * weights[wy][wx];
538                    }
539                }
540            }
541
542            let threshold = (weighted_sum - f32::from(c)).max(0.0) as u8;
543
544            let idx = y * width + x;
545            output[idx] = if input[idx] >= threshold { 255 } else { 0 };
546        }
547    }
548
549    Ok(())
550}
551
552/// Hysteresis thresholding (two-level threshold with connectivity)
553///
554/// Used in Canny edge detection. Pixels above high_threshold are strong edges.
555/// Pixels between low_threshold and high_threshold are weak edges, kept only
556/// if connected to strong edges.
557///
558/// # Errors
559///
560/// Returns an error if parameters are invalid
561pub fn hysteresis_threshold(
562    input: &[u8],
563    output: &mut [u8],
564    width: usize,
565    height: usize,
566    low_threshold: u8,
567    high_threshold: u8,
568) -> Result<()> {
569    if input.len() != width * height || output.len() != width * height {
570        return Err(AlgorithmError::InvalidParameter {
571            parameter: "buffers",
572            message: format!(
573                "Buffer size mismatch: input={}, output={}, expected={}",
574                input.len(),
575                output.len(),
576                width * height
577            ),
578        });
579    }
580
581    if low_threshold >= high_threshold {
582        return Err(AlgorithmError::InvalidParameter {
583            parameter: "thresholds",
584            message: format!(
585                "low_threshold must be < high_threshold: {low_threshold} >= {high_threshold}"
586            ),
587        });
588    }
589
590    // Initialize output
591    output.fill(0);
592
593    // Mark strong edges
594    for i in 0..input.len() {
595        if input[i] >= high_threshold {
596            output[i] = 255;
597        }
598    }
599
600    // Propagate from strong edges to weak edges
601    let mut changed = true;
602    while changed {
603        changed = false;
604
605        for y in 1..(height - 1) {
606            for x in 1..(width - 1) {
607                let idx = y * width + x;
608
609                // If this is a weak edge and not yet marked
610                if input[idx] >= low_threshold && input[idx] < high_threshold && output[idx] == 0 {
611                    // Check if connected to a strong edge
612                    let mut connected = false;
613                    for dy in 0..3 {
614                        for dx in 0..3 {
615                            if dx == 1 && dy == 1 {
616                                continue;
617                            }
618                            let ny = y + dy - 1;
619                            let nx = x + dx - 1;
620                            if output[ny * width + nx] == 255 {
621                                connected = true;
622                                break;
623                            }
624                        }
625                        if connected {
626                            break;
627                        }
628                    }
629
630                    if connected {
631                        output[idx] = 255;
632                        changed = true;
633                    }
634                }
635            }
636        }
637    }
638
639    Ok(())
640}
641
642/// Multi-level thresholding
643///
644/// Apply multiple thresholds to create multiple output levels.
645///
646/// # Arguments
647///
648/// * `input` - Input data
649/// * `output` - Output data
650/// * `thresholds` - Sorted threshold values
651/// * `levels` - Output level for each threshold range (length = thresholds.len() + 1)
652///
653/// # Errors
654///
655/// Returns an error if parameters are invalid
656pub fn multi_threshold(
657    input: &[u8],
658    output: &mut [u8],
659    thresholds: &[u8],
660    levels: &[u8],
661) -> Result<()> {
662    if input.len() != output.len() {
663        return Err(AlgorithmError::InvalidParameter {
664            parameter: "buffers",
665            message: format!(
666                "Buffer size mismatch: input={}, output={}",
667                input.len(),
668                output.len()
669            ),
670        });
671    }
672
673    if levels.len() != thresholds.len() + 1 {
674        return Err(AlgorithmError::InvalidParameter {
675            parameter: "levels",
676            message: format!(
677                "Levels length must be thresholds.len() + 1: {} != {}",
678                levels.len(),
679                thresholds.len() + 1
680            ),
681        });
682    }
683
684    const LANES: usize = 16;
685    let chunks = input.len() / LANES;
686
687    for i in 0..chunks {
688        let start = i * LANES;
689        let end = start + LANES;
690
691        for j in start..end {
692            let val = input[j];
693            let mut level_idx = 0;
694
695            for (t_idx, &threshold) in thresholds.iter().enumerate() {
696                if val >= threshold {
697                    level_idx = t_idx + 1;
698                } else {
699                    break;
700                }
701            }
702
703            output[j] = levels[level_idx];
704        }
705    }
706
707    let remainder_start = chunks * LANES;
708    for i in remainder_start..input.len() {
709        let val = input[i];
710        let mut level_idx = 0;
711
712        for (t_idx, &threshold) in thresholds.iter().enumerate() {
713            if val >= threshold {
714                level_idx = t_idx + 1;
715            } else {
716                break;
717            }
718        }
719
720        output[i] = levels[level_idx];
721    }
722
723    Ok(())
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    #[test]
731    fn test_binary_threshold() {
732        let input = vec![50, 100, 150, 200, 250];
733        let mut output = vec![0; 5];
734
735        binary_threshold(&input, &mut output, 128, 255, 0)
736            .expect("binary threshold should succeed");
737
738        assert_eq!(output, vec![0, 0, 255, 255, 255]);
739    }
740
741    #[test]
742    fn test_threshold_to_zero() {
743        let input = vec![50, 100, 150, 200, 250];
744        let mut output = vec![0; 5];
745
746        threshold_to_zero(&input, &mut output, 128).expect("threshold to zero should succeed");
747
748        assert_eq!(output, vec![0, 0, 150, 200, 250]);
749    }
750
751    #[test]
752    fn test_threshold_truncate() {
753        let input = vec![50, 100, 150, 200, 250];
754        let mut output = vec![0; 5];
755
756        threshold_truncate(&input, &mut output, 128).expect("threshold truncate should succeed");
757
758        assert_eq!(output, vec![50, 100, 128, 128, 128]);
759    }
760
761    #[test]
762    fn test_threshold_range() {
763        let input = vec![50, 100, 150, 200, 250];
764        let mut output = vec![0; 5];
765
766        threshold_range(&input, &mut output, 100, 200).expect("threshold range should succeed");
767
768        assert_eq!(output, vec![0, 100, 150, 200, 0]);
769    }
770
771    #[test]
772    fn test_otsu_threshold() {
773        // Bimodal distribution
774        let mut data = vec![50u8; 500];
775        data.extend(vec![200u8; 500]);
776
777        let threshold = otsu_threshold(&data).expect("Otsu threshold calculation should succeed");
778
779        // Threshold should be between the two modes
780        assert!(threshold > 50 && threshold < 200);
781    }
782
783    #[test]
784    fn test_adaptive_threshold_mean() {
785        let width = 10;
786        let height = 10;
787        let input = vec![128u8; width * height];
788        let mut output = vec![0u8; width * height];
789
790        adaptive_threshold_mean(&input, &mut output, width, height, 3, 10)
791            .expect("adaptive threshold mean should succeed");
792
793        // Uniform input should produce mostly uniform output
794        assert!(output.iter().filter(|&&x| x == 255).count() > 50);
795    }
796
797    #[test]
798    fn test_multi_threshold() {
799        let input = vec![10, 50, 100, 150, 200, 250];
800        let mut output = vec![0; 6];
801        let thresholds = vec![64, 128, 192];
802        let levels = vec![0, 85, 170, 255];
803
804        multi_threshold(&input, &mut output, &thresholds, &levels)
805            .expect("multi-level threshold should succeed");
806
807        assert_eq!(output[0], 0); // < 64
808        assert_eq!(output[1], 0); // < 64
809        assert_eq!(output[2], 85); // >= 64, < 128
810        assert_eq!(output[3], 170); // >= 128, < 192
811        assert_eq!(output[4], 255); // >= 192
812        assert_eq!(output[5], 255); // >= 192
813    }
814
815    #[test]
816    fn test_hysteresis_threshold() {
817        let width = 5;
818        let height = 5;
819        let mut input = vec![0u8; width * height];
820
821        // Create a strong edge
822        input[2 * width + 2] = 200;
823        // Create weak edges connected to strong edge
824        input[2 * width + 1] = 80;
825        input[width + 2] = 80;
826        // Create isolated weak edge
827        input[4 * width + 4] = 80;
828
829        let mut output = vec![0u8; width * height];
830        hysteresis_threshold(&input, &mut output, width, height, 50, 150)
831            .expect("hysteresis threshold should succeed");
832
833        // Strong edge should be marked
834        assert_eq!(output[2 * width + 2], 255);
835        // Connected weak edges should be marked
836        assert_eq!(output[2 * width + 1], 255);
837        assert_eq!(output[width + 2], 255);
838        // Isolated weak edge should not be marked
839        assert_eq!(output[4 * width + 4], 0);
840    }
841
842    #[test]
843    fn test_buffer_size_mismatch() {
844        let input = vec![0u8; 10];
845        let mut output = vec![0u8; 5]; // Wrong size
846
847        let result = binary_threshold(&input, &mut output, 128, 255, 0);
848        assert!(result.is_err());
849    }
850
851    #[test]
852    fn test_invalid_range() {
853        let input = vec![0u8; 10];
854        let mut output = vec![0u8; 10];
855
856        let result = threshold_range(&input, &mut output, 200, 100);
857        assert!(result.is_err());
858    }
859
860    fn make_image(len: usize, seed: u64) -> Vec<u8> {
861        let mut state = seed;
862        (0..len)
863            .map(|_| {
864                state = state
865                    .wrapping_mul(6364136223846793005)
866                    .wrapping_add(1442695040888963407);
867                (state >> 33) as u8
868            })
869            .collect()
870    }
871
872    #[test]
873    fn test_pointwise_thresholds_match_scalar_reference() {
874        // Lengths chosen to exercise both the 16-wide NEON stride and the tail.
875        for len in [0usize, 1, 15, 16, 17, 31, 33, 100, 256, 257] {
876            let input = make_image(len, 0xDEAD ^ len as u64);
877            let (thr, lo, hi) = (100u8, 60u8, 190u8);
878
879            // binary_threshold
880            let mut out = vec![0u8; len];
881            binary_threshold(&input, &mut out, thr, 255, 0).expect("binary ok");
882            for i in 0..len {
883                let want = if input[i] >= thr { 255 } else { 0 };
884                assert_eq!(out[i], want, "binary mismatch len={len} i={i}");
885            }
886
887            // threshold_to_zero
888            threshold_to_zero(&input, &mut out, thr).expect("to_zero ok");
889            for i in 0..len {
890                let want = if input[i] >= thr { input[i] } else { 0 };
891                assert_eq!(out[i], want, "to_zero mismatch len={len} i={i}");
892            }
893
894            // threshold_truncate
895            threshold_truncate(&input, &mut out, thr).expect("truncate ok");
896            for i in 0..len {
897                assert_eq!(
898                    out[i],
899                    input[i].min(thr),
900                    "truncate mismatch len={len} i={i}"
901                );
902            }
903
904            // threshold_range
905            threshold_range(&input, &mut out, lo, hi).expect("range ok");
906            for i in 0..len {
907                let v = input[i];
908                let want = if v >= lo && v <= hi { v } else { 0 };
909                assert_eq!(out[i], want, "range mismatch len={len} i={i}");
910            }
911        }
912    }
913}