Skip to content

Commit cbec75c

Browse files
committed
Vendor imageproc canny to fix hysteresis u32 underflow panic
imageproc 0.26.0's hysteresis BFS panics when edges reach the image border: `nx - 1` on a u32 of 0 wraps to u32::MAX, causing get_pixel to panic (RuntimeError: unreachable in WASM). The BFS also only checks 6 of 8 neighbors, omitting north and northeast. Vendor the canny/non_maximum_suppression/hysteresis functions with two targeted fixes: - Bounds-check neighbor coordinates before get_pixel access - Add the two missing neighbor directions Upstream: image-rs/imageproc#705 Upstream fix PR: image-rs/imageproc#746 Removal tracked by: #69
1 parent c91da2c commit cbec75c

File tree

3 files changed

+242
-2
lines changed

3 files changed

+242
-2
lines changed

crates/mujou-pipeline/src/canny.rs

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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+
}

crates/mujou-pipeline/src/edge.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Canny edge detection and edge map inversion.
22
//!
3-
//! Wraps [`imageproc::edges::canny`] to detect edges in a blurred grayscale
3+
//! Wraps [`crate::canny::canny`] (a vendored + patched copy of
4+
//! `imageproc::edges::canny`) to detect edges in a blurred grayscale
45
//! image. Returns a binary image where white pixels (255) are edges and
56
//! black pixels (0) are background.
67
//!
@@ -36,7 +37,7 @@ const _: () = assert!(MIN_THRESHOLD > 0.0);
3637
pub fn canny(image: &GrayImage, low_threshold: f32, high_threshold: f32) -> GrayImage {
3738
let high = high_threshold.max(MIN_THRESHOLD);
3839
let low = low_threshold.max(MIN_THRESHOLD).min(high);
39-
imageproc::edges::canny(image, low, high)
40+
crate::canny::canny(image, low, high)
4041
}
4142

4243
/// Invert a binary edge map (bitwise NOT).

crates/mujou-pipeline/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
//! interaction lives in `mujou-io`.
1010
1111
pub mod blur;
12+
mod canny;
1213
pub mod contour;
1314
pub mod edge;
1415
pub mod grayscale;

0 commit comments

Comments
 (0)