use crate::border::BorderPolicy;
use crate::error::Error;
use crate::features::Corner;
use crate::image::{Decimated, Image, ImageView, RasterImage, RasterImageMut};
use crate::pixel::{LinearPixel, MonoF32, SingleChannel};
use crate::{CoordinateF64, Offset, Rectangle, Size};
use super::peaks::{NmsRadius, corner_peaks, lift_peaks, scan_peaks};
pub const FAST_RING_RADIUS: usize = 3;
pub const FAST_RING: [Offset; 16] = [
Offset::new(0, -3),
Offset::new(1, -3),
Offset::new(2, -2),
Offset::new(3, -1),
Offset::new(3, 0),
Offset::new(3, 1),
Offset::new(2, 2),
Offset::new(1, 3),
Offset::new(0, 3),
Offset::new(-1, 3),
Offset::new(-2, 2),
Offset::new(-3, 1),
Offset::new(-3, 0),
Offset::new(-3, -1),
Offset::new(-2, -2),
Offset::new(-1, -3),
];
const FOOTPRINT: usize = 2 * FAST_RING_RADIUS + 1;
const FAST_RING_ROWS: [(usize, isize); 16] = {
let mut table = [(0usize, 0isize); 16];
let mut index = 0;
while index < 16 {
let offset = FAST_RING[index];
table[index] = (
(offset.dy + FAST_RING_RADIUS as i32) as usize,
offset.dx as isize,
);
index += 1;
}
table
};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SegmentTest {
threshold: f32,
arc_length: usize,
}
impl SegmentTest {
#[must_use]
pub const fn new(threshold: f32, arc_length: usize) -> Option<Self> {
if !(threshold.is_finite() && threshold > 0.0) {
return None;
}
if arc_length < 9 || arc_length > 16 {
return None;
}
Some(Self {
threshold,
arc_length,
})
}
pub fn try_new(threshold: f32, arc_length: usize) -> Result<Self, Error> {
if !(threshold.is_finite() && threshold > 0.0) {
return Err(Error::InvalidParameter(format!(
"FAST threshold must be finite and strictly positive, got {threshold}"
)));
}
if !(9..=16).contains(&arc_length) {
return Err(Error::InvalidParameter(format!(
"FAST arc_length must satisfy 9 <= n <= 16, got {arc_length}"
)));
}
Ok(Self {
threshold,
arc_length,
})
}
#[must_use]
pub const fn threshold(self) -> f32 {
self.threshold
}
#[must_use]
pub const fn arc_length(self) -> usize {
self.arc_length
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FastParams {
test: SegmentTest,
nms_radius: NmsRadius,
}
impl FastParams {
#[must_use]
pub const fn new(test: SegmentTest, nms_radius: NmsRadius) -> Self {
Self { test, nms_radius }
}
#[must_use]
pub const fn test(self) -> SegmentTest {
self.test
}
#[must_use]
pub const fn nms_radius(self) -> NmsRadius {
self.nms_radius
}
}
#[must_use]
pub fn fast_score_at<I, P, Acc, B>(
image: &I,
x: usize,
y: usize,
test: SegmentTest,
border: &B,
) -> Option<f32>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: SingleChannel,
f64: From<Acc::Channel>,
B: BorderPolicy<I>,
{
let region = scored_region(image, border);
if x < region.left() || x >= region.right() || y < region.top() || y >= region.bottom() {
return None;
}
Some(score_in_region(image, x, y, test, border))
}
#[must_use]
pub fn fast_score_map<I, P, Acc, B>(image: &I, test: SegmentTest, border: &B) -> Image<MonoF32>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: SingleChannel,
f64: From<Acc::Channel>,
B: BorderPolicy<I>,
{
let region = scored_region(image, border);
let (width, height) = (image.width(), image.height());
let mut out = Image::fill(width, height, MonoF32::new(0.0));
let hot_left = region.left().max(FAST_RING_RADIUS).min(region.right());
let hot_right = region
.right()
.min(width.saturating_sub(FAST_RING_RADIUS))
.max(hot_left);
for y in region.top()..region.bottom() {
let rows_fit = y >= FAST_RING_RADIUS && y + FAST_RING_RADIUS < height;
let (hot_left, hot_right) = if rows_fit {
(hot_left, hot_right)
} else {
(region.left(), region.left())
};
let rows: Option<[&[P]; FOOTPRINT]> = rows_fit
.then(|| core::array::from_fn(|offset| image.row(y - FAST_RING_RADIUS + offset)));
let row = out.row_mut(y);
let cold = |slots: &mut [MonoF32], from: usize| {
for (offset, slot) in slots.iter_mut().enumerate() {
*slot = MonoF32::new(score_in_region(image, from + offset, y, test, border));
}
};
cold(&mut row[region.left()..hot_left], region.left());
cold(&mut row[hot_right..region.right()], hot_right);
if let Some(rows) = rows {
for (offset, slot) in row[hot_left..hot_right].iter_mut().enumerate() {
*slot = MonoF32::new(score_interior(&rows, hot_left + offset, test));
}
}
}
out
}
#[must_use]
pub fn fast<I, P, Acc, B>(image: &I, params: FastParams, border: &B) -> Vec<Corner>
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: SingleChannel,
f64: From<Acc::Channel>,
B: BorderPolicy<I>,
{
let scores = fast_score_map(image, params.test(), border);
score_peaks(
&scores,
params.test().threshold(),
params.nms_radius().get(),
)
}
#[must_use]
pub fn fast_in_level<L, P, Acc, B>(level: &L, params: FastParams, border: &B) -> Vec<Corner>
where
L: Decimated<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: SingleChannel,
f64: From<Acc::Channel>,
B: BorderPolicy<Image<P>>,
{
let scores = fast_score_map(level.as_image(), params.test(), border);
lift_peaks(
level,
scan_score_peaks(
&scores,
params.test().threshold(),
params.nms_radius().get(),
),
)
}
fn score_peaks(scores: &Image<MonoF32>, threshold: f32, radius: usize) -> Vec<Corner> {
corner_peaks(scores, threshold, radius)
}
fn scan_score_peaks(
scores: &Image<MonoF32>,
threshold: f32,
radius: usize,
) -> Vec<(CoordinateF64, f32)> {
scan_peaks(scores, threshold, radius)
}
fn scored_region<I, B>(image: &I, border: &B) -> Rectangle
where
I: ImageView,
I::Pixel: Copy,
B: BorderPolicy<I>,
{
let size = image.size();
let region = border.output_region(
size,
Size::new(FOOTPRINT, FOOTPRINT),
(FAST_RING_RADIUS, FAST_RING_RADIUS),
);
let left = region.left().min(size.width);
let top = region.top().min(size.height);
Rectangle::new(
(left, top),
(
region.right().min(size.width) - left,
region.bottom().min(size.height) - top,
),
)
}
fn score_in_region<I, P, Acc, B>(
image: &I,
x: usize,
y: usize,
test: SegmentTest,
border: &B,
) -> f32
where
I: RasterImage<Pixel = P>,
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: SingleChannel,
f64: From<Acc::Channel>,
B: BorderPolicy<I>,
{
let (w, h) = (image.width() as isize, image.height() as isize);
let sample = |index: usize| {
let offset = FAST_RING[index];
let (nx, ny) = (
x as isize + offset.dx as isize,
y as isize + offset.dy as isize,
);
let pixel = if (0..w).contains(&nx) && (0..h).contains(&ny) {
image.row(ny as usize)[nx as usize]
} else {
border.pixel_at(image, nx, ny)
};
intensity::<P, Acc>(pixel)
};
score_ring(intensity::<P, Acc>(image.row(y)[x]), test, sample)
}
fn score_interior<P, Acc>(rows: &[&[P]; FOOTPRINT], x: usize, test: SegmentTest) -> f32
where
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: SingleChannel,
f64: From<Acc::Channel>,
{
let sample = |index: usize| {
let (row, dx) = FAST_RING_ROWS[index];
intensity::<P, Acc>(rows[row][x.wrapping_add_signed(dx)])
};
score_ring(intensity::<P, Acc>(rows[FAST_RING_RADIUS][x]), test, sample)
}
#[inline(always)]
fn intensity<P, Acc>(pixel: P) -> f64
where
P: Copy + LinearPixel<f32, Accumulator = Acc>,
Acc: SingleChannel,
f64: From<Acc::Channel>,
{
f64::from(pixel.to_accumulator().channel(0))
}
#[inline(always)]
fn score_ring(centre: f64, test: SegmentTest, sample: impl Fn(usize) -> f64) -> f32 {
let threshold = <f64 as From<f32>>::from(test.threshold());
let mut ring = [0.0f64; 16];
for index in CARDINALS {
ring[index] = sample(index);
}
if !cardinals_admit(centre, &ring, test.arc_length(), threshold) {
return 0.0;
}
for index in NON_CARDINALS {
ring[index] = sample(index);
}
let score = segment_score(centre, &ring, test.arc_length());
if score >= threshold {
score as f32
} else {
0.0
}
}
const CARDINALS: [usize; 4] = [0, 4, 8, 12];
const NON_CARDINALS: [usize; 12] = [1, 2, 3, 5, 6, 7, 9, 10, 11, 13, 14, 15];
fn cardinals_admit(centre: f64, ring: &[f64; 16], arc_length: usize, threshold: f64) -> bool {
let needed = arc_length / 4;
let (mut bright, mut dark) = (0usize, 0usize);
for index in CARDINALS {
let difference = ring[index] - centre;
if difference >= threshold {
bright += 1;
} else if -difference >= threshold {
dark += 1;
}
}
bright >= needed || dark >= needed
}
fn segment_score(centre: f64, ring: &[f64; 16], arc_length: usize) -> f64 {
let mut best = f64::NEG_INFINITY;
for start in 0..16 {
let (mut bright, mut dark) = (f64::INFINITY, f64::INFINITY);
let mut usable = true;
for k in 0..arc_length {
let difference = ring[(start + k) % 16] - centre;
if difference.is_nan() {
usable = false;
break;
}
if difference < bright {
bright = difference;
}
if -difference < dark {
dark = -difference;
}
}
if !usable {
continue;
}
if bright > best {
best = bright;
}
if dark > best {
best = dark;
}
}
if best > 0.0 { best } else { 0.0 }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Coordinate;
use crate::border::{Clamp, Constant, Mirror, Skip};
use crate::features::{HasPosition, HasResponse, retain_top_n};
use crate::image::{OriginOffset, PlacedImage, PlacedPyramid, Pyramid};
use crate::pixel::{Mono8, Mono16, 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 dot(n: usize, at: (usize, usize)) -> Image<MonoF32> {
Image::generate(n, n, |x, y| {
MonoF32::new(if (x, y) == at { 1.0 } else { 0.0 })
})
}
fn square_corner_pixels(lo: usize, hi: usize) -> [(f64, f64); 4] {
let (a, b) = (lo as f64, (hi - 1) as f64);
[(a, a), (b, a), (a, b), (b, b)]
}
fn assert_one_per_corner(corners: &[Corner], truth: [(f64, f64); 4], tolerance: f64) {
assert_eq!(corners.len(), 4, "{corners:?}");
for &(tx, ty) in &truth {
let nearest = corners
.iter()
.map(|c| {
let p = c.position();
((p.x - tx).powi(2) + (p.y - ty).powi(2)).sqrt()
})
.fold(f64::INFINITY, f64::min);
assert!(
nearest <= tolerance,
"({tx}, {ty}) unmatched in {corners:?}"
);
}
}
fn positions(corners: &[Corner]) -> Vec<(f64, f64)> {
corners
.iter()
.map(|c| (c.position().x, c.position().y))
.collect()
}
fn arc_ring(bright: usize, contrast: f64) -> [f64; 16] {
let mut ring = [0.0; 16];
for slot in ring.iter_mut().take(bright) {
*slot = contrast;
}
ring
}
#[test]
fn the_ring_is_a_closed_bresenham_circle_of_radius_three() {
assert_eq!(FAST_RING.len(), 16);
assert_eq!(FAST_RING_RADIUS, 3);
for (i, &offset) in FAST_RING.iter().enumerate() {
let squared = offset.dx * offset.dx + offset.dy * offset.dy;
assert!((8..=10).contains(&squared), "offset {i} is off the ring");
let next = FAST_RING[(i + 1) % 16];
assert!(
(next.dx - offset.dx).abs() <= 1 && (next.dy - offset.dy).abs() <= 1,
"offsets {i} and {} are not adjacent",
(i + 1) % 16
);
}
}
#[test]
fn the_ring_is_symmetric_under_a_quarter_turn() {
for (i, &offset) in FAST_RING.iter().enumerate() {
assert_eq!(
FAST_RING[(i + 4) % 16],
Offset::new(-offset.dy, offset.dx),
"at index {i}"
);
}
}
#[test]
fn segment_test_accepts_the_documented_range() {
for n in 9..=16 {
assert_eq!(SegmentTest::try_new(0.1, n).unwrap().arc_length(), n);
}
const FAST9: SegmentTest = SegmentTest::new(0.08, 9).unwrap();
assert_eq!(FAST9.threshold(), 0.08);
assert_eq!(FAST9.arc_length(), 9);
}
#[test]
fn segment_test_rejects_a_threshold_that_admits_everything() {
for threshold in [0.0, -0.1, f32::NAN, f32::INFINITY] {
match SegmentTest::try_new(threshold, 9).unwrap_err() {
Error::InvalidParameter(reason) => assert!(
reason.contains("threshold"),
"reason {reason:?} does not mention the threshold"
),
other => panic!("expected InvalidParameter, got {other:?}"),
}
}
}
#[test]
fn segment_test_rejects_an_arc_that_admits_edges_or_nothing() {
for n in [0, 1, 8, 17, 100] {
match SegmentTest::try_new(0.1, n).unwrap_err() {
Error::InvalidParameter(reason) => assert!(
reason.contains("arc_length"),
"reason {reason:?} does not mention arc_length"
),
other => panic!("expected InvalidParameter, got {other:?}"),
}
}
}
#[test]
fn segment_test_new_rejects_a_zero_threshold() {
assert!(SegmentTest::new(0.0, 9).is_none());
}
#[test]
fn segment_test_new_rejects_an_edge_admitting_arc() {
assert!(SegmentTest::new(0.1, 8).is_none());
assert!(SegmentTest::new(0.1, 17).is_none());
}
#[test]
fn fast_params_round_trip() {
const PARAMS: FastParams = FastParams::new(
SegmentTest::new(0.08, 12).unwrap(),
NmsRadius::new(4).unwrap(),
);
assert_eq!(PARAMS.nms_radius().get(), 4);
assert_eq!(PARAMS.test(), SegmentTest::new(0.08, 12).unwrap());
}
#[test]
fn nms_radius_carries_the_at_least_one_invariant() {
assert!(NmsRadius::new(0).is_none());
match NmsRadius::try_new(0).unwrap_err() {
Error::InvalidParameter(reason) => assert!(reason.contains("radius")),
other => panic!("expected InvalidParameter, got {other:?}"),
}
assert_eq!(NmsRadius::try_new(2).unwrap().get(), 2);
}
#[test]
fn a_flat_ring_scores_zero() {
assert_eq!(segment_score(0.5, &[0.5; 16], 9), 0.0);
assert_eq!(segment_score(0.0, &[0.0; 16], 16), 0.0);
}
#[test]
fn an_arc_one_short_of_the_requirement_scores_zero() {
assert_eq!(segment_score(0.0, &arc_ring(8, 1.0), 9), 0.0);
assert!(segment_score(0.0, &arc_ring(9, 1.0), 9) > 0.0);
}
#[test]
fn the_score_is_the_arc_weakest_link() {
let mut ring = arc_ring(9, 1.0);
ring[4] = 0.4;
assert!((segment_score(0.0, &ring, 9) - 0.4).abs() < 1e-12);
}
#[test]
fn an_arc_may_wrap_around_index_zero() {
let mut ring = [0.0; 16];
for i in (12..16).chain(0..5) {
ring[i] = 1.0;
}
assert!((segment_score(0.0, &ring, 9) - 1.0).abs() < 1e-12);
}
#[test]
fn the_test_is_symmetric_in_brightness() {
let bright = arc_ring(9, 1.0);
let dark: [f64; 16] = core::array::from_fn(|i| -bright[i]);
assert_eq!(segment_score(0.0, &bright, 9), segment_score(0.0, &dark, 9));
}
#[test]
fn the_score_is_exactly_the_largest_passing_threshold() {
let mut ring = arc_ring(11, 0.9);
ring[2] = 0.62;
ring[9] = 0.4; let score = segment_score(0.0, &ring, 9);
let passes = |t: f64| {
(0..16).any(|start| {
(0..9).all(|k| ring[(start + k) % 16] >= t)
|| (0..9).all(|k| ring[(start + k) % 16] <= -t)
})
};
for step in 1..200 {
let t = f64::from(step) * 0.005;
assert_eq!(passes(t), score >= t, "at t = {t}, score = {score}");
}
}
#[test]
fn a_longer_arc_requirement_never_scores_higher() {
let mut ring = arc_ring(12, 1.0);
ring[10] = 0.3;
let (nine, twelve) = (segment_score(0.0, &ring, 9), segment_score(0.0, &ring, 12));
assert!(nine >= twelve, "{nine} < {twelve}");
assert!((nine - 1.0).abs() < 1e-12);
assert!((twelve - 0.3).abs() < 1e-12);
}
#[test]
fn a_full_ring_requirement_needs_every_pixel() {
let mut ring = [1.0; 16];
assert!((segment_score(0.0, &ring, 16) - 1.0).abs() < 1e-12);
ring[7] = 0.0;
assert_eq!(segment_score(0.0, &ring, 16), 0.0);
}
#[test]
fn a_nan_sample_poisons_only_the_arcs_through_it() {
let mut ring = [1.0; 16];
ring[0] = f64::NAN;
assert!((segment_score(0.0, &ring, 9) - 1.0).abs() < 1e-12);
assert_eq!(segment_score(0.0, &ring, 16), 0.0);
assert_eq!(segment_score(f64::NAN, &[1.0; 16], 9), 0.0);
}
#[test]
fn the_cardinal_bound_holds_for_every_arc_placement() {
for arc_length in 9..=16usize {
for start in 0..16 {
let covered = (0..arc_length)
.filter(|k| CARDINALS.contains(&((start + k) % 16)))
.count();
assert!(
covered >= arc_length / 4,
"n = {arc_length}, start = {start}: only {covered} cardinals"
);
}
}
}
#[test]
fn the_early_rejection_never_changes_an_answer() {
let image: Image<MonoF32> = Image::generate(64, 64, |x, y| {
let checker = if (x / 7 + y / 5) % 2 == 0 { 0.2 } else { 0.8 };
let ripple = ((x * 5 + y * 3) % 11) as f32 * 0.01;
MonoF32::new(checker + ripple)
});
for arc_length in 9usize..=16 {
for &threshold in &[0.05f32, 0.2, 0.5] {
let test = SegmentTest::new(threshold, arc_length).unwrap();
let scores = fast_score_map(&image, test, &Skip);
for y in FAST_RING_RADIUS..64 - FAST_RING_RADIUS {
for x in FAST_RING_RADIUS..64 - FAST_RING_RADIUS {
let ring: [f64; 16] = core::array::from_fn(|i| {
let at = Coordinate::new(x, y)
.checked_add(FAST_RING[i])
.expect("the ring fits inside the scored margin");
f64::from(image.pixel_at(at.x, at.y).value())
});
let plain = segment_score(
f64::from(image.pixel_at(x, y).value()),
&ring,
arc_length,
);
let expected = if plain >= f64::from(threshold) {
plain as f32
} else {
0.0
};
assert_eq!(
scores.pixel_at(x, y).value(),
expected,
"n = {arc_length}, t = {threshold}, at ({x}, {y})"
);
}
}
}
}
}
#[test]
fn the_interior_and_boundary_paths_agree() {
let image: Image<MonoF32> = Image::generate(24, 20, |x, y| {
let checker = if (x / 5 + y / 3) % 2 == 0 { 0.15 } else { 0.85 };
MonoF32::new(checker + ((x * 3 + y * 7) % 9) as f32 * 0.01)
});
for arc_length in [9usize, 12, 16] {
let test = SegmentTest::new(0.05, arc_length).unwrap();
let clamped = fast_score_map(&image, test, &Clamp);
let skipped = fast_score_map(&image, test, &Skip);
for y in 0..image.height() {
for x in 0..image.width() {
assert_eq!(
clamped.pixel_at(x, y).value(),
fast_score_at(&image, x, y, test, &Clamp).unwrap(),
"clamp, n = {arc_length}, at ({x}, {y})"
);
let expected = fast_score_at(&image, x, y, test, &Skip).unwrap_or(0.0);
assert_eq!(
skipped.pixel_at(x, y).value(),
expected,
"skip, n = {arc_length}, at ({x}, {y})"
);
}
}
}
}
#[test]
fn an_image_narrower_or_shorter_than_the_ring_has_no_hot_span() {
for (w, h) in [(2usize, 12usize), (12, 2), (1, 1), (7, 7), (6, 40)] {
let image: Image<MonoF32> =
Image::generate(w, h, |x, y| MonoF32::new(((x * 3 + y) % 5) as f32 * 0.2));
for arc_length in [9usize, 16] {
let test = SegmentTest::new(0.05, arc_length).unwrap();
for &clamped in &[true, false] {
let scores = if clamped {
fast_score_map(&image, test, &Clamp)
} else {
fast_score_map(&image, test, &Skip)
};
assert_eq!(scores.size(), image.size(), "{w}x{h}");
for y in 0..h {
for x in 0..w {
let expected = if clamped {
fast_score_at(&image, x, y, test, &Clamp).unwrap_or(0.0)
} else {
fast_score_at(&image, x, y, test, &Skip).unwrap_or(0.0)
};
assert_eq!(
scores.pixel_at(x, y).value(),
expected,
"{w}x{h}, clamp = {clamped}, n = {arc_length}, at ({x}, {y})"
);
}
}
}
}
}
}
#[test]
fn a_sub_threshold_corner_is_reported_as_zero_not_as_its_margin() {
let faint: Image<MonoF32> = Image::generate(32, 32, |x, y| {
MonoF32::new(if x >= 16 && y >= 16 { 0.1 } else { 0.0 })
});
let coarse = fast_score_map(&faint, SegmentTest::new(0.3, 9).unwrap(), &Skip);
assert_eq!(coarse.pixel_at(16, 16).value(), 0.0);
let fine = fast_score_map(&faint, SegmentTest::new(0.05, 9).unwrap(), &Skip);
assert!((fine.pixel_at(16, 16).value() - 0.1).abs() < 1e-6);
}
#[test]
fn raising_the_threshold_on_an_existing_map_is_exact() {
let image = square(32, 10, 22);
let scores = fast_score_map(&image, SegmentTest::new(0.05, 9).unwrap(), &Skip);
for step in 1..20 {
let t = 0.05 * step as f32;
let from_map = corner_peaks(&scores, t, 3);
let params =
FastParams::new(SegmentTest::new(t, 9).unwrap(), NmsRadius::new(3).unwrap());
let rebuilt = fast(&image, params, &Skip);
assert_eq!(from_map, rebuilt, "at t = {t}");
}
}
#[test]
fn a_quadrant_corner_scores_its_full_contrast() {
let image: Image<MonoF32> = Image::generate(24, 24, |x, y| {
MonoF32::new(if x >= 8 && y >= 8 { 1.0 } else { 0.0 })
});
let test = SegmentTest::new(0.1, 9).unwrap();
let score = fast_score_at(&image, 8, 8, test, &Skip).unwrap();
assert!((score - 1.0).abs() < 1e-6, "{score}");
}
#[test]
fn a_straight_edge_scores_nothing_anywhere() {
let image: Image<MonoF32> =
Image::generate(24, 24, |x, _| MonoF32::new(if x < 12 { 0.0 } else { 1.0 }));
let scores = fast_score_map(&image, SegmentTest::new(0.05, 9).unwrap(), &Skip);
for y in 0..24 {
for x in 0..24 {
assert_eq!(scores.pixel_at(x, y).value(), 0.0, "at ({x}, {y})");
}
}
}
#[test]
fn a_diagonal_edge_scores_nothing_either() {
let image: Image<MonoF32> =
Image::generate(32, 32, |x, y| MonoF32::new(if x < y { 0.0 } else { 1.0 }));
let scores = fast_score_map(&image, SegmentTest::new(0.05, 9).unwrap(), &Skip);
let peak = (3..29)
.flat_map(|y| (3..29).map(move |x| (x, y)))
.map(|(x, y)| scores.pixel_at(x, y).value())
.fold(0.0f32, f32::max);
assert_eq!(peak, 0.0);
}
#[test]
fn a_flat_field_scores_nothing() {
let image = Image::fill(16, 16, MonoF32::new(0.5));
let scores = fast_score_map(&image, SegmentTest::new(0.01, 9).unwrap(), &Skip);
assert_eq!(scores.pixel_at(8, 8).value(), 0.0);
}
#[test]
fn the_score_map_keeps_the_input_size_under_every_policy() {
let image = square(24, 8, 16);
let test = SegmentTest::new(0.1, 9).unwrap();
for size in [
fast_score_map(&image, test, &Skip).size(),
fast_score_map(&image, test, &Clamp).size(),
fast_score_map(&image, test, &Mirror).size(),
fast_score_map(&image, test, &Constant(MonoF32::new(0.0))).size(),
] {
assert_eq!(size, image.size());
}
}
#[test]
fn skip_declines_the_ring_radius_border_and_clamp_does_not() {
let image: Image<MonoF32> = Image::generate(24, 24, |x, y| {
MonoF32::new(if x >= 2 && y >= 2 { 1.0 } else { 0.0 })
});
let test = SegmentTest::new(0.1, 9).unwrap();
for d in 0..FAST_RING_RADIUS {
assert_eq!(fast_score_at(&image, d, d, test, &Skip), None, "at {d}");
assert!(fast_score_at(&image, d, d, test, &Clamp).is_some());
}
assert!(fast_score_at(&image, 3, 3, test, &Skip).is_some());
assert_eq!(
fast_score_map(&image, test, &Skip).pixel_at(2, 2).value(),
0.0
);
assert!(fast_score_map(&image, test, &Clamp).pixel_at(2, 2).value() > 0.5);
}
#[test]
fn fast_score_at_declines_positions_outside_the_image() {
let image = square(24, 8, 16);
let test = SegmentTest::new(0.1, 9).unwrap();
assert_eq!(fast_score_at(&image, 24, 0, test, &Clamp), None);
assert_eq!(fast_score_at(&image, 0, 24, test, &Clamp), None);
assert_eq!(fast_score_at(&image, 999, 999, test, &Skip), None);
}
#[test]
fn an_image_smaller_than_the_ring_yields_an_empty_skip_region() {
let image = Image::fill(5, 5, MonoF32::new(0.5));
let test = SegmentTest::new(0.1, 9).unwrap();
let scores = fast_score_map(&image, test, &Skip);
assert_eq!(scores.size(), image.size());
assert_eq!(fast_score_at(&image, 2, 2, test, &Skip), None);
assert_eq!(fast_score_at(&image, 2, 2, test, &Clamp), Some(0.0));
}
#[test]
fn the_score_map_agrees_with_the_single_pixel_score() {
let image = square(24, 8, 16);
let test = SegmentTest::new(0.1, 9).unwrap();
let scores = fast_score_map(&image, test, &Skip);
for y in 0..24 {
for x in 0..24 {
let expected = fast_score_at(&image, x, y, test, &Skip).unwrap_or(0.0);
assert_eq!(scores.pixel_at(x, y).value(), expected, "at ({x}, {y})");
}
}
}
#[test]
fn a_square_has_four_corners() {
let image = square(32, 10, 22);
let params = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(3).unwrap(),
);
let corners = fast(&image, params, &Skip);
assert_one_per_corner(&corners, square_corner_pixels(10, 22), 2.0);
assert!(corners.iter().all(|c| (c.response() - 1.0).abs() < 1e-6));
}
#[test]
fn a_right_angle_saturates_into_a_tied_cluster() {
let image = square(32, 10, 22);
let scores = fast_score_map(&image, SegmentTest::new(0.1, 9).unwrap(), &Skip);
let cluster: Vec<(usize, usize)> = (9..14)
.flat_map(|y| (9..14).map(move |x| (x, y)))
.filter(|&(x, y)| scores.pixel_at(x, y).value() > 0.0)
.collect();
assert_eq!(
cluster,
[(10, 10), (11, 10), (12, 10), (10, 11), (11, 11), (10, 12)]
);
assert!(
cluster
.iter()
.all(|&(x, y)| scores.pixel_at(x, y).value() == 1.0)
);
let params = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(3).unwrap(),
);
let corners = fast(&image, params, &Skip);
assert_eq!(
positions(&corners),
[(10.0, 10.0), (19.0, 10.0), (10.0, 19.0), (21.0, 19.0)]
);
}
#[test]
fn the_reported_pixels_do_not_move_with_the_threshold() {
let image = square(32, 10, 22);
let reference = positions(&fast(
&image,
FastParams::new(
SegmentTest::new(0.05, 9).unwrap(),
NmsRadius::new(3).unwrap(),
),
&Skip,
));
for threshold in [0.2f32, 0.5, 0.9, 1.0] {
let params = FastParams::new(
SegmentTest::new(threshold, 9).unwrap(),
NmsRadius::new(3).unwrap(),
);
assert_eq!(
positions(&fast(&image, params, &Skip)),
reference,
"t = {threshold}"
);
}
}
#[test]
fn raising_the_threshold_filters_detections_without_moving_the_survivors() {
let image: Image<MonoF32> = Image::generate(44, 24, |x, y| {
let bright = (4..16).contains(&x) && (4..16).contains(&y);
let faint = (26..38).contains(&x) && (4..16).contains(&y);
MonoF32::new(if bright {
1.0
} else if faint {
0.3
} else {
0.0
})
});
let both = positions(&fast(
&image,
FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(3).unwrap(),
),
&Skip,
));
assert_eq!(both.len(), 8, "{both:?}");
let bright_only = positions(&fast(
&image,
FastParams::new(
SegmentTest::new(0.5, 9).unwrap(),
NmsRadius::new(3).unwrap(),
),
&Skip,
));
assert_eq!(bright_only.len(), 4, "{bright_only:?}");
for p in &bright_only {
assert!(both.contains(p), "{p:?} moved when the threshold rose");
}
}
#[test]
fn a_dot_is_a_corner_at_every_arc_length() {
let image = dot(16, (8, 8));
for n in 9..=16 {
let params = FastParams::new(
SegmentTest::new(0.1, n).unwrap(),
NmsRadius::new(3).unwrap(),
);
let corners = fast(&image, params, &Skip);
assert_eq!(positions(&corners), [(8.0, 8.0)], "n = {n}");
assert_eq!(corners[0].response(), 1.0);
}
}
#[test]
fn a_longer_arc_rejects_a_right_angle_entirely() {
let image = square(32, 10, 22);
for n in [9, 10, 11] {
let params = FastParams::new(
SegmentTest::new(0.1, n).unwrap(),
NmsRadius::new(3).unwrap(),
);
assert_eq!(fast(&image, params, &Skip).len(), 4, "n = {n}");
}
for n in [12, 16] {
let params = FastParams::new(
SegmentTest::new(0.1, n).unwrap(),
NmsRadius::new(3).unwrap(),
);
assert!(fast(&image, params, &Skip).is_empty(), "n = {n}");
}
}
#[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 params = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(4).unwrap(),
);
assert_eq!(positions(&fast(&image, params, &Skip)), [(12.0, 12.0)]);
}
#[test]
fn a_dark_square_on_a_light_field_has_four_corners_too() {
let bright = square(32, 10, 22);
let dark: Image<MonoF32> = Image::generate(32, 32, |x, y| {
MonoF32::new(1.0 - bright.pixel_at(x, y).value())
});
let params = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(3).unwrap(),
);
assert_eq!(
positions(&fast(&dark, params, &Skip)),
positions(&fast(&bright, params, &Skip))
);
}
#[test]
fn a_flat_field_and_a_straight_edge_have_no_corners() {
let params = FastParams::new(
SegmentTest::new(0.01, 9).unwrap(),
NmsRadius::new(2).unwrap(),
);
assert!(fast(&Image::fill(16, 16, MonoF32::new(0.5)), params, &Skip).is_empty());
let edge: Image<MonoF32> =
Image::generate(24, 24, |x, _| MonoF32::new(if x < 12 { 0.0 } else { 1.0 }));
assert!(fast(&edge, params, &Skip).is_empty());
}
#[test]
fn a_higher_threshold_only_ever_removes_corners() {
let image: Image<MonoF32> = Image::generate(60, 24, |x, y| {
let inside = |x0: usize| (x0..x0 + 8).contains(&x) && (8..16).contains(&y);
MonoF32::new(if inside(4) {
1.0
} else if inside(24) {
0.5
} else if inside(44) {
0.2
} else {
0.0
})
});
let count = |t: f32| {
let params =
FastParams::new(SegmentTest::new(t, 9).unwrap(), NmsRadius::new(3).unwrap());
fast(&image, params, &Skip).len()
};
assert_eq!(count(0.1), 3);
assert_eq!(count(0.3), 2);
assert_eq!(count(0.6), 1);
assert_eq!(count(1.1), 0);
}
#[test]
fn the_score_is_invariant_under_a_quarter_turn() {
let image = square(24, 7, 17);
let rotated: Image<MonoF32> = rotate_90(&image);
let test = SegmentTest::new(0.1, 9).unwrap();
let scores = fast_score_map(&image, test, &Skip);
let rotated_scores = fast_score_map(&rotated, test, &Skip);
for y in 0..24 {
for x in 0..24 {
assert_eq!(
scores.pixel_at(x, y).value(),
rotated_scores.pixel_at(23 - y, x).value(),
"at ({x}, {y})"
);
}
}
}
#[test]
fn the_suppression_radius_merges_clusters_it_can_reach() {
let image = square(24, 8, 16);
let at = |r: usize| {
let params = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(r).unwrap(),
);
fast(&image, params, &Skip)
};
assert_eq!(at(2).len(), 4);
assert_eq!(positions(&at(3)), [(8.0, 8.0)]);
let wider = square(32, 10, 22);
let params = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(3).unwrap(),
);
assert_eq!(fast(&wider, params, &Skip).len(), 4);
}
#[test]
fn fast_is_the_documented_composition() {
let image = square(24, 8, 16);
let params = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(3).unwrap(),
);
let staged = {
let scores = fast_score_map(&image, params.test(), &Skip);
corner_peaks(
&scores,
params.test().threshold(),
params.nms_radius().get(),
)
};
assert_eq!(fast(&image, params, &Skip), staged);
assert!(!staged.is_empty());
}
#[test]
fn detection_and_the_score_agree_on_the_threshold() {
let image = square(24, 8, 16);
let scores = fast_score_map(&image, SegmentTest::new(0.05, 9).unwrap(), &Skip);
for step in 1..20 {
let t = 0.05 * step as f32;
let params =
FastParams::new(SegmentTest::new(t, 9).unwrap(), NmsRadius::new(3).unwrap());
let detected = fast(&image, params, &Skip);
let above = (0..24)
.flat_map(|y| (0..24).map(move |x| (x, y)))
.filter(|&(x, y)| scores.pixel_at(x, y).value() >= t)
.count();
assert_eq!(detected.is_empty(), above == 0, "at t = {t}");
assert!(detected.len() <= above, "at t = {t}");
}
}
#[test]
fn accepts_integer_input_with_a_threshold_in_grey_levels() {
let image: Image<Mono8> = Image::generate(32, 32, |x, y| {
let inside = (10..22).contains(&x) && (10..22).contains(&y);
Mono8::new(if inside { 255 } else { 0 })
});
let params = FastParams::new(
SegmentTest::new(20.0, 9).unwrap(),
NmsRadius::new(3).unwrap(),
);
let corners = fast(&image, params, &Skip);
assert_one_per_corner(&corners, square_corner_pixels(10, 22), 2.0);
assert!(corners.iter().all(|c| c.response() == 255.0));
}
#[test]
fn accepts_sixteen_bit_input() {
let image: Image<Mono16> = Image::generate(32, 32, |x, y| {
let inside = (10..22).contains(&x) && (10..22).contains(&y);
Mono16::new(if inside { 65535 } else { 0 })
});
let corners = fast(
&image,
FastParams::new(
SegmentTest::new(5000.0, 9).unwrap(),
NmsRadius::new(3).unwrap(),
),
&Skip,
);
assert_one_per_corner(&corners, square_corner_pixels(10, 22), 2.0);
assert!(corners.iter().all(|c| c.response() == 65535.0));
}
#[test]
fn accepts_f64_float_input() {
let image: Image<MonoF64> = Image::generate(32, 32, |x, y| {
let inside = (10..22).contains(&x) && (10..22).contains(&y);
MonoF64::new(if inside { 1.0 } else { 0.0 })
});
let params = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(3).unwrap(),
);
assert_one_per_corner(
&fast(&image, params, &Skip),
square_corner_pixels(10, 22),
2.0,
);
}
#[test]
fn the_detector_is_invariant_to_a_constant_brightness_shift() {
let base = square(32, 10, 22);
let lifted: Image<MonoF32> = Image::generate(32, 32, |x, y| {
MonoF32::new(base.pixel_at(x, y).value() * 0.5 + 0.25)
});
let test = SegmentTest::new(0.1, 9).unwrap();
let params = FastParams::new(test, NmsRadius::new(3).unwrap());
assert_eq!(
positions(&fast(&base, params, &Skip)),
positions(&fast(&lifted, params, &Skip))
);
for corner in fast(&lifted, params, &Skip) {
assert!((corner.response() - 0.5).abs() < 1e-6);
}
}
#[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 = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(3).unwrap(),
);
assert_eq!(
fast_in_level(pyramid.finest(), params, &Skip),
fast(&image, params, &Skip)
);
}
#[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 params = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(2).unwrap(),
);
let corners = fast_in_level(pyramid.level(1), params, &Skip);
assert_eq!(corners.len(), 4, "{corners:?}");
for corner in &corners {
let p = corner.position();
assert!(p.x % 2.0 == 0.0 && p.y % 2.0 == 0.0, "{corner:?}");
assert!((14.0..34.0).contains(&p.x), "{corner:?}");
}
}
#[test]
fn a_level_with_an_origin_offset_lifts_through_it() {
let base = square(48, 16, 32);
let coarse = pyr_down(&base);
let params = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(2).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 = fast_in_level(&unshifted, params, &Skip);
let b = fast_in_level(&shifted, params, &Skip);
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);
}
}
#[test]
fn a_pyramid_can_be_swept_level_by_level() {
let base = square(48, 12, 36);
let pyramid: PlacedPyramid<MonoF32> = Gaussian.build(&base, 2);
let params = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(2).unwrap(),
);
let mut corners: Vec<Corner> = pyramid
.iter()
.flat_map(|level| fast_in_level(level, params, &Skip))
.collect();
assert_eq!(corners.len(), 8, "{corners:?}");
retain_top_n(&mut corners, 4);
assert!(corners.iter().all(|c| c.response() > 0.5));
}
#[test]
fn fast_and_the_structure_tensor_find_the_same_square_corners() {
use super::super::{CornerParams, ShiTomasi, detect_corners};
let image = square(32, 10, 22);
let truth = square_corner_pixels(10, 22);
let tensor = detect_corners(
&image,
ShiTomasi,
CornerParams::new(sigma!(1.0), 0.5, NmsRadius::new(3).unwrap()).unwrap(),
);
let params = FastParams::new(
SegmentTest::new(0.1, 9).unwrap(),
NmsRadius::new(3).unwrap(),
);
let segment = fast(&image, params, &Skip);
assert_one_per_corner(&tensor, truth, 2.0);
assert_one_per_corner(&segment, truth, 2.0);
assert_eq!(tensor[0].position(), segment[0].position());
assert!(tensor[0].response() != segment[0].response());
}
}