├── clippy.toml ├── testdata ├── circle.png ├── line-fuzzy.png ├── line-simple.png └── line-weakening.png ├── media ├── demo-circle.png └── demo-peppers.png ├── .gitignore ├── src ├── lib.rs └── edge.rs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── Cargo.toml ├── README.md └── LICENSE /clippy.toml: -------------------------------------------------------------------------------- 1 | single-char-binding-names-threshold = 16 2 | -------------------------------------------------------------------------------- /testdata/circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/edge-detection-rs/HEAD/testdata/circle.png -------------------------------------------------------------------------------- /media/demo-circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/edge-detection-rs/HEAD/media/demo-circle.png -------------------------------------------------------------------------------- /media/demo-peppers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/edge-detection-rs/HEAD/media/demo-peppers.png -------------------------------------------------------------------------------- /testdata/line-fuzzy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/edge-detection-rs/HEAD/testdata/line-fuzzy.png -------------------------------------------------------------------------------- /testdata/line-simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/edge-detection-rs/HEAD/testdata/line-simple.png -------------------------------------------------------------------------------- /testdata/line-weakening.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polyfloyd/edge-detection-rs/HEAD/testdata/line-weakening.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.swo 3 | 4 | /target 5 | /Cargo.lock 6 | 7 | *.0-vectors.png 8 | *.1-edges.png 9 | *.2-minmax.png 10 | *.3-hysteresis.png 11 | *.4-result.png 12 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | #![cfg_attr(all(test, feature = "unstable"), feature(test))] 3 | #![warn(missing_docs)] 4 | 5 | mod edge; 6 | 7 | pub use crate::edge::*; 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: / 5 | schedule: 6 | interval: monthly 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: monthly 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | 13 | build: 14 | strategy: 15 | matrix: 16 | toolchain: [stable, nightly] 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - run: rustup default ${{ matrix.toolchain }} 24 | - run: rustup update 25 | - run: cargo build 26 | - run: cargo test 27 | 28 | style: 29 | runs-on: ubuntu-latest 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | - run: cargo fmt -- --check 34 | - run: cargo clippy 35 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "edge-detection" 3 | version = "0.3.0" 4 | edition = "2018" 5 | authors = ["polyfloyd "] 6 | license = "MIT" 7 | repository = "https://github.com/polyfloyd/edge-detection-rs" 8 | description = "The canny edge detection algorithm" 9 | readme = "README.md" 10 | keywords = ["computer-vision", "canny"] 11 | categories = ["algorithms", "multimedia::images", "science", "visualization"] 12 | 13 | include = [ 14 | "src/**/*.rs", 15 | "media/**/*.png", 16 | "Cargo.toml", 17 | ] 18 | 19 | [profile.dev] 20 | opt-level = 3 21 | 22 | [features] 23 | unstable = [] 24 | 25 | [dependencies] 26 | image = "0.25" 27 | rayon = "1" 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Edge Detection 2 | ============== 3 | [![Crate](https://img.shields.io/crates/v/edge-detection.svg)](https://crates.io/crates/edge-detection) 4 | [![Documentation](https://docs.rs/edge-detection/badge.svg)](https://docs.rs/edge-detection/) 5 | 6 | An implementation of the Canny edge detection algorithm in Rust. The base for 7 | many computer vision applications. 8 | 9 | ```rust 10 | let source_image = image::open("testdata/line-simple.png") 11 | .expect("failed to read image"); 12 | 13 | let detection = edge_detection::canny( 14 | source_image, 15 | 1.2, // sigma 16 | 0.2, // strong threshold 17 | 0.01, // weak threshold 18 | ); 19 | ``` 20 | 21 | ![alt tag](https://raw.githubusercontent.com/polyfloyd/edge-detection-rs/master/media/demo-circle.png "Circle") 22 | 23 | ![alt tag](https://raw.githubusercontent.com/polyfloyd/edge-detection-rs/master/media/demo-peppers.png "Peppers") 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 polyfloyd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/edge.rs: -------------------------------------------------------------------------------- 1 | use image::{self, DynamicImage, GenericImageView, GrayImage}; 2 | use rayon::prelude::*; 3 | use std::f32::consts::*; 4 | use std::*; 5 | 6 | const TAU: f32 = PI * 2.0; 7 | 8 | #[inline(always)] 9 | fn clamp(f: T, lo: T, hi: T) -> T { 10 | debug_assert!(lo < hi); 11 | if f > hi { 12 | hi 13 | } else if f < lo { 14 | lo 15 | } else { 16 | f 17 | } 18 | } 19 | 20 | /// The result of a computation. 21 | #[derive(Clone)] 22 | pub struct Detection { 23 | edges: Vec>, 24 | } 25 | 26 | impl Detection { 27 | /// Returns the width of the computed image. 28 | pub fn width(&self) -> usize { 29 | self.edges.len() 30 | } 31 | 32 | /// Returns the height of the computed image. 33 | pub fn height(&self) -> usize { 34 | self.edges[0].len() 35 | } 36 | 37 | /// Linearly interpolates the edge at the specified location. 38 | /// 39 | /// Similar to as if the edges detection were continuous. 40 | pub fn interpolate(&self, x: f32, y: f32) -> Edge { 41 | let ax = clamp(x.floor() as isize, 0, self.width() as isize - 1) as usize; 42 | let ay = clamp(y.floor() as isize, 0, self.height() as isize - 1) as usize; 43 | let bx = clamp(x.ceil() as isize, 0, self.width() as isize - 1) as usize; 44 | let by = clamp(y.ceil() as isize, 0, self.height() as isize - 1) as usize; 45 | let e1 = self.edges[ax][ay]; 46 | let e2 = self.edges[bx][ay]; 47 | let e3 = self.edges[ax][by]; 48 | let e4 = self.edges[bx][by]; 49 | let nx = (x.fract() + 1.0).fract(); 50 | let ny = (y.fract() + 1.0).fract(); 51 | 52 | let x1 = Edge { 53 | magnitude: e1.magnitude * (1.0 - nx) + e2.magnitude * nx, 54 | vec_x: e1.vec_x * (1.0 - nx) + e2.vec_x * nx, 55 | vec_y: e1.vec_y * (1.0 - nx) + e2.vec_y * nx, 56 | }; 57 | let x2 = Edge { 58 | magnitude: e3.magnitude * (1.0 - nx) + e4.magnitude * nx, 59 | vec_x: e3.vec_x * (1.0 - nx) + e4.vec_x * nx, 60 | vec_y: e3.vec_y * (1.0 - nx) + e4.vec_y * nx, 61 | }; 62 | Edge { 63 | magnitude: x1.magnitude * (1.0 - ny) + x2.magnitude * ny, 64 | vec_x: x1.vec_x * (1.0 - ny) + x2.vec_x * ny, 65 | vec_y: x1.vec_y * (1.0 - ny) + x2.vec_y * ny, 66 | } 67 | } 68 | 69 | /// Renders the detected edges to an image. 70 | /// 71 | /// The intensity of the pixel represents the magnitude of the change in brightnes while the 72 | /// color represents the direction. 73 | /// 74 | /// Useful for debugging. 75 | pub fn as_image(&self) -> image::DynamicImage { 76 | let img = image::RgbImage::from_fn(self.width() as u32, self.height() as u32, |x, y| { 77 | let (h, s, v) = { 78 | let edge = &self[(x as usize, y as usize)]; 79 | ((edge.angle() + TAU) % TAU, 1.0, edge.magnitude()) 80 | }; 81 | let (r, g, b) = { 82 | // http://www.rapidtables.com/convert/color/hsv-to-rgb.htm 83 | let c = v * s; 84 | let x = c * (1.0 - ((h / FRAC_PI_3) % 2.0 - 1.0).abs()); 85 | let m = v - c; 86 | let (r, g, b) = match h { 87 | h if h < FRAC_PI_3 => (c, x, 0.0), 88 | h if h < FRAC_PI_3 * 2.0 => (x, c, 0.0), 89 | h if h < PI => (0.0, c, x), 90 | h if h < PI + FRAC_PI_3 => (0.0, x, c), 91 | h if h < PI + FRAC_PI_3 * 2.0 => (x, 0.0, c), 92 | h if h < TAU => (c, 0.0, x), 93 | _ => unreachable!(), 94 | }; 95 | (r + m, g + m, b + m) 96 | }; 97 | image::Rgb([ 98 | (r * 255.0).round() as u8, 99 | (g * 255.0).round() as u8, 100 | (b * 255.0).round() as u8, 101 | ]) 102 | }); 103 | image::DynamicImage::ImageRgb8(img) 104 | } 105 | } 106 | 107 | impl ops::Index for Detection { 108 | type Output = Edge; 109 | fn index(&self, index: usize) -> &Self::Output { 110 | let x = index % self.width(); 111 | let y = index / self.height(); 112 | &self.edges[x][y] 113 | } 114 | } 115 | 116 | impl ops::Index<(usize, usize)> for Detection { 117 | type Output = Edge; 118 | fn index(&self, index: (usize, usize)) -> &Self::Output { 119 | &self.edges[index.0][index.1] 120 | } 121 | } 122 | 123 | /// The computed result for a single pixel. 124 | #[derive(Copy, Clone, Debug)] 125 | pub struct Edge { 126 | vec_x: f32, 127 | vec_y: f32, 128 | magnitude: f32, 129 | } 130 | 131 | impl Edge { 132 | fn new(vec_x: f32, vec_y: f32) -> Edge { 133 | let vec_x = FRAC_1_SQRT_2 * clamp(vec_x, -1.0, 1.0); 134 | let vec_y = FRAC_1_SQRT_2 * clamp(vec_y, -1.0, 1.0); 135 | let magnitude = f32::hypot(vec_x, vec_y); 136 | debug_assert!(0.0 <= magnitude && magnitude <= 1.0); 137 | let frac_1_mag = if magnitude != 0.0 { 138 | magnitude.recip() 139 | } else { 140 | 1.0 141 | }; 142 | Edge { 143 | vec_x: vec_x * frac_1_mag, 144 | vec_y: vec_y * frac_1_mag, 145 | magnitude, 146 | } 147 | } 148 | 149 | /// The direction of the gradient in radians. 150 | /// 151 | /// This is a convenience function for `atan2(direction)`. 152 | pub fn angle(&self) -> f32 { 153 | f32::atan2(self.vec_y, self.vec_x) 154 | } 155 | 156 | /// Returns the direction of the edge scaled by it's magnitude. 157 | pub fn dir(&self) -> (f32, f32) { 158 | (self.vec_x * self.magnitude(), self.vec_y * self.magnitude()) 159 | } 160 | 161 | /// Returns a normalized vector of the direction of the change in brightness 162 | /// 163 | /// The vector will point away from the detected line. 164 | /// E.g. a vertical line separating a dark area on the left and light area on the right will 165 | /// have it's direction point towards the light area on the right. 166 | pub fn dir_norm(&self) -> (f32, f32) { 167 | (self.vec_x, self.vec_y) 168 | } 169 | 170 | /// The absolute magnitude of the change in brightness. 171 | /// 172 | /// Between 0 and 1 inclusive. 173 | pub fn magnitude(&self) -> f32 { 174 | self.magnitude 175 | } 176 | } 177 | 178 | /// Computes the canny edges of an image. 179 | /// 180 | /// The variable `sigma` determines the size of the filter kernel which affects the precision and 181 | /// SNR of the computation: 182 | /// 183 | /// * A small sigma (3.0<) creates a kernel which is able to discern fine details but is more prone 184 | /// to noise. 185 | /// * Larger values result in detail being lost and are thus best used for detecting large 186 | /// features. Computation time also increases. 187 | /// 188 | /// The `weak_threshold` and `strong_threshold` determine what detected pixels are to be regarded 189 | /// as edges and which should be discarded. They are compared with the absolute magnitude of the 190 | /// change in brightness. 191 | /// 192 | /// # Panics: 193 | /// * If either `strong_threshold` or `weak_threshold` are outisde the range of 0 to 1 inclusive. 194 | /// * If `strong_threshold` is less than `weak_threshold`. 195 | /// * If `image` contains no pixels (either it's width or height is 0). 196 | pub fn canny>( 197 | image: T, 198 | sigma: f32, 199 | strong_threshold: f32, 200 | weak_threshold: f32, 201 | ) -> Detection { 202 | let dyn_img: DynamicImage = image.into(); 203 | let gs_image: GrayImage = dyn_img.into_luma8(); 204 | assert!(gs_image.width() > 0); 205 | assert!(gs_image.height() > 0); 206 | let edges = detect_edges(&gs_image, sigma); 207 | let edges = minmax_suppression(&Detection { edges }, weak_threshold); 208 | let edges = hysteresis(&edges, strong_threshold, weak_threshold); 209 | Detection { edges } 210 | } 211 | 212 | /// Calculates a 2nd order 2D gaussian derivative with size sigma. 213 | fn filter_kernel(sigma: f32) -> (usize, Vec<(f32, f32)>) { 214 | let size = (sigma * 10.0).round() as usize; 215 | let mul_2_sigma_2 = 2.0 * sigma.powi(2); 216 | let kernel = (0..size) 217 | .flat_map(|y| { 218 | (0..size).map(move |x| { 219 | let (xf, yf) = (x as f32 - size as f32 / 2.0, y as f32 - size as f32 / 2.0); 220 | let g = (-(xf.powi(2) + yf.powi(2)) / mul_2_sigma_2).exp() / mul_2_sigma_2; 221 | (xf * g, yf * g) 222 | }) 223 | }) 224 | .collect(); 225 | (size, kernel) 226 | } 227 | 228 | fn neighbour_pos_delta(theta: f32) -> (i32, i32) { 229 | let neighbours = [ 230 | (1, 0), // middle right 231 | (1, 1), // bottom right 232 | (0, 1), // center bottom 233 | (-1, 1), // bottom left 234 | (-1, 0), // middle left 235 | (-1, -1), // top left 236 | (0, -1), // center top 237 | (1, -1), // top right 238 | ]; 239 | let n = ((theta + TAU) % TAU) / TAU; 240 | let i = (n * 8.0).round() as usize % 8; 241 | neighbours[i] 242 | } 243 | 244 | /// Computes the edges in an image using the Canny Method. 245 | /// 246 | /// `sigma` determines the radius of the Gaussian kernel. 247 | fn detect_edges(image: &image::GrayImage, sigma: f32) -> Vec> { 248 | let (width, height) = (image.width() as i32, image.height() as i32); 249 | let (ksize, g_kernel) = filter_kernel(sigma); 250 | let ks = ksize as i32; 251 | (0..width) 252 | .into_par_iter() 253 | .map(|g_ix| { 254 | let ix = g_ix; 255 | let kernel = &g_kernel; 256 | (0..height) 257 | .into_par_iter() 258 | .map(move |iy| { 259 | let mut sum_x = 0.0; 260 | let mut sum_y = 0.0; 261 | 262 | for kyi in 0..ks { 263 | let ky = kyi - ks / 2; 264 | for kxi in 0..ks { 265 | let kx = kxi - ks / 2; 266 | let k = unsafe { 267 | let i = (kyi * ks + kxi) as usize; 268 | debug_assert!(i < kernel.len()); 269 | kernel.get_unchecked(i) 270 | }; 271 | 272 | let pix = unsafe { 273 | // Clamp x and y within the image bounds so no non-existing borders are be 274 | // detected based on some background color outside image bounds. 275 | let x = clamp(ix + kx, 0, width - 1); 276 | let y = clamp(iy + ky, 0, height - 1); 277 | f32::from(image.unsafe_get_pixel(x as u32, y as u32).0[0]) 278 | }; 279 | sum_x += pix * k.0; 280 | sum_y += pix * k.1; 281 | } 282 | } 283 | Edge::new(sum_x / 255.0, sum_y / 255.0) 284 | }) 285 | .collect() 286 | }) 287 | .collect() 288 | } 289 | 290 | /// Narrows the width of detected edges down to a single pixel. 291 | fn minmax_suppression(edges: &Detection, weak_threshold: f32) -> Vec> { 292 | let (width, height) = (edges.edges.len(), edges.edges[0].len()); 293 | (0..width) 294 | .into_par_iter() 295 | .map(|x| { 296 | (0..height) 297 | .into_par_iter() 298 | .map(|y| { 299 | let edge = edges.edges[x][y]; 300 | if edge.magnitude < weak_threshold { 301 | // Skip distance computation for non-edges. 302 | return Edge::new(0.0, 0.0); 303 | } 304 | // Truncating the edge magnitudes helps mitigate rounding errors for thick edges. 305 | let truncate = |f: f32| (f * 1e5).round() * 1e-6; 306 | 307 | // Find out the current pixel represents the highest, most intense, point of an edge by 308 | // traveling in a direction perpendicular to the edge to see if there are any more 309 | // intense edges that are supposedly part of the current edge. 310 | // 311 | // We travel in both directions concurrently, this enables us to stop if one side 312 | // extends longer than the other, greatly improving performance. 313 | let mut select = 0; 314 | let mut select_flip_bit = 1; 315 | 316 | // The parameters and variables for each side. 317 | let directions = [1.0, -1.0]; 318 | let mut distances = [0i32; 2]; 319 | let mut seek_positions = [(x as f32, y as f32); 2]; 320 | let mut seek_magnitudes = [truncate(edge.magnitude); 2]; 321 | 322 | while (distances[0] - distances[1]).abs() <= 1 { 323 | let seek_pos = &mut seek_positions[select]; 324 | let seek_magnitude = &mut seek_magnitudes[select]; 325 | let direction = directions[select]; 326 | 327 | seek_pos.0 += edge.dir_norm().0 * direction; 328 | seek_pos.1 += edge.dir_norm().1 * direction; 329 | let interpolated_magnitude = 330 | truncate(edges.interpolate(seek_pos.0, seek_pos.1).magnitude()); 331 | 332 | let trunc_edge_magnitude = truncate(edge.magnitude); 333 | // Keep searching until either: 334 | let end = 335 | // The next edge has a lesser magnitude than the reference edge. 336 | interpolated_magnitude < trunc_edge_magnitude 337 | // The gradient increases, meaning we are going up against an (other) edge. 338 | || *seek_magnitude > trunc_edge_magnitude && interpolated_magnitude < *seek_magnitude; 339 | *seek_magnitude = interpolated_magnitude; 340 | distances[select] += 1; 341 | 342 | // Switch to the other side. 343 | select ^= select_flip_bit; 344 | if end { 345 | if select_flip_bit == 0 { 346 | break; 347 | } 348 | // After switching to the other side, we set the XOR bit to 0 so we stay there. 349 | select_flip_bit = 0; 350 | } 351 | } 352 | 353 | // Equal distances denote the middle of the edge. 354 | // A deviation of 1 is allowed for edges over two equal pixels, in which case, the 355 | // outer edge (near the dark side) is preferred. 356 | let is_apex = 357 | // The distances are equal, the edge's width is odd, making the apex lie on a 358 | // single pixel. 359 | distances[0] == distances[1] 360 | // There is a difference of 1, the edge's width is even, spreading the apex over 361 | // two pixels. This is a special case to handle edges that run along either the X- or X-axis. 362 | || (distances[0] - distances[1] == 1 && ((1.0 - edge.vec_x.abs()).abs() < 1e-5 || (1.0 - edge.vec_y.abs()).abs() < 1e-5)); 363 | if is_apex { 364 | edge 365 | } else { 366 | Edge::new(0.0, 0.0) 367 | } 368 | }) 369 | .collect() 370 | }) 371 | .collect() 372 | } 373 | 374 | /// Links lines together and discards noise. 375 | fn hysteresis(edges: &[Vec], strong_threshold: f32, weak_threshold: f32) -> Vec> { 376 | assert!(0.0 < strong_threshold && strong_threshold < 1.0); 377 | assert!(0.0 < weak_threshold && weak_threshold < 1.0); 378 | assert!(weak_threshold < strong_threshold); 379 | 380 | let (width, height) = (edges.len(), edges.first().unwrap().len()); 381 | let mut edges_out: Vec> = vec![vec![Edge::new(0.0, 0.0); height]; width]; 382 | for x in 0..width { 383 | for y in 0..height { 384 | if edges[x][y].magnitude < strong_threshold 385 | || edges_out[x][y].magnitude >= strong_threshold 386 | { 387 | continue; 388 | } 389 | 390 | // Follow along the edge along both sides, preserving all edges which magnitude is at 391 | // least weak_threshold. 392 | for side in &[0.0, PI] { 393 | let mut current_pos = (x, y); 394 | loop { 395 | let edge = edges[current_pos.0][current_pos.1]; 396 | edges_out[current_pos.0][current_pos.1] = edge; 397 | // Attempt to find the next line-segment of the edge in tree directions ahead. 398 | let (nb_pos, nb_magnitude) = [FRAC_PI_4, 0.0, -FRAC_PI_4] 399 | .iter() 400 | .map(|bearing| { 401 | neighbour_pos_delta(edge.angle() + FRAC_PI_2 + side + bearing) 402 | }) 403 | // Filter out hypothetical neighbours that are outside image bounds. 404 | .filter_map(|(nb_dx, nb_dy)| { 405 | let nb_x = current_pos.0 as i32 + nb_dx; 406 | let nb_y = current_pos.1 as i32 + nb_dy; 407 | if 0 <= nb_x && nb_x < width as i32 && 0 <= nb_y && nb_y < height as i32 408 | { 409 | let nb = (nb_x as usize, nb_y as usize); 410 | Some((nb, edges[nb.0][nb.1].magnitude)) 411 | } else { 412 | None 413 | } 414 | }) 415 | // Select the neighbouring edge with the highest magnitude as the next 416 | // line-segment. 417 | .fold(((0, 0), 0.0), |(max_pos, max_mag), (pos, mag)| { 418 | if mag > max_mag { 419 | (pos, mag) 420 | } else { 421 | (max_pos, max_mag) 422 | } 423 | }); 424 | if nb_magnitude < weak_threshold 425 | || edges_out[nb_pos.0][nb_pos.1].magnitude > weak_threshold 426 | { 427 | break; 428 | } 429 | current_pos = nb_pos; 430 | } 431 | } 432 | } 433 | } 434 | edges_out 435 | } 436 | 437 | #[cfg(test)] 438 | mod tests { 439 | use super::*; 440 | 441 | fn edges_to_image(edges: &Vec>) -> image::GrayImage { 442 | let (width, height) = (edges.len(), edges.first().unwrap().len()); 443 | let mut image = image::GrayImage::from_pixel(width as u32, height as u32, image::Luma([0])); 444 | for x in 0..width { 445 | for y in 0..height { 446 | let edge = edges[x][y]; 447 | *image.get_pixel_mut(x as u32, y as u32) = 448 | image::Luma([(edge.magnitude * 255.0).round() as u8]); 449 | } 450 | } 451 | image 452 | } 453 | 454 | fn canny_output_stages>( 455 | path: T, 456 | sigma: f32, 457 | strong_threshold: f32, 458 | weak_threshold: f32, 459 | ) -> Detection { 460 | let path = path.as_ref(); 461 | let image = image::open(path).unwrap(); 462 | let edges = detect_edges(&image.to_luma8(), sigma); 463 | let intermediage_d = Detection { edges }; 464 | intermediage_d 465 | .as_image() 466 | .save(format!("{}.0-vectors.png", path)) 467 | .unwrap(); 468 | edges_to_image(&intermediage_d.edges) 469 | .save(format!("{}.1-edges.png", path)) 470 | .unwrap(); 471 | let edges = minmax_suppression(&intermediage_d, weak_threshold); 472 | edges_to_image(&edges) 473 | .save(format!("{}.2-minmax.png", path)) 474 | .unwrap(); 475 | let edges = hysteresis(&edges, strong_threshold, weak_threshold); 476 | edges_to_image(&edges) 477 | .save(format!("{}.3-hysteresis.png", path)) 478 | .unwrap(); 479 | let detection = Detection { edges }; 480 | detection 481 | .as_image() 482 | .save(format!("{}.4-result.png", path)) 483 | .unwrap(); 484 | detection 485 | } 486 | 487 | #[test] 488 | fn neighbour_pos_delta_from_theta() { 489 | let neighbours = [ 490 | (1, 0), 491 | (1, 1), 492 | (0, 1), 493 | (-1, 1), 494 | (-1, 0), 495 | (-1, -1), 496 | (0, -1), 497 | (1, -1), 498 | ]; 499 | for nb in neighbours.iter() { 500 | let d = neighbour_pos_delta(f32::atan2(nb.1 as f32, nb.0 as f32)); 501 | assert_eq!(*nb, d); 502 | } 503 | } 504 | 505 | #[test] 506 | fn edge_new() { 507 | let e = Edge::new(1.0, 0.0); 508 | assert!(1.0 - 1e-6 < e.vec_x && e.vec_x < 1.0 + 1e-6); 509 | assert!(-1e-5 < e.vec_y && e.vec_y < 1e-6); 510 | 511 | let e = Edge::new(1.0, 1.0); 512 | assert!(FRAC_1_SQRT_2 - 1e-5 < e.vec_x && e.vec_x < FRAC_1_SQRT_2 + 1e-6); 513 | assert!(FRAC_1_SQRT_2 - 1e-5 < e.vec_y && e.vec_y < FRAC_1_SQRT_2 + 1e-6); 514 | assert!(1.0 - 1e-6 < e.magnitude && e.magnitude < 1.0 + 1e-6); 515 | } 516 | 517 | #[test] 518 | fn detection_interpolate() { 519 | let dummy = |magnitude| Edge { 520 | magnitude, 521 | vec_x: 0.0, 522 | vec_y: 0.0, 523 | }; 524 | let d = Detection { 525 | edges: vec![vec![dummy(2.0), dummy(8.0)], vec![dummy(4.0), dummy(16.0)]], 526 | }; 527 | assert!((d.interpolate(0.0, 0.0).magnitude() - 2.0).abs() <= 1e-6); 528 | assert!((d.interpolate(1.0, 0.0).magnitude() - 4.0).abs() <= 1e-6); 529 | assert!((d.interpolate(0.0, 1.0).magnitude() - 8.0).abs() <= 1e-6); 530 | assert!((d.interpolate(1.0, 1.0).magnitude() - 16.0).abs() <= 1e-6); 531 | assert!((d.interpolate(0.5, 0.0).magnitude() - 3.0).abs() <= 1e-6); 532 | assert!((d.interpolate(0.0, 0.5).magnitude() - 5.0).abs() <= 1e-6); 533 | assert!((d.interpolate(0.5, 1.0).magnitude() - 12.0).abs() <= 1e-6); 534 | assert!((d.interpolate(1.0, 0.5).magnitude() - 10.0).abs() <= 1e-6); 535 | } 536 | 537 | #[test] 538 | fn kernel_integral_in_bounds() { 539 | // The integral for the filter kernel should approximate 0. 540 | for sigma_i in 1..200 { 541 | let sigma = sigma_i as f32 / 10.0; 542 | let (ksize, kernel) = filter_kernel(sigma); 543 | assert!(ksize.pow(2) == kernel.len()); 544 | let mut sum_x = 0.0; 545 | let mut sum_y = 0.0; 546 | for (gx, gy) in kernel { 547 | sum_x += gx; 548 | sum_y += gy; 549 | } 550 | println!( 551 | "sum = ({}, {}), sigma = {}, kernel_size = {}", 552 | sum_x, sum_y, sigma, ksize 553 | ); 554 | assert!(-0.0001 < sum_x && sum_x <= 0.0001); 555 | assert!(-0.0001 < sum_y && sum_y <= 0.0001); 556 | } 557 | } 558 | 559 | /// Tests whether a vertical line of 1px wide exists in the middle of the image. 560 | /// 561 | /// Returns the location of the line on the X-axis. 562 | fn detect_vertical_line(detection: &Detection) -> usize { 563 | // Find the line. 564 | let mut line_x = None; 565 | for x in 0..detection.width() { 566 | if detection.edges[x][detection.height() / 2].magnitude > 0.5 { 567 | if line_x.is_some() { 568 | panic!("the line is thicker than 1px"); 569 | } 570 | line_x = Some(x) 571 | } 572 | } 573 | let line_x = line_x.expect("no line detected"); 574 | // The line should be at about the middle of the image. 575 | let middle = detection.width() / 2; 576 | assert!(middle - 1 <= line_x && line_x <= middle); 577 | // The line should be continuous. 578 | for y in 0..detection.height() { 579 | let edge = detection.edges[line_x][y]; 580 | assert!(edge.magnitude > 0.0); 581 | } 582 | // The line should be the only thing detected. 583 | for x in 0..detection.width() { 584 | if x == line_x { 585 | continue; 586 | } 587 | for y in 0..detection.height() { 588 | assert!(detection.edges[x][y].magnitude == 0.0); 589 | } 590 | } 591 | line_x 592 | } 593 | 594 | #[test] 595 | fn detect_vertical_line_simple() { 596 | let d = canny_output_stages("testdata/line-simple.png", 1.2, 0.4, 0.05); 597 | let x = detect_vertical_line(&d); 598 | // The direction of the line's surface normal should follow the X-axis. 599 | for y in 0..d.height() { 600 | assert!(d.edges[x][y].angle().abs() < 1e-5); 601 | } 602 | } 603 | 604 | #[test] 605 | fn detect_vertical_line_fuzzy() { 606 | let d = canny_output_stages("testdata/line-fuzzy.png", 2.0, 0.4, 0.05); 607 | let x = detect_vertical_line(&d); 608 | // The direction of the line's surface normal should follow the X-axis. 609 | for y in 0..d.height() { 610 | assert!(d.edges[x][y].angle().abs() < 0.01); 611 | } 612 | } 613 | 614 | #[test] 615 | fn detect_vertical_line_weakening() { 616 | let d = canny_output_stages("testdata/line-weakening.png", 1.2, 0.7, 0.05); 617 | detect_vertical_line(&d); 618 | // The line vectors are not tested because they are distorted by the gradient. 619 | } 620 | } 621 | 622 | #[cfg(all(test, feature = "unstable"))] 623 | mod benchmarks { 624 | extern crate test; 625 | use super::*; 626 | 627 | static IMG_PATH: &str = "testdata/circle.png"; 628 | 629 | #[bench] 630 | fn bench_filter_kernel_low_sigma(b: &mut test::Bencher) { 631 | b.iter(|| filter_kernel(1.2)); 632 | } 633 | 634 | #[bench] 635 | fn bench_filter_kernel_high_sigma(b: &mut test::Bencher) { 636 | b.iter(|| filter_kernel(5.0)); 637 | } 638 | 639 | #[bench] 640 | fn bench_detect_edges_low_sigma(b: &mut test::Bencher) { 641 | let image = image::open(IMG_PATH).unwrap().to_luma8(); 642 | b.iter(|| detect_edges(&image, 1.2)); 643 | } 644 | 645 | #[bench] 646 | fn bench_detect_edges_high_sigma(b: &mut test::Bencher) { 647 | let image = image::open(IMG_PATH).unwrap().to_luma8(); 648 | b.iter(|| detect_edges(&image, 5.0)); 649 | } 650 | 651 | #[bench] 652 | fn bench_minmax_suppression_low_sigma(b: &mut test::Bencher) { 653 | let image = image::open(IMG_PATH).unwrap().to_luma8(); 654 | let edges = Detection { 655 | edges: detect_edges(&image, 1.2), 656 | }; 657 | b.iter(|| minmax_suppression(&edges, 0.01)); 658 | } 659 | 660 | #[bench] 661 | fn bench_minmax_suppression_high_sigma(b: &mut test::Bencher) { 662 | let image = image::open(IMG_PATH).unwrap().to_luma8(); 663 | let edges = Detection { 664 | edges: detect_edges(&image, 5.0), 665 | }; 666 | b.iter(|| minmax_suppression(&edges, 0.01)); 667 | } 668 | 669 | #[bench] 670 | fn bench_hysteresis(b: &mut test::Bencher) { 671 | let image = image::open(IMG_PATH).unwrap().to_luma8(); 672 | let edges = Detection { 673 | edges: detect_edges(&image, 1.2), 674 | }; 675 | let edges = minmax_suppression(&edges, 0.1); 676 | b.iter(|| hysteresis(&edges, 0.4, 0.1)); 677 | } 678 | } 679 | --------------------------------------------------------------------------------