From c23225c27a89dfd463424a3cee00713de1b3e8cc Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Fri, 22 May 2026 23:05:51 +0200 Subject: [PATCH] feat(features2d): add FAST keypoint detection algorithm - Implement Bresenham circle patterns (FAST-5, FAST-7, FAST-9) - Port Edward Rosten's optimized C++ corner scoring math - Add early rejection filters and contiguous segment checks - Add 2-pass detection grid with thread-safe 8-neighborhood NMS - Implement extensive validation and synthetic corner test suite Ref: #58, Parent: #65 --- src/features2d/fast.rs | 481 +++++++++++++++++++++++++++++++++++++++- src/features2d/tests.rs | 114 ++++++++++ 2 files changed, 591 insertions(+), 4 deletions(-) diff --git a/src/features2d/fast.rs b/src/features2d/fast.rs index cd5bbbb..f172678 100644 --- a/src/features2d/fast.rs +++ b/src/features2d/fast.rs @@ -35,9 +35,13 @@ */ use super::keypoint::KeyPoint; -use crate::core::error::Result; +use crate::core::error::{PureCvError, Result}; +use crate::core::types::Point2f; use crate::core::Matrix; +#[cfg(feature = "parallel")] +use rayon::prelude::*; + /// The type of FAST detector. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FastType { @@ -49,6 +53,51 @@ pub enum FastType { Type9_16, } +static OFFSETS_16: [(i32, i32); 16] = [ + (0, 3), + (1, 3), + (2, 2), + (3, 1), + (3, 0), + (3, -1), + (2, -2), + (1, -3), + (0, -3), + (-1, -3), + (-2, -2), + (-3, -1), + (-3, 0), + (-3, 1), + (-2, 2), + (-1, 3), +]; + +static OFFSETS_12: [(i32, i32); 12] = [ + (0, 2), + (1, 2), + (2, 1), + (2, 0), + (2, -1), + (1, -2), + (0, -2), + (-1, -2), + (-2, -1), + (-2, 0), + (-2, 1), + (-1, 2), +]; + +static OFFSETS_8: [(i32, i32); 8] = [ + (0, 1), + (1, 1), + (1, 0), + (1, -1), + (0, -1), + (-1, -1), + (-1, 0), + (-1, 1), +]; + /// Features from Accelerated Segment Test (FAST) keypoint detector. /// /// Ref: https://docs.opencv.org/4.10.0/df/d74/classcv_1_1FastFeatureDetector.html @@ -117,8 +166,432 @@ impl FastFeatureDetector { /// Detects keypoints in an image. /// /// * `image` - Grayscale input image (matrix). - pub fn detect(&self, _image: &Matrix) -> Result> { - // TODO: Implement FAST keypoint detection algorithm - todo!() + #[allow(clippy::needless_range_loop)] + pub fn detect(&self, image: &Matrix) -> Result> { + if image.channels != 1 { + return Err(PureCvError::InvalidInput( + "FAST keypoint detection requires a single-channel grayscale image".to_string(), + )); + } + + let rows = image.rows; + let cols = image.cols; + let radius = match self.detector_type { + FastType::Type5_8 => 1, + FastType::Type7_12 => 3, + FastType::Type9_16 => 3, + }; + + if rows <= radius * 2 || cols <= radius * 2 { + return Ok(Vec::new()); + } + + let threshold = self.threshold; + let detector_type = self.detector_type; + let stride = cols as isize; + + // Precompute circle flat index offsets + let circle_offsets: Vec = match detector_type { + FastType::Type5_8 => OFFSETS_8 + .iter() + .map(|&(dx, dy)| dy as isize * stride + dx as isize) + .collect(), + FastType::Type7_12 => OFFSETS_12 + .iter() + .map(|&(dx, dy)| dy as isize * stride + dx as isize) + .collect(), + FastType::Type9_16 => OFFSETS_16 + .iter() + .map(|&(dx, dy)| dy as isize * stride + dx as isize) + .collect(), + }; + + // Pass 1: Compute corner scores for all pixels in parallel or sequential + let mut scores = vec![0u8; rows * cols]; + + #[cfg(feature = "parallel")] + { + scores + .par_chunks_exact_mut(cols) + .enumerate() + .for_each(|(y, score_row)| { + if y >= radius && y < rows - radius { + for x in radius..(cols - radius) { + let idx = y * cols + x; + score_row[x] = detect_pixel( + &image.data, + idx, + detector_type, + threshold, + &circle_offsets, + ); + } + } + }); + } + + #[cfg(not(feature = "parallel"))] + { + scores + .chunks_exact_mut(cols) + .enumerate() + .for_each(|(y, score_row)| { + if y >= radius && y < rows - radius { + for x in radius..(cols - radius) { + let idx = y * cols + x; + score_row[x] = detect_pixel( + &image.data, + idx, + detector_type, + threshold, + &circle_offsets, + ); + } + } + }); + } + + // Pass 2: Extract keypoints, applying Non-Maximum Suppression if enabled + let nonmax = self.nonmax_suppression; + + #[cfg(feature = "parallel")] + let keypoints: Vec = (radius..(rows - radius)) + .into_par_iter() + .flat_map(|y| { + let mut row_kps = Vec::new(); + let offset = y * cols; + let prev_offset = (y - 1) * cols; + let next_offset = (y + 1) * cols; + + for x in radius..(cols - radius) { + let score = scores[offset + x]; + if score > 0 { + if !nonmax { + row_kps.push(KeyPoint::new( + Point2f::new(x as f32, y as f32), + 7.0, + -1.0, + score as f32, + 0, + -1, + )); + } else { + // 8-neighborhood strictly-greater validation + if score > scores[offset + x - 1] + && score > scores[offset + x + 1] + && score > scores[prev_offset + x - 1] + && score > scores[prev_offset + x] + && score > scores[prev_offset + x + 1] + && score > scores[next_offset + x - 1] + && score > scores[next_offset + x] + && score > scores[next_offset + x + 1] + { + row_kps.push(KeyPoint::new( + Point2f::new(x as f32, y as f32), + 7.0, + -1.0, + score as f32, + 0, + -1, + )); + } + } + } + } + row_kps + }) + .collect(); + + #[cfg(not(feature = "parallel"))] + let keypoints: Vec = (radius..(rows - radius)) + .into_iter() + .flat_map(|y| { + let mut row_kps = Vec::new(); + let offset = y * cols; + let prev_offset = (y - 1) * cols; + let next_offset = (y + 1) * cols; + + for x in radius..(cols - radius) { + let score = scores[offset + x]; + if score > 0 { + if !nonmax { + row_kps.push(KeyPoint::new( + Point2f::new(x as f32, y as f32), + 7.0, + -1.0, + score as f32, + 0, + -1, + )); + } else { + if score > scores[offset + x - 1] + && score > scores[offset + x + 1] + && score > scores[prev_offset + x - 1] + && score > scores[prev_offset + x] + && score > scores[prev_offset + x + 1] + && score > scores[next_offset + x - 1] + && score > scores[next_offset + x] + && score > scores[next_offset + x + 1] + { + row_kps.push(KeyPoint::new( + Point2f::new(x as f32, y as f32), + 7.0, + -1.0, + score as f32, + 0, + -1, + )); + } + } + } + } + row_kps + }) + .collect(); + + Ok(keypoints) } } + +/// Helper function to perform FAST corner check and compute response score for a single pixel. +fn detect_pixel( + image_data: &[u8], + idx: usize, + detector_type: FastType, + threshold: u8, + circle_offsets: &[isize], +) -> u8 { + let v = image_data[idx]; + let vt_bright = v.saturating_add(threshold); + let vt_dark = v.saturating_sub(threshold); + + match detector_type { + FastType::Type5_8 => { + let mut circle = [0u8; 8]; + for i in 0..8 { + circle[i] = image_data[(idx as isize + circle_offsets[i]) as usize]; + } + + // Early rejection check: at least one in opposite pairs must exceed threshold bounds + let maybe_bright = (circle[0] > vt_bright || circle[4] > vt_bright) + && (circle[2] > vt_bright || circle[6] > vt_bright); + let maybe_dark = (circle[0] < vt_dark || circle[4] < vt_dark) + && (circle[2] < vt_dark || circle[6] < vt_dark); + + if !maybe_bright && !maybe_dark { + return 0; + } + + let (is_bright, is_dark) = check_contiguous(&circle, v, 5, threshold); + if is_bright || is_dark { + corner_score_8(&circle, v, threshold) + } else { + 0 + } + } + FastType::Type7_12 => { + let mut circle = [0u8; 12]; + for i in 0..12 { + circle[i] = image_data[(idx as isize + circle_offsets[i]) as usize]; + } + + let maybe_bright = (circle[0] > vt_bright || circle[6] > vt_bright) + && (circle[3] > vt_bright || circle[9] > vt_bright); + let maybe_dark = (circle[0] < vt_dark || circle[6] < vt_dark) + && (circle[3] < vt_dark || circle[9] < vt_dark); + + if !maybe_bright && !maybe_dark { + return 0; + } + + let (is_bright, is_dark) = check_contiguous(&circle, v, 7, threshold); + if is_bright || is_dark { + corner_score_12(&circle, v, threshold) + } else { + 0 + } + } + FastType::Type9_16 => { + let mut circle = [0u8; 16]; + for i in 0..16 { + circle[i] = image_data[(idx as isize + circle_offsets[i]) as usize]; + } + + let maybe_bright = (circle[0] > vt_bright || circle[8] > vt_bright) + && (circle[4] > vt_bright || circle[12] > vt_bright); + let maybe_dark = (circle[0] < vt_dark || circle[8] < vt_dark) + && (circle[4] < vt_dark || circle[12] < vt_dark); + + if !maybe_bright && !maybe_dark { + return 0; + } + + let (is_bright, is_dark) = check_contiguous(&circle, v, 9, threshold); + if is_bright || is_dark { + corner_score_16(&circle, v, threshold) + } else { + 0 + } + } + } +} + +/// Checks if there is a contiguous sequence of at least `k` pixels in the circle that are +/// all strictly brighter than `v + threshold` or strictly darker than `v - threshold`. +fn check_contiguous(circle: &[u8], v: u8, k: usize, threshold: u8) -> (bool, bool) { + let n = circle.len(); + let vt_bright = v.saturating_add(threshold); + let vt_dark = v.saturating_sub(threshold); + + let mut bright_len = 0; + let mut dark_len = 0; + let mut is_bright = false; + let mut is_dark = false; + + // Scan with wrap-around buffer expansion of size k-1 + for i in 0..(n + k - 1) { + let val = circle[i % n]; + if val > vt_bright { + bright_len += 1; + if bright_len >= k { + is_bright = true; + } + } else { + bright_len = 0; + } + + if val < vt_dark { + dark_len += 1; + if dark_len >= k { + is_dark = true; + } + } else { + dark_len = 0; + } + } + + (is_bright, is_dark) +} + +/// Edward Rosten's optimized corner score algorithm for FAST-9 (Type9_16) +fn corner_score_16(circle: &[u8; 16], v: u8, threshold: u8) -> u8 { + let mut d = [0i16; 25]; + for i in 0..16 { + d[i] = v as i16 - circle[i] as i16; + } + for i in 0..9 { + d[16 + i] = d[i]; + } + + let mut a0 = threshold as i16; + for k in (0..16).step_by(2) { + let mut a = d[k + 1].min(d[k + 2]); + a = a.min(d[k + 3]); + if a <= a0 { + continue; + } + a = a.min(d[k + 4]); + a = a.min(d[k + 5]); + a = a.min(d[k + 6]); + a = a.min(d[k + 7]); + a = a.min(d[k + 8]); + a0 = a0.max(a.min(d[k])); + a0 = a0.max(a.min(d[k + 9])); + } + + let mut b0 = -a0; + for k in (0..16).step_by(2) { + let mut b = d[k + 1].max(d[k + 2]); + b = b.max(d[k + 3]); + b = b.max(d[k + 4]); + b = b.max(d[k + 5]); + if b >= b0 { + continue; + } + b = b.max(d[k + 6]); + b = b.max(d[k + 7]); + b = b.max(d[k + 8]); + b0 = b0.min(b.max(d[k])); + b0 = b0.min(b.max(d[k + 9])); + } + + (-b0 - 1) as u8 +} + +/// Edward Rosten's optimized corner score algorithm for FAST-7 (Type7_12) +fn corner_score_12(circle: &[u8; 12], v: u8, threshold: u8) -> u8 { + let mut d = [0i16; 19]; + for i in 0..12 { + d[i] = v as i16 - circle[i] as i16; + } + for i in 0..7 { + d[12 + i] = d[i]; + } + + let mut a0 = threshold as i16; + for k in (0..12).step_by(2) { + let mut a = d[k + 1].min(d[k + 2]); + if a <= a0 { + continue; + } + a = a.min(d[k + 3]); + a = a.min(d[k + 4]); + a = a.min(d[k + 5]); + a = a.min(d[k + 6]); + a0 = a0.max(a.min(d[k])); + a0 = a0.max(a.min(d[k + 7])); + } + + let mut b0 = -a0; + for k in (0..12).step_by(2) { + let mut b = d[k + 1].max(d[k + 2]); + b = b.max(d[k + 3]); + b = b.max(d[k + 4]); + if b >= b0 { + continue; + } + b = b.max(d[k + 5]); + b = b.max(d[k + 6]); + b0 = b0.min(b.max(d[k])); + b0 = b0.min(b.max(d[k + 7])); + } + + (-b0 - 1) as u8 +} + +/// Edward Rosten's optimized corner score algorithm for FAST-5 (Type5_8) +fn corner_score_8(circle: &[u8; 8], v: u8, threshold: u8) -> u8 { + let mut d = [0i16; 13]; + for i in 0..8 { + d[i] = v as i16 - circle[i] as i16; + } + for i in 0..5 { + d[8 + i] = d[i]; + } + + let mut a0 = threshold as i16; + for k in (0..8).step_by(2) { + let mut a = d[k + 1].min(d[k + 2]); + if a <= a0 { + continue; + } + a = a.min(d[k + 3]); + a = a.min(d[k + 4]); + a0 = a0.max(a.min(d[k])); + a0 = a0.max(a.min(d[k + 5])); + } + + let mut b0 = -a0; + for k in (0..8).step_by(2) { + let mut b = d[k + 1].max(d[k + 2]); + b = b.max(d[k + 3]); + if b >= b0 { + continue; + } + b = b.max(d[k + 4]); + b0 = b0.min(b.max(d[k])); + b0 = b0.min(b.max(d[k + 5])); + } + + (-b0 - 1) as u8 +} diff --git a/src/features2d/tests.rs b/src/features2d/tests.rs index d36604b..5ceb841 100644 --- a/src/features2d/tests.rs +++ b/src/features2d/tests.rs @@ -173,3 +173,117 @@ fn test_keypoint_overlap() { // Calculated theoretical overlap ratio is approx 0.337463 assert!((overlap_val - 0.337463).abs() < 1e-4); } + +#[test] +fn test_fast_grayscale_validation() { + use crate::core::Matrix; + use crate::features2d::FastFeatureDetector; + + // Create a 3-channel matrix (RGB) + let img = Matrix::::new(10, 10, 3); + let detector = FastFeatureDetector::default(); + let res = detector.detect(&img); + assert!(res.is_err()); + if let Err(e) = res { + assert!(e.to_string().contains("grayscale")); + } +} + +#[test] +fn test_fast_uniform_image() { + use crate::core::Matrix; + use crate::features2d::{FastFeatureDetector, FastType}; + + let img = Matrix::::from_vec(20, 20, 1, vec![128; 400]); + + for fast_type in &[FastType::Type5_8, FastType::Type7_12, FastType::Type9_16] { + let detector = FastFeatureDetector::new(10, true, *fast_type); + let kps = detector.detect(&img).unwrap(); + assert_eq!(kps.len(), 0); + } +} + +#[test] +fn test_fast_synthetic_corner() { + use crate::core::Matrix; + use crate::features2d::{FastFeatureDetector, FastType}; + + // Construct an 11x11 grayscale image with a dark region in the top-left and bright region in the bottom-right + let rows = 11; + let cols = 11; + let mut data = vec![100u8; rows * cols]; + for y in 5..rows { + for x in 5..cols { + data[y * cols + x] = 200; + } + } + let img = Matrix::::from_vec(rows, cols, 1, data.clone()); + + // Check FAST-9 (Type9_16) + let detector_9 = FastFeatureDetector::new(10, false, FastType::Type9_16); + let kps_9 = detector_9.detect(&img).unwrap(); + + println!("Detected Keypoints count: {}", kps_9.len()); + for kp in &kps_9 { + println!( + "Keypoint at: ({}, {}), response: {}", + kp.pt.x, kp.pt.y, kp.response + ); + } + + assert!( + !kps_9.is_empty(), + "FAST-9 should detect at least one keypoint at the corner boundary" + ); + + // Check if the corner is found around the boundary region (5, 5) + let has_near_boundary = kps_9 + .iter() + .any(|kp| (kp.pt.x - 5.0).abs() <= 1.0 && (kp.pt.y - 5.0).abs() <= 1.0); + assert!(has_near_boundary, "FAST-9 keypoint should be near (5, 5)"); + + // Check FAST-7 (Type7_12) + let detector_7 = FastFeatureDetector::new(10, false, FastType::Type7_12); + let kps_7 = detector_7.detect(&img).unwrap(); + assert!( + !kps_7.is_empty(), + "FAST-7 should detect at least one keypoint" + ); + + // Check FAST-5 (Type5_8) + let detector_5 = FastFeatureDetector::new(10, false, FastType::Type5_8); + let kps_5 = detector_5.detect(&img).unwrap(); + assert!( + !kps_5.is_empty(), + "FAST-5 should detect at least one keypoint" + ); +} + +#[test] +fn test_fast_nonmax_suppression() { + use crate::core::Matrix; + use crate::features2d::{FastFeatureDetector, FastType}; + + let rows = 11; + let cols = 11; + let mut data = vec![100u8; rows * cols]; + for y in 5..rows { + for x in 5..cols { + data[y * cols + x] = 200; + } + } + let img = Matrix::::from_vec(rows, cols, 1, data); + + // With NMS + let detector_nms = FastFeatureDetector::new(10, true, FastType::Type9_16); + let kps_nms = detector_nms.detect(&img).unwrap(); + + // Without NMS + let detector_no_nms = FastFeatureDetector::new(10, false, FastType::Type9_16); + let kps_no_nms = detector_no_nms.detect(&img).unwrap(); + + assert!( + kps_no_nms.len() >= kps_nms.len(), + "Without NMS, we should detect at least as many (and usually more) keypoints than with NMS" + ); +}