Skip to main content

spatialrust_vision/
advanced_filter.rs

1//! Non-linear filters, image derivatives, and Gaussian pyramids.
2
3use pulp::Arch;
4use rayon::prelude::*;
5use spatialrust_image::{Image, ImageView};
6
7use crate::dispatch::{
8    bounded_workers, items_per_worker, LARGE_PARALLEL_COMPONENTS, ROW_PARALLEL_COMPONENTS,
9};
10use crate::{
11    filter2d_f32, separable_filter, separable_filter_f32, BorderMode, Kernel1D, Kernel2D,
12    PixelComponent, VisionError, VisionResult,
13};
14
15#[path = "bilateral.rs"]
16mod bilateral;
17#[path = "median.rs"]
18mod median;
19
20/// Applies a per-channel median filter with an odd square aperture.
21/// Uses the original f64 conversion/total ordering, selecting the median without
22/// fully sorting windows larger than 3x3. Large native images use bounded Rayon row
23/// bands; small/single-row/one-worker images and WASM retain caller execution.
24pub fn median_blur<T: PixelComponent, const CHANNELS: usize>(
25    input: ImageView<'_, T, CHANNELS>,
26    kernel_size: usize,
27    border: BorderMode<T, CHANNELS>,
28) -> VisionResult<Image<T, CHANNELS>> {
29    median::run(input, kernel_size, border)
30}
31
32/// Applies an edge-preserving bilateral filter.
33///
34/// Color distance is the squared sum of per-channel absolute differences,
35/// matching OpenCV's CPU implementation. The operation is out-of-place and
36/// never performs a device transfer. Large native images use bounded row bands;
37/// small, single-row, one-worker and WASM calls retain the original serial loop.
38pub fn bilateral_filter<T: PixelComponent, const CHANNELS: usize>(
39    input: ImageView<'_, T, CHANNELS>,
40    diameter: usize,
41    sigma_color: f64,
42    sigma_space: f64,
43    border: BorderMode<T, CHANNELS>,
44) -> VisionResult<Image<T, CHANNELS>> {
45    bilateral::run(input, diameter, sigma_color, sigma_space, border)
46}
47/// Computes a Sobel derivative as signed `f32` values.
48pub fn sobel<T: PixelComponent, const CHANNELS: usize>(
49    input: ImageView<'_, T, CHANNELS>,
50    dx: usize,
51    dy: usize,
52    kernel_size: usize,
53    scale: f64,
54    delta: f64,
55    border: BorderMode<T, CHANNELS>,
56) -> VisionResult<Image<f32, CHANNELS>> {
57    validate_derivative(dx, dy, kernel_size, scale, delta)?;
58    let mut kernel_x = derivative_kernel(kernel_size, dx)?;
59    let kernel_y = derivative_kernel(kernel_size, dy)?;
60    kernel_x =
61        Kernel1D::try_new(kernel_x.coefficients().iter().map(|value| value * scale).collect())?;
62    separable_filter_f32(input, &kernel_x, &kernel_y, delta, border)
63}
64
65/// Computes a first-order 3x3 Sobel derivative directly into signed `f32` output.
66///
67/// This specialization accepts grayscale `u8` input, derivative order `(1, 0)`
68/// or `(0, 1)`, and Replicate or Reflect101 borders. It avoids the generic
69/// separable filter's full-image `f64` intermediate while preserving the same
70/// coefficients, scale, delta, metadata, and strided-input behavior.
71pub fn sobel_3x3_u8(
72    input: ImageView<'_, u8, 1>,
73    dx: usize,
74    dy: usize,
75    scale: f64,
76    delta: f64,
77    border: BorderMode<u8, 1>,
78) -> VisionResult<Image<f32, 1>> {
79    let len = input
80        .width()
81        .checked_mul(input.height())
82        .ok_or_else(|| VisionError::InvalidDimensions("Sobel output size overflows".into()))?;
83    let mut output = vec![0.0; len];
84    sobel_3x3_u8_into(input, dx, dy, scale, delta, border, &mut output)?;
85    Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
86}
87
88/// Computes a first-order 3x3 Sobel derivative into caller-owned packed output.
89pub fn sobel_3x3_u8_into(
90    input: ImageView<'_, u8, 1>,
91    dx: usize,
92    dy: usize,
93    scale: f64,
94    delta: f64,
95    border: BorderMode<u8, 1>,
96    output: &mut [f32],
97) -> VisionResult<()> {
98    validate_derivative(dx, dy, 3, scale, delta)?;
99    if dx + dy != 1 {
100        return Err(VisionError::InvalidParameter(
101            "specialized 3x3 Sobel requires derivative order (1, 0) or (0, 1)".into(),
102        ));
103    }
104    if !matches!(border, BorderMode::Replicate | BorderMode::Reflect101) {
105        return Err(VisionError::InvalidParameter(
106            "specialized 3x3 Sobel supports only Replicate and Reflect101 borders".into(),
107        ));
108    }
109    let len = input
110        .width()
111        .checked_mul(input.height())
112        .ok_or_else(|| VisionError::InvalidDimensions("Sobel output size overflows".into()))?;
113    if output.len() != len {
114        return Err(VisionError::ShapeMismatch(format!(
115            "Sobel output needs {len} elements, found {}",
116            output.len()
117        )));
118    }
119    if len == 0 {
120        return Ok(());
121    }
122
123    if scale == 1.0 && delta == 0.0 {
124        sobel_3x3_identity_into(input, dx, border, output);
125        return Ok(());
126    }
127
128    let width = input.width();
129    let height = input.height();
130    let arch = Arch::new();
131    let workers =
132        bounded_workers(len, height, ROW_PARALLEL_COMPONENTS, rayon::current_num_threads());
133    if workers > 1 {
134        let rows_per_worker = items_per_worker(height, workers);
135        output.par_chunks_mut(rows_per_worker * width).enumerate().for_each(|(chunk, output)| {
136            arch.dispatch(|| {
137                sobel_3x3_rows_dispatch(
138                    input,
139                    dx,
140                    scale,
141                    delta,
142                    border,
143                    chunk * rows_per_worker,
144                    output,
145                );
146            });
147        });
148    } else {
149        arch.dispatch(|| {
150            sobel_3x3_rows_dispatch(input, dx, scale, delta, border, 0, output);
151        });
152    }
153    Ok(())
154}
155
156/// Computes an exact absolute first-order 3x3 Sobel response as saturated `u8`.
157///
158/// This fuses signed derivative calculation, absolute value, and saturation,
159/// avoiding the signed intermediate image required by a two-stage pipeline.
160pub fn sobel_abs_3x3_u8(
161    input: ImageView<'_, u8, 1>,
162    dx: usize,
163    dy: usize,
164    border: BorderMode<u8, 1>,
165) -> VisionResult<Image<u8, 1>> {
166    let len = input.width().checked_mul(input.height()).ok_or_else(|| {
167        VisionError::InvalidDimensions("absolute Sobel output size overflows".into())
168    })?;
169    let mut output = vec![0; len];
170    sobel_abs_3x3_u8_into(input, dx, dy, border, &mut output)?;
171    Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
172}
173
174/// Computes an exact absolute first-order 3x3 Sobel response into packed output.
175pub fn sobel_abs_3x3_u8_into(
176    input: ImageView<'_, u8, 1>,
177    dx: usize,
178    dy: usize,
179    border: BorderMode<u8, 1>,
180    output: &mut [u8],
181) -> VisionResult<()> {
182    validate_derivative(dx, dy, 3, 1.0, 0.0)?;
183    if dx + dy != 1 {
184        return Err(VisionError::InvalidParameter(
185            "absolute 3x3 Sobel requires derivative order (1, 0) or (0, 1)".into(),
186        ));
187    }
188    if !matches!(border, BorderMode::Replicate | BorderMode::Reflect101) {
189        return Err(VisionError::InvalidParameter(
190            "absolute 3x3 Sobel supports only Replicate and Reflect101 borders".into(),
191        ));
192    }
193    let len = input.width().checked_mul(input.height()).ok_or_else(|| {
194        VisionError::InvalidDimensions("absolute Sobel output size overflows".into())
195    })?;
196    if output.len() != len {
197        return Err(VisionError::ShapeMismatch(format!(
198            "absolute Sobel output needs {len} elements, found {}",
199            output.len()
200        )));
201    }
202    if len == 0 {
203        return Ok(());
204    }
205
206    sobel_abs_or_threshold_3x3_into(input, dx, border, None, output);
207    Ok(())
208}
209
210/// Computes a binary edge mask from absolute 3x3 Sobel response.
211///
212/// Pixels whose saturated absolute response is strictly greater than
213/// `threshold` become 255; all others become zero. This matches OpenCV's
214/// `Sobel(CV_16S)` → `convertScaleAbs` → `THRESH_BINARY` pipeline.
215pub fn sobel_threshold_3x3_u8(
216    input: ImageView<'_, u8, 1>,
217    dx: usize,
218    dy: usize,
219    threshold: u8,
220    border: BorderMode<u8, 1>,
221) -> VisionResult<Image<u8, 1>> {
222    let len = input
223        .width()
224        .checked_mul(input.height())
225        .ok_or_else(|| VisionError::InvalidDimensions("Sobel mask size overflows".into()))?;
226    let mut output = vec![0; len];
227    sobel_threshold_3x3_u8_into(input, dx, dy, threshold, border, &mut output)?;
228    Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
229}
230
231/// Computes a binary absolute-Sobel edge mask into caller-owned packed output.
232pub fn sobel_threshold_3x3_u8_into(
233    input: ImageView<'_, u8, 1>,
234    dx: usize,
235    dy: usize,
236    threshold: u8,
237    border: BorderMode<u8, 1>,
238    output: &mut [u8],
239) -> VisionResult<()> {
240    validate_derivative(dx, dy, 3, 1.0, 0.0)?;
241    if dx + dy != 1 {
242        return Err(VisionError::InvalidParameter(
243            "Sobel threshold requires derivative order (1, 0) or (0, 1)".into(),
244        ));
245    }
246    if !matches!(border, BorderMode::Replicate | BorderMode::Reflect101) {
247        return Err(VisionError::InvalidParameter(
248            "Sobel threshold supports only Replicate and Reflect101 borders".into(),
249        ));
250    }
251    let len = input
252        .width()
253        .checked_mul(input.height())
254        .ok_or_else(|| VisionError::InvalidDimensions("Sobel mask size overflows".into()))?;
255    if output.len() != len {
256        return Err(VisionError::ShapeMismatch(format!(
257            "Sobel mask needs {len} elements, found {}",
258            output.len()
259        )));
260    }
261    if len == 0 {
262        return Ok(());
263    }
264    sobel_abs_or_threshold_3x3_into(input, dx, border, Some(threshold), output);
265    Ok(())
266}
267
268fn sobel_abs_or_threshold_3x3_into(
269    input: ImageView<'_, u8, 1>,
270    dx: usize,
271    border: BorderMode<u8, 1>,
272    threshold: Option<u8>,
273    output: &mut [u8],
274) {
275    let width = input.width();
276    let height = input.height();
277    let len = output.len();
278    let workers =
279        bounded_workers(len, height, ROW_PARALLEL_COMPONENTS, rayon::current_num_threads());
280    let rows_per_worker = items_per_worker(height, workers);
281    let mut scratch = vec![0_i16; workers * 3 * width];
282    scratch
283        .par_chunks_mut(3 * width)
284        .zip(output.par_chunks_mut(rows_per_worker * width))
285        .enumerate()
286        .for_each(|(chunk, (scratch, output))| {
287            sobel_abs_3x3_stripe(
288                input,
289                dx,
290                border,
291                threshold,
292                chunk * rows_per_worker,
293                scratch,
294                output,
295            );
296        });
297}
298
299fn sobel_abs_3x3_stripe(
300    input: ImageView<'_, u8, 1>,
301    dx: usize,
302    border: BorderMode<u8, 1>,
303    threshold: Option<u8>,
304    start_y: usize,
305    scratch: &mut [i16],
306    output: &mut [u8],
307) {
308    let width = input.width();
309    let height = input.height();
310    let mut slots = [0, 1, 2];
311    let (top_y, bottom_y) = gradient_neighbors(start_y, height, border);
312    sobel_horizontal_row(input, top_y, dx, border, ring_row_mut(scratch, width, slots[0]));
313    sobel_horizontal_row(input, start_y, dx, border, ring_row_mut(scratch, width, slots[1]));
314    sobel_horizontal_row(input, bottom_y, dx, border, ring_row_mut(scratch, width, slots[2]));
315
316    let stripe_rows = output.len() / width;
317    for (local_y, output) in output.chunks_mut(width).enumerate() {
318        let y = start_y + local_y;
319        let top = ring_row(scratch, width, slots[0]);
320        let middle = ring_row(scratch, width, slots[1]);
321        let bottom = ring_row(scratch, width, slots[2]);
322        if dx == 1 {
323            for (((output, &top), &middle), &bottom) in
324                output.iter_mut().zip(top).zip(middle).zip(bottom)
325            {
326                *output = threshold_sobel(top + 2 * middle + bottom, threshold);
327            }
328        } else {
329            for ((output, &top), &bottom) in output.iter_mut().zip(top).zip(bottom) {
330                *output = threshold_sobel(bottom - top, threshold);
331            }
332        }
333        if local_y + 1 < stripe_rows {
334            slots.rotate_left(1);
335            let (_, next_bottom_y) = gradient_neighbors(y + 1, height, border);
336            sobel_horizontal_row(
337                input,
338                next_bottom_y,
339                dx,
340                border,
341                ring_row_mut(scratch, width, slots[2]),
342            );
343        }
344    }
345}
346
347#[inline(always)]
348fn saturating_abs_u8(value: i16) -> u8 {
349    value.unsigned_abs().min(u16::from(u8::MAX)) as u8
350}
351
352#[inline(always)]
353fn threshold_sobel(value: i16, threshold: Option<u8>) -> u8 {
354    let value = saturating_abs_u8(value);
355    threshold.map_or(value, |threshold| u8::from(value > threshold) * u8::MAX)
356}
357
358fn sobel_3x3_identity_into(
359    input: ImageView<'_, u8, 1>,
360    dx: usize,
361    border: BorderMode<u8, 1>,
362    output: &mut [f32],
363) {
364    let width = input.width();
365    let height = input.height();
366    let len = width * height;
367    let workers =
368        bounded_workers(len, height, ROW_PARALLEL_COMPONENTS, rayon::current_num_threads());
369    let rows_per_worker = items_per_worker(height, workers);
370    let mut scratch = vec![0_i16; workers * 3 * width];
371    scratch
372        .par_chunks_mut(3 * width)
373        .zip(output.par_chunks_mut(rows_per_worker * width))
374        .enumerate()
375        .for_each(|(chunk, (scratch, output))| {
376            let start_y = chunk * rows_per_worker;
377            sobel_3x3_identity_stripe(input, dx, border, start_y, scratch, output);
378        });
379}
380
381fn sobel_3x3_identity_stripe(
382    input: ImageView<'_, u8, 1>,
383    dx: usize,
384    border: BorderMode<u8, 1>,
385    start_y: usize,
386    scratch: &mut [i16],
387    output: &mut [f32],
388) {
389    let width = input.width();
390    let height = input.height();
391    let mut slots = [0, 1, 2];
392    let (top_y, bottom_y) = gradient_neighbors(start_y, height, border);
393    sobel_horizontal_row(input, top_y, dx, border, ring_row_mut(scratch, width, slots[0]));
394    sobel_horizontal_row(input, start_y, dx, border, ring_row_mut(scratch, width, slots[1]));
395    sobel_horizontal_row(input, bottom_y, dx, border, ring_row_mut(scratch, width, slots[2]));
396
397    let stripe_rows = output.len() / width;
398    for (local_y, output) in output.chunks_mut(width).enumerate() {
399        let y = start_y + local_y;
400        let top = ring_row(scratch, width, slots[0]);
401        let middle = ring_row(scratch, width, slots[1]);
402        let bottom = ring_row(scratch, width, slots[2]);
403        if dx == 1 {
404            for (((output, &top), &middle), &bottom) in
405                output.iter_mut().zip(top).zip(middle).zip(bottom)
406            {
407                *output = (top + 2 * middle + bottom) as f32;
408            }
409        } else {
410            for ((output, &top), &bottom) in output.iter_mut().zip(top).zip(bottom) {
411                *output = (bottom - top) as f32;
412            }
413        }
414
415        if local_y + 1 < stripe_rows {
416            slots.rotate_left(1);
417            let next_y = y + 1;
418            let (_, next_bottom_y) = gradient_neighbors(next_y, height, border);
419            sobel_horizontal_row(
420                input,
421                next_bottom_y,
422                dx,
423                border,
424                ring_row_mut(scratch, width, slots[2]),
425            );
426        }
427    }
428}
429
430fn sobel_horizontal_row(
431    input: ImageView<'_, u8, 1>,
432    y: usize,
433    dx: usize,
434    border: BorderMode<u8, 1>,
435    output: &mut [i16],
436) {
437    let width = input.width();
438    let row = input.row(y).expect("Sobel horizontal row in bounds");
439    if width == 1 {
440        output[0] = if dx == 1 { 0 } else { 4 * i16::from(row[0]) };
441        return;
442    }
443    let (left, _) = gradient_neighbors(0, width, border);
444    output[0] = if dx == 1 {
445        i16::from(row[1]) - i16::from(row[left])
446    } else {
447        i16::from(row[left]) + 2 * i16::from(row[0]) + i16::from(row[1])
448    };
449    if dx == 1 {
450        for (output, (&left, &right)) in
451            output[1..width - 1].iter_mut().zip(row[..width - 2].iter().zip(&row[2..]))
452        {
453            *output = i16::from(right) - i16::from(left);
454        }
455    } else {
456        for (output, ((&left, &center), &right)) in output[1..width - 1]
457            .iter_mut()
458            .zip(row[..width - 2].iter().zip(&row[1..width - 1]).zip(&row[2..]))
459        {
460            *output = i16::from(left) + 2 * i16::from(center) + i16::from(right);
461        }
462    }
463    let x = width - 1;
464    let (_, right) = gradient_neighbors(x, width, border);
465    output[x] = if dx == 1 {
466        i16::from(row[right]) - i16::from(row[x - 1])
467    } else {
468        i16::from(row[x - 1]) + 2 * i16::from(row[x]) + i16::from(row[right])
469    };
470}
471
472fn ring_row(scratch: &[i16], width: usize, slot: usize) -> &[i16] {
473    &scratch[slot * width..(slot + 1) * width]
474}
475
476fn ring_row_mut(scratch: &mut [i16], width: usize, slot: usize) -> &mut [i16] {
477    &mut scratch[slot * width..(slot + 1) * width]
478}
479
480#[inline]
481fn sobel_3x3_rows_dispatch(
482    input: ImageView<'_, u8, 1>,
483    dx: usize,
484    scale: f64,
485    delta: f64,
486    border: BorderMode<u8, 1>,
487    start_y: usize,
488    output: &mut [f32],
489) {
490    if scale == 1.0 && delta == 0.0 {
491        if dx == 1 {
492            sobel_x_3x3_rows(input, border, start_y, output);
493        } else {
494            sobel_y_3x3_rows(input, border, start_y, output);
495        }
496    } else {
497        sobel_3x3_rows(input, dx, scale, delta, border, start_y, output);
498    }
499}
500
501fn sobel_x_3x3_rows(
502    input: ImageView<'_, u8, 1>,
503    border: BorderMode<u8, 1>,
504    start_y: usize,
505    output: &mut [f32],
506) {
507    let width = input.width();
508    let height = input.height();
509    for (local_y, output) in output.chunks_mut(width).enumerate() {
510        let y = start_y + local_y;
511        let (top_y, bottom_y) = gradient_neighbors(y, height, border);
512        let top = input.row(top_y).expect("Sobel X row in bounds");
513        let middle = input.row(y).expect("Sobel X row in bounds");
514        let bottom = input.row(bottom_y).expect("Sobel X row in bounds");
515        if width == 1 {
516            output[0] = 0.0;
517            continue;
518        }
519        let (left, _) = gradient_neighbors(0, width, border);
520        output[0] = sobel_x_value(top, middle, bottom, left, 1) as f32;
521        for (
522            ((output, (top_left, top_right)), (middle_left, middle_right)),
523            (bottom_left, bottom_right),
524        ) in output[1..width - 1]
525            .iter_mut()
526            .zip(top[..width - 2].iter().zip(&top[2..]))
527            .zip(middle[..width - 2].iter().zip(&middle[2..]))
528            .zip(bottom[..width - 2].iter().zip(&bottom[2..]))
529        {
530            *output = (i16::from(*top_right) - i16::from(*top_left)
531                + 2 * (i16::from(*middle_right) - i16::from(*middle_left))
532                + i16::from(*bottom_right)
533                - i16::from(*bottom_left)) as f32;
534        }
535        let x = width - 1;
536        let (_, right) = gradient_neighbors(x, width, border);
537        output[x] = sobel_x_value(top, middle, bottom, x - 1, right) as f32;
538    }
539}
540
541fn sobel_y_3x3_rows(
542    input: ImageView<'_, u8, 1>,
543    border: BorderMode<u8, 1>,
544    start_y: usize,
545    output: &mut [f32],
546) {
547    let width = input.width();
548    let height = input.height();
549    for (local_y, output) in output.chunks_mut(width).enumerate() {
550        let y = start_y + local_y;
551        let (top_y, bottom_y) = gradient_neighbors(y, height, border);
552        let top = input.row(top_y).expect("Sobel Y row in bounds");
553        let bottom = input.row(bottom_y).expect("Sobel Y row in bounds");
554        if width == 1 {
555            output[0] = (4 * (i16::from(bottom[0]) - i16::from(top[0]))) as f32;
556            continue;
557        }
558        let (left, _) = gradient_neighbors(0, width, border);
559        output[0] = sobel_y_value(top, bottom, left, 0, 1) as f32;
560        for (
561            (output, ((top_left, top_center), top_right)),
562            ((bottom_left, bottom_center), bottom_right),
563        ) in output[1..width - 1]
564            .iter_mut()
565            .zip(top[..width - 2].iter().zip(&top[1..width - 1]).zip(&top[2..]))
566            .zip(bottom[..width - 2].iter().zip(&bottom[1..width - 1]).zip(&bottom[2..]))
567        {
568            *output = (i16::from(*bottom_left) - i16::from(*top_left)
569                + 2 * (i16::from(*bottom_center) - i16::from(*top_center))
570                + i16::from(*bottom_right)
571                - i16::from(*top_right)) as f32;
572        }
573        let x = width - 1;
574        let (_, right) = gradient_neighbors(x, width, border);
575        output[x] = sobel_y_value(top, bottom, x - 1, x, right) as f32;
576    }
577}
578
579#[inline(always)]
580fn sobel_x_value(top: &[u8], middle: &[u8], bottom: &[u8], left: usize, right: usize) -> i16 {
581    i16::from(top[right]) - i16::from(top[left])
582        + 2 * (i16::from(middle[right]) - i16::from(middle[left]))
583        + i16::from(bottom[right])
584        - i16::from(bottom[left])
585}
586
587#[inline(always)]
588fn sobel_y_value(top: &[u8], bottom: &[u8], left: usize, center: usize, right: usize) -> i16 {
589    i16::from(bottom[left]) - i16::from(top[left])
590        + 2 * (i16::from(bottom[center]) - i16::from(top[center]))
591        + i16::from(bottom[right])
592        - i16::from(top[right])
593}
594
595fn sobel_3x3_rows(
596    input: ImageView<'_, u8, 1>,
597    dx: usize,
598    scale: f64,
599    delta: f64,
600    border: BorderMode<u8, 1>,
601    start_y: usize,
602    output: &mut [f32],
603) {
604    let width = input.width();
605    let height = input.height();
606    for (local_y, output) in output.chunks_mut(width).enumerate() {
607        let y = start_y + local_y;
608        let (top_y, bottom_y) = gradient_neighbors(y, height, border);
609        let top = input.row(top_y).expect("Sobel row in bounds");
610        let middle = input.row(y).expect("Sobel row in bounds");
611        let bottom = input.row(bottom_y).expect("Sobel row in bounds");
612        if width == 1 {
613            output[0] = scaled_sobel_3x3_pixel(top, middle, bottom, 0, 0, 0, dx, scale, delta);
614            continue;
615        }
616        let (left, _) = gradient_neighbors(0, width, border);
617        output[0] = scaled_sobel_3x3_pixel(top, middle, bottom, left, 0, 1, dx, scale, delta);
618        for (x, value) in output.iter_mut().enumerate().take(width - 1).skip(1) {
619            *value = scaled_sobel_3x3_pixel(top, middle, bottom, x - 1, x, x + 1, dx, scale, delta);
620        }
621        let x = width - 1;
622        let (_, right) = gradient_neighbors(x, width, border);
623        output[x] = scaled_sobel_3x3_pixel(top, middle, bottom, x - 1, x, right, dx, scale, delta);
624    }
625}
626
627#[inline(always)]
628#[allow(clippy::too_many_arguments)]
629fn scaled_sobel_3x3_pixel(
630    top: &[u8],
631    middle: &[u8],
632    bottom: &[u8],
633    left: usize,
634    center: usize,
635    right: usize,
636    dx: usize,
637    scale: f64,
638    delta: f64,
639) -> f32 {
640    let value = if dx == 1 {
641        i16::from(top[right]) + 2 * i16::from(middle[right]) + i16::from(bottom[right])
642            - i16::from(top[left])
643            - 2 * i16::from(middle[left])
644            - i16::from(bottom[left])
645    } else {
646        i16::from(bottom[left]) + 2 * i16::from(bottom[center]) + i16::from(bottom[right])
647            - i16::from(top[left])
648            - 2 * i16::from(top[center])
649            - i16::from(top[right])
650    };
651    f64::from(value).mul_add(scale, delta) as f32
652}
653
654/// Computes exact 3×3 Sobel X/Y gradients together for grayscale `u8` input.
655///
656/// This matches OpenCV `spatialGradient`: outputs are signed `i16`, the two
657/// first derivatives share one source traversal, and only replicated or
658/// Reflect101 borders are accepted. CPU storage remains caller-owned and no
659/// device transfer is performed.
660pub fn spatial_gradient_u8(
661    input: ImageView<'_, u8, 1>,
662    border: BorderMode<u8, 1>,
663) -> VisionResult<(Image<i16, 1>, Image<i16, 1>)> {
664    let len = input
665        .width()
666        .checked_mul(input.height())
667        .ok_or_else(|| VisionError::InvalidDimensions("spatial gradient size overflows".into()))?;
668    let mut gradient_x = vec![0; len];
669    let mut gradient_y = vec![0; len];
670    spatial_gradient_u8_into(input, border, &mut gradient_x, &mut gradient_y)?;
671    Ok((
672        Image::try_new_with_metadata(input.width(), input.height(), gradient_x, input.metadata())?,
673        Image::try_new_with_metadata(input.width(), input.height(), gradient_y, input.metadata())?,
674    ))
675}
676
677/// Computes exact paired 3×3 Sobel gradients into caller-owned packed output.
678///
679/// Both output slices must contain exactly `input.width() * input.height()`
680/// elements and must not overlap each other. Safe Rust borrowing prevents the
681/// `u8` input from aliasing either `i16` output.
682pub fn spatial_gradient_u8_into(
683    input: ImageView<'_, u8, 1>,
684    border: BorderMode<u8, 1>,
685    gradient_x: &mut [i16],
686    gradient_y: &mut [i16],
687) -> VisionResult<()> {
688    if !matches!(border, BorderMode::Replicate | BorderMode::Reflect101) {
689        return Err(VisionError::InvalidParameter(
690            "spatial gradient supports only Replicate and Reflect101 borders".into(),
691        ));
692    }
693    let len = input
694        .width()
695        .checked_mul(input.height())
696        .ok_or_else(|| VisionError::InvalidDimensions("spatial gradient size overflows".into()))?;
697    validate_gradient_output(gradient_x, len, "gradient_x")?;
698    validate_gradient_output(gradient_y, len, "gradient_y")?;
699    if len == 0 {
700        return Ok(());
701    }
702
703    let width = input.width();
704    let height = input.height();
705    let arch = Arch::new();
706    let workers =
707        bounded_workers(len, height, LARGE_PARALLEL_COMPONENTS, rayon::current_num_threads());
708    if workers > 1 {
709        let rows_per_worker = items_per_worker(height, workers);
710        gradient_x
711            .par_chunks_mut(rows_per_worker * width)
712            .zip(gradient_y.par_chunks_mut(rows_per_worker * width))
713            .enumerate()
714            .for_each(|(chunk, (gradient_x, gradient_y))| {
715                arch.dispatch(|| {
716                    spatial_gradient_rows(
717                        input,
718                        border,
719                        chunk * rows_per_worker,
720                        gradient_x,
721                        gradient_y,
722                    );
723                });
724            });
725    } else {
726        arch.dispatch(|| spatial_gradient_rows(input, border, 0, gradient_x, gradient_y));
727    }
728    Ok(())
729}
730
731/// Computes exact 3×3 Sobel L1 magnitude (`|Gx| + |Gy|`) for grayscale `u8`.
732///
733/// The non-negative signed `i16` output ranges from 0 through 2040. Fusing the
734/// paired derivatives and magnitude avoids materializing two gradient images.
735pub fn sobel_l1_magnitude_u8(
736    input: ImageView<'_, u8, 1>,
737    border: BorderMode<u8, 1>,
738) -> VisionResult<Image<i16, 1>> {
739    let len = input
740        .width()
741        .checked_mul(input.height())
742        .ok_or_else(|| VisionError::InvalidDimensions("Sobel magnitude size overflows".into()))?;
743    let mut magnitude = vec![0; len];
744    sobel_l1_magnitude_u8_into(input, border, &mut magnitude)?;
745    Ok(Image::try_new_with_metadata(input.width(), input.height(), magnitude, input.metadata())?)
746}
747
748/// Computes exact fused 3×3 Sobel L1 magnitude into caller-owned packed output.
749pub fn sobel_l1_magnitude_u8_into(
750    input: ImageView<'_, u8, 1>,
751    border: BorderMode<u8, 1>,
752    magnitude: &mut [i16],
753) -> VisionResult<()> {
754    if !matches!(border, BorderMode::Replicate | BorderMode::Reflect101) {
755        return Err(VisionError::InvalidParameter(
756            "Sobel L1 magnitude supports only Replicate and Reflect101 borders".into(),
757        ));
758    }
759    let len = input
760        .width()
761        .checked_mul(input.height())
762        .ok_or_else(|| VisionError::InvalidDimensions("Sobel magnitude size overflows".into()))?;
763    validate_gradient_output(magnitude, len, "magnitude")?;
764    if len == 0 {
765        return Ok(());
766    }
767    let width = input.width();
768    let height = input.height();
769    let arch = Arch::new();
770    let workers =
771        bounded_workers(len, height, LARGE_PARALLEL_COMPONENTS, rayon::current_num_threads());
772    if workers > 1 {
773        let rows_per_worker = items_per_worker(height, workers);
774        magnitude.par_chunks_mut(rows_per_worker * width).enumerate().for_each(
775            |(chunk, magnitude)| {
776                arch.dispatch(|| {
777                    sobel_l1_rows(input, border, chunk * rows_per_worker, magnitude);
778                });
779            },
780        );
781    } else {
782        arch.dispatch(|| sobel_l1_rows(input, border, 0, magnitude));
783    }
784    Ok(())
785}
786
787fn validate_gradient_output(output: &[i16], len: usize, name: &str) -> VisionResult<()> {
788    if output.len() != len {
789        return Err(VisionError::ShapeMismatch(format!(
790            "{name} needs {len} elements, found {}",
791            output.len()
792        )));
793    }
794    Ok(())
795}
796
797fn spatial_gradient_rows(
798    input: ImageView<'_, u8, 1>,
799    border: BorderMode<u8, 1>,
800    start_y: usize,
801    gradient_x: &mut [i16],
802    gradient_y: &mut [i16],
803) {
804    let width = input.width();
805    let height = input.height();
806    for (local_y, (gradient_x, gradient_y)) in
807        gradient_x.chunks_mut(width).zip(gradient_y.chunks_mut(width)).enumerate()
808    {
809        let y = start_y + local_y;
810        let (top_y, bottom_y) = gradient_neighbors(y, height, border);
811        let top = input.row(top_y).expect("gradient row in bounds");
812        let middle = input.row(y).expect("gradient row in bounds");
813        let bottom = input.row(bottom_y).expect("gradient row in bounds");
814        if width == 1 {
815            write_spatial_gradient_pixel(top, middle, bottom, 0, 0, 0, gradient_x, gradient_y);
816            continue;
817        }
818        let (left, _) = gradient_neighbors(0, width, border);
819        write_spatial_gradient_pixel(top, middle, bottom, left, 0, 1, gradient_x, gradient_y);
820        for ((((top, middle), bottom), gradient_x), gradient_y) in top
821            .windows(3)
822            .zip(middle.windows(3))
823            .zip(bottom.windows(3))
824            .zip(gradient_x[1..width - 1].iter_mut())
825            .zip(gradient_y[1..width - 1].iter_mut())
826        {
827            let top_left = i16::from(top[0]);
828            let top_middle = i16::from(top[1]);
829            let top_right = i16::from(top[2]);
830            let middle_left = i16::from(middle[0]);
831            let middle_right = i16::from(middle[2]);
832            let bottom_left = i16::from(bottom[0]);
833            let bottom_middle = i16::from(bottom[1]);
834            let bottom_right = i16::from(bottom[2]);
835            *gradient_x = top_right + 2 * middle_right + bottom_right
836                - top_left
837                - 2 * middle_left
838                - bottom_left;
839            *gradient_y = bottom_left + 2 * bottom_middle + bottom_right
840                - top_left
841                - 2 * top_middle
842                - top_right;
843        }
844        let x = width - 1;
845        let (_, right) = gradient_neighbors(x, width, border);
846        write_spatial_gradient_pixel(top, middle, bottom, x - 1, x, right, gradient_x, gradient_y);
847    }
848}
849
850fn sobel_l1_rows(
851    input: ImageView<'_, u8, 1>,
852    border: BorderMode<u8, 1>,
853    start_y: usize,
854    magnitude: &mut [i16],
855) {
856    let width = input.width();
857    let height = input.height();
858    for (local_y, magnitude) in magnitude.chunks_mut(width).enumerate() {
859        let y = start_y + local_y;
860        let (top_y, bottom_y) = gradient_neighbors(y, height, border);
861        let top = input.row(top_y).expect("Sobel magnitude row in bounds");
862        let middle = input.row(y).expect("Sobel magnitude row in bounds");
863        let bottom = input.row(bottom_y).expect("Sobel magnitude row in bounds");
864        if width == 1 {
865            magnitude[0] = sobel_l1_pixel(top, middle, bottom, 0, 0, 0);
866            continue;
867        }
868        let (left, _) = gradient_neighbors(0, width, border);
869        magnitude[0] = sobel_l1_pixel(top, middle, bottom, left, 0, 1);
870        for (((top, middle), bottom), magnitude) in top
871            .windows(3)
872            .zip(middle.windows(3))
873            .zip(bottom.windows(3))
874            .zip(magnitude[1..width - 1].iter_mut())
875        {
876            *magnitude = sobel_l1_window(top, middle, bottom);
877        }
878        let x = width - 1;
879        let (_, right) = gradient_neighbors(x, width, border);
880        magnitude[x] = sobel_l1_pixel(top, middle, bottom, x - 1, x, right);
881    }
882}
883
884#[inline(always)]
885fn sobel_l1_window(top: &[u8], middle: &[u8], bottom: &[u8]) -> i16 {
886    sobel_l1_values(top[0], top[1], top[2], middle[0], middle[2], bottom[0], bottom[1], bottom[2])
887}
888
889#[inline(always)]
890#[allow(clippy::too_many_arguments)]
891fn sobel_l1_pixel(
892    top: &[u8],
893    middle: &[u8],
894    bottom: &[u8],
895    left: usize,
896    center: usize,
897    right: usize,
898) -> i16 {
899    sobel_l1_values(
900        top[left],
901        top[center],
902        top[right],
903        middle[left],
904        middle[right],
905        bottom[left],
906        bottom[center],
907        bottom[right],
908    )
909}
910
911#[inline(always)]
912#[allow(clippy::too_many_arguments)]
913fn sobel_l1_values(
914    top_left: u8,
915    top_middle: u8,
916    top_right: u8,
917    middle_left: u8,
918    middle_right: u8,
919    bottom_left: u8,
920    bottom_middle: u8,
921    bottom_right: u8,
922) -> i16 {
923    let gradient_x = i16::from(top_right) + 2 * i16::from(middle_right) + i16::from(bottom_right)
924        - i16::from(top_left)
925        - 2 * i16::from(middle_left)
926        - i16::from(bottom_left);
927    let gradient_y =
928        i16::from(bottom_left) + 2 * i16::from(bottom_middle) + i16::from(bottom_right)
929            - i16::from(top_left)
930            - 2 * i16::from(top_middle)
931            - i16::from(top_right);
932    gradient_x.abs() + gradient_y.abs()
933}
934
935#[inline(always)]
936#[allow(clippy::too_many_arguments)]
937fn write_spatial_gradient_pixel(
938    top: &[u8],
939    middle: &[u8],
940    bottom: &[u8],
941    left: usize,
942    center: usize,
943    right: usize,
944    gradient_x: &mut [i16],
945    gradient_y: &mut [i16],
946) {
947    let top_left = i16::from(top[left]);
948    let top_middle = i16::from(top[center]);
949    let top_right = i16::from(top[right]);
950    let middle_left = i16::from(middle[left]);
951    let middle_right = i16::from(middle[right]);
952    let bottom_left = i16::from(bottom[left]);
953    let bottom_middle = i16::from(bottom[center]);
954    let bottom_right = i16::from(bottom[right]);
955    gradient_x[center] =
956        top_right + 2 * middle_right + bottom_right - top_left - 2 * middle_left - bottom_left;
957    gradient_y[center] =
958        bottom_left + 2 * bottom_middle + bottom_right - top_left - 2 * top_middle - top_right;
959}
960
961fn gradient_neighbors(index: usize, length: usize, border: BorderMode<u8, 1>) -> (usize, usize) {
962    if length <= 1 {
963        return (0, 0);
964    }
965    let left = if index > 0 {
966        index - 1
967    } else if matches!(border, BorderMode::Reflect101) {
968        1
969    } else {
970        0
971    };
972    let right = if index + 1 < length {
973        index + 1
974    } else if matches!(border, BorderMode::Reflect101) {
975        length - 2
976    } else {
977        length - 1
978    };
979    (left, right)
980}
981
982/// Computes a 3×3 Scharr first derivative as signed `f32` values.
983pub fn scharr<T: PixelComponent, const CHANNELS: usize>(
984    input: ImageView<'_, T, CHANNELS>,
985    dx: usize,
986    dy: usize,
987    scale: f64,
988    delta: f64,
989    border: BorderMode<T, CHANNELS>,
990) -> VisionResult<Image<f32, CHANNELS>> {
991    if dx + dy != 1 || dx > 1 || dy > 1 {
992        return Err(VisionError::InvalidParameter(
993            "Scharr requires derivative order (1, 0) or (0, 1)".into(),
994        ));
995    }
996    if !scale.is_finite() || !delta.is_finite() {
997        return Err(VisionError::InvalidParameter("scale and delta must be finite".into()));
998    }
999    let derivative = Kernel1D::try_new(vec![-scale, 0.0, scale])?;
1000    let smoothing = Kernel1D::try_new(vec![3.0, 10.0, 3.0])?;
1001    if dx == 1 {
1002        separable_filter_f32(input, &derivative, &smoothing, delta, border)
1003    } else {
1004        separable_filter_f32(input, &smoothing, &derivative, delta, border)
1005    }
1006}
1007
1008/// Computes a Laplacian response as signed `f32` values.
1009pub fn laplacian<T: PixelComponent, const CHANNELS: usize>(
1010    input: ImageView<'_, T, CHANNELS>,
1011    kernel_size: usize,
1012    scale: f64,
1013    delta: f64,
1014    border: BorderMode<T, CHANNELS>,
1015) -> VisionResult<Image<f32, CHANNELS>> {
1016    if kernel_size == 1 {
1017        if !scale.is_finite() || !delta.is_finite() {
1018            return Err(VisionError::InvalidParameter("scale and delta must be finite".into()));
1019        }
1020        let kernel = Kernel2D::try_new(
1021            3,
1022            3,
1023            vec![0.0, scale, 0.0, scale, -4.0 * scale, scale, 0.0, scale, 0.0],
1024        )?;
1025        return filter2d_f32(input, &kernel, delta, border);
1026    }
1027    validate_derivative(2, 0, kernel_size, scale, delta)?;
1028    let dxx = sobel(input, 2, 0, kernel_size, scale, 0.0, border)?;
1029    let dyy = sobel(input, 0, 2, kernel_size, scale, 0.0, border)?;
1030    let output = dxx
1031        .as_slice()
1032        .iter()
1033        .zip(dyy.as_slice())
1034        .map(|(&x, &y)| (f64::from(x) + f64::from(y) + delta) as f32)
1035        .collect();
1036    Ok(Image::try_new_with_metadata(input.width(), input.height(), output, input.metadata())?)
1037}
1038
1039/// Reduces each dimension by approximately two using the canonical 5×5 kernel.
1040pub fn pyr_down<T: PixelComponent, const CHANNELS: usize>(
1041    input: ImageView<'_, T, CHANNELS>,
1042    border: BorderMode<T, CHANNELS>,
1043) -> VisionResult<Image<T, CHANNELS>> {
1044    let kernel =
1045        Kernel1D::try_new(vec![1.0 / 16.0, 4.0 / 16.0, 6.0 / 16.0, 4.0 / 16.0, 1.0 / 16.0])?;
1046    let blurred = separable_filter(input, &kernel, &kernel, 0.0, border)?;
1047    let width = input.width().div_ceil(2);
1048    let height = input.height().div_ceil(2);
1049    let mut output = Vec::with_capacity(width * height * CHANNELS);
1050    for y in 0..height {
1051        for x in 0..width {
1052            output.extend_from_slice(blurred.get(x * 2, y * 2).expect("pyramid sample in bounds"));
1053        }
1054    }
1055    Ok(Image::try_new_with_metadata(width, height, output, input.metadata())?)
1056}
1057
1058/// Doubles each dimension using zero insertion and the canonical 5×5 kernel.
1059pub fn pyr_up<T: PixelComponent, const CHANNELS: usize>(
1060    input: ImageView<'_, T, CHANNELS>,
1061    border: BorderMode<T, CHANNELS>,
1062) -> VisionResult<Image<T, CHANNELS>> {
1063    let width = input
1064        .width()
1065        .checked_mul(2)
1066        .ok_or_else(|| VisionError::InvalidDimensions("pyramid width overflow".into()))?;
1067    let height = input
1068        .height()
1069        .checked_mul(2)
1070        .ok_or_else(|| VisionError::InvalidDimensions("pyramid height overflow".into()))?;
1071    let mut expanded =
1072        Image::<T, CHANNELS>::from_pixel(width, height, std::array::from_fn(|_| T::from_f64(0.0)))?;
1073    expanded.set_metadata(input.metadata())?;
1074    for y in 0..input.height() {
1075        for x in 0..input.width() {
1076            *expanded.get_mut(x * 2, y * 2).expect("expanded coordinate in bounds") =
1077                *input.get(x, y).expect("source coordinate in bounds");
1078        }
1079    }
1080    let kernel = Kernel1D::try_new(vec![1.0 / 8.0, 4.0 / 8.0, 6.0 / 8.0, 4.0 / 8.0, 1.0 / 8.0])?;
1081    separable_filter(expanded.view(), &kernel, &kernel, 0.0, border)
1082}
1083
1084/// Builds a packed Gaussian pyramid including the input as level zero.
1085pub fn build_gaussian_pyramid<T: PixelComponent, const CHANNELS: usize>(
1086    input: ImageView<'_, T, CHANNELS>,
1087    levels: usize,
1088    border: BorderMode<T, CHANNELS>,
1089) -> VisionResult<Vec<Image<T, CHANNELS>>> {
1090    if levels == 0 {
1091        return Err(VisionError::InvalidParameter("pyramid levels must be non-zero".into()));
1092    }
1093    let mut packed = Vec::with_capacity(input.width() * input.height() * CHANNELS);
1094    for y in 0..input.height() {
1095        for x in 0..input.width() {
1096            packed.extend_from_slice(input.get(x, y).expect("input coordinate in bounds"));
1097        }
1098    }
1099    let first =
1100        Image::try_new_with_metadata(input.width(), input.height(), packed, input.metadata())?;
1101    let mut pyramid = Vec::with_capacity(levels);
1102    pyramid.push(first);
1103    while pyramid.len() < levels {
1104        let next = pyr_down(pyramid.last().expect("level zero exists").view(), border)?;
1105        pyramid.push(next);
1106    }
1107    Ok(pyramid)
1108}
1109
1110fn validate_odd_size(size: usize, name: &str) -> VisionResult<()> {
1111    if size == 0 || size % 2 == 0 {
1112        return Err(VisionError::InvalidParameter(format!(
1113            "{name} kernel size must be positive and odd"
1114        )));
1115    }
1116    Ok(())
1117}
1118
1119fn validate_positive_finite(value: f64, name: &str) -> VisionResult<()> {
1120    if !value.is_finite() || value <= 0.0 {
1121        return Err(VisionError::InvalidParameter(format!("{name} must be finite and positive")));
1122    }
1123    Ok(())
1124}
1125
1126fn validate_derivative(
1127    dx: usize,
1128    dy: usize,
1129    kernel_size: usize,
1130    scale: f64,
1131    delta: f64,
1132) -> VisionResult<()> {
1133    if !matches!(kernel_size, 3 | 5 | 7) {
1134        return Err(VisionError::InvalidParameter("Sobel kernel size must be 3, 5, or 7".into()));
1135    }
1136    if dx + dy == 0 || dx > 2 || dy > 2 || dx >= kernel_size || dy >= kernel_size {
1137        return Err(VisionError::InvalidParameter(
1138            "Sobel derivative orders must total at least one and each be at most two".into(),
1139        ));
1140    }
1141    if !scale.is_finite() || !delta.is_finite() {
1142        return Err(VisionError::InvalidParameter("scale and delta must be finite".into()));
1143    }
1144    Ok(())
1145}
1146
1147fn derivative_kernel(size: usize, order: usize) -> VisionResult<Kernel1D> {
1148    let mut polynomial = vec![1.0];
1149    for _ in 0..order {
1150        polynomial = convolve_coefficients(&polynomial, &[-1.0, 1.0]);
1151    }
1152    for _ in 0..(size - 1 - order) {
1153        polynomial = convolve_coefficients(&polynomial, &[1.0, 1.0]);
1154    }
1155    Kernel1D::try_new(polynomial)
1156}
1157
1158fn convolve_coefficients(left: &[f64], right: &[f64]) -> Vec<f64> {
1159    let mut output = vec![0.0; left.len() + right.len() - 1];
1160    for (i, &a) in left.iter().enumerate() {
1161        for (j, &b) in right.iter().enumerate() {
1162            output[i + j] += a * b;
1163        }
1164    }
1165    output
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170    use super::{
1171        bilateral_filter, build_gaussian_pyramid, laplacian, median_blur, pyr_down, scharr, sobel,
1172        sobel_3x3_u8, sobel_3x3_u8_into, sobel_abs_3x3_u8, sobel_abs_3x3_u8_into,
1173        sobel_l1_magnitude_u8, sobel_l1_magnitude_u8_into, sobel_threshold_3x3_u8,
1174        sobel_threshold_3x3_u8_into, spatial_gradient_u8, spatial_gradient_u8_into,
1175    };
1176    use crate::BorderMode;
1177    use spatialrust_image::{Image, ImageRegion};
1178
1179    #[test]
1180    fn median_removes_impulse_and_accepts_strided_roi() {
1181        let parent =
1182            Image::<u8, 1>::try_new(5, 3, vec![0, 0, 0, 0, 0, 0, 10, 255, 10, 0, 0, 10, 10, 10, 0])
1183                .unwrap();
1184        let roi = parent.view().subview(ImageRegion::new(1, 0, 3, 3)).unwrap();
1185        let output = median_blur(roi, 3, BorderMode::Replicate).unwrap();
1186        assert_eq!(output[(1, 1)][0], 10);
1187    }
1188
1189    #[test]
1190    fn bilateral_preserves_sharp_constant_regions() {
1191        let image = Image::<u8, 1>::try_new(5, 1, vec![0, 0, 0, 255, 255]).unwrap();
1192        let output = bilateral_filter(image.view(), 3, 5.0, 1.0, BorderMode::Replicate).unwrap();
1193        assert_eq!(output.as_slice(), image.as_slice());
1194    }
1195
1196    #[test]
1197    fn sobel_and_scharr_detect_horizontal_ramp() {
1198        let image =
1199            Image::<u8, 1>::try_new(5, 3, (0..3).flat_map(|_| [0, 10, 20, 30, 40]).collect())
1200                .unwrap();
1201        let sx = sobel(image.view(), 1, 0, 3, 1.0, 0.0, BorderMode::Reflect101).unwrap();
1202        let sy = sobel(image.view(), 0, 1, 3, 1.0, 0.0, BorderMode::Reflect101).unwrap();
1203        let scharr_x = scharr(image.view(), 1, 0, 1.0, 0.0, BorderMode::Reflect101).unwrap();
1204        assert_eq!(sx[(2, 1)][0], 80.0);
1205        assert_eq!(sy[(2, 1)][0], 0.0);
1206        assert_eq!(scharr_x[(2, 1)][0], 320.0);
1207    }
1208
1209    #[test]
1210    fn paired_spatial_gradient_matches_independent_sobel_for_strided_input() {
1211        let parent = Image::<u8, 1>::try_new(
1212            9,
1213            6,
1214            (0..54).map(|index| ((index * 37 + 11) % 256) as u8).collect(),
1215        )
1216        .unwrap();
1217        let input = parent.view().subview(ImageRegion::new(1, 1, 7, 4)).unwrap();
1218        for border in [BorderMode::Replicate, BorderMode::Reflect101] {
1219            let (gradient_x, gradient_y) = spatial_gradient_u8(input, border).unwrap();
1220            let expected_x = sobel(input, 1, 0, 3, 1.0, 0.0, border).unwrap();
1221            let expected_y = sobel(input, 0, 1, 3, 1.0, 0.0, border).unwrap();
1222            assert_eq!(
1223                gradient_x.as_slice(),
1224                expected_x.as_slice().iter().map(|value| *value as i16).collect::<Vec<_>>()
1225            );
1226            assert_eq!(
1227                gradient_y.as_slice(),
1228                expected_y.as_slice().iter().map(|value| *value as i16).collect::<Vec<_>>()
1229            );
1230            assert_eq!(gradient_x.metadata(), input.metadata());
1231            assert_eq!(gradient_y.metadata(), input.metadata());
1232        }
1233    }
1234
1235    #[test]
1236    fn specialized_sobel_matches_generic_for_strided_input() {
1237        let parent = Image::<u8, 1>::try_new(
1238            11,
1239            8,
1240            (0..88).map(|index| ((index * 41 + 19) % 256) as u8).collect(),
1241        )
1242        .unwrap();
1243        let input = parent.view().subview(ImageRegion::new(1, 1, 9, 6)).unwrap();
1244        for border in [BorderMode::Replicate, BorderMode::Reflect101] {
1245            for (dx, dy) in [(1, 0), (0, 1)] {
1246                let expected = sobel(input, dx, dy, 3, 0.75, -2.5, border).unwrap();
1247                let actual = sobel_3x3_u8(input, dx, dy, 0.75, -2.5, border).unwrap();
1248                assert_eq!(actual, expected);
1249                assert_eq!(actual.metadata(), input.metadata());
1250            }
1251        }
1252    }
1253
1254    #[test]
1255    fn specialized_sobel_into_validates_contract() {
1256        let image = Image::<u8, 1>::try_new(3, 2, vec![0, 1, 2, 3, 4, 5]).unwrap();
1257        let mut output = vec![0.0; 6];
1258        sobel_3x3_u8_into(image.view(), 1, 0, 1.0, 0.0, BorderMode::Reflect101, &mut output)
1259            .unwrap();
1260        assert!(sobel_3x3_u8_into(
1261            image.view(),
1262            1,
1263            0,
1264            1.0,
1265            0.0,
1266            BorderMode::Reflect101,
1267            &mut output[..5],
1268        )
1269        .is_err());
1270        assert!(sobel_3x3_u8_into(
1271            image.view(),
1272            1,
1273            1,
1274            1.0,
1275            0.0,
1276            BorderMode::Reflect101,
1277            &mut output,
1278        )
1279        .is_err());
1280        assert!(sobel_3x3_u8_into(image.view(), 1, 0, 1.0, 0.0, BorderMode::Wrap, &mut output,)
1281            .is_err());
1282    }
1283
1284    #[test]
1285    fn absolute_sobel_matches_saturated_specialized_derivative() {
1286        let parent = Image::<u8, 1>::try_new(
1287            12,
1288            9,
1289            (0..108).map(|index| ((index * 67 + 13) % 256) as u8).collect(),
1290        )
1291        .unwrap();
1292        let input = parent.view().subview(ImageRegion::new(1, 1, 10, 7)).unwrap();
1293        for border in [BorderMode::Replicate, BorderMode::Reflect101] {
1294            for (dx, dy) in [(1, 0), (0, 1)] {
1295                let derivative = sobel_3x3_u8(input, dx, dy, 1.0, 0.0, border).unwrap();
1296                let expected = derivative
1297                    .as_slice()
1298                    .iter()
1299                    .map(|value| value.abs().min(255.0) as u8)
1300                    .collect::<Vec<_>>();
1301                let actual = sobel_abs_3x3_u8(input, dx, dy, border).unwrap();
1302                assert_eq!(actual.as_slice(), expected);
1303                let mut reused = vec![0; input.width() * input.height()];
1304                sobel_abs_3x3_u8_into(input, dx, dy, border, &mut reused).unwrap();
1305                assert_eq!(reused, expected);
1306            }
1307        }
1308    }
1309
1310    #[test]
1311    fn thresholded_sobel_matches_absolute_response() {
1312        let image = Image::<u8, 1>::try_new(
1313            17,
1314            13,
1315            (0..221).map(|index| ((index * 29 + 7) % 256) as u8).collect(),
1316        )
1317        .unwrap();
1318        for (dx, dy) in [(1, 0), (0, 1)] {
1319            let absolute = sobel_abs_3x3_u8(image.view(), dx, dy, BorderMode::Reflect101).unwrap();
1320            let expected = absolute
1321                .as_slice()
1322                .iter()
1323                .map(|&value| u8::from(value > 96) * u8::MAX)
1324                .collect::<Vec<_>>();
1325            let actual =
1326                sobel_threshold_3x3_u8(image.view(), dx, dy, 96, BorderMode::Reflect101).unwrap();
1327            assert_eq!(actual.as_slice(), expected);
1328            let mut reused = vec![0; expected.len()];
1329            sobel_threshold_3x3_u8_into(
1330                image.view(),
1331                dx,
1332                dy,
1333                96,
1334                BorderMode::Reflect101,
1335                &mut reused,
1336            )
1337            .unwrap();
1338            assert_eq!(reused, expected);
1339        }
1340    }
1341
1342    #[test]
1343    fn paired_spatial_gradient_into_validates_outputs_and_borders() {
1344        let image = Image::<u8, 1>::try_new(3, 2, vec![0, 1, 2, 3, 4, 5]).unwrap();
1345        let mut gradient_x = vec![0; 6];
1346        let mut gradient_y = vec![0; 6];
1347        spatial_gradient_u8_into(
1348            image.view(),
1349            BorderMode::Reflect101,
1350            &mut gradient_x,
1351            &mut gradient_y,
1352        )
1353        .unwrap();
1354        assert!(spatial_gradient_u8_into(
1355            image.view(),
1356            BorderMode::Reflect101,
1357            &mut gradient_x[..5],
1358            &mut gradient_y,
1359        )
1360        .is_err());
1361        assert!(spatial_gradient_u8_into(
1362            image.view(),
1363            BorderMode::Wrap,
1364            &mut gradient_x,
1365            &mut gradient_y,
1366        )
1367        .is_err());
1368    }
1369
1370    #[test]
1371    fn fused_sobel_l1_matches_paired_gradients_for_strided_input() {
1372        let parent = Image::<u8, 1>::try_new(
1373            10,
1374            7,
1375            (0..70).map(|index| ((index * 53 + 7) % 256) as u8).collect(),
1376        )
1377        .unwrap();
1378        let input = parent.view().subview(ImageRegion::new(1, 1, 8, 5)).unwrap();
1379        for border in [BorderMode::Replicate, BorderMode::Reflect101] {
1380            let (gradient_x, gradient_y) = spatial_gradient_u8(input, border).unwrap();
1381            let magnitude = sobel_l1_magnitude_u8(input, border).unwrap();
1382            let expected = gradient_x
1383                .as_slice()
1384                .iter()
1385                .zip(gradient_y.as_slice())
1386                .map(|(&x, &y)| x.abs() + y.abs())
1387                .collect::<Vec<_>>();
1388            assert_eq!(magnitude.as_slice(), expected);
1389            assert_eq!(magnitude.metadata(), input.metadata());
1390        }
1391    }
1392
1393    #[test]
1394    fn fused_sobel_l1_into_validates_output() {
1395        let image = Image::<u8, 1>::try_new(3, 2, vec![0, 1, 2, 3, 4, 5]).unwrap();
1396        let mut output = vec![0; 6];
1397        sobel_l1_magnitude_u8_into(image.view(), BorderMode::Replicate, &mut output).unwrap();
1398        assert!(sobel_l1_magnitude_u8_into(image.view(), BorderMode::Replicate, &mut output[..5])
1399            .is_err());
1400    }
1401
1402    #[test]
1403    fn laplacian_of_constant_is_zero() {
1404        let image = Image::<u16, 1>::from_pixel(7, 5, [42]).unwrap();
1405        for size in [1, 3, 5, 7] {
1406            let output = laplacian(image.view(), size, 1.0, 0.0, BorderMode::Reflect101).unwrap();
1407            assert!(output.as_slice().iter().all(|value| value.abs() < f32::EPSILON));
1408        }
1409    }
1410
1411    #[test]
1412    fn pyramid_dimensions_follow_ceil_halving() {
1413        let image = Image::<f32, 3>::from_pixel(7, 5, [1.0, 2.0, 3.0]).unwrap();
1414        let down = pyr_down(image.view(), BorderMode::Reflect101).unwrap();
1415        assert_eq!((down.width(), down.height()), (4, 3));
1416        let pyramid = build_gaussian_pyramid(image.view(), 4, BorderMode::Reflect101).unwrap();
1417        assert_eq!(
1418            pyramid.iter().map(|level| (level.width(), level.height())).collect::<Vec<_>>(),
1419            vec![(7, 5), (4, 3), (2, 2), (1, 1)]
1420        );
1421    }
1422
1423    #[test]
1424    fn invalid_parameters_are_rejected() {
1425        let image = Image::<u8, 1>::try_new(1, 1, vec![0]).unwrap();
1426        assert!(median_blur(image.view(), 2, BorderMode::Replicate).is_err());
1427        assert!(bilateral_filter(image.view(), 0, 1.0, 1.0, BorderMode::Replicate).is_err());
1428        assert!(sobel(image.view(), 0, 0, 3, 1.0, 0.0, BorderMode::Replicate).is_err());
1429    }
1430}