#[cfg(all(feature = "imagers", feature = "opencv"))]
compile_error!(
"Features 'imagers' and 'opencv' cannot be enabled at the same time. \
Please choose only one image processing backend:\n\
- 'imagers': Pure Rust implementation using image/imageproc crates\n\
- 'opencv': OpenCV-based implementation (requires OpenCV installation)"
);
#[cfg(not(any(feature = "imagers", feature = "opencv")))]
compile_error!(
"Either 'imagers' or 'opencv' feature must be enabled.\n\n\
Add one of these to your Cargo.toml:\n\
[dependencies]\n\
subpixel-edge = {{ version = \"0.2.0\", features = [\"imagers\"] }} # Pure Rust\n\
# OR\n\
subpixel-edge = {{ version = \"0.2.0\", features = [\"opencv\"] }} # OpenCV backend"
);
#[cfg(feature = "imagers")]
use image::{buffer::ConvertBuffer, GenericImageView, GrayImage, ImageBuffer, Luma, Rgb, RgbImage};
#[cfg(feature = "imagers")]
use imageproc::definitions::{HasBlack, HasWhite};
#[cfg(feature = "opencv")]
use opencv::{
core::{Mat, Vector, CV_32FC1, CV_8UC1, CV_8UC3},
imgcodecs, imgproc,
prelude::*,
};
use rayon::prelude::*;
use std::{
f32::consts::PI,
sync::{Arc, Mutex},
};
#[cfg(feature = "logger")]
macro_rules! debug {
($($arg:tt)*) => {
log::debug!($($arg)*);
};
}
#[cfg(not(feature = "logger"))]
macro_rules! debug {
($($arg:tt)*) => {};
}
#[cfg(feature = "imagers")]
pub fn canny_based_subpixel_edges_optimized(
image: &GrayImage,
low_threshold: f32,
high_threshold: f32,
edge_point_threshold: f32,
) -> Vec<(f32, f32)> {
let (width, height) = image.dimensions();
debug!("start calcualte subpixel edges");
let (gx_data, gy_data) = parallel_sobel_gradients(image);
debug!("gx_data and gy_data ok");
let gx_image_data: Vec<i16> = gx_data.par_iter().map(|p| *p as i16).collect();
let gy_image_data: Vec<i16> = gy_data.par_iter().map(|p| *p as i16).collect();
let gx_image = ImageBuffer::from_raw(width, height, gx_image_data).unwrap();
let gy_image = ImageBuffer::from_raw(width, height, gy_image_data).unwrap();
debug!("gx_image and gy_image ok");
debug!("gx_data and gy_data ok");
let mag_data: Vec<f32> = gx_data
.par_iter()
.zip(gy_data.par_iter())
.map(|(gx, gy)| (gx.powi(2) + gy.powi(2)).sqrt())
.collect();
debug!("mag_data ok");
let g: Vec<f32> = gx_image
.iter()
.zip(gy_image.iter())
.map(|(h, v)| (*h as f32).hypot(*v as f32))
.collect::<Vec<f32>>();
debug!("g ok");
let g = ImageBuffer::from_raw(image.width(), image.height(), g).unwrap();
debug!("g image ok");
let thinned = non_maximum_suppression(&g, &gx_image, &gy_image);
debug!("thinned ok");
let canny_edge_points = hysteresis(&thinned, low_threshold, high_threshold);
debug!("canny_edge_points ok");
canny_edge_points
.into_par_iter()
.filter_map(|(x, y)| {
subpixel_in_3x3(
x,
y,
&gx_data,
&gy_data,
&mag_data,
width,
height,
edge_point_threshold,
)
})
.collect()
}
#[cfg(feature = "opencv")]
pub fn canny_based_subpixel_edges_optimized(
image: &Mat,
low_threshold: f32,
high_threshold: f32,
edge_point_threshold: f32,
) -> Vec<(f32, f32)> {
use opencv::{core::Size, imgproc::INTER_LINEAR};
let mut gray = Mat::default();
if image.channels() == 3 {
imgproc::cvt_color(&image, &mut gray, imgproc::COLOR_BGR2GRAY, 0).unwrap();
} else {
gray = image.clone();
}
let mut grad_x = Mat::default();
let mut grad_y = Mat::default();
imgproc::sobel(
&gray,
&mut grad_x,
CV_32FC1,
1,
0,
3,
1.0,
0.0,
opencv::core::BORDER_DEFAULT,
)
.unwrap();
imgproc::sobel(
&gray,
&mut grad_y,
CV_32FC1,
0,
1,
3,
1.0,
0.0,
opencv::core::BORDER_DEFAULT,
)
.unwrap();
let mut edges = Mat::default();
imgproc::canny(
&gray,
&mut edges,
low_threshold as f64,
high_threshold as f64,
3,
false,
)
.unwrap();
let mut edge_points = Vec::new();
let rows = edges.rows();
let cols = edges.cols();
for y in 1..rows - 1 {
for x in 1..cols - 1 {
let val = *edges.at_2d::<u8>(y, x).unwrap();
if val > 0 {
let gx = *grad_x.at_2d::<f32>(y, x).unwrap();
let gy = *grad_y.at_2d::<f32>(y, x).unwrap();
let mag = (gx * gx + gy * gy).sqrt();
if mag > edge_point_threshold {
let angle = gy.atan2(gx);
let dx = angle.cos() * 0.5;
let dy = angle.sin() * 0.5;
edge_points.push((x as f32 + dx, y as f32 + dy));
}
}
}
}
edge_points
}
#[cfg(feature = "imagers")]
pub fn parallel_sobel_gradients(image: &GrayImage) -> (Vec<f32>, Vec<f32>) {
let (width, height) = image.dimensions();
let gx = Arc::new(Mutex::new(vec![0.0; (width * height) as usize]));
let gy = Arc::new(Mutex::new(vec![0.0; (width * height) as usize]));
let pixels = image.as_raw();
const SOBEL_KERNEL_X: [f32; 9] = [-1.0, 0.0, 1.0, -2.0, 0.0, 2.0, -1.0, 0.0, 1.0];
const SOBEL_KERNEL_Y: [f32; 9] = [-1.0, -2.0, -1.0, 0.0, 0.0, 0.0, 1.0, 2.0, 1.0];
(1..height - 1).into_par_iter().for_each(|y| {
let row_start = (y * width) as usize;
let prev_row =
&pixels[(row_start - width as usize)..(row_start - width as usize + width as usize)];
let curr_row = &pixels[row_start..(row_start + width as usize)];
let next_row =
&pixels[(row_start + width as usize)..(row_start + width as usize + width as usize)];
let mut gx_mutex = gx.lock().unwrap();
let mut gy_mutex = gy.lock().unwrap();
for x in 1..(width - 1) {
let mut gx_val = 0.0;
let mut gy_val = 0.0;
for ky in 0..3 {
for kx in 0..3 {
let pixel_x = x + kx - 1;
let pixel = if ky == 0 {
prev_row[pixel_x as usize] as f32
} else if ky == 1 {
curr_row[pixel_x as usize] as f32
} else {
next_row[pixel_x as usize] as f32
};
let kernel_index = (ky * 3 + kx) as usize;
gx_val += pixel * SOBEL_KERNEL_X[kernel_index];
gy_val += pixel * SOBEL_KERNEL_Y[kernel_index];
}
}
let index = (y * width + x) as usize;
gx_mutex[index] = gx_val;
gy_mutex[index] = gy_val;
}
});
let gx_result = Arc::try_unwrap(gx).unwrap().into_inner().unwrap();
let gy_result = Arc::try_unwrap(gy).unwrap().into_inner().unwrap();
(gx_result, gy_result)
}
#[cfg(feature = "imagers")]
fn non_maximum_suppression(
g: &ImageBuffer<Luma<f32>, Vec<f32>>,
gx: &ImageBuffer<Luma<i16>, Vec<i16>>,
gy: &ImageBuffer<Luma<i16>, Vec<i16>>,
) -> ImageBuffer<Luma<f32>, Vec<f32>> {
const RADIANS_TO_DEGREES: f32 = 180f32 / PI;
let mut out = ImageBuffer::from_pixel(g.width(), g.height(), Luma([0.0]));
let mut points = Vec::new();
for y in 1..g.height() - 1 {
for x in 1..g.width() - 1 {
let x_gradient = gx[(x, y)][0] as f32;
let y_gradient = gy[(x, y)][0] as f32;
let mut angle = (y_gradient).atan2(x_gradient) * RADIANS_TO_DEGREES;
if angle < 0.0 {
angle += 180.0
}
let clamped_angle = if !(22.5..157.5).contains(&angle) {
0 } else if (22.5..67.5).contains(&angle) {
45 } else if (67.5..112.5).contains(&angle) {
90 } else if (112.5..157.5).contains(&angle) {
135 } else {
unreachable!()
};
let (cmp1, cmp2) = unsafe {
match clamped_angle {
0 => (g.unsafe_get_pixel(x - 1, y), g.unsafe_get_pixel(x + 1, y)),
45 => (
g.unsafe_get_pixel(x + 1, y + 1),
g.unsafe_get_pixel(x - 1, y - 1),
),
90 => (g.unsafe_get_pixel(x, y - 1), g.unsafe_get_pixel(x, y + 1)),
135 => (
g.unsafe_get_pixel(x - 1, y + 1),
g.unsafe_get_pixel(x + 1, y - 1),
),
_ => unreachable!(),
}
};
let pixel = *g.get_pixel(x, y);
if pixel[0] < cmp1[0] || pixel[0] < cmp2[0] {
out.put_pixel(x, y, Luma([0.0]));
} else {
out.put_pixel(x, y, pixel);
points.push((x, y));
}
}
}
debug!("points len:{}", points.len());
out
}
#[cfg(feature = "imagers")]
fn hysteresis(
input: &ImageBuffer<Luma<f32>, Vec<f32>>,
low_thresh: f32,
high_thresh: f32,
) -> Vec<(u32, u32)> {
let max_brightness = Luma::<u8>::white();
let min_brightness = Luma::<u8>::black();
let mut out = ImageBuffer::from_pixel(input.width(), input.height(), min_brightness);
let mut edges = Vec::with_capacity(((input.width() * input.height()) / 2) as usize);
let mut result_edge_points = Vec::new();
for y in 1..input.height() - 1 {
for x in 1..input.width() - 1 {
let inp_pix = *input.get_pixel(x, y);
let out_pix = *out.get_pixel(x, y);
if inp_pix[0] >= high_thresh && out_pix[0] == 0 {
out.put_pixel(x, y, max_brightness);
edges.push((x, y));
while let Some((nx, ny)) = edges.pop() {
let neighbor_indices = [
(nx + 1, ny), (nx + 1, ny + 1), (nx, ny + 1), (nx.wrapping_sub(1), ny.wrapping_sub(1)), (nx.wrapping_sub(1), ny), (nx.wrapping_sub(1), ny + 1), ];
for neighbor_idx in &neighbor_indices {
if neighbor_idx.0 >= input.width()
|| neighbor_idx.1 >= input.height()
|| neighbor_idx.0 == u32::MAX || neighbor_idx.1 == u32::MAX
{
continue;
}
let in_neighbor = *input.get_pixel(neighbor_idx.0, neighbor_idx.1);
let out_neighbor = *out.get_pixel(neighbor_idx.0, neighbor_idx.1);
if in_neighbor[0] >= low_thresh && out_neighbor[0] == 0 {
out.put_pixel(neighbor_idx.0, neighbor_idx.1, max_brightness);
edges.push((neighbor_idx.0, neighbor_idx.1));
result_edge_points.push((neighbor_idx.0, neighbor_idx.1));
}
}
}
}
}
}
result_edge_points
}
#[cfg(feature = "imagers")]
pub fn visualize_edges(image: &GrayImage, edge_points: &[(f32, f32)]) -> RgbImage {
let mut canvas: RgbImage = image.convert();
let red = Rgb([255u8, 0, 0]);
let valid_points: Vec<(u32, u32)> = edge_points
.par_iter()
.filter_map(|&(x, y)| {
let sx = x as i32;
let sy = y as i32;
if sx >= 0 && sy >= 0 && sx < canvas.width() as i32 && sy < canvas.height() as i32 {
Some((sx as u32, sy as u32))
} else {
None
}
})
.collect();
for (sx, sy) in valid_points {
canvas.put_pixel(sx, sy, red);
}
canvas
}
#[cfg(feature = "opencv")]
pub fn visualize_edges(image: &Mat, edge_points: &[(f32, f32)]) -> Mat {
let mut result = image.clone();
if result.channels() == 1 {
let mut temp = Mat::default();
imgproc::cvt_color(&result, &mut temp, imgproc::COLOR_GRAY2BGR, 0).unwrap();
result = temp;
}
let red = opencv::core::Scalar::new(0.0, 0.0, 255.0, 0.0);
for &(x, y) in edge_points {
let pt = opencv::core::Point::new(x as i32, y as i32);
imgproc::circle(&mut result, pt, 1, red, -1, imgproc::LINE_8, 0).unwrap();
}
result
}
#[cfg(feature = "imagers")]
fn bilinear_interpolation(data: &[f32], width: u32, height: u32, x: f32, y: f32) -> f32 {
let x0 = x.floor() as i32;
let y0 = y.floor() as i32;
let x1 = x0 + 1;
let y1 = y0 + 1;
if x0 < 0 || y0 < 0 || x1 >= width as i32 || y1 >= height as i32 {
return 0.0;
}
let p00 = data[(y0 as u32 * width + x0 as u32) as usize];
let p10 = data[(y0 as u32 * width + x1 as u32) as usize];
let p01 = data[(y1 as u32 * width + x0 as u32) as usize];
let p11 = data[(y1 as u32 * width + x1 as u32) as usize];
let dx = x - x0 as f32;
let dy = y - y0 as f32;
p00 * (1.0 - dx) * (1.0 - dy) + p10 * dx * (1.0 - dy) + p01 * (1.0 - dx) * dy + p11 * dx * dy
}
#[allow(clippy::too_many_arguments)]
#[cfg(feature = "imagers")]
fn subpixel_in_3x3(
x: u32,
y: u32,
gx_data: &[f32],
gy_data: &[f32],
mag_data: &[f32],
width: u32,
height: u32,
edge_point_threshold: f32,
) -> Option<(f32, f32)> {
let idx = (y * width + x) as usize;
let mag = mag_data[idx];
let gx_val = gx_data[idx];
let gy_val = gy_data[idx];
let theta = gy_val.atan2(gx_val);
let dx = theta.cos();
let dy = theta.sin();
let x1 = x as f32 + 0.5 * dx; let y1 = y as f32 + 0.5 * dy;
let x2 = x as f32 - 0.5 * dx; let y2 = y as f32 - 0.5 * dy;
if x1 < 1.0
|| x1 >= (width - 1) as f32
|| y1 < 1.0
|| y1 >= (height - 1) as f32
|| x2 < 1.0
|| x2 >= (width - 1) as f32
|| y2 < 1.0
|| y2 >= (height - 1) as f32
{
return None;
}
let m1 = bilinear_interpolation(mag_data, width, height, x1, y1);
let m2 = bilinear_interpolation(mag_data, width, height, x2, y2);
let a = 2.0 * (m1 + m2 - 2.0 * mag);
if a >= 0.0 {
return None;
}
let offset = (m2 - m1) / (4.0 * (m1 + m2 - 2.0 * mag));
if offset.abs() > edge_point_threshold {
return None;
}
let sub_x = x as f32 + offset * dx;
let sub_y = y as f32 + offset * dy;
Some((sub_x, sub_y))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "imagers")]
mod imagers_tests {
use super::*;
use image::{GrayImage, ImageBuffer, Luma};
fn create_test_image() -> GrayImage {
ImageBuffer::from_fn(10, 10, |x, y| {
let val = ((x + y) * 10) as u8;
Luma([val])
})
}
fn create_edge_image() -> GrayImage {
ImageBuffer::from_fn(
10,
10,
|x, _y| {
if x < 5 {
Luma([0u8])
} else {
Luma([255u8])
}
},
)
}
#[test]
fn test_parallel_sobel_gradients() {
let image = create_test_image();
let (gx, gy) = parallel_sobel_gradients(&image);
assert_eq!(gx.len(), 100); assert_eq!(gy.len(), 100);
let non_zero_gx = gx.iter().any(|&v| v != 0.0);
let non_zero_gy = gy.iter().any(|&v| v != 0.0);
assert!(non_zero_gx, "Horizontal gradients should not be all zero");
assert!(non_zero_gy, "Vertical gradients should not be all zero");
}
#[test]
fn test_canny_subpixel_edges() {
let image = create_edge_image();
let edges = canny_based_subpixel_edges_optimized(&image, 10.0, 30.0, 0.5);
assert!(!edges.is_empty(), "Should detect edges in the test image");
for (x, y) in &edges {
assert!(
*x >= 0.0 && *x < 10.0,
"X coordinate should be within bounds"
);
assert!(
*y >= 0.0 && *y < 10.0,
"Y coordinate should be within bounds"
);
}
}
#[test]
fn test_visualize_edges() {
let image = create_test_image();
let edges = vec![(2.0, 3.0), (5.0, 5.0), (7.0, 8.0)];
let result = visualize_edges(&image, &edges);
assert_eq!(result.width(), image.width());
assert_eq!(result.height(), image.height());
let edge_pixels = vec![(2u32, 3u32), (5u32, 5u32), (7u32, 8u32)];
for (px, py) in edge_pixels {
if px < result.width() && py < result.height() {
let pixel = result.get_pixel(px, py);
assert_eq!(
pixel[0], 255,
"Red channel at ({}, {}) should be 255",
px, py
);
assert_eq!(pixel[1], 0, "Green channel at ({}, {}) should be 0", px, py);
assert_eq!(pixel[2], 0, "Blue channel at ({}, {}) should be 0", px, py);
}
}
let non_edge_pixel = result.get_pixel(0, 0);
let original_value = image.get_pixel(0, 0)[0];
assert_eq!(
non_edge_pixel[0], original_value,
"Non-edge pixel should retain original value in red channel"
);
assert_eq!(
non_edge_pixel[1], original_value,
"Non-edge pixel should retain original value in green channel"
);
assert_eq!(
non_edge_pixel[2], original_value,
"Non-edge pixel should retain original value in blue channel"
);
}
#[test]
fn test_bilinear_interpolation() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
let result = bilinear_interpolation(&data, 3, 3, 1.0, 1.0);
assert_eq!(result, 5.0);
let result = bilinear_interpolation(&data, 3, 3, 0.5, 0.5);
assert!((result - 3.0).abs() < 0.01);
let result = bilinear_interpolation(&data, 3, 3, -1.0, 0.0);
assert_eq!(result, 0.0);
}
#[test]
#[cfg(feature = "logger")]
fn test_logger_with_imagers() {
debug!("Testing imagers feature with logging");
let image = create_test_image();
debug!("Created test image");
let edges = canny_based_subpixel_edges_optimized(&image, 20.0, 60.0, 0.5);
debug!("Found {} edges", edges.len());
assert!(true); }
}
#[cfg(feature = "opencv")]
mod opencv_tests {
use super::*;
use opencv::{
core::{Mat, Scalar, CV_8UC1},
prelude::*,
};
fn create_test_mat() -> Mat {
let mut mat = Mat::zeros(10, 10, CV_8UC1).unwrap().to_mat().unwrap();
for y in 0..10 {
for x in 0..10 {
let val = ((x + y) * 10) as f64;
*mat.at_2d_mut::<u8>(y, x).unwrap() = val as u8;
}
}
mat
}
fn create_edge_mat() -> Mat {
let mut mat = Mat::zeros(10, 10, CV_8UC1).unwrap().to_mat().unwrap();
for y in 0..10 {
for x in 0..10 {
let val = if x < 5 { 0u8 } else { 255u8 };
*mat.at_2d_mut::<u8>(y, x).unwrap() = val;
}
}
mat
}
#[test]
fn test_canny_subpixel_edges_opencv() {
let image = create_edge_mat();
let edges = canny_based_subpixel_edges_optimized(&image, 10.0, 30.0, 0.5);
assert!(!edges.is_empty(), "Should detect edges in the test Mat");
let rows = image.rows();
let cols = image.cols();
for (x, y) in &edges {
assert!(
*x >= 0.0 && *x < cols as f32,
"X coordinate should be within bounds"
);
assert!(
*y >= 0.0 && *y < rows as f32,
"Y coordinate should be within bounds"
);
}
}
#[test]
fn test_visualize_edges_opencv() {
let image = create_test_mat();
let edges = vec![(2.5, 3.5), (5.0, 5.0), (7.2, 8.1)];
let result = visualize_edges(&image, &edges);
assert_eq!(result.rows(), image.rows());
assert_eq!(result.cols(), image.cols());
assert_eq!(result.channels(), 3);
for &(x, y) in &edges {
let px = x.round() as i32;
let py = y.round() as i32;
if px >= 0 && px < result.cols() && py >= 0 && py < result.rows() {
let pixel = result.at_2d::<opencv::core::Vec3b>(py, px).unwrap();
assert_eq!(pixel[2], 255); }
}
}
#[test]
#[cfg(feature = "logger")]
fn test_logger_with_opencv() {
debug!("Testing opencv feature with logging");
let image = create_test_mat();
debug!("Created test Mat");
let edges = canny_based_subpixel_edges_optimized(&image, 20.0, 60.0, 0.5);
debug!("Found {} edges", edges.len());
assert!(true); }
}
#[test]
fn test_debug_macro_no_panic() {
debug!("Starting test");
debug!("Processing data: {}", 42);
debug!("Test completed successfully");
assert!(true);
}
#[test]
#[cfg(all(feature = "imagers", feature = "opencv"))]
fn test_mutual_exclusion() {
panic!("Features 'imagers' and 'opencv' should be mutually exclusive!");
}
#[test]
#[cfg(not(any(feature = "imagers", feature = "opencv")))]
fn test_no_backend() {
panic!("At least one backend feature must be enabled!");
}
}