Skip to main content

spatialrust_vision/
canny.rs

1//! Canny edge detection with inspectable CPU intermediates.
2
3use std::collections::VecDeque;
4
5use rayon::prelude::*;
6use spatialrust_image::{Image, ImageView, ImageViewMut};
7
8use crate::dispatch::{
9    bounded_workers, items_per_worker, should_parallelize, LARGE_PARALLEL_COMPONENTS,
10};
11use crate::{
12    sobel, spatial_gradient_u8, spatial_gradient_u8_into, BorderMode, VisionError, VisionResult,
13};
14
15/// Validated Canny thresholds and gradient settings.
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub struct CannyOptions {
18    /// Lower hysteresis threshold.
19    pub low_threshold: f64,
20    /// Upper strong-edge threshold.
21    pub high_threshold: f64,
22    /// Sobel aperture size: 3, 5, or 7.
23    pub aperture_size: usize,
24    /// Use Euclidean gradient magnitude instead of the L1 approximation.
25    pub l2_gradient: bool,
26}
27
28impl Default for CannyOptions {
29    fn default() -> Self {
30        Self { low_threshold: 100.0, high_threshold: 200.0, aperture_size: 3, l2_gradient: false }
31    }
32}
33
34impl CannyOptions {
35    fn validate(self) -> VisionResult<Self> {
36        if !self.low_threshold.is_finite()
37            || !self.high_threshold.is_finite()
38            || self.low_threshold < 0.0
39            || self.high_threshold < 0.0
40        {
41            return Err(VisionError::InvalidParameter(
42                "Canny thresholds must be finite and non-negative".into(),
43            ));
44        }
45        if !matches!(self.aperture_size, 3 | 5 | 7) {
46            return Err(VisionError::InvalidParameter(
47                "Canny aperture size must be 3, 5, or 7".into(),
48            ));
49        }
50        Ok(self)
51    }
52}
53
54/// Edge map and numerical stages produced by Canny.
55#[derive(Clone, Debug, PartialEq)]
56pub struct CannyResult {
57    /// Final binary edge map containing 0 or 255.
58    pub edges: Image<u8, 1>,
59    /// Horizontal signed Sobel derivative.
60    pub gradient_x: Image<f32, 1>,
61    /// Vertical signed Sobel derivative.
62    pub gradient_y: Image<f32, 1>,
63    /// L1 or Euclidean gradient magnitude before suppression.
64    pub magnitude: Image<f32, 1>,
65    /// Magnitude retained by directional non-maximum suppression.
66    pub suppressed: Image<f32, 1>,
67}
68
69/// Reusable caller-owned storage for the allocation-light Canny path.
70///
71/// The workspace grows to the largest image seen and retains that capacity.
72/// It contains CPU buffers only and never performs an implicit device copy.
73#[derive(Clone, Debug, Default)]
74pub struct CannyWorkspace {
75    gradient_x: Vec<i16>,
76    gradient_y: Vec<i16>,
77    magnitude_rows: Vec<i32>,
78    states: Vec<u8>,
79    frontier: Vec<usize>,
80}
81
82impl CannyWorkspace {
83    /// Creates an empty workspace that allocates on its first call.
84    #[must_use]
85    pub const fn new() -> Self {
86        Self {
87            gradient_x: Vec::new(),
88            gradient_y: Vec::new(),
89            magnitude_rows: Vec::new(),
90            states: Vec::new(),
91            frontier: Vec::new(),
92        }
93    }
94
95    /// Returns the reusable pixel capacity without counting the edge stack.
96    #[must_use]
97    pub fn capacity(&self) -> usize {
98        self.gradient_x.capacity().min(self.gradient_y.capacity()).min(self.states.capacity())
99    }
100
101    /// Returns bytes reserved by all reusable vectors.
102    #[must_use]
103    pub fn allocated_bytes(&self) -> usize {
104        self.gradient_x.capacity() * std::mem::size_of::<i16>()
105            + self.gradient_y.capacity() * std::mem::size_of::<i16>()
106            + self.magnitude_rows.capacity() * std::mem::size_of::<i32>()
107            + self.states.capacity() * std::mem::size_of::<u8>()
108            + self.frontier.capacity() * std::mem::size_of::<usize>()
109    }
110
111    fn resize(&mut self, len: usize, magnitude_elements: usize) {
112        self.gradient_x.resize(len, 0);
113        self.gradient_y.resize(len, 0);
114        self.magnitude_rows.resize(magnitude_elements, 0);
115        self.states.resize(len, 1);
116        self.frontier.clear();
117    }
118}
119
120/// Finds edges in a single-channel u8 image.
121pub fn canny(input: ImageView<'_, u8, 1>, options: CannyOptions) -> VisionResult<Image<u8, 1>> {
122    let len = checked_len(input.width(), input.height())?;
123    let mut output = Image::try_new_with_metadata(
124        input.width(),
125        input.height(),
126        vec![0; len],
127        input.metadata(),
128    )?;
129    let mut workspace = CannyWorkspace::new();
130    canny_into(input, options, output.view_mut(), &mut workspace)?;
131    Ok(output)
132}
133
134/// Finds edges into caller-owned output using reusable CPU workspace.
135///
136/// The output may be packed or strided, must match the input dimensions, and
137/// receives only binary values (`0` or `255`). The specialized allocation-light
138/// path applies to the common 3x3 aperture; larger apertures retain the exact
139/// inspectable implementation.
140pub fn canny_into(
141    input: ImageView<'_, u8, 1>,
142    options: CannyOptions,
143    mut output: ImageViewMut<'_, u8, 1>,
144    workspace: &mut CannyWorkspace,
145) -> VisionResult<()> {
146    let options = options.validate()?;
147    if output.width() != input.width() || output.height() != input.height() {
148        return Err(VisionError::ShapeMismatch(format!(
149            "Canny output must be {}x{}, found {}x{}",
150            input.width(),
151            input.height(),
152            output.width(),
153            output.height()
154        )));
155    }
156    if options.aperture_size != 3 {
157        let result = canny_with_intermediates(input, options)?;
158        for y in 0..input.height() {
159            let start = y * input.width();
160            output
161                .row_mut(y)
162                .expect("validated Canny output row")
163                .copy_from_slice(&result.edges.as_slice()[start..start + input.width()]);
164        }
165        return Ok(());
166    }
167
168    canny_3x3_into(input, options, &mut output, workspace)
169}
170
171fn checked_len(width: usize, height: usize) -> VisionResult<usize> {
172    width
173        .checked_mul(height)
174        .ok_or_else(|| VisionError::InvalidDimensions("Canny image dimensions overflow".into()))
175}
176
177fn canny_3x3_into(
178    input: ImageView<'_, u8, 1>,
179    options: CannyOptions,
180    output: &mut ImageViewMut<'_, u8, 1>,
181    workspace: &mut CannyWorkspace,
182) -> VisionResult<()> {
183    let width = input.width();
184    let height = input.height();
185    let len = checked_len(width, height)?;
186    let workers =
187        bounded_workers(len, height, LARGE_PARALLEL_COMPONENTS, rayon::current_num_threads());
188    let parallel = workers > 1;
189    let magnitude_elements = workers
190        .checked_mul(3)
191        .and_then(|rows| rows.checked_mul(width))
192        .ok_or_else(|| VisionError::InvalidDimensions("Canny magnitude ring overflows".into()))?;
193    workspace.resize(len, magnitude_elements);
194    spatial_gradient_u8_into(
195        input,
196        BorderMode::Replicate,
197        &mut workspace.gradient_x,
198        &mut workspace.gradient_y,
199    )?;
200
201    let (low, high) = canny_thresholds(options);
202    workspace.states.fill(1);
203    if parallel {
204        let rows_per_worker = items_per_worker(height, workers);
205        let gradient_x = &workspace.gradient_x;
206        let gradient_y = &workspace.gradient_y;
207        let (mut candidates, has_weak) = workspace
208            .states
209            .par_chunks_mut(rows_per_worker * width)
210            .zip(workspace.magnitude_rows.par_chunks_mut(3 * width))
211            .enumerate()
212            .map(|(chunk, (states, magnitude_rows))| {
213                let mut local_candidates = Vec::new();
214                let has_weak = classify_canny_rows(
215                    chunk * rows_per_worker,
216                    width,
217                    height,
218                    gradient_x,
219                    gradient_y,
220                    options.l2_gradient,
221                    low,
222                    high,
223                    states,
224                    magnitude_rows,
225                    &mut local_candidates,
226                );
227                (local_candidates, has_weak)
228            })
229            .reduce(
230                || (Vec::new(), false),
231                |(mut left, left_weak), (mut right, right_weak)| {
232                    left.append(&mut right);
233                    (left, left_weak || right_weak)
234                },
235            );
236        workspace.frontier.clear();
237        workspace.frontier.append(&mut candidates);
238        if !has_weak {
239            workspace.frontier.clear();
240        }
241    } else {
242        workspace.frontier.clear();
243        let has_weak = classify_canny_rows(
244            0,
245            width,
246            height,
247            &workspace.gradient_x,
248            &workspace.gradient_y,
249            options.l2_gradient,
250            low,
251            high,
252            &mut workspace.states,
253            &mut workspace.magnitude_rows,
254            &mut workspace.frontier,
255        );
256        if !has_weak {
257            workspace.frontier.clear();
258        }
259    }
260
261    let mut seed_count = 0;
262    for candidate in 0..workspace.frontier.len() {
263        let index = workspace.frontier[candidate];
264        if has_strong_neighbor(&workspace.states, width, height, index) {
265            workspace.states[index] = 2;
266            workspace.frontier[seed_count] = index;
267            seed_count += 1;
268        }
269    }
270    workspace.frontier.truncate(seed_count);
271
272    while let Some(index) = workspace.frontier.pop() {
273        let x = index % width;
274        let y = index / width;
275        let x0 = x.saturating_sub(1);
276        let x1 = (x + 1).min(width.saturating_sub(1));
277        let y0 = y.saturating_sub(1);
278        let y1 = (y + 1).min(height.saturating_sub(1));
279        for ny in y0..=y1 {
280            for nx in x0..=x1 {
281                let neighbor = ny * width + nx;
282                if workspace.states[neighbor] == 0 {
283                    workspace.states[neighbor] = 2;
284                    workspace.frontier.push(neighbor);
285                }
286            }
287        }
288    }
289
290    if should_parallelize(len, height, LARGE_PARALLEL_COMPONENTS) && output.row_stride() == width {
291        output
292            .as_mut_slice()
293            .par_iter_mut()
294            .zip(&workspace.states)
295            .for_each(|(pixel, &state)| *pixel = if state == 2 { 255 } else { 0 });
296    } else {
297        for y in 0..height {
298            let start = y * width;
299            let target = output.row_mut(y).expect("validated Canny output row");
300            for (pixel, &state) in target.iter_mut().zip(&workspace.states[start..start + width]) {
301                *pixel = if state == 2 { 255 } else { 0 };
302            }
303        }
304    }
305    Ok(())
306}
307
308#[allow(clippy::too_many_arguments)]
309fn classify_canny_rows(
310    start_y: usize,
311    width: usize,
312    height: usize,
313    gradient_x: &[i16],
314    gradient_y: &[i16],
315    l2_gradient: bool,
316    low: i64,
317    high: i64,
318    states: &mut [u8],
319    magnitude_rows: &mut [i32],
320    candidates: &mut Vec<usize>,
321) -> bool {
322    if width == 0 || height == 0 || states.is_empty() {
323        return false;
324    }
325    debug_assert_eq!(magnitude_rows.len(), 3 * width);
326    let rows = states.len() / width;
327    fill_magnitude_row(
328        start_y.checked_sub(1),
329        width,
330        height,
331        gradient_x,
332        gradient_y,
333        l2_gradient,
334        &mut magnitude_rows[..width],
335    );
336    fill_magnitude_row(
337        Some(start_y),
338        width,
339        height,
340        gradient_x,
341        gradient_y,
342        l2_gradient,
343        &mut magnitude_rows[width..2 * width],
344    );
345    fill_magnitude_row(
346        start_y.checked_add(1),
347        width,
348        height,
349        gradient_x,
350        gradient_y,
351        l2_gradient,
352        &mut magnitude_rows[2 * width..],
353    );
354
355    let mut has_weak = false;
356    for local_y in 0..rows {
357        let y = start_y + local_y;
358        let previous_slot = local_y % 3;
359        let current_slot = (local_y + 1) % 3;
360        let next_slot = (local_y + 2) % 3;
361        if local_y != 0 {
362            let next_y = y.checked_add(1);
363            fill_magnitude_row(
364                next_y,
365                width,
366                height,
367                gradient_x,
368                gradient_y,
369                l2_gradient,
370                &mut magnitude_rows[next_slot * width..(next_slot + 1) * width],
371            );
372        }
373        let global_row = y * width;
374        let local_row = local_y * width;
375        for x in 0..width {
376            let index = global_row + x;
377            let magnitude = magnitude_rows[current_slot * width + x];
378            if i64::from(magnitude) <= low
379                || !is_directional_maximum_ring(
380                    x,
381                    y,
382                    width,
383                    height,
384                    i32::from(gradient_x[index]),
385                    i32::from(gradient_y[index]),
386                    magnitude,
387                    magnitude_rows,
388                    previous_slot,
389                    current_slot,
390                    next_slot,
391                )
392            {
393                continue;
394            }
395            let state = &mut states[local_row + x];
396            if i64::from(magnitude) > high {
397                *state = 2;
398            } else {
399                *state = 0;
400                candidates.push(index);
401                has_weak = true;
402            }
403        }
404    }
405    has_weak
406}
407
408fn has_strong_neighbor(states: &[u8], width: usize, height: usize, index: usize) -> bool {
409    let x = index % width;
410    let y = index / width;
411    let x0 = x.saturating_sub(1);
412    let x1 = (x + 1).min(width.saturating_sub(1));
413    let y0 = y.saturating_sub(1);
414    let y1 = (y + 1).min(height.saturating_sub(1));
415    (y0..=y1).any(|ny| {
416        (x0..=x1).any(|nx| {
417            let neighbor = ny * width + nx;
418            neighbor != index && states[neighbor] == 2
419        })
420    })
421}
422
423#[allow(clippy::too_many_arguments)]
424fn fill_magnitude_row(
425    y: Option<usize>,
426    width: usize,
427    height: usize,
428    gradient_x: &[i16],
429    gradient_y: &[i16],
430    l2_gradient: bool,
431    output: &mut [i32],
432) {
433    let Some(y) = y.filter(|&y| y < height) else {
434        output.fill(0);
435        return;
436    };
437    let start = y * width;
438    let gradient_x = &gradient_x[start..start + width];
439    let gradient_y = &gradient_y[start..start + width];
440    if l2_gradient {
441        for ((magnitude, &x), &y) in output.iter_mut().zip(gradient_x).zip(gradient_y) {
442            let x = i32::from(x);
443            let y = i32::from(y);
444            *magnitude = x * x + y * y;
445        }
446    } else {
447        for ((magnitude, &x), &y) in output.iter_mut().zip(gradient_x).zip(gradient_y) {
448            *magnitude = i32::from(x).abs() + i32::from(y).abs();
449        }
450    }
451}
452
453#[inline]
454#[allow(clippy::too_many_arguments)]
455fn is_directional_maximum_ring(
456    x: usize,
457    y: usize,
458    width: usize,
459    height: usize,
460    gradient_x: i32,
461    gradient_y: i32,
462    magnitude: i32,
463    magnitudes: &[i32],
464    previous_slot: usize,
465    current_slot: usize,
466    next_slot: usize,
467) -> bool {
468    const TG22: i64 = 13_573;
469    let abs_x = i64::from(gradient_x.abs());
470    let abs_y_scaled = i64::from(gradient_y.abs()) << 15;
471    let tg22_x = abs_x * TG22;
472    let get = |offset_x: isize, offset_y: isize| {
473        let nx = x as isize + offset_x;
474        let ny = y as isize + offset_y;
475        if nx < 0 || ny < 0 || nx >= width as isize || ny >= height as isize {
476            0
477        } else {
478            let slot = match offset_y {
479                -1 => previous_slot,
480                0 => current_slot,
481                1 => next_slot,
482                _ => unreachable!("Canny NMS only reads adjacent rows"),
483            };
484            magnitudes[slot * width + nx as usize]
485        }
486    };
487    if abs_y_scaled < tg22_x {
488        magnitude > get(-1, 0) && magnitude >= get(1, 0)
489    } else if abs_y_scaled > tg22_x + (abs_x << 16) {
490        magnitude > get(0, -1) && magnitude >= get(0, 1)
491    } else {
492        let sign = if (gradient_x ^ gradient_y) < 0 { -1 } else { 1 };
493        magnitude > get(-sign, -1) && magnitude > get(sign, 1)
494    }
495}
496
497fn canny_thresholds(options: CannyOptions) -> (i64, i64) {
498    let (mut low, mut high) = (options.low_threshold, options.high_threshold);
499    if options.aperture_size == 7 {
500        low /= 16.0;
501        high /= 16.0;
502    }
503    if low > high {
504        std::mem::swap(&mut low, &mut high);
505    }
506    if options.l2_gradient {
507        (
508            low.min(32767.0).mul_add(low.min(32767.0), 0.0).floor() as i64,
509            high.min(32767.0).mul_add(high.min(32767.0), 0.0).floor() as i64,
510        )
511    } else {
512        (low.floor() as i64, high.floor() as i64)
513    }
514}
515
516/// Runs Canny and retains gradient, magnitude, and suppression stages.
517pub fn canny_with_intermediates(
518    input: ImageView<'_, u8, 1>,
519    options: CannyOptions,
520) -> VisionResult<CannyResult> {
521    let options = options.validate()?;
522    let width = input.width();
523    let height = input.height();
524    let len = width
525        .checked_mul(height)
526        .ok_or_else(|| VisionError::InvalidDimensions("Canny image dimensions overflow".into()))?;
527    let (gradient_x, gradient_y, gx, gy) = if options.aperture_size == 3 {
528        let (gradient_x_i16, gradient_y_i16) = spatial_gradient_u8(input, BorderMode::Replicate)?;
529        let gx = gradient_x_i16.as_slice().iter().map(|&value| i32::from(value)).collect();
530        let gy = gradient_y_i16.as_slice().iter().map(|&value| i32::from(value)).collect();
531        let gradient_x = gradient_x_i16.as_slice().iter().map(|&value| f32::from(value)).collect();
532        let gradient_y = gradient_y_i16.as_slice().iter().map(|&value| f32::from(value)).collect();
533        (
534            Image::try_new_with_metadata(width, height, gradient_x, input.metadata())?,
535            Image::try_new_with_metadata(width, height, gradient_y, input.metadata())?,
536            gx,
537            gy,
538        )
539    } else {
540        let scale = if options.aperture_size == 7 { 1.0 / 16.0 } else { 1.0 };
541        let gradient_x =
542            sobel(input, 1, 0, options.aperture_size, scale, 0.0, BorderMode::Replicate)?;
543        let gradient_y =
544            sobel(input, 0, 1, options.aperture_size, scale, 0.0, BorderMode::Replicate)?;
545        let gx = gradient_x
546            .as_slice()
547            .iter()
548            // OpenCV's CV_16S Sobel path uses cvRound, whose supported CPU paths
549            // round half-way values to even. This matters for aperture 7's 1/16 scale.
550            .map(|&value| round_i16_ties_even(value))
551            .collect::<Vec<_>>();
552        let gy = gradient_y
553            .as_slice()
554            .iter()
555            .map(|&value| round_i16_ties_even(value))
556            .collect::<Vec<_>>();
557        (gradient_x, gradient_y, gx, gy)
558    };
559    let comparison_magnitude = gx
560        .iter()
561        .zip(&gy)
562        .map(|(&x, &y)| {
563            if options.l2_gradient {
564                i64::from(x) * i64::from(x) + i64::from(y) * i64::from(y)
565            } else {
566                i64::from(x.abs() + y.abs())
567            }
568        })
569        .collect::<Vec<_>>();
570    let magnitude_values = comparison_magnitude
571        .iter()
572        .map(|&value| if options.l2_gradient { (value as f64).sqrt() as f32 } else { value as f32 })
573        .collect::<Vec<_>>();
574
575    let (mut low, mut high) = (options.low_threshold, options.high_threshold);
576    if options.aperture_size == 7 {
577        low /= 16.0;
578        high /= 16.0;
579    }
580    if low > high {
581        std::mem::swap(&mut low, &mut high);
582    }
583    let (low, high) = if options.l2_gradient {
584        (
585            low.min(32767.0).mul_add(low.min(32767.0), 0.0).floor() as i64,
586            high.min(32767.0).mul_add(high.min(32767.0), 0.0).floor() as i64,
587        )
588    } else {
589        (low.floor() as i64, high.floor() as i64)
590    };
591
592    let mut states = vec![1_u8; len];
593    let mut suppressed_values = vec![0.0_f32; len];
594    let mut strong = VecDeque::new();
595    for y in 0..height {
596        for x in 0..width {
597            let index = y * width + x;
598            let magnitude = comparison_magnitude[index];
599            if magnitude <= low
600                || !is_directional_maximum(
601                    x,
602                    y,
603                    width,
604                    height,
605                    gx[index],
606                    gy[index],
607                    magnitude,
608                    &comparison_magnitude,
609                )
610            {
611                continue;
612            }
613            suppressed_values[index] = magnitude_values[index];
614            if magnitude > high {
615                states[index] = 2;
616                strong.push_back(index);
617            } else {
618                states[index] = 0;
619            }
620        }
621    }
622
623    while let Some(index) = strong.pop_front() {
624        let x = index % width;
625        let y = index / width;
626        for dy in -1_isize..=1 {
627            for dx in -1_isize..=1 {
628                if dx == 0 && dy == 0 {
629                    continue;
630                }
631                let nx = x as isize + dx;
632                let ny = y as isize + dy;
633                if nx < 0 || ny < 0 || nx >= width as isize || ny >= height as isize {
634                    continue;
635                }
636                let neighbor = ny as usize * width + nx as usize;
637                if states[neighbor] == 0 {
638                    states[neighbor] = 2;
639                    strong.push_back(neighbor);
640                }
641            }
642        }
643    }
644    let edges = states.into_iter().map(|state| if state == 2 { 255 } else { 0 }).collect();
645    let metadata = input.metadata();
646    Ok(CannyResult {
647        edges: Image::try_new_with_metadata(width, height, edges, metadata)?,
648        gradient_x,
649        gradient_y,
650        magnitude: Image::try_new_with_metadata(width, height, magnitude_values, metadata)?,
651        suppressed: Image::try_new_with_metadata(width, height, suppressed_values, metadata)?,
652    })
653}
654
655fn round_i16_ties_even(value: f32) -> i32 {
656    let value = value.clamp(f32::from(i16::MIN), f32::from(i16::MAX));
657    let lower = value.floor();
658    let fraction = value - lower;
659    if fraction < 0.5 {
660        lower as i32
661    } else if fraction > 0.5 {
662        lower as i32 + 1
663    } else {
664        let lower = lower as i32;
665        if lower & 1 == 0 {
666            lower
667        } else {
668            lower + 1
669        }
670    }
671}
672
673fn is_directional_maximum(
674    x: usize,
675    y: usize,
676    width: usize,
677    height: usize,
678    gradient_x: i32,
679    gradient_y: i32,
680    magnitude: i64,
681    magnitudes: &[i64],
682) -> bool {
683    const TG22: i64 = 13_573;
684    let abs_x = i64::from(gradient_x.abs());
685    let abs_y_scaled = i64::from(gradient_y.abs()) << 15;
686    let tg22_x = abs_x * TG22;
687    let get = |offset_x: isize, offset_y: isize| {
688        let nx = x as isize + offset_x;
689        let ny = y as isize + offset_y;
690        if nx < 0 || ny < 0 || nx >= width as isize || ny >= height as isize {
691            0
692        } else {
693            magnitudes[ny as usize * width + nx as usize]
694        }
695    };
696    if abs_y_scaled < tg22_x {
697        magnitude > get(-1, 0) && magnitude >= get(1, 0)
698    } else if abs_y_scaled > tg22_x + (abs_x << 16) {
699        magnitude > get(0, -1) && magnitude >= get(0, 1)
700    } else {
701        let sign = if (gradient_x ^ gradient_y) < 0 { -1 } else { 1 };
702        magnitude > get(-sign, -1) && magnitude > get(sign, 1)
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::{canny, canny_into, canny_with_intermediates, CannyOptions, CannyWorkspace};
709    use spatialrust_image::{Image, ImageRegion, ImageViewMut};
710
711    #[test]
712    fn finds_both_sides_of_bright_bar_on_strided_roi() {
713        let mut data = vec![0_u8; 9 * 7];
714        for y in 1..6 {
715            for x in 3..6 {
716                data[y * 9 + x] = 255;
717            }
718        }
719        let image = Image::<u8, 1>::try_new(9, 7, data).unwrap();
720        let roi = image.view().subview(ImageRegion::new(1, 1, 7, 5)).unwrap();
721        let edges = canny(
722            roi,
723            CannyOptions { low_threshold: 50.0, high_threshold: 100.0, ..Default::default() },
724        )
725        .unwrap();
726        assert!(edges.as_slice().iter().filter(|&&value| value == 255).count() >= 6);
727        assert!(edges.as_slice().iter().all(|&value| value == 0 || value == 255));
728    }
729
730    #[test]
731    fn intermediates_preserve_shape_and_signed_gradients() {
732        let image =
733            Image::<u8, 1>::try_new(5, 3, (0..3).flat_map(|_| [0, 10, 20, 30, 40]).collect())
734                .unwrap();
735        let result = canny_with_intermediates(image.view(), CannyOptions::default()).unwrap();
736        assert_eq!((result.edges.width(), result.edges.height()), (5, 3));
737        assert!(result.gradient_x.as_slice().iter().any(|&value| value > 0.0));
738        assert!(result.gradient_y.as_slice().iter().all(|&value| value == 0.0));
739    }
740
741    #[test]
742    fn flat_and_empty_images_have_no_edges() {
743        for image in [
744            Image::<u8, 1>::from_pixel(4, 3, [42]).unwrap(),
745            Image::<u8, 1>::try_new(0, 0, Vec::new()).unwrap(),
746        ] {
747            assert!(canny(image.view(), CannyOptions::default())
748                .unwrap()
749                .as_slice()
750                .iter()
751                .all(|&value| value == 0));
752        }
753    }
754
755    #[test]
756    fn validates_thresholds_and_aperture() {
757        let image = Image::<u8, 1>::from_pixel(1, 1, [0]).unwrap();
758        assert!(
759            canny(image.view(), CannyOptions { aperture_size: 4, ..Default::default() }).is_err()
760        );
761        assert!(canny(image.view(), CannyOptions { low_threshold: -1.0, ..Default::default() })
762            .is_err());
763    }
764
765    #[test]
766    fn fast_path_matches_inspectable_path_for_l1_and_l2() {
767        let data = (0..31 * 19).map(|index| ((index * 37 + index / 31 * 17) & 255) as u8).collect();
768        let image = Image::<u8, 1>::try_new(31, 19, data).unwrap();
769        for l2_gradient in [false, true] {
770            let options = CannyOptions {
771                low_threshold: 80.0,
772                high_threshold: 160.0,
773                l2_gradient,
774                ..Default::default()
775            };
776            assert_eq!(
777                canny(image.view(), options).unwrap(),
778                canny_with_intermediates(image.view(), options).unwrap().edges
779            );
780        }
781    }
782
783    #[test]
784    fn reusable_path_supports_strided_output_without_touching_padding() {
785        let image = Image::<u8, 1>::try_new(
786            7,
787            5,
788            (0..35).map(|index| ((index * 53) & 255) as u8).collect(),
789        )
790        .unwrap();
791        let expected = canny(image.view(), CannyOptions::default()).unwrap();
792        let mut storage = vec![17_u8; 5 * 11];
793        let output = ImageViewMut::<u8, 1>::new(7, 5, 11, &mut storage).unwrap();
794        let mut workspace = CannyWorkspace::new();
795        canny_into(image.view(), CannyOptions::default(), output, &mut workspace).unwrap();
796        for y in 0..5 {
797            assert_eq!(&storage[y * 11..y * 11 + 7], &expected.as_slice()[y * 7..y * 7 + 7]);
798            if y < 4 {
799                assert_eq!(&storage[y * 11 + 7..(y + 1) * 11], &[17; 4]);
800            }
801        }
802    }
803
804    #[test]
805    fn parallel_ring_matches_intermediates_and_avoids_full_magnitude_image() {
806        let (width, height) = (1_000, 1_000);
807        let data = (0..width * height)
808            .map(|index| if (index / width) % 80 < 3 { 255 } else { 0 })
809            .collect();
810        let image = Image::<u8, 1>::try_new(width, height, data).unwrap();
811        let options = CannyOptions {
812            low_threshold: 80.0,
813            high_threshold: 160.0,
814            l2_gradient: true,
815            ..Default::default()
816        };
817        let expected = canny_with_intermediates(image.view(), options).unwrap().edges;
818        let mut actual = Image::<u8, 1>::from_pixel(width, height, [0]).unwrap();
819        let mut workspace = CannyWorkspace::new();
820        canny_into(image.view(), options, actual.view_mut(), &mut workspace).unwrap();
821        assert_eq!(actual, expected);
822        assert!(workspace.allocated_bytes() < width * height * 6);
823    }
824}