use crate::border::BorderPolicy;
use crate::error::Error;
use crate::image::{
Image, ImageRef, Neighborhood, RasterImage, RasterImageMut, SeparableKernel, gaussian_kernel_1d,
};
use crate::pixel::{FromLinear, HomogeneousPixel, LinearPixel, ZeroablePixel};
use crate::transform::combine::{
Direction, DirectionChannel, Magnitude, MagnitudeChannel, combine_images,
};
use crate::transform::convolve::convolve;
use crate::transform::convolve_separable::{
SeparableScratch, convolve_separable, convolve_separable_into,
};
use crate::{Offset, Sigma};
#[must_use]
pub fn box_blur_3x3<I, B, P, Acc, Out>(image: &I, border: &B) -> Image<Out>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
B: BorderPolicy<I> + BorderPolicy<Image<Acc>>,
Out: ZeroablePixel + FromLinear<Acc>,
{
convolve_separable(image, &SeparableKernel::box_blur_3(), border)
}
#[must_use]
pub fn box_blur_5x5<I, B, P, Acc, Out>(image: &I, border: &B) -> Image<Out>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
B: BorderPolicy<I> + BorderPolicy<Image<Acc>>,
Out: ZeroablePixel + FromLinear<Acc>,
{
convolve_separable(image, &SeparableKernel::box_blur_5(), border)
}
#[must_use]
pub fn gaussian_blur_3x3<I, B, P, Acc, Out>(image: &I, border: &B) -> Image<Out>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
B: BorderPolicy<I> + BorderPolicy<Image<Acc>>,
Out: ZeroablePixel + FromLinear<Acc>,
{
convolve_separable(image, &SeparableKernel::gaussian_3(), border)
}
#[must_use]
pub fn gaussian_blur_5x5<I, B, P, Acc, Out>(image: &I, border: &B) -> Image<Out>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
B: BorderPolicy<I> + BorderPolicy<Image<Acc>>,
Out: ZeroablePixel + FromLinear<Acc>,
{
convolve_separable(image, &SeparableKernel::gaussian_5(), border)
}
pub const DEFAULT_TRUNCATE: f32 = 4.0;
#[must_use]
pub fn gaussian_blur<I, B, P, Acc, Out>(image: &I, sigma: Sigma, border: &B) -> Image<Out>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
B: BorderPolicy<I> + BorderPolicy<Image<Acc>>,
Out: ZeroablePixel + FromLinear<Acc>,
{
convolve_separable(image, &gaussian_kernel_1d(sigma, DEFAULT_TRUNCATE), border)
}
pub fn gaussian_blur_into<I, B, O, P, Acc, Out>(image: &I, sigma: Sigma, border: &B, output: &mut O)
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
B: BorderPolicy<I> + BorderPolicy<Image<Acc>>,
O: RasterImageMut<Pixel = Out>,
Out: FromLinear<Acc>,
{
convolve_separable_into(
image,
&gaussian_kernel_1d(sigma, DEFAULT_TRUNCATE),
border,
output,
);
}
impl<Acc> SeparableScratch<Acc>
where
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
{
pub fn gaussian_blur_into<I, B, O, P, Out>(
&mut self,
image: &I,
sigma: Sigma,
border: &B,
output: &mut O,
) where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
B: BorderPolicy<I> + for<'r> BorderPolicy<ImageRef<'r, Acc>>,
O: RasterImageMut<Pixel = Out>,
Out: FromLinear<Acc>,
{
self.convolve_separable_into(
image,
&gaussian_kernel_1d(sigma, DEFAULT_TRUNCATE),
border,
output,
);
}
}
#[must_use]
pub fn sobel_x<I, B, P>(image: &I, border: &B) -> Image<<P as LinearPixel<f32>>::Accumulator>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32>,
<P as LinearPixel<f32>>::Accumulator: Default + ZeroablePixel,
B: BorderPolicy<I>,
{
convolve(image, &Neighborhood::<f32, 3, 3>::sobel_y(), border)
}
#[must_use]
pub fn sobel_y<I, B, P>(image: &I, border: &B) -> Image<<P as LinearPixel<f32>>::Accumulator>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32>,
<P as LinearPixel<f32>>::Accumulator: Default + ZeroablePixel,
B: BorderPolicy<I>,
{
convolve(image, &Neighborhood::<f32, 3, 3>::sobel_x(), border)
}
#[must_use]
pub fn scharr_x<I, B, P>(image: &I, border: &B) -> Image<<P as LinearPixel<f32>>::Accumulator>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32>,
<P as LinearPixel<f32>>::Accumulator: Default + ZeroablePixel,
B: BorderPolicy<I>,
{
convolve(image, &Neighborhood::<f32, 3, 3>::scharr_y(), border)
}
#[must_use]
pub fn scharr_y<I, B, P>(image: &I, border: &B) -> Image<<P as LinearPixel<f32>>::Accumulator>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32>,
<P as LinearPixel<f32>>::Accumulator: Default + ZeroablePixel,
B: BorderPolicy<I>,
{
convolve(image, &Neighborhood::<f32, 3, 3>::scharr_x(), border)
}
#[must_use]
pub fn prewitt_x<I, B, P>(image: &I, border: &B) -> Image<<P as LinearPixel<f32>>::Accumulator>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32>,
<P as LinearPixel<f32>>::Accumulator: Default + ZeroablePixel,
B: BorderPolicy<I>,
{
convolve(image, &Neighborhood::<f32, 3, 3>::prewitt_y(), border)
}
#[must_use]
pub fn prewitt_y<I, B, P>(image: &I, border: &B) -> Image<<P as LinearPixel<f32>>::Accumulator>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32>,
<P as LinearPixel<f32>>::Accumulator: Default + ZeroablePixel,
B: BorderPolicy<I>,
{
convolve(image, &Neighborhood::<f32, 3, 3>::prewitt_x(), border)
}
#[must_use]
pub fn laplacian<I, B, P>(image: &I, border: &B) -> Image<<P as LinearPixel<f32>>::Accumulator>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32>,
<P as LinearPixel<f32>>::Accumulator: Default + ZeroablePixel,
B: BorderPolicy<I>,
{
convolve(image, &Neighborhood::<f32, 3, 3>::laplacian(), border)
}
#[must_use]
pub fn laplacian_8<I, B, P>(image: &I, border: &B) -> Image<<P as LinearPixel<f32>>::Accumulator>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32>,
<P as LinearPixel<f32>>::Accumulator: Default + ZeroablePixel,
B: BorderPolicy<I>,
{
convolve(image, &Neighborhood::<f32, 3, 3>::laplacian_8(), border)
}
#[must_use]
pub fn sharpen<I, B, P, Out>(image: &I, border: &B) -> Image<Out>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32>,
<P as LinearPixel<f32>>::Accumulator: Default,
B: BorderPolicy<I>,
Out: ZeroablePixel + FromLinear<<P as LinearPixel<f32>>::Accumulator>,
{
convolve(image, &Neighborhood::<f32, 3, 3>::sharpen(), border)
}
#[must_use]
pub fn emboss<I, B, P>(image: &I, border: &B) -> Image<<P as LinearPixel<f32>>::Accumulator>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32>,
<P as LinearPixel<f32>>::Accumulator: Default + ZeroablePixel,
B: BorderPolicy<I>,
{
convolve(image, &Neighborhood::<f32, 3, 3>::emboss(), border)
}
pub fn gradient_magnitude<IA, IB, P>(gx: &IA, gy: &IB) -> Result<Image<P>, Error>
where
IA: RasterImage<Pixel = P>,
IB: RasterImage<Pixel = P>,
P: HomogeneousPixel + ZeroablePixel,
P::Channel: MagnitudeChannel,
{
combine_images(gx, gy, Magnitude)
}
pub fn gradient_direction<IA, IB, P>(gx: &IA, gy: &IB) -> Result<Image<P>, Error>
where
IA: RasterImage<Pixel = P>,
IB: RasterImage<Pixel = P>,
P: HomogeneousPixel + ZeroablePixel,
P::Channel: DirectionChannel,
{
combine_images(gx, gy, Direction)
}
#[inline]
pub(crate) fn nms_sector(theta: f64) -> Offset {
use core::f64::consts::PI;
let mut a = theta;
if a < 0.0 {
a += PI;
}
const SEG: f64 = PI / 8.0; if a < SEG {
Offset::new(1, 0) } else if a < 3.0 * SEG {
Offset::new(1, 1) } else if a < 5.0 * SEG {
Offset::new(0, 1) } else if a < 7.0 * SEG {
Offset::new(-1, 1) } else {
Offset::new(1, 0) }
}
#[inline]
pub(crate) fn nms_sector_from_gradient(gx: f64, gy: f64) -> Offset {
const T22: f64 = 0.414_213_562_373_095_05;
const T67: f64 = 2.414_213_562_373_095;
let ax = gx.abs();
let ay = gy.abs();
if gx * gy >= 0.0 {
if ay < ax * T22 {
Offset::new(1, 0)
} else if ay < ax * T67 {
Offset::new(1, 1)
} else {
Offset::new(0, 1)
}
} else {
if ay > ax * T67 {
Offset::new(0, 1)
} else if ay > ax * T22 {
Offset::new(-1, 1)
} else {
Offset::new(1, 0)
}
}
}
#[inline]
fn nms_at<P>(row: Option<&[P]>, x: usize, dx: isize, w: usize) -> Option<P::Channel>
where
P: HomogeneousPixel,
{
let row = row?;
let nx = x.checked_add_signed(dx)?;
if nx >= w {
return None;
}
Some(row[nx].channel(0))
}
#[inline]
fn nms_survives<P>(
cur: &[P],
prev: Option<&[P]>,
next: Option<&[P]>,
x: usize,
w: usize,
step: Offset,
) -> bool
where
P: HomogeneousPixel,
P::Channel: PartialOrd,
{
let dx = step.dx as isize;
let (forward, backward) = if step.dy == 0 {
(Some(cur), Some(cur))
} else {
(next, prev)
};
match (nms_at(forward, x, dx, w), nms_at(backward, x, -dx, w)) {
(Some(a), Some(b)) => {
let m = cur[x].channel(0);
m >= a && m >= b
}
_ => false,
}
}
pub fn non_maximum_suppression<IM, IA, P>(magnitude: &IM, direction: &IA) -> Result<Image<P>, Error>
where
IM: RasterImage<Pixel = P>,
IA: RasterImage<Pixel = P>,
P: HomogeneousPixel + ZeroablePixel,
P::Channel: PartialOrd,
f64: From<P::Channel>,
{
if magnitude.size() != direction.size() {
return Err(Error::SizeMismatch {
expected: magnitude.size(),
actual: direction.size(),
});
}
let (w, h) = (magnitude.width(), magnitude.height());
let mut out = Image::fill(w, h, P::zero());
for y in 0..h {
let cur = magnitude.row(y);
let prev = (y > 0).then(|| magnitude.row(y - 1));
let next = (y + 1 < h).then(|| magnitude.row(y + 1));
let dir = direction.row(y);
let dst = out.row_mut(y);
for x in 0..w {
let step = nms_sector(f64::from(dir[x].channel(0)));
if nms_survives(cur, prev, next, x, w, step) {
dst[x] = cur[x];
}
}
}
Ok(out)
}
#[must_use]
pub(crate) fn non_maximum_suppression_from_gradients<IM, IX, IY, P>(
magnitude: &IM,
gx: &IX,
gy: &IY,
) -> Image<P>
where
IM: RasterImage<Pixel = P>,
IX: RasterImage<Pixel = P>,
IY: RasterImage<Pixel = P>,
P: HomogeneousPixel + ZeroablePixel,
P::Channel: PartialOrd,
f64: From<P::Channel>,
{
assert!(
magnitude.size() == gx.size() && gx.size() == gy.size(),
"non_maximum_suppression: magnitude, gx and gy must have the same size",
);
let (w, h) = (magnitude.width(), magnitude.height());
let mut out = Image::fill(w, h, P::zero());
for y in 0..h {
let cur = magnitude.row(y);
let prev = (y > 0).then(|| magnitude.row(y - 1));
let next = (y + 1 < h).then(|| magnitude.row(y + 1));
let gx_row = gx.row(y);
let gy_row = gy.row(y);
let dst = out.row_mut(y);
for x in 0..w {
let step = nms_sector_from_gradient(
f64::from(gx_row[x].channel(0)),
f64::from(gy_row[x].channel(0)),
);
if nms_survives(cur, prev, next, x, w, step) {
dst[x] = cur[x];
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Size;
use crate::border::{Clamp, Constant, Skip};
use crate::image::{ImageView, ImageViewMut, gaussian_kernel_1d};
use crate::pixel::{Mono8, MonoF32};
use crate::sigma;
use crate::transform::convolve;
fn make_gradient_8x8() -> Image<MonoF32> {
Image::generate(8, 8, |x, y| MonoF32::new((x + y * 8) as f32))
}
#[test]
fn box_blur_3x3_uniform_f32() {
let src = Image::fill(8, 8, MonoF32::new(7.0));
let result: Image<MonoF32> = box_blur_3x3(&src, &Clamp);
assert_eq!(result.width(), 8);
assert_eq!(result.height(), 8);
for y in 0..result.height() {
for x in 0..result.width() {
assert!(
(result.pixel_at(x, y).0 - 7.0).abs() < 1e-4,
"at ({x}, {y}): {}",
result.pixel_at(x, y).0,
);
}
}
}
#[test]
fn box_blur_3x3_uniform_u8() {
let src = Image::fill(8, 8, Mono8::new(100));
let result: Image<Mono8> = box_blur_3x3(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert_eq!(result.pixel_at(x, y), Mono8::new(100));
}
}
}
#[test]
fn box_blur_5x5_uniform_f32() {
let src = Image::fill(10, 10, MonoF32::new(3.0));
let result: Image<MonoF32> = box_blur_5x5(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!((result.pixel_at(x, y).0 - 3.0).abs() < 1e-4);
}
}
}
#[test]
fn box_blur_5x5_uniform_u8() {
let src = Image::fill(10, 10, Mono8::new(200));
let result: Image<Mono8> = box_blur_5x5(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert_eq!(result.pixel_at(x, y), Mono8::new(200));
}
}
}
#[test]
fn box_blur_3x3_matches_full_convolution() {
let src = make_gradient_8x8();
let full_kernel = Neighborhood::<f32, 3, 3>::box_blur_3x3();
let full: Image<MonoF32> = convolve(&src, &full_kernel, &Clamp);
let sep: Image<MonoF32> = box_blur_3x3(&src, &Clamp);
assert_eq!(full.width(), sep.width());
assert_eq!(full.height(), sep.height());
for y in 0..full.height() {
for x in 0..full.width() {
assert!(
(full.pixel_at(x, y).0 - sep.pixel_at(x, y).0).abs() < 1e-3,
"mismatch at ({x}, {y}): full={}, sep={}",
full.pixel_at(x, y).0,
sep.pixel_at(x, y).0,
);
}
}
}
#[test]
fn gaussian_blur_3x3_uniform_f32() {
let src = Image::fill(8, 8, MonoF32::new(1.0));
let result: Image<MonoF32> = gaussian_blur_3x3(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!(
(result.pixel_at(x, y).0 - 1.0).abs() < 1e-4,
"at ({x}, {y}): {}",
result.pixel_at(x, y).0,
);
}
}
}
#[test]
fn gaussian_blur_3x3_matches_full_convolution() {
let src = make_gradient_8x8();
let full_kernel = Neighborhood::<f32, 3, 3>::gaussian_3x3();
let full: Image<MonoF32> = convolve(&src, &full_kernel, &Clamp);
let sep: Image<MonoF32> = gaussian_blur_3x3(&src, &Clamp);
for y in 0..full.height() {
for x in 0..full.width() {
assert!(
(full.pixel_at(x, y).0 / 16.0 - sep.pixel_at(x, y).0).abs() < 1e-2,
"mismatch at ({x}, {y})",
);
}
}
}
#[test]
fn gaussian_blur_5x5_uniform_f32() {
let src = Image::fill(10, 10, MonoF32::new(1.0));
let result: Image<MonoF32> = gaussian_blur_5x5(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!((result.pixel_at(x, y).0 - 1.0).abs() < 1e-4);
}
}
}
#[test]
fn gaussian_blur_5x5_matches_full_convolution() {
let src = make_gradient_8x8();
let full_kernel = Neighborhood::<f32, 5, 5>::gaussian_5x5();
let full: Image<MonoF32> = convolve(&src, &full_kernel, &Clamp);
let sep: Image<MonoF32> = gaussian_blur_5x5(&src, &Clamp);
for y in 0..full.height() {
for x in 0..full.width() {
assert!(
(full.pixel_at(x, y).0 / 256.0 - sep.pixel_at(x, y).0).abs() < 1e-3,
"mismatch at ({x}, {y}): full/256={}, sep={}",
full.pixel_at(x, y).0 / 256.0,
sep.pixel_at(x, y).0,
);
}
}
}
#[test]
fn gaussian_blur_uniform_image_preserved_f32() {
let src = Image::fill(16, 16, MonoF32::new(0.7));
let result: Image<MonoF32> = gaussian_blur(&src, sigma!(2.0), &Clamp);
assert_eq!(result.size(), src.size());
for y in 0..result.height() {
for x in 0..result.width() {
assert!(
(result.pixel_at(x, y).0 - 0.7).abs() < 1e-4,
"at ({x}, {y}): {}",
result.pixel_at(x, y).0,
);
}
}
}
#[test]
fn gaussian_blur_uniform_image_preserved_u8() {
let src = Image::fill(16, 16, Mono8::new(120));
let result: Image<Mono8> = gaussian_blur(&src, sigma!(1.5), &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert_eq!(result.pixel_at(x, y), Mono8::new(120));
}
}
}
#[test]
fn gaussian_blur_impulse_response_is_the_kernel() {
let sigma = sigma!(1.0);
let truncate = 2.0; let kernel = gaussian_kernel_1d(sigma, truncate);
let w = kernel.weights();
let r = kernel.radius();
let c = 5usize;
let mut src = Image::fill(11, 11, MonoF32::new(0.0));
*src.pixel_at_mut(c, c) = MonoF32::new(1.0);
let out: Image<MonoF32> = convolve_separable(&src, &kernel, &Constant(MonoF32(0.0)));
for dy in -(r as isize)..=(r as isize) {
for dx in -(r as isize)..=(r as isize) {
let expected = w[(r as isize + dx) as usize] * w[(r as isize + dy) as usize];
let got = out
.pixel_at((c as isize + dx) as usize, (c as isize + dy) as usize)
.0;
assert!(
(got - expected).abs() < 1e-6,
"impulse response at ({dx}, {dy}): got {got}, expected {expected}"
);
}
}
}
#[test]
fn gaussian_blur_matches_full_2d_convolution() {
let sigma = sigma!(1.0);
let truncate = 2.0; let kernel = gaussian_kernel_1d(sigma, truncate);
let w = kernel.weights();
let r = kernel.radius();
let src = Image::generate(9, 9, |x, y| MonoF32::new((x * 3 + y * 5) as f32));
let out: Image<MonoF32> = convolve_separable(&src, &kernel, &Clamp);
for y in r..(9 - r) {
for x in r..(9 - r) {
let mut reference = 0.0f32;
for (j, &wj) in w.iter().enumerate() {
for (i, &wi) in w.iter().enumerate() {
let sx = x + i - r;
let sy = y + j - r;
reference += src.pixel_at(sx, sy).0 * wi * wj;
}
}
let got = out.pixel_at(x, y).0;
assert!(
(got - reference).abs() < 1e-3,
"at ({x}, {y}): separable={got}, full2d={reference}"
);
}
}
}
#[test]
fn gaussian_blur_larger_sigma_smooths_more() {
let src = Image::generate(41, 5, |x, _y| {
MonoF32::new(if x < 20 { 0.0 } else { 100.0 })
});
let max_slope = |sigma: f32| -> f32 {
let blurred: Image<MonoF32> = gaussian_blur(&src, Sigma::new(sigma).unwrap(), &Clamp);
let mut m = 0.0f32;
for y in 0..blurred.height() {
for x in 1..blurred.width() {
let d = (blurred.pixel_at(x, y).0 - blurred.pixel_at(x - 1, y).0).abs();
if d > m {
m = d;
}
}
}
m
};
let slope_small = max_slope(1.0);
let slope_large = max_slope(3.0);
assert!(
slope_large < slope_small,
"larger sigma should reduce the max slope: sigma=1 → {slope_small}, sigma=3 → {slope_large}"
);
}
#[test]
fn gaussian_blur_into_matches_owned() {
let src = Image::generate(12, 12, |x, y| MonoF32::new((x + y) as f32));
let owned: Image<MonoF32> = gaussian_blur(&src, sigma!(1.5), &Clamp);
let mut into = Image::<MonoF32>::zero(owned.width(), owned.height());
gaussian_blur_into(&src, sigma!(1.5), &Clamp, &mut into);
for y in 0..owned.height() {
for x in 0..owned.width() {
assert!(
(owned.pixel_at(x, y).0 - into.pixel_at(x, y).0).abs() < 1e-6,
"mismatch at ({x}, {y})",
);
}
}
}
#[test]
fn scratch_gaussian_blur_into_matches_owned_across_frames() {
let sigma = sigma!(1.5);
let mut scratch = SeparableScratch::new();
let mut reused = Image::<MonoF32>::zero(12, 12);
for frame in 0..3 {
let src = Image::generate(12, 12, |x, y| MonoF32::new((x + y + frame) as f32));
let owned: Image<MonoF32> = gaussian_blur(&src, sigma, &Clamp);
scratch.gaussian_blur_into(&src, sigma, &Clamp, &mut reused);
for y in 0..owned.height() {
for x in 0..owned.width() {
assert!(
(owned.pixel_at(x, y).0 - reused.pixel_at(x, y).0).abs() < 1e-6,
"frame {frame}: mismatch at ({x}, {y}): owned={}, scratch={}",
owned.pixel_at(x, y).0,
reused.pixel_at(x, y).0,
);
}
}
}
}
#[test]
fn gaussian_blur_equals_convolve_with_its_own_kernel() {
let src = Image::generate(17, 13, |x, y| MonoF32::new((x * 5 + y * 3) as f32));
for sigma in [sigma!(0.05), sigma!(0.8), sigma!(1.5), sigma!(3.0)] {
let via_blur: Image<MonoF32> = gaussian_blur(&src, sigma, &Clamp);
let via_kernel: Image<MonoF32> =
convolve_separable(&src, &gaussian_kernel_1d(sigma, DEFAULT_TRUNCATE), &Clamp);
assert_eq!(via_blur.size(), via_kernel.size());
for y in 0..via_blur.height() {
for x in 0..via_blur.width() {
assert_eq!(
via_blur.pixel_at(x, y).0,
via_kernel.pixel_at(x, y).0,
"σ={}: mismatch at ({x}, {y})",
sigma.get(),
);
}
}
}
let sigma = sigma!(1.2);
let kernel = gaussian_kernel_1d(sigma, DEFAULT_TRUNCATE);
let expected: Image<MonoF32> = convolve_separable(&src, &kernel, &Skip);
let mut actual = Image::<MonoF32>::zero(expected.width(), expected.height());
gaussian_blur_into(&src, sigma, &Skip, &mut actual);
for y in 0..expected.height() {
for x in 0..expected.width() {
assert_eq!(expected.pixel_at(x, y).0, actual.pixel_at(x, y).0);
}
}
}
#[test]
fn fixed_size_blurs_equal_their_kernel_form() {
let src = Image::generate(11, 9, |x, y| MonoF32::new((x * 7 + y) as f32));
let cases: [(Image<MonoF32>, Image<MonoF32>, &str); 4] = [
(
gaussian_blur_3x3(&src, &Clamp),
convolve_separable(&src, &SeparableKernel::gaussian_3(), &Clamp),
"gaussian_blur_3x3",
),
(
gaussian_blur_5x5(&src, &Clamp),
convolve_separable(&src, &SeparableKernel::gaussian_5(), &Clamp),
"gaussian_blur_5x5",
),
(
box_blur_3x3(&src, &Clamp),
convolve_separable(&src, &SeparableKernel::box_blur_3(), &Clamp),
"box_blur_3x3",
),
(
box_blur_5x5(&src, &Clamp),
convolve_separable(&src, &SeparableKernel::box_blur_5(), &Clamp),
"box_blur_5x5",
),
];
for (sugar, kernel_form, name) in cases {
assert_eq!(sugar.size(), kernel_form.size(), "{name}: size");
for y in 0..sugar.height() {
for x in 0..sugar.width() {
assert_eq!(
sugar.pixel_at(x, y).0,
kernel_form.pixel_at(x, y).0,
"{name}: mismatch at ({x}, {y})",
);
}
}
}
}
#[test]
fn scratch_convolve_separable_into_matches_owned_for_sigma_kernels() {
let src = Image::generate(16, 11, |x, y| MonoF32::new((x * 2 + y) as f32));
let mut scratch = SeparableScratch::new();
for sigma in [sigma!(2.0), sigma!(0.8), sigma!(2.0)] {
let kernel = gaussian_kernel_1d(sigma, 3.0);
let owned: Image<MonoF32> = convolve_separable(&src, &kernel, &Skip);
let mut actual = Image::<MonoF32>::zero(owned.width(), owned.height());
scratch.convolve_separable_into(&src, &kernel, &Skip, &mut actual);
for y in 0..owned.height() {
for x in 0..owned.width() {
assert!(
(owned.pixel_at(x, y).0 - actual.pixel_at(x, y).0).abs() < 1e-4,
"sigma {}: mismatch at ({x}, {y}): owned={}, scratch={}",
sigma.get(),
owned.pixel_at(x, y).0,
actual.pixel_at(x, y).0,
);
}
}
}
}
#[test]
fn scratch_gaussian_blur_into_u8_round_trip() {
let src = Image::fill(10, 10, Mono8::new(200));
let mut scratch = SeparableScratch::<MonoF32>::new();
let mut out = Image::<Mono8>::zero(10, 10);
scratch.gaussian_blur_into(&src, sigma!(1.2), &Clamp, &mut out);
for y in 0..out.height() {
for x in 0..out.width() {
assert_eq!(out.pixel_at(x, y), Mono8::new(200), "at ({x}, {y})");
}
}
}
#[test]
#[should_panic(expected = "exceeds MAX_RADIUS")]
fn gaussian_blur_over_radius_sigma_panics() {
let src = Image::fill(8, 8, MonoF32::new(1.0));
let _: Image<MonoF32> = gaussian_blur(&src, sigma!(20.0), &Clamp);
}
#[test]
fn gaussian_blur_tiny_sigma_is_near_identity() {
let src = Image::generate(8, 8, |x, y| MonoF32::new((x * 2 + y) as f32));
let result: Image<MonoF32> = gaussian_blur(&src, sigma!(0.05), &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!((result.pixel_at(x, y).0 - src.pixel_at(x, y).0).abs() < 1e-6);
}
}
}
#[test]
fn smaller_truncate_is_a_smaller_kernel() {
let src = Image::fill(20, 20, MonoF32::new(0.5));
let r4: Image<MonoF32> =
convolve_separable(&src, &gaussian_kernel_1d(sigma!(2.0), 4.0), &Clamp);
let r3: Image<MonoF32> =
convolve_separable(&src, &gaussian_kernel_1d(sigma!(2.0), 3.0), &Clamp);
for y in 0..src.height() {
for x in 0..src.width() {
assert!((r4.pixel_at(x, y).0 - 0.5).abs() < 1e-4);
assert!((r3.pixel_at(x, y).0 - 0.5).abs() < 1e-4);
}
}
}
#[test]
fn sobel_x_uniform_is_zero() {
let src = Image::fill(8, 8, Mono8::new(50));
let result: Image<crate::pixel::MonoF32> = sobel_x(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!(result.pixel_at(x, y).abs().0 < 1e-4);
}
}
}
#[test]
fn sobel_y_uniform_is_zero() {
let src = Image::fill(8, 8, Mono8::new(50));
let result: Image<crate::pixel::MonoF32> = sobel_y(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!(result.pixel_at(x, y).abs().0 < 1e-4);
}
}
}
#[test]
fn sobel_x_on_horizontal_gradient() {
let src = Image::generate(8, 8, |x, _y| MonoF32::new(x as f32));
let result: Image<MonoF32> = sobel_x(&src, &Skip);
let first = result.pixel_at(0, 0);
assert!(first.0.abs() > 0.1, "expected non-zero response");
for y in 0..result.height() {
for x in 0..result.width() {
assert!(
(result.pixel_at(x, y).0 - first.0).abs() < 1e-4,
"at ({x}, {y}): got {}, expected {}",
result.pixel_at(x, y).0,
first.0,
);
}
}
}
#[test]
fn sobel_y_on_vertical_gradient() {
let src = Image::generate(8, 8, |_x, y| MonoF32::new(y as f32));
let result: Image<MonoF32> = sobel_y(&src, &Skip);
let first = result.pixel_at(0, 0);
assert!(first.0.abs() > 0.1, "expected non-zero response");
for y in 0..result.height() {
for x in 0..result.width() {
assert!(
(result.pixel_at(x, y).0 - first.0).abs() < 1e-4,
"at ({x}, {y}): got {}, expected {}",
result.pixel_at(x, y).0,
first.0,
);
}
}
}
#[test]
fn scharr_x_uniform_is_zero() {
let src = Image::fill(8, 8, Mono8::new(50));
let result: Image<crate::pixel::MonoF32> = scharr_x(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!(result.pixel_at(x, y).abs().0 < 1e-4);
}
}
}
#[test]
fn scharr_y_uniform_is_zero() {
let src = Image::fill(8, 8, Mono8::new(50));
let result: Image<crate::pixel::MonoF32> = scharr_y(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!(result.pixel_at(x, y).abs().0 < 1e-4);
}
}
}
#[test]
fn prewitt_x_uniform_is_zero() {
let src = Image::fill(8, 8, Mono8::new(50));
let result: Image<crate::pixel::MonoF32> = prewitt_x(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!(result.pixel_at(x, y).abs().0 < 1e-4);
}
}
}
#[test]
fn prewitt_y_uniform_is_zero() {
let src = Image::fill(8, 8, Mono8::new(50));
let result: Image<crate::pixel::MonoF32> = prewitt_y(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!(result.pixel_at(x, y).abs().0 < 1e-4);
}
}
}
#[test]
fn laplacian_uniform_is_zero() {
let src = Image::fill(8, 8, Mono8::new(10));
let result: Image<crate::pixel::MonoF32> = laplacian(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!(result.pixel_at(x, y).abs().0 < 1e-4);
}
}
}
#[test]
fn laplacian_8_uniform_is_zero() {
let src = Image::fill(8, 8, Mono8::new(10));
let result: Image<crate::pixel::MonoF32> = laplacian_8(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!(result.pixel_at(x, y).abs().0 < 1e-4);
}
}
}
#[test]
fn laplacian_matches_full_convolution() {
let src = make_gradient_8x8();
let full_kernel = Neighborhood::<f32, 3, 3>::laplacian();
let full: Image<MonoF32> = convolve(&src, &full_kernel, &Clamp);
let convenience: Image<MonoF32> = laplacian(&src, &Clamp);
for y in 0..full.height() {
for x in 0..full.width() {
assert!((full.pixel_at(x, y).0 - convenience.pixel_at(x, y).0).abs() < 1e-4);
}
}
}
#[test]
fn sharpen_uniform_is_identity() {
let src = Image::fill(8, 8, Mono8::new(100));
let result: Image<Mono8> = sharpen(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert_eq!(result.pixel_at(x, y), Mono8::new(100));
}
}
}
#[test]
fn sharpen_f32_uniform_is_identity() {
let src = Image::fill(8, 8, MonoF32::new(3.5));
let result: Image<MonoF32> = sharpen(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!(
(result.pixel_at(x, y).0 - 3.5).abs() < 1e-4,
"at ({x}, {y}): {}",
result.pixel_at(x, y).0,
);
}
}
}
#[test]
fn sharpen_matches_full_convolution() {
let src = make_gradient_8x8();
let full_kernel = Neighborhood::<f32, 3, 3>::sharpen();
let full: Image<MonoF32> = convolve(&src, &full_kernel, &Clamp);
let convenience: Image<MonoF32> = sharpen(&src, &Clamp);
for y in 0..full.height() {
for x in 0..full.width() {
assert!((full.pixel_at(x, y).0 - convenience.pixel_at(x, y).0).abs() < 1e-4);
}
}
}
#[test]
fn emboss_uniform_is_original() {
let src = Image::fill(8, 8, MonoF32::new(25.0));
let result: Image<MonoF32> = emboss(&src, &Clamp);
for y in 0..result.height() {
for x in 0..result.width() {
assert!(
(result.pixel_at(x, y).0 - 25.0).abs() < 1e-4,
"at ({x}, {y}): {}",
result.pixel_at(x, y).0,
);
}
}
}
#[test]
fn emboss_matches_full_convolution() {
let src = make_gradient_8x8();
let full_kernel = Neighborhood::<f32, 3, 3>::emboss();
let full: Image<MonoF32> = convolve(&src, &full_kernel, &Clamp);
let convenience: Image<MonoF32> = emboss(&src, &Clamp);
for y in 0..full.height() {
for x in 0..full.width() {
assert!((full.pixel_at(x, y).0 - convenience.pixel_at(x, y).0).abs() < 1e-4);
}
}
}
#[test]
fn sobel_detects_step_edge() {
let src = Image::generate(10, 10, |x, _y| {
MonoF32::new(if x < 5 { 0.0 } else { 100.0 })
});
let result: Image<MonoF32> = sobel_x(&src, &Clamp);
let edge_val = result.pixel_at(4, 5).0.abs();
let flat_val = result.pixel_at(1, 5).0.abs();
assert!(
edge_val > flat_val * 5.0,
"edge response ({edge_val}) should be much larger than flat ({flat_val})",
);
}
#[test]
fn laplacian_detects_blob() {
let mut src = Image::fill(7, 7, MonoF32::new(0.0));
*src.pixel_at_mut(3, 3) = MonoF32::new(100.0);
let result: Image<MonoF32> = laplacian(&src, &Clamp);
assert!(
result.pixel_at(3, 3).0 > 200.0,
"center Laplacian response should be large, got {}",
result.pixel_at(3, 3).0,
);
}
#[test]
fn all_filters_handle_single_pixel() {
let src_f32 = Image::fill(1, 1, MonoF32::new(42.0));
let src_u8 = Image::fill(1, 1, Mono8::new(42));
let _: Image<MonoF32> = box_blur_3x3(&src_f32, &Clamp);
let _: Image<MonoF32> = box_blur_5x5(&src_f32, &Clamp);
let _: Image<MonoF32> = gaussian_blur_3x3(&src_f32, &Clamp);
let _: Image<MonoF32> = gaussian_blur_5x5(&src_f32, &Clamp);
let _: Image<MonoF32> = sobel_x(&src_f32, &Clamp);
let _: Image<MonoF32> = sobel_y(&src_f32, &Clamp);
let _: Image<MonoF32> = scharr_x(&src_f32, &Clamp);
let _: Image<MonoF32> = scharr_y(&src_f32, &Clamp);
let _: Image<MonoF32> = prewitt_x(&src_f32, &Clamp);
let _: Image<MonoF32> = prewitt_y(&src_f32, &Clamp);
let _: Image<MonoF32> = laplacian(&src_f32, &Clamp);
let _: Image<MonoF32> = laplacian_8(&src_f32, &Clamp);
let _: Image<MonoF32> = sharpen(&src_f32, &Clamp);
let _: Image<MonoF32> = emboss(&src_f32, &Clamp);
let _: Image<crate::pixel::MonoF32> = sobel_x(&src_u8, &Clamp);
let _: Image<crate::pixel::MonoF32> = sobel_y(&src_u8, &Clamp);
}
#[test]
fn magnitude_of_axis_gradients() {
let gx = Image::fill(4, 3, MonoF32::new(3.0));
let gy = Image::fill(4, 3, MonoF32::new(4.0));
let mag = gradient_magnitude(&gx, &gy).unwrap();
for y in 0..mag.height() {
for x in 0..mag.width() {
assert!((mag.pixel_at(x, y).0 - 5.0).abs() < 1e-5);
}
}
}
#[test]
fn magnitude_size_mismatch_err() {
let gx = Image::fill(2, 2, MonoF32::new(1.0));
let gy = Image::fill(3, 3, MonoF32::new(1.0));
assert!(gradient_magnitude(&gx, &gy).is_err());
assert!(gradient_direction(&gx, &gy).is_err());
}
#[test]
fn direction_cardinal_angles() {
use std::f32::consts::{FRAC_PI_2, PI};
let cases = [
(1.0, 0.0, 0.0), (0.0, 1.0, FRAC_PI_2), (-1.0, 0.0, PI), (0.0, -1.0, -FRAC_PI_2), ];
for (gx_v, gy_v, expected) in cases {
let gx = Image::fill(1, 1, MonoF32::new(gx_v));
let gy = Image::fill(1, 1, MonoF32::new(gy_v));
let dir = gradient_direction(&gx, &gy).unwrap();
assert!(
(dir.pixel_at(0, 0).0 - expected).abs() < 1e-6,
"gx={gx_v}, gy={gy_v}: got {}, expected {expected}",
dir.pixel_at(0, 0).0,
);
}
}
#[test]
fn magnitude_direction_generic_over_mono_f64() {
use crate::pixel::MonoF64;
let gx = Image::fill(2, 2, MonoF64::new(3.0));
let gy = Image::fill(2, 2, MonoF64::new(4.0));
let mag = gradient_magnitude(&gx, &gy).unwrap();
let dir = gradient_direction(&gx, &gy).unwrap();
assert!((mag.pixel_at(0, 0).0 - 5.0).abs() < 1e-12);
assert!((dir.pixel_at(0, 0).0 - 4.0_f64.atan2(3.0)).abs() < 1e-12);
}
fn mag_grid(width: usize, height: usize, vals: &[f32]) -> Image<MonoF32> {
Image::from_vec(
width,
height,
vals.iter().map(|&v| MonoF32::new(v)).collect(),
)
.unwrap()
}
#[test]
fn ridge_thins_to_one_pixel() {
let mag = mag_grid(5, 1, &[1.0, 2.0, 3.0, 2.0, 1.0]);
let dir = Image::fill(5, 1, MonoF32::new(0.0));
let thin = non_maximum_suppression(&mag, &dir).unwrap();
let row: Vec<f32> = (0..5).map(|x| thin.pixel_at(x, 0).0).collect();
assert_eq!(row, vec![0.0, 0.0, 3.0, 0.0, 0.0]);
}
#[test]
fn uniform_magnitude_plateau_kept() {
let mag = mag_grid(3, 1, &[2.0, 2.0, 2.0]);
let dir = Image::fill(3, 1, MonoF32::new(0.0));
let thin = non_maximum_suppression(&mag, &dir).unwrap();
assert_eq!(thin.pixel_at(1, 0).0, 2.0);
}
#[test]
fn border_pixels_suppressed() {
let mag = Image::fill(3, 3, MonoF32::new(5.0));
let dir = Image::fill(3, 3, MonoF32::new(0.0));
let thin = non_maximum_suppression(&mag, &dir).unwrap();
for y in 0..3 {
assert_eq!(thin.pixel_at(0, y).0, 0.0, "left border at y={y}");
assert_eq!(thin.pixel_at(2, y).0, 0.0, "right border at y={y}");
assert_eq!(thin.pixel_at(1, y).0, 5.0, "interior at y={y}");
}
}
#[test]
fn each_sector_picks_correct_neighbours() {
use std::f32::consts::PI;
struct Case {
theta: f32,
along: [(usize, usize); 2],
off: [(usize, usize); 2],
}
let cases = [
Case {
theta: 0.0,
along: [(0, 1), (2, 1)],
off: [(0, 0), (2, 2)],
},
Case {
theta: PI / 4.0,
along: [(0, 0), (2, 2)],
off: [(0, 1), (2, 1)],
},
Case {
theta: PI / 2.0,
along: [(1, 0), (1, 2)],
off: [(0, 0), (2, 2)],
},
Case {
theta: 3.0 * PI / 4.0,
along: [(2, 0), (0, 2)],
off: [(1, 0), (1, 2)],
},
];
for (i, case) in cases.iter().enumerate() {
let dir = Image::fill(3, 3, MonoF32::new(case.theta));
let mut mag = Image::fill(3, 3, MonoF32::new(0.0));
*mag.pixel_at_mut(1, 1) = MonoF32::new(5.0);
*mag.pixel_at_mut(case.along[0].0, case.along[0].1) = MonoF32::new(9.0);
let thin = non_maximum_suppression(&mag, &dir).unwrap();
assert_eq!(thin.pixel_at(1, 1).0, 0.0, "case {i}: should suppress");
let mut mag = Image::fill(3, 3, MonoF32::new(0.0));
*mag.pixel_at_mut(1, 1) = MonoF32::new(5.0);
for &(x, y) in &case.off {
*mag.pixel_at_mut(x, y) = MonoF32::new(9.0);
}
let thin = non_maximum_suppression(&mag, &dir).unwrap();
assert_eq!(thin.pixel_at(1, 1).0, 5.0, "case {i}: should keep");
}
}
#[test]
fn nms_generic_over_mono_f64() {
use crate::pixel::MonoF64;
let mag = Image::from_vec(
5,
1,
[1.0, 2.0, 3.0, 2.0, 1.0]
.iter()
.map(|&v| MonoF64::new(v))
.collect(),
)
.unwrap();
let dir = Image::fill(5, 1, MonoF64::new(0.0));
let thin = non_maximum_suppression(&mag, &dir).unwrap();
let row: Vec<f64> = (0..5).map(|x| thin.pixel_at(x, 0).0).collect();
assert_eq!(row, vec![0.0, 0.0, 3.0, 0.0, 0.0]);
let dir = Image::fill(5, 1, MonoF64::new(std::f64::consts::FRAC_PI_2));
let thin = non_maximum_suppression(&mag, &dir).unwrap();
assert!((0..5).all(|x| thin.pixel_at(x, 0).0 == 0.0));
}
#[test]
fn nms_size_mismatch_is_error() {
let mag = Image::fill(4, 4, MonoF32::new(1.0));
let dir = Image::fill(3, 4, MonoF32::new(0.0));
let result: Result<Image<MonoF32>, Error> = non_maximum_suppression(&mag, &dir);
assert_eq!(
result.unwrap_err(),
Error::SizeMismatch {
expected: Size::new(4, 4),
actual: Size::new(3, 4),
}
);
}
#[test]
fn gradient_sector_matches_angle_sector() {
use std::f64::consts::{PI, TAU};
for i in 0..3600 {
let theta = -PI + (i as f64 + 0.37) * TAU / 3600.0;
let (gx, gy) = (theta.cos(), theta.sin());
assert_eq!(
nms_sector_from_gradient(gx, gy),
nms_sector(gy.atan2(gx)),
"theta = {theta}",
);
}
for &(gx, gy) in &[
(1.0, 0.0),
(0.0, 1.0),
(-1.0, 0.0),
(0.0, -1.0),
(1.0, 1.0),
(-1.0, 1.0),
(1.0, -1.0),
(-1.0, -1.0),
(3.0, 0.5),
(-0.25, 7.0),
] {
assert_eq!(
nms_sector_from_gradient(gx, gy),
nms_sector(gy.atan2(gx)),
"(gx, gy) = ({gx}, {gy})",
);
}
}
#[test]
fn fused_nms_matches_staged_nms() {
let src = Image::generate(11, 9, |x, y| {
MonoF32::new(((x * 13 + y * 7) % 5) as f32 * 0.25 + (x as f32 * 0.1).sin())
});
let gx = scharr_x(&src, &Clamp);
let gy = scharr_y(&src, &Clamp);
let mag = gradient_magnitude(&gx, &gy).unwrap();
let dir = gradient_direction(&gx, &gy).unwrap();
let staged = non_maximum_suppression(&mag, &dir).unwrap();
let fused = non_maximum_suppression_from_gradients(&mag, &gx, &gy);
for y in 0..9 {
for x in 0..11 {
assert_eq!(staged.pixel_at(x, y).0, fused.pixel_at(x, y).0, "({x},{y})");
}
}
}
#[test]
#[should_panic(expected = "must have the same size")]
fn fused_nms_size_mismatch_panics() {
let mag = Image::fill(4, 4, MonoF32::new(1.0));
let gx = Image::fill(4, 4, MonoF32::new(1.0));
let gy = Image::fill(3, 3, MonoF32::new(1.0));
let _ = non_maximum_suppression_from_gradients(&mag, &gx, &gy);
}
}