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::{Coordinate, CoordinateF64, Error, Sigma};
use crate::analyze::peak::interpolate_ridge_points;
use crate::analyze::threshold::{HysteresisThresholds, hysteresis_threshold};
#[must_use]
pub fn canny<I, P, Acc>(
image: &I,
thresholds: HysteresisThresholds<f32>,
sigma: Sigma,
) -> 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,
thresholds.map_monotone(<Acc::Channel as From<f32>>::from),
)
}
pub fn interpolate_edge_points<IB, IM, IX, IY, P>(
mask: &IB,
magnitude: &IM,
gx: &IX,
gy: &IY,
) -> Result<Vec<CoordinateF64>, Error>
where
IB: RasterImage<Pixel = bool>,
IM: RasterImage<Pixel = P>,
IX: RasterImage<Pixel = P>,
IY: RasterImage<Pixel = P>,
P: SingleChannel,
f64: From<P::Channel>,
{
if mask.size() != magnitude.size() {
return Err(Error::SizeMismatch {
expected: magnitude.size(),
actual: mask.size(),
});
}
let sites = (0..mask.height()).flat_map(|y| {
mask.row(y)
.iter()
.enumerate()
.filter(|&(_, &on)| on)
.map(move |(x, _)| Coordinate::new(x, y))
});
Ok(interpolate_ridge_points(sites, magnitude, gx, gy)?
.into_iter()
.flatten()
.collect())
}
#[cfg(test)]
mod tests {
use super::{HysteresisThresholds, canny, interpolate_edge_points};
use crate::image::{Image, ImageView, RasterImage};
use crate::pixel::{Mono8, MonoF32, MonoF64};
use crate::sigma;
fn t(low: f32, high: f32) -> HysteresisThresholds<f32> {
HysteresisThresholds::try_new(low, high).unwrap()
}
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, t(0.10, 0.30), sigma!(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, t(0.05, 0.15), sigma!(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, t(0.10, 0.30), sigma!(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, t(0.02, 0.20), sigma!(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, t(8.0, 30.0), sigma!(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, t(0.10, 0.30), sigma!(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 thresholds = t(0.02, 0.08);
let sigma = sigma!(1.2);
let fused = canny(&image, thresholds, 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).unwrap();
let staged = hysteresis_threshold(&thin, thresholds);
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})"
);
}
}
}
use crate::CoordinateF64;
use crate::border::Clamp;
use crate::error::Error;
use crate::transform::{gaussian_blur, gradient_magnitude, scharr_x, scharr_y};
fn step_edge_stages(
w: usize,
h: usize,
edge_x: usize,
) -> (
crate::image::BinaryImage,
Image<MonoF32>,
Image<MonoF32>,
Image<MonoF32>,
) {
let sigma = sigma!(1.0);
let image: Image<MonoF32> = Image::generate(w, h, |x, _| {
MonoF32::new(if x < edge_x { 0.0 } else { 1.0 })
});
let mask = canny(&image, t(0.10, 0.30), sigma);
let blurred: Image<MonoF32> = gaussian_blur(&image, sigma, &Clamp);
let gx = scharr_x(&blurred, &Clamp);
let gy = scharr_y(&blurred, &Clamp);
let magnitude = gradient_magnitude(&gx, &gy).unwrap();
(mask, magnitude, gx, gy)
}
#[test]
fn edge_points_land_between_the_pixel_columns() {
let (mask, magnitude, gx, gy) = step_edge_stages(12, 6, 6);
let points = interpolate_edge_points(&mask, &magnitude, &gx, &gy).unwrap();
assert!(!points.is_empty(), "the fixture should produce edges");
for p in &points {
assert!((p.x - 5.5).abs() < 1e-4, "{p:?}");
}
}
#[test]
fn an_edge_on_a_pixel_centre_is_not_moved() {
let sigma = sigma!(1.0);
let image: Image<MonoF32> = Image::generate(13, 5, |x, _| {
MonoF32::new(match x.cmp(&6) {
core::cmp::Ordering::Less => 0.0,
core::cmp::Ordering::Equal => 0.5,
core::cmp::Ordering::Greater => 1.0,
})
});
let mask = canny(&image, t(0.05, 0.15), sigma);
let blurred: Image<MonoF32> = gaussian_blur(&image, sigma, &Clamp);
let gx = scharr_x(&blurred, &Clamp);
let gy = scharr_y(&blurred, &Clamp);
let magnitude = gradient_magnitude(&gx, &gy).unwrap();
let points = interpolate_edge_points(&mask, &magnitude, &gx, &gy).unwrap();
assert!(!points.is_empty(), "the fixture should produce edges");
for p in &points {
assert!((p.x - 6.0).abs() < 1e-6, "{p:?}");
}
}
#[test]
fn edge_points_come_out_in_raster_order() {
let (mask, magnitude, gx, gy) = step_edge_stages(12, 6, 6);
let points = interpolate_edge_points(&mask, &magnitude, &gx, &gy).unwrap();
let ys: Vec<f64> = points.iter().map(|p| p.y).collect();
assert!(ys.windows(2).all(|w| w[0] <= w[1]), "{ys:?}");
}
#[test]
fn an_empty_mask_yields_no_points() {
let (_, magnitude, gx, gy) = step_edge_stages(12, 6, 6);
let empty = Image::fill(12, 6, false);
let points = interpolate_edge_points(&empty, &magnitude, &gx, &gy).unwrap();
assert_eq!(points, Vec::<CoordinateF64>::new());
}
#[test]
fn a_thinned_magnitude_leaves_every_point_on_its_pixel() {
use crate::transform::non_maximum_suppression;
let sigma = sigma!(1.0);
let image: Image<MonoF32> =
Image::generate(12, 6, |x, _| MonoF32::new(if x < 6 { 0.0 } else { 1.0 }));
let mask = canny(&image, t(0.10, 0.30), sigma);
let blurred: Image<MonoF32> = gaussian_blur(&image, sigma, &Clamp);
let gx = scharr_x(&blurred, &Clamp);
let gy = scharr_y(&blurred, &Clamp);
let magnitude = gradient_magnitude(&gx, &gy).unwrap();
let direction = crate::transform::gradient_direction(&gx, &gy).unwrap();
let thinned = non_maximum_suppression(&magnitude, &direction).unwrap();
let wrong = interpolate_edge_points(&mask, &thinned, &gx, &gy).unwrap();
assert!(!wrong.is_empty());
for p in &wrong {
assert_eq!(p.x, p.x.round(), "{p:?}");
}
let right = interpolate_edge_points(&mask, &magnitude, &gx, &gy).unwrap();
assert!(right.iter().all(|p| (p.x - p.x.round()).abs() > 1e-6));
}
#[test]
fn mismatched_stages_are_rejected() {
let (mask, magnitude, gx, gy) = step_edge_stages(12, 6, 6);
let small: crate::image::BinaryImage = Image::fill(11, 6, true);
assert!(matches!(
interpolate_edge_points(&small, &magnitude, &gx, &gy),
Err(Error::SizeMismatch { .. })
));
let wrong_gradient: Image<MonoF32> = Image::fill(11, 6, MonoF32::new(1.0));
assert!(matches!(
interpolate_edge_points(&mask, &magnitude, &wrong_gradient, &gy),
Err(Error::SizeMismatch { .. })
));
}
}