use core::ops::{Add, Mul};
use crate::border::Clamp;
use crate::error::Error;
use crate::features::Corner;
use crate::image::{Decimated, Image, ImageView, RasterImage, RasterImageMut};
use crate::pixel::{FromLinear, LinearPixel, SingleChannel, ZeroablePixel};
use crate::transform::{PixelMultiply, combine_images, gaussian_blur, sobel_x, sobel_y};
use crate::{Sigma, Size};
use super::peaks::{NmsRadius, corner_peaks, lift_peaks, scan_peaks};
mod response_sealed {
pub trait Sealed: Copy {}
}
pub trait CornerResponseChannel: response_sealed::Sealed + Copy {
fn harris(sxx: Self, sxy: Self, syy: Self, k: f32) -> Self;
fn shi_tomasi(sxx: Self, sxy: Self, syy: Self) -> Self;
}
impl response_sealed::Sealed for f32 {}
impl CornerResponseChannel for f32 {
#[inline(always)]
fn harris(sxx: f32, sxy: f32, syy: f32, k: f32) -> f32 {
let trace = sxx + syy;
sxx * syy - sxy * sxy - k * trace * trace
}
#[inline(always)]
fn shi_tomasi(sxx: f32, sxy: f32, syy: f32) -> f32 {
let half_sum = 0.5 * (sxx + syy);
let half_diff = 0.5 * (sxx - syy);
half_sum - (half_diff * half_diff + sxy * sxy).sqrt()
}
}
impl response_sealed::Sealed for f64 {}
impl CornerResponseChannel for f64 {
#[inline(always)]
fn harris(sxx: f64, sxy: f64, syy: f64, k: f32) -> f64 {
let trace = sxx + syy;
sxx * syy - sxy * sxy - f64::from(k) * trace * trace
}
#[inline(always)]
fn shi_tomasi(sxx: f64, sxy: f64, syy: f64) -> f64 {
let half_sum = 0.5 * (sxx + syy);
let half_diff = 0.5 * (sxx - syy);
half_sum - (half_diff * half_diff + sxy * sxy).sqrt()
}
}
pub trait CornerResponse<C> {
fn response(&self, sxx: C, sxy: C, syy: C) -> C;
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Harris(f32);
impl Harris {
#[must_use]
pub const fn new(k: f32) -> Option<Self> {
if k.is_finite() && k > 0.0 && k < 0.25 {
Some(Self(k))
} else {
None
}
}
pub fn try_new(k: f32) -> Result<Self, Error> {
if k.is_finite() && k > 0.0 && k < 0.25 {
Ok(Self(k))
} else {
Err(Error::InvalidParameter(format!(
"Harris k must satisfy 0 < k < 0.25, got {k}"
)))
}
}
#[must_use]
pub const fn k(self) -> f32 {
self.0
}
}
#[macro_export]
macro_rules! harris {
($k:expr) => {
const { $crate::features::detect::Harris::new($k).expect("Harris k must satisfy 0 < k < 0.25") }
};
}
impl<C: CornerResponseChannel> CornerResponse<C> for Harris {
#[inline(always)]
fn response(&self, sxx: C, sxy: C, syy: C) -> C {
C::harris(sxx, sxy, syy, self.0)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ShiTomasi;
impl<C: CornerResponseChannel> CornerResponse<C> for ShiTomasi {
#[inline(always)]
fn response(&self, sxx: C, sxy: C, syy: C) -> C {
C::shi_tomasi(sxx, sxy, syy)
}
}
#[derive(Clone, Debug)]
pub struct StructureTensor<P: Copy> {
xx: Image<P>,
xy: Image<P>,
yy: Image<P>,
}
impl<P> StructureTensor<P>
where
P: Copy
+ Default
+ ZeroablePixel
+ FromLinear<P>
+ LinearPixel<f32, Accumulator = P>
+ Add<Output = P>
+ Mul<Output = P>,
{
pub fn from_gradients<IX, IY>(gx: &IX, gy: &IY, window: Sigma) -> Result<Self, Error>
where
IX: RasterImage<Pixel = P>,
IY: RasterImage<Pixel = P>,
{
let xy = combine_images(gx, gy, PixelMultiply)?;
let xx = combine_images(gx, gx, PixelMultiply).expect("gx shares its own size");
let yy = combine_images(gy, gy, PixelMultiply).expect("gy shares its own size");
Ok(Self {
xx: gaussian_blur(&xx, window, &Clamp),
xy: gaussian_blur(&xy, window, &Clamp),
yy: gaussian_blur(&yy, window, &Clamp),
})
}
}
impl<P: Copy> StructureTensor<P> {
pub fn from_smoothed(sxx: Image<P>, sxy: Image<P>, syy: Image<P>) -> Result<Self, Error> {
for other in [sxy.size(), syy.size()] {
if other != sxx.size() {
return Err(Error::SizeMismatch {
expected: sxx.size(),
actual: other,
});
}
}
Ok(Self {
xx: sxx,
xy: sxy,
yy: syy,
})
}
#[must_use]
pub fn xx(&self) -> &Image<P> {
&self.xx
}
#[must_use]
pub fn xy(&self) -> &Image<P> {
&self.xy
}
#[must_use]
pub fn yy(&self) -> &Image<P> {
&self.yy
}
#[must_use]
pub fn size(&self) -> Size {
self.xx.size()
}
#[must_use]
pub fn response<M>(&self, method: M) -> Image<P>
where
P: SingleChannel + ZeroablePixel,
P::Channel: CornerResponseChannel,
M: CornerResponse<P::Channel>,
{
let (w, h) = (self.xx.width(), self.xx.height());
let mut out = Image::fill(w, h, P::zero());
for y in 0..h {
let (xx, xy, yy) = (self.xx.row(y), self.xy.row(y), self.yy.row(y));
let dst = out.row_mut(y);
for x in 0..w {
let value = method.response(xx[x].channel(0), xy[x].channel(0), yy[x].channel(0));
dst[x] = P::from_channels(&[value]);
}
}
out
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CornerParams {
window: Sigma,
threshold: f32,
nms_radius: NmsRadius,
}
impl CornerParams {
#[must_use]
pub const fn new(window: Sigma, threshold: f32, nms_radius: NmsRadius) -> Option<Self> {
if !threshold.is_finite() {
return None;
}
Some(Self {
window,
threshold,
nms_radius,
})
}
pub fn try_new(window: Sigma, threshold: f32, nms_radius: NmsRadius) -> Result<Self, Error> {
if !threshold.is_finite() {
return Err(Error::InvalidParameter(format!(
"corner response threshold must be finite, got {threshold}"
)));
}
Ok(Self {
window,
threshold,
nms_radius,
})
}
#[must_use]
pub const fn window(self) -> Sigma {
self.window
}
#[must_use]
pub const fn threshold(self) -> f32 {
self.threshold
}
#[must_use]
pub const fn nms_radius(self) -> NmsRadius {
self.nms_radius
}
}
#[must_use]
pub fn corner_response_map<I, M, P, Acc>(image: &I, method: M, window: Sigma) -> Image<Acc>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ SingleChannel
+ FromLinear<Acc>
+ LinearPixel<f32, Accumulator = Acc>
+ Add<Output = Acc>
+ Mul<Output = Acc>,
Acc::Channel: CornerResponseChannel,
M: CornerResponse<Acc::Channel>,
{
let gx = sobel_x(image, &Clamp);
let gy = sobel_y(image, &Clamp);
let tensor = StructureTensor::from_gradients(&gx, &gy, window).expect("gx and gy share a size");
tensor.response(method)
}
#[must_use]
pub fn detect_corners<I, M, P, Acc>(image: &I, method: M, params: CornerParams) -> Vec<Corner>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ SingleChannel
+ FromLinear<Acc>
+ LinearPixel<f32, Accumulator = Acc>
+ Add<Output = Acc>
+ Mul<Output = Acc>,
Acc::Channel: CornerResponseChannel + PartialOrd + From<f32>,
f64: From<Acc::Channel>,
M: CornerResponse<Acc::Channel>,
{
let response = corner_response_map(image, method, params.window());
corner_peaks(&response, params.threshold(), params.nms_radius().get())
}
#[must_use]
pub fn detect_corners_in_level<L, M, P, Acc>(
level: &L,
method: M,
params: CornerParams,
) -> Vec<Corner>
where
L: Decimated<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ SingleChannel
+ FromLinear<Acc>
+ LinearPixel<f32, Accumulator = Acc>
+ Add<Output = Acc>
+ Mul<Output = Acc>,
Acc::Channel: CornerResponseChannel + PartialOrd + From<f32>,
f64: From<Acc::Channel>,
M: CornerResponse<Acc::Channel>,
{
let response = corner_response_map(level.as_image(), method, params.window());
lift_peaks(
level,
scan_peaks(&response, params.threshold(), params.nms_radius().get()),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::features::HasPosition;
use crate::image::{
ImageView, OriginOffset, PlacedImage, PlacedPyramid, Pyramid, PyramidLevel,
};
use crate::pixel::{Mono8, Mono16, MonoF32, MonoF64};
use crate::transform::{Gaussian, PyramidMethod, pyr_down, rotate_90};
use crate::{pixel_distance, sigma};
fn square(n: usize, lo: usize, hi: usize) -> Image<MonoF32> {
Image::generate(n, n, |x, y| {
let inside = (lo..hi).contains(&x) && (lo..hi).contains(&y);
MonoF32::new(if inside { 1.0 } else { 0.0 })
})
}
fn square_corners(lo: usize, hi: usize) -> [(f64, f64); 4] {
let (a, b) = ((lo as f64) - 0.5, (hi as f64) - 0.5);
[(a, a), (b, a), (a, b), (b, b)]
}
fn square_corner_pixels(lo: usize, hi: usize) -> Vec<(f64, f64)> {
let (a, b) = (lo as f64, (hi - 1) as f64);
vec![(a, a), (b, a), (a, b), (b, b)]
}
fn positions(corners: &[Corner]) -> Vec<(f64, f64)> {
corners
.iter()
.map(|c| (c.position().x, c.position().y))
.collect()
}
fn max_response<P>(map: &Image<P>) -> f32
where
P: SingleChannel,
f64: From<P::Channel>,
{
(0..map.height())
.flat_map(|y| (0..map.width()).map(move |x| (x, y)))
.map(|(x, y)| f64::from(map.pixel_at(x, y).channel(0)) as f32)
.fold(f32::NEG_INFINITY, f32::max)
}
fn distance_to_nearest(corner: &Corner, truth: &[(f64, f64); 4]) -> f64 {
truth
.iter()
.map(|&(tx, ty)| {
let p = corner.position();
((p.x - tx).powi(2) + (p.y - ty).powi(2)).sqrt()
})
.fold(f64::INFINITY, f64::min)
}
#[test]
fn harris_accepts_the_conventional_range() {
for k in [0.01, 0.04, 0.06, 0.2, 0.249] {
assert_eq!(Harris::try_new(k).unwrap().k(), k);
}
const CLASSIC: Harris = harris!(0.04);
assert_eq!(CLASSIC.k(), 0.04);
}
#[test]
fn harris_try_new_rejects_a_dead_or_inverted_detector() {
for k in [0.0, -0.04, 0.25, 0.5, f32::NAN, f32::INFINITY] {
let err = Harris::try_new(k).unwrap_err();
match err {
Error::InvalidParameter(reason) => {
assert!(reason.contains("k"), "reason {reason:?} does not mention k");
}
other => panic!("expected InvalidParameter, got {other:?}"),
}
}
}
#[test]
fn harris_new_rejects_an_invalid_k() {
assert!(Harris::new(0.3).is_none());
assert!(Harris::new(0.25).is_none());
assert!(Harris::new(0.0).is_none());
}
#[test]
fn harris_k_at_the_upper_bound_would_zero_every_response() {
let (sxx, sxy, syy) = (1.0f32, 0.0, 1.0);
let at_bound = CornerResponseChannel::harris(sxx, sxy, syy, 0.25);
assert!(at_bound.abs() < 1e-6, "{at_bound}");
assert!(harris!(0.249).response(sxx, sxy, syy) > 0.0);
}
#[test]
fn harris_response_matches_the_formula() {
let (sxx, sxy, syy) = (5.0f32, 2.0, 3.0);
let expected = (5.0 * 3.0 - 2.0 * 2.0) - 0.04 * (5.0 + 3.0) * (5.0 + 3.0);
assert!((harris!(0.04).response(sxx, sxy, syy) - expected).abs() < 1e-6);
}
#[test]
fn shi_tomasi_response_is_the_smaller_eigenvalue() {
assert!((ShiTomasi.response(4.0f32, 0.0, 1.0) - 1.0).abs() < 1e-6);
assert!((ShiTomasi.response(1.0f32, 0.0, 4.0) - 1.0).abs() < 1e-6);
assert!((ShiTomasi.response(2.0f32, 1.0, 2.0) - 1.0).abs() < 1e-6);
assert!((ShiTomasi.response(3.0f32, 4.0, 3.0) + 1.0).abs() < 1e-6);
}
#[test]
fn both_responses_reject_a_pure_edge() {
for strength in [1.0f32, 100.0, 1e6] {
assert!(ShiTomasi.response(strength, 0.0, 0.0).abs() <= 1e-3 * strength);
assert!(harris!(0.04).response(strength, 0.0, 0.0) < 0.0);
}
}
#[test]
fn responses_are_generic_over_f64() {
assert!((harris!(0.04).response(5.0f64, 2.0, 3.0) - 8.44).abs() < 1e-6);
assert!((ShiTomasi.response(2.0f64, 1.0, 2.0) - 1.0).abs() < 1e-12);
}
#[test]
fn shi_tomasi_keeps_precision_on_a_near_degenerate_tensor() {
let (sxx, sxy, syy) = (1e8f64, 1.0, 1e8);
let lambda_min = ShiTomasi.response(sxx, sxy, syy);
assert!((lambda_min - (1e8 - 1.0)).abs() < 1e-3, "{lambda_min}");
}
#[test]
fn a_custom_response_strategy_composes() {
struct Noble;
impl CornerResponse<f32> for Noble {
fn response(&self, sxx: f32, sxy: f32, syy: f32) -> f32 {
let trace = sxx + syy;
if trace == 0.0 {
0.0
} else {
2.0 * (sxx * syy - sxy * sxy) / trace
}
}
}
let image = square(24, 8, 16);
let map: Image<MonoF32> = corner_response_map(&image, Noble, sigma!(1.2));
let peak = max_response(&map);
assert_eq!(corner_peaks(&map, 0.3 * peak, 3).len(), 4);
}
#[test]
fn corner_params_round_trip() {
const PARAMS: CornerParams =
CornerParams::new(sigma!(1.4), 0.01, NmsRadius::new(3).unwrap()).unwrap();
assert_eq!(PARAMS.window(), sigma!(1.4));
assert_eq!(PARAMS.threshold(), 0.01);
assert_eq!(PARAMS.nms_radius().get(), 3);
let computed =
CornerParams::try_new(sigma!(2.0), -1.5, NmsRadius::new(1).unwrap()).unwrap();
assert_eq!(computed.threshold(), -1.5);
}
#[test]
fn corner_params_try_new_rejects_a_non_finite_threshold() {
for threshold in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let err = CornerParams::try_new(sigma!(1.0), threshold, NmsRadius::new(2).unwrap())
.unwrap_err();
match err {
Error::InvalidParameter(reason) => assert!(
reason.contains("threshold"),
"reason {reason:?} does not mention the threshold"
),
other => panic!("expected InvalidParameter, got {other:?}"),
}
}
}
#[test]
fn corner_params_new_rejects_an_invalid_threshold() {
assert!(CornerParams::new(sigma!(1.0), f32::NAN, NmsRadius::new(2).unwrap()).is_none());
assert!(
CornerParams::new(sigma!(1.0), f32::INFINITY, NmsRadius::new(2).unwrap()).is_none()
);
}
#[test]
fn structure_tensor_of_a_vertical_edge_is_all_xx() {
let image: Image<MonoF32> =
Image::generate(16, 16, |x, _| MonoF32::new(if x < 8 { 0.0 } else { 1.0 }));
let tensor = StructureTensor::from_gradients(
&sobel_x(&image, &Clamp),
&sobel_y(&image, &Clamp),
sigma!(1.0),
)
.unwrap();
assert_eq!(tensor.size(), image.size());
assert!(tensor.xx().pixel_at(8, 8).value() > 1.0);
assert!(tensor.yy().pixel_at(8, 8).value().abs() < 1e-6);
assert!(tensor.xy().pixel_at(8, 8).value().abs() < 1e-6);
}
#[test]
fn structure_tensor_of_a_diagonal_edge_has_an_off_diagonal_term() {
let image: Image<MonoF32> =
Image::generate(16, 16, |x, y| MonoF32::new(if x < y { 0.0 } else { 1.0 }));
let tensor = StructureTensor::from_gradients(
&sobel_x(&image, &Clamp),
&sobel_y(&image, &Clamp),
sigma!(1.0),
)
.unwrap();
assert!(tensor.xy().pixel_at(8, 8).value() < 0.0);
}
#[test]
fn structure_tensor_reports_a_gradient_size_mismatch() {
let gx: Image<MonoF32> = Image::zero(8, 8);
let gy: Image<MonoF32> = Image::zero(8, 4);
let err = StructureTensor::from_gradients(&gx, &gy, sigma!(1.0)).unwrap_err();
assert_eq!(
err,
Error::SizeMismatch {
expected: Size::new(8, 8),
actual: Size::new(8, 4),
}
);
}
#[test]
fn structure_tensor_from_smoothed_takes_the_images_as_given() {
let tensor = StructureTensor::from_smoothed(
Image::fill(4, 4, MonoF32::new(4.0)),
Image::fill(4, 4, MonoF32::new(0.0)),
Image::fill(4, 4, MonoF32::new(1.0)),
)
.unwrap();
assert_eq!(tensor.size(), Size::new(4, 4));
let response = tensor.response(ShiTomasi);
assert!((response.pixel_at(2, 2).value() - 1.0).abs() < 1e-6);
}
#[test]
fn structure_tensor_from_smoothed_reports_a_size_mismatch() {
let err = StructureTensor::from_smoothed(
Image::<MonoF32>::zero(4, 4),
Image::<MonoF32>::zero(4, 4),
Image::<MonoF32>::zero(4, 3),
)
.unwrap_err();
assert_eq!(
err,
Error::SizeMismatch {
expected: Size::new(4, 4),
actual: Size::new(4, 3),
}
);
}
#[test]
fn a_square_has_four_corners() {
let image = square(24, 8, 16);
let method = harris!(0.04);
let map: Image<MonoF32> = corner_response_map(&image, method, sigma!(1.0));
let params = CornerParams::try_new(
sigma!(1.0),
0.2 * max_response(&map),
NmsRadius::new(3).unwrap(),
)
.unwrap();
let corners = detect_corners(&image, method, params);
assert_eq!(positions(&corners), square_corner_pixels(8, 16));
let truth = square_corners(8, 16);
assert!(
corners
.iter()
.all(|c| distance_to_nearest(c, &truth) <= 0.75),
"{corners:?}"
);
}
#[test]
fn shi_tomasi_finds_the_same_four_corners() {
let image = square(24, 8, 16);
let map: Image<MonoF32> = corner_response_map(&image, ShiTomasi, sigma!(1.0));
let params = CornerParams::try_new(
sigma!(1.0),
0.3 * max_response(&map),
NmsRadius::new(3).unwrap(),
)
.unwrap();
let corners = detect_corners(&image, ShiTomasi, params);
assert_eq!(positions(&corners), square_corner_pixels(8, 16));
let harris_map: Image<MonoF32> = corner_response_map(&image, harris!(0.04), sigma!(1.0));
let harris_params = CornerParams::try_new(
sigma!(1.0),
0.2 * max_response(&harris_map),
NmsRadius::new(3).unwrap(),
)
.unwrap();
assert_eq!(
positions(&detect_corners(&image, harris!(0.04), harris_params)),
positions(&corners)
);
}
#[test]
fn a_larger_window_drags_the_peak_inward() {
let image = square(24, 8, 16);
let corners_of = |sigma: Sigma| {
let map: Image<MonoF32> = corner_response_map(&image, ShiTomasi, sigma);
let params =
CornerParams::try_new(sigma, 0.3 * max_response(&map), NmsRadius::new(3).unwrap())
.unwrap();
positions(&detect_corners(&image, ShiTomasi, params))
};
assert_eq!(corners_of(sigma!(1.0)), square_corner_pixels(8, 16));
assert_eq!(
corners_of(sigma!(1.6)),
vec![(9.0, 9.0), (14.0, 9.0), (9.0, 14.0), (14.0, 14.0)]
);
}
#[test]
fn a_straight_edge_has_no_corners() {
let image: Image<MonoF32> =
Image::generate(24, 24, |x, _| MonoF32::new(if x < 12 { 0.0 } else { 1.0 }));
let params = CornerParams::new(sigma!(1.2), 1e-4, NmsRadius::new(3).unwrap()).unwrap();
assert!(detect_corners(&image, harris!(0.04), params).is_empty());
assert!(detect_corners(&image, ShiTomasi, params).is_empty());
}
#[test]
fn a_flat_field_has_no_corners() {
let image = Image::fill(16, 16, MonoF32::new(0.5));
let params = CornerParams::new(sigma!(1.0), 1e-6, NmsRadius::new(2).unwrap()).unwrap();
assert!(detect_corners(&image, harris!(0.04), params).is_empty());
}
#[test]
fn an_l_junction_has_one_corner() {
let image: Image<MonoF32> = Image::generate(24, 24, |x, y| {
MonoF32::new(if x >= 12 && y >= 12 { 1.0 } else { 0.0 })
});
let map: Image<MonoF32> = corner_response_map(&image, ShiTomasi, sigma!(1.0));
let params = CornerParams::try_new(
sigma!(1.0),
0.4 * max_response(&map),
NmsRadius::new(4).unwrap(),
)
.unwrap();
let corners = detect_corners(&image, ShiTomasi, params);
assert_eq!(positions(&corners), [(12.0, 12.0)], "{corners:?}");
}
#[test]
fn the_response_is_invariant_under_a_quarter_turn() {
let image = square(24, 7, 17);
let rotated: Image<MonoF32> = rotate_90(&image);
let map: Image<MonoF32> = corner_response_map(&image, harris!(0.04), sigma!(1.2));
let rotated_map: Image<MonoF32> = corner_response_map(&rotated, harris!(0.04), sigma!(1.2));
let scale = max_response(&map);
assert!(scale > 0.0);
for y in 0..24 {
for x in 0..24 {
let here = map.pixel_at(x, y).value();
let there = rotated_map.pixel_at(23 - y, x).value();
assert!(
(here - there).abs() <= 1e-3 * scale,
"({x},{y}): {here} vs {there}"
);
}
}
}
#[test]
fn detect_corners_is_the_documented_composition() {
let image = square(24, 8, 16);
let method = harris!(0.05);
let params = CornerParams::new(sigma!(1.1), 1.0, NmsRadius::new(3).unwrap()).unwrap();
let staged = {
let map: Image<MonoF32> = corner_response_map(&image, method, params.window());
corner_peaks(&map, params.threshold(), params.nms_radius().get())
};
assert_eq!(detect_corners(&image, method, params), staged);
assert!(!staged.is_empty());
}
#[test]
fn accepts_integer_input() {
let image: Image<Mono8> = Image::generate(24, 24, |x, y| {
let inside = (8..16).contains(&x) && (8..16).contains(&y);
Mono8::new(if inside { 255 } else { 0 })
});
let map: Image<MonoF32> = corner_response_map(&image, ShiTomasi, sigma!(1.2));
let params = CornerParams::try_new(
sigma!(1.2),
0.3 * max_response(&map),
NmsRadius::new(3).unwrap(),
)
.unwrap();
assert_eq!(detect_corners(&image, ShiTomasi, params).len(), 4);
}
#[test]
fn accepts_sixteen_bit_input() {
let image: Image<Mono16> = Image::generate(24, 24, |x, y| {
let inside = (8..16).contains(&x) && (8..16).contains(&y);
Mono16::new(if inside { 65535 } else { 0 })
});
let map: Image<MonoF32> = corner_response_map(&image, harris!(0.04), sigma!(1.2));
let params = CornerParams::try_new(
sigma!(1.2),
0.2 * max_response(&map),
NmsRadius::new(3).unwrap(),
)
.unwrap();
assert_eq!(detect_corners(&image, harris!(0.04), params).len(), 4);
}
#[test]
fn accepts_f64_float_input() {
let image: Image<MonoF64> = Image::generate(24, 24, |x, y| {
let inside = (8..16).contains(&x) && (8..16).contains(&y);
MonoF64::new(if inside { 1.0 } else { 0.0 })
});
let map: Image<MonoF64> = corner_response_map(&image, ShiTomasi, sigma!(1.2));
let params = CornerParams::try_new(
sigma!(1.2),
0.3 * max_response(&map),
NmsRadius::new(3).unwrap(),
)
.unwrap();
assert_eq!(detect_corners(&image, ShiTomasi, params).len(), 4);
}
#[test]
fn detection_on_the_base_level_is_the_identity_lift() {
let image = square(24, 8, 16);
let pyramid: PlacedPyramid<MonoF32> = Gaussian.build(&image, 1);
let params = CornerParams::new(sigma!(1.2), 1.0, NmsRadius::new(3).unwrap()).unwrap();
assert_eq!(
detect_corners_in_level(pyramid.finest(), ShiTomasi, params),
detect_corners(&image, ShiTomasi, params)
);
}
#[test]
fn detection_on_a_coarse_level_reports_base_coordinates() {
let base = square(48, 16, 32);
let pyramid: PlacedPyramid<MonoF32> = Gaussian.build(&base, 2);
let level = pyramid.level(1);
let map: Image<MonoF32> = corner_response_map(level.as_image(), ShiTomasi, sigma!(1.0));
let params = CornerParams::try_new(
sigma!(1.0),
0.3 * max_response(&map),
NmsRadius::new(2).unwrap(),
)
.unwrap();
let corners = detect_corners_in_level(level, ShiTomasi, params);
assert_eq!(corners.len(), 4, "{corners:?}");
let truth = square_corners(16, 32);
for corner in &corners {
let p = corner.position();
assert!(p.x % 2.0 == 0.0 && p.y % 2.0 == 0.0, "{corner:?}");
assert!(distance_to_nearest(corner, &truth) <= 4.0, "{corner:?}");
}
}
#[test]
fn a_pyramid_can_be_swept_level_by_level() {
let base = square(32, 8, 24);
let pyramid: PlacedPyramid<MonoF32> = Gaussian.build(&base, 2);
let params = CornerParams::new(sigma!(1.0), 0.5, NmsRadius::new(2).unwrap()).unwrap();
let corners: Vec<Corner> = pyramid
.iter()
.flat_map(|level| detect_corners_in_level(level, ShiTomasi, params))
.collect();
assert!(corners.len() >= 8, "{corners:?}");
let truth = square_corners(8, 24);
assert!(
corners
.iter()
.all(|c| distance_to_nearest(c, &truth) <= 4.0),
"{corners:?}"
);
}
#[test]
fn a_level_with_an_origin_offset_lifts_through_it() {
let base = square(48, 16, 32);
let coarse = pyr_down(&base);
let params = CornerParams::new(sigma!(1.0), 0.5, NmsRadius::new(2).unwrap()).unwrap();
let unshifted = PlacedImage::new(coarse.clone(), pixel_distance!(2.0), OriginOffset::ZERO);
let shifted = PlacedImage::new(
coarse,
pixel_distance!(2.0),
OriginOffset::new(0.5, 0.5).unwrap(),
);
let a = detect_corners_in_level(&unshifted, ShiTomasi, params);
let b = detect_corners_in_level(&shifted, ShiTomasi, params);
assert!(!a.is_empty());
assert_eq!(a.len(), b.len());
for (unshifted, shifted) in a.iter().zip(&b) {
assert_eq!(shifted.position().x - unshifted.position().x, 0.5);
assert_eq!(shifted.position().y - unshifted.position().y, 0.5);
}
}
}