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