|
| 1 | +//! Vendored Canny edge detection from `imageproc 0.26.0`. |
| 2 | +//! |
| 3 | +//! This is a local copy of `imageproc::edges::{canny, non_maximum_suppression, |
| 4 | +//! hysteresis}` with two bug fixes applied to the `hysteresis` function: |
| 5 | +//! |
| 6 | +//! 1. **`u32` underflow in BFS neighbor computation** — when a pixel at |
| 7 | +//! `x=0` or `y=0` is popped from the BFS stack, `nx - 1` wraps to |
| 8 | +//! `u32::MAX`, causing `get_pixel` to panic (in WASM this manifests as |
| 9 | +//! `RuntimeError: unreachable`). Fixed by bounds-checking each neighbor |
| 10 | +//! coordinate before access. |
| 11 | +//! |
| 12 | +//! 2. **Missing neighbors** — the original only checks 6 of 8 |
| 13 | +//! cardinal/diagonal neighbors, omitting north `(nx, ny-1)` and |
| 14 | +//! northeast `(nx+1, ny-1)`. Fixed by adding the two missing entries. |
| 15 | +//! |
| 16 | +//! Upstream references: |
| 17 | +//! - Issue: <https://github.com/image-rs/imageproc/issues/705> |
| 18 | +//! - Fix PR (not yet merged): <https://github.com/image-rs/imageproc/pull/746> |
| 19 | +//! |
| 20 | +//! Remove this module once the upstream fix is released. Tracked by: |
| 21 | +//! <https://github.com/altendky/mujou/issues/69> |
| 22 | +
|
| 23 | +// Vendored code — match upstream style, only vary by the fixes. |
| 24 | +#[allow( |
| 25 | + clippy::unwrap_used, |
| 26 | + clippy::cast_possible_truncation, |
| 27 | + clippy::cast_sign_loss, |
| 28 | + clippy::cast_precision_loss, |
| 29 | + clippy::cast_lossless, |
| 30 | + clippy::items_after_statements, |
| 31 | + clippy::semicolon_if_nothing_returned, |
| 32 | + clippy::panic, |
| 33 | + unsafe_code |
| 34 | +)] |
| 35 | +mod inner { |
| 36 | + use image::{GenericImageView, GrayImage, Luma}; |
| 37 | + use imageproc::definitions::{HasBlack, HasWhite, Image}; |
| 38 | + use imageproc::filter::{filter_clamped, gaussian_blur_f32}; |
| 39 | + use imageproc::kernel; |
| 40 | + use std::f32; |
| 41 | + |
| 42 | + /// Runs the canny edge detection algorithm. |
| 43 | + /// |
| 44 | + /// Identical to `imageproc::edges::canny` except that `hysteresis` is |
| 45 | + /// patched (see module-level docs). |
| 46 | + pub fn canny(image: &GrayImage, low_threshold: f32, high_threshold: f32) -> GrayImage { |
| 47 | + assert!(high_threshold >= low_threshold); |
| 48 | + // Heavily based on the implementation proposed by wikipedia. |
| 49 | + // 1. Gaussian blur. |
| 50 | + const SIGMA: f32 = 1.4; |
| 51 | + let blurred = gaussian_blur_f32(image, SIGMA); |
| 52 | + |
| 53 | + // 2. Intensity of gradients. |
| 54 | + let gx = filter_clamped(&blurred, kernel::SOBEL_HORIZONTAL_3X3); |
| 55 | + let gy = filter_clamped(&blurred, kernel::SOBEL_VERTICAL_3X3); |
| 56 | + let g: Vec<f32> = gx |
| 57 | + .iter() |
| 58 | + .zip(gy.iter()) |
| 59 | + .map(|(h, v)| (*h as f32).hypot(*v as f32)) |
| 60 | + .collect::<Vec<f32>>(); |
| 61 | + |
| 62 | + let g = Image::from_raw(image.width(), image.height(), g).unwrap(); |
| 63 | + |
| 64 | + // 3. Non-maximum-suppression (Make edges thinner) |
| 65 | + let thinned = non_maximum_suppression(&g, &gx, &gy); |
| 66 | + |
| 67 | + // 4. Hysteresis to filter out edges based on thresholds. |
| 68 | + hysteresis(&thinned, low_threshold, high_threshold) |
| 69 | + } |
| 70 | + |
| 71 | + /// Finds local maxima to make the edges thinner. |
| 72 | + fn non_maximum_suppression( |
| 73 | + g: &Image<Luma<f32>>, |
| 74 | + gx: &Image<Luma<i16>>, |
| 75 | + gy: &Image<Luma<i16>>, |
| 76 | + ) -> Image<Luma<f32>> { |
| 77 | + const RADIANS_TO_DEGREES: f32 = 180f32 / f32::consts::PI; |
| 78 | + let mut out = Image::from_pixel(g.width(), g.height(), Luma([0.0])); |
| 79 | + for y in 1..g.height() - 1 { |
| 80 | + for x in 1..g.width() - 1 { |
| 81 | + let x_gradient = gx[(x, y)][0] as f32; |
| 82 | + let y_gradient = gy[(x, y)][0] as f32; |
| 83 | + let mut angle = (y_gradient).atan2(x_gradient) * RADIANS_TO_DEGREES; |
| 84 | + if angle < 0.0 { |
| 85 | + angle += 180.0 |
| 86 | + } |
| 87 | + // Clamp angle. |
| 88 | + let clamped_angle = if !(22.5..157.5).contains(&angle) { |
| 89 | + 0 |
| 90 | + } else if (22.5..67.5).contains(&angle) { |
| 91 | + 45 |
| 92 | + } else if (67.5..112.5).contains(&angle) { |
| 93 | + 90 |
| 94 | + } else if (112.5..157.5).contains(&angle) { |
| 95 | + 135 |
| 96 | + } else { |
| 97 | + unreachable!() |
| 98 | + }; |
| 99 | + |
| 100 | + // Get the two perpendicular neighbors. |
| 101 | + let (cmp1, cmp2) = unsafe { |
| 102 | + match clamped_angle { |
| 103 | + 0 => (g.unsafe_get_pixel(x - 1, y), g.unsafe_get_pixel(x + 1, y)), |
| 104 | + 45 => ( |
| 105 | + g.unsafe_get_pixel(x + 1, y + 1), |
| 106 | + g.unsafe_get_pixel(x - 1, y - 1), |
| 107 | + ), |
| 108 | + 90 => (g.unsafe_get_pixel(x, y - 1), g.unsafe_get_pixel(x, y + 1)), |
| 109 | + 135 => ( |
| 110 | + g.unsafe_get_pixel(x - 1, y + 1), |
| 111 | + g.unsafe_get_pixel(x + 1, y - 1), |
| 112 | + ), |
| 113 | + _ => unreachable!(), |
| 114 | + } |
| 115 | + }; |
| 116 | + let pixel = *g.get_pixel(x, y); |
| 117 | + // If the pixel is not a local maximum, suppress it. |
| 118 | + if pixel[0] < cmp1[0] || pixel[0] < cmp2[0] { |
| 119 | + out.put_pixel(x, y, Luma([0.0])); |
| 120 | + } else { |
| 121 | + out.put_pixel(x, y, pixel); |
| 122 | + } |
| 123 | + } |
| 124 | + } |
| 125 | + out |
| 126 | + } |
| 127 | + |
| 128 | + /// Filter out edges with the thresholds. |
| 129 | + /// Non-recursive breadth-first search. |
| 130 | + /// |
| 131 | + /// # Changes from upstream `imageproc 0.26.0` |
| 132 | + /// |
| 133 | + /// - **Bounds check**: neighbor coordinates are checked against image |
| 134 | + /// dimensions before `get_pixel`, preventing `u32` underflow panic |
| 135 | + /// when BFS reaches the image border. |
| 136 | + /// (<https://github.com/image-rs/imageproc/issues/705>) |
| 137 | + /// |
| 138 | + /// - **Missing neighbors**: added `(nx, ny-1)` and `(nx+1, ny-1)` to |
| 139 | + /// check all 8 cardinal/diagonal neighbors (upstream only checked 6). |
| 140 | + /// (<https://github.com/image-rs/imageproc/pull/746>) |
| 141 | + fn hysteresis(input: &Image<Luma<f32>>, low_thresh: f32, high_thresh: f32) -> Image<Luma<u8>> { |
| 142 | + let max_brightness = Luma::white(); |
| 143 | + let min_brightness = Luma::black(); |
| 144 | + // Init output image as all black. |
| 145 | + let mut out = Image::from_pixel(input.width(), input.height(), min_brightness); |
| 146 | + // Stack. Possible optimization: Use previously allocated memory, i.e. gx. |
| 147 | + let mut edges = Vec::with_capacity(((input.width() * input.height()) / 2) as usize); |
| 148 | + let (w, h) = (input.width(), input.height()); // FIX: cache for bounds checks |
| 149 | + for y in 1..input.height() - 1 { |
| 150 | + for x in 1..input.width() - 1 { |
| 151 | + let inp_pix = *input.get_pixel(x, y); |
| 152 | + let out_pix = *out.get_pixel(x, y); |
| 153 | + // If the edge strength is higher than high_thresh, mark it as an edge. |
| 154 | + if inp_pix[0] >= high_thresh && out_pix[0] == 0 { |
| 155 | + out.put_pixel(x, y, max_brightness); |
| 156 | + edges.push((x, y)); |
| 157 | + // Track neighbors until no neighbor is >= low_thresh. |
| 158 | + while let Some((nx, ny)) = edges.pop() { |
| 159 | + // FIX: all 8 neighbors (upstream omitted north and northeast), |
| 160 | + // using wrapping_sub to avoid u32 underflow panic. |
| 161 | + let neighbor_indices = [ |
| 162 | + (nx + 1, ny), |
| 163 | + (nx + 1, ny + 1), |
| 164 | + (nx, ny + 1), |
| 165 | + (nx.wrapping_sub(1), ny.wrapping_sub(1)), |
| 166 | + (nx.wrapping_sub(1), ny), |
| 167 | + (nx.wrapping_sub(1), ny + 1), |
| 168 | + (nx, ny.wrapping_sub(1)), // FIX: north (was missing) |
| 169 | + (nx + 1, ny.wrapping_sub(1)), // FIX: northeast (was missing) |
| 170 | + ]; |
| 171 | + |
| 172 | + for neighbor_idx in &neighbor_indices { |
| 173 | + // FIX: bounds check — skip out-of-bounds neighbors instead |
| 174 | + // of panicking on u32::MAX from wrapping_sub. |
| 175 | + if neighbor_idx.0 >= w || neighbor_idx.1 >= h { |
| 176 | + continue; |
| 177 | + } |
| 178 | + let in_neighbor = *input.get_pixel(neighbor_idx.0, neighbor_idx.1); |
| 179 | + let out_neighbor = *out.get_pixel(neighbor_idx.0, neighbor_idx.1); |
| 180 | + if in_neighbor[0] >= low_thresh && out_neighbor[0] == 0 { |
| 181 | + out.put_pixel(neighbor_idx.0, neighbor_idx.1, max_brightness); |
| 182 | + edges.push((neighbor_idx.0, neighbor_idx.1)); |
| 183 | + } |
| 184 | + } |
| 185 | + } |
| 186 | + } |
| 187 | + } |
| 188 | + } |
| 189 | + out |
| 190 | + } |
| 191 | +} |
| 192 | + |
| 193 | +pub use inner::canny; |
| 194 | + |
| 195 | +#[cfg(test)] |
| 196 | +#[allow(clippy::unwrap_used)] |
| 197 | +mod tests { |
| 198 | + use super::*; |
| 199 | + use image::{GrayImage, Luma}; |
| 200 | + |
| 201 | + /// Regression test for imageproc#705: hysteresis panics when BFS |
| 202 | + /// reaches the image border due to u32 underflow on `nx - 1`. |
| 203 | + /// |
| 204 | + /// Creates an image with a strong edge at x=1 (one pixel from the |
| 205 | + /// left border). With low thresholds, hysteresis BFS expands to |
| 206 | + /// x=0, then tries to compute 0u32 - 1 = `u32::MAX` for the next |
| 207 | + /// iteration. Without the bounds-check fix this panics. |
| 208 | + #[test] |
| 209 | + fn border_edge_does_not_panic() { |
| 210 | + let mut img = GrayImage::from_pixel(10, 10, Luma([0])); |
| 211 | + // Place a bright column at x=1 to create a strong gradient |
| 212 | + // right next to the left border. |
| 213 | + for y in 0..10 { |
| 214 | + img.put_pixel(0, y, Luma([0])); |
| 215 | + img.put_pixel(1, y, Luma([255])); |
| 216 | + } |
| 217 | + // Low thresholds ensure the BFS will try to expand into border pixels. |
| 218 | + let _edges = canny(&img, 1.0, 2.0); |
| 219 | + } |
| 220 | + |
| 221 | + /// Verify output dimensions match input. |
| 222 | + #[test] |
| 223 | + fn output_dimensions_match_input() { |
| 224 | + let img = GrayImage::new(17, 31); |
| 225 | + let edges = canny(&img, 50.0, 150.0); |
| 226 | + assert_eq!(edges.width(), 17); |
| 227 | + assert_eq!(edges.height(), 31); |
| 228 | + } |
| 229 | + |
| 230 | + /// Verify edges are detected on a sharp boundary. |
| 231 | + #[test] |
| 232 | + fn sharp_edge_detected() { |
| 233 | + let img = GrayImage::from_fn(20, 20, |x, _y| if x < 10 { Luma([0]) } else { Luma([255]) }); |
| 234 | + let edges = canny(&img, 50.0, 150.0); |
| 235 | + let edge_count: u32 = edges.pixels().map(|p| u32::from(p.0[0] > 0)).sum(); |
| 236 | + assert!(edge_count > 0, "expected edges at sharp boundary"); |
| 237 | + } |
| 238 | +} |
0 commit comments