use core::ops::Add;
use crate::border::Clamp;
use crate::image::{BinaryImage, Image, RasterImage};
use crate::pixel::{FromLinear, LinearPixel, SingleChannel, ZeroablePixel};
use crate::transform::{
MagnitudeChannel, gaussian_blur, gradient_magnitude, non_maximum_suppression_from_gradients,
scharr_x, scharr_y,
};
use crate::analyze::threshold::hysteresis_threshold;
#[must_use]
pub fn canny<I, P, Acc>(image: &I, low: f32, high: f32, sigma: f32) -> BinaryImage
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ SingleChannel
+ FromLinear<Acc>
+ LinearPixel<f32, Accumulator = Acc>
+ Add<Output = Acc>,
Acc::Channel: PartialOrd + Copy + core::fmt::Debug + From<f32> + MagnitudeChannel,
f64: From<Acc::Channel>,
{
let blurred: Image<Acc> = gaussian_blur(image, sigma, &Clamp);
let gx = scharr_x(&blurred, &Clamp);
let gy = scharr_y(&blurred, &Clamp);
let magnitude = gradient_magnitude(&gx, &gy).expect("gx and gy share a size");
let thinned = non_maximum_suppression_from_gradients(&magnitude, &gx, &gy);
hysteresis_threshold(
&thinned,
<Acc::Channel as From<f32>>::from(low),
<Acc::Channel as From<f32>>::from(high),
)
}
#[cfg(test)]
mod tests {
use super::canny;
use crate::image::{Image, ImageView, RasterImage};
use crate::pixel::{Mono8, MonoF32, MonoF64};
fn count_true(mask: &crate::image::BinaryImage) -> usize {
(0..mask.height())
.map(|y| mask.row(y).iter().filter(|&&b| b).count())
.sum()
}
fn edge_columns(mask: &crate::image::BinaryImage) -> Vec<usize> {
(0..mask.width())
.filter(|&x| (0..mask.height()).any(|y| mask.pixel_at(x, y)))
.collect()
}
fn assert_thin_edge(mask: &crate::image::BinaryImage, allowed: &[usize]) {
let cols = edge_columns(mask);
assert!(!cols.is_empty(), "expected an edge, got none");
assert!(cols.len() <= 2, "expected a thin edge, got {cols:?}");
assert!(
cols.iter().all(|x| allowed.contains(x)),
"edge columns {cols:?} not within {allowed:?}",
);
}
#[test]
fn step_edge_single_response() {
let image = Image::generate(8, 6, |x, _| MonoF32::new(if x < 4 { 0.0 } else { 1.0 }));
let edges = canny(&image, 0.10, 0.30, 1.0);
assert_thin_edge(&edges, &[3, 4]);
}
#[test]
fn uniform_image_no_edges() {
let image = Image::fill(12, 12, MonoF32::new(0.5));
let edges = canny(&image, 0.05, 0.15, 1.2);
assert_eq!(count_true(&edges), 0);
}
#[test]
fn noise_below_low_suppressed() {
let image = Image::generate(16, 16, |x, y| {
MonoF32::new(if (x + y) % 2 == 0 { 0.50 } else { 0.502 })
});
let edges = canny(&image, 0.10, 0.30, 1.0);
assert_eq!(count_true(&edges), 0);
}
#[test]
fn weak_edge_linked_to_strong_kept() {
let h = 8;
let image = Image::generate(10, h, |x, y| {
let high_side = if y < h / 2 { 1.0 } else { 0.10 };
MonoF32::new(if x < 5 { 0.0 } else { high_side })
});
let edges = canny(&image, 0.02, 0.20, 1.0);
let cols = edge_columns(&edges);
assert!(cols.contains(&5), "edge column present: {cols:?}");
let weak_rows_present = (h / 2..h).any(|y| edges.pixel_at(5, y));
assert!(weak_rows_present, "weak segment linked to strong and kept");
}
#[test]
fn accepts_integer_input() {
let image = Image::generate(8, 6, |x, _| Mono8::new(if x < 4 { 0 } else { 255 }));
let edges = canny(&image, 8.0, 30.0, 1.0);
assert_thin_edge(&edges, &[3, 4]);
}
#[test]
fn generic_over_mono_f64() {
let image = Image::generate(8, 6, |x, _| MonoF64::new(if x < 4 { 0.0 } else { 1.0 }));
let edges = canny(&image, 0.10, 0.30, 1.0);
assert_thin_edge(&edges, &[3, 4]);
}
#[test]
fn fused_pipeline_matches_staged_composition() {
use crate::analyze::threshold::hysteresis_threshold;
use crate::border::Clamp;
use crate::transform::{
gaussian_blur, gradient_direction, gradient_magnitude, non_maximum_suppression,
scharr_x, scharr_y,
};
const N: usize = 24;
let image = Image::generate(N, N, |x, y| {
let (dx, dy) = (x as i32 - 12, y as i32 - 12);
let v = if dx * dx + dy * dy < 36 {
1.0 } else if x % 7 == 0 || y % 5 == 0 {
0.6 } else if x == y || x + y == N - 1 {
0.35 } else {
0.1
};
MonoF32::new(v)
});
let (low, high, sigma) = (0.02f32, 0.08f32, 1.2f32);
let fused = canny(&image, low, high, sigma);
let blurred: Image<MonoF32> = gaussian_blur(&image, sigma, &Clamp);
let gx = scharr_x(&blurred, &Clamp);
let gy = scharr_y(&blurred, &Clamp);
let mag = gradient_magnitude(&gx, &gy).unwrap();
let dir = gradient_direction(&gx, &gy).unwrap();
let thin = non_maximum_suppression(&mag, &dir);
let staged = hysteresis_threshold(&thin, low, high);
assert!(count_true(&fused) > 0, "the fixture should produce edges");
for y in 0..N {
for x in 0..N {
assert_eq!(
fused.pixel_at(x, y),
staged.pixel_at(x, y),
"fused and staged disagree at ({x},{y})"
);
}
}
}
#[test]
#[should_panic(expected = "sigma")]
fn non_positive_sigma_panics() {
let image = Image::fill(4, 4, MonoF32::new(0.5));
let _ = canny(&image, 0.1, 0.2, 0.0);
}
}