use super::filters::{gaussian_blur, sobel_x, sobel_y};
use super::{ColorSpace, CvError, Image};
use crate::error::NumRs2Error;
#[derive(Debug, Clone)]
pub struct Keypoint {
pub row: usize,
pub col: usize,
pub response: f64,
pub scale: f64,
pub orientation: f64,
}
impl Keypoint {
pub fn new(row: usize, col: usize, response: f64) -> Self {
Self {
row,
col,
response,
scale: 1.0,
orientation: 0.0,
}
}
pub fn with_full(row: usize, col: usize, response: f64, scale: f64, orientation: f64) -> Self {
Self {
row,
col,
response,
scale,
orientation,
}
}
}
#[derive(Debug, Clone)]
pub struct FeatureDescriptor {
pub keypoint: Keypoint,
pub descriptor: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct FeatureMatch {
pub query_idx: usize,
pub train_idx: usize,
pub distance: f64,
}
pub fn harris_corner_detect(
img: &Image,
block_size: usize,
k_sensitivity: f64,
threshold: f64,
) -> Result<Vec<Keypoint>, NumRs2Error> {
if img.color_space() != ColorSpace::Grayscale {
return Err(CvError::RequiresGrayscale.into());
}
if block_size == 0 {
return Err(
CvError::InvalidParameter("block_size must be greater than zero".to_string()).into(),
);
}
let h = img.height();
let w = img.width();
let half = (block_size / 2) as isize;
let smoothed = if h >= 5 && w >= 5 {
gaussian_blur(img, 3, 0.8)?
} else {
img.clone()
};
let gx = sobel_x(&smoothed)?;
let gy = sobel_y(&smoothed)?;
let mut keypoints = Vec::new();
for row in 0..h {
for col in 0..w {
let mut sum_ix_sq = 0.0;
let mut sum_iy_sq = 0.0;
let mut sum_ix_iy = 0.0;
for di in -half..=half {
for dj in -half..=half {
let nr = row as isize + di;
let nc = col as isize + dj;
if nr >= 0 && nr < h as isize && nc >= 0 && nc < w as isize {
let ix = gx.get_pixel(nr as usize, nc as usize, 0).unwrap_or(0.0);
let iy = gy.get_pixel(nr as usize, nc as usize, 0).unwrap_or(0.0);
sum_ix_sq += ix * ix;
sum_iy_sq += iy * iy;
sum_ix_iy += ix * iy;
}
}
}
let det = sum_ix_sq * sum_iy_sq - sum_ix_iy * sum_ix_iy;
let trace = sum_ix_sq + sum_iy_sq;
let response = det - k_sensitivity * trace * trace;
if response > threshold {
keypoints.push(Keypoint::new(row, col, response));
}
}
}
keypoints.sort_by(|a, b| {
b.response
.partial_cmp(&a.response)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(keypoints)
}
pub fn fast_corner_detect(
img: &Image,
threshold: f64,
n_consecutive: usize,
) -> Result<Vec<Keypoint>, NumRs2Error> {
if img.color_space() != ColorSpace::Grayscale {
return Err(CvError::RequiresGrayscale.into());
}
if !(1..=16).contains(&n_consecutive) {
return Err(CvError::InvalidParameter(
"n_consecutive must be between 1 and 16".to_string(),
)
.into());
}
if threshold < 0.0 {
return Err(CvError::InvalidParameter("threshold must be non-negative".to_string()).into());
}
let h = img.height();
let w = img.width();
let circle: [(isize, isize); 16] = [
(-3, 0), (-3, 1), (-2, 2), (-1, 3), (0, 3), (1, 3), (2, 2), (3, 1), (3, 0), (3, -1), (2, -2), (1, -3), (0, -3), (-1, -3), (-2, -2), (-3, -1), ];
let mut keypoints = Vec::new();
let margin = 3_usize;
if h <= 2 * margin || w <= 2 * margin {
return Ok(keypoints);
}
for row in margin..(h - margin) {
for col in margin..(w - margin) {
let center = img
.get_pixel(row, col, 0)
.map_err(|e| NumRs2Error::ComputationError(format!("FAST center: {}", e)))?;
let bright_thresh = center + threshold;
let dark_thresh = center - threshold;
let min_cardinal = (n_consecutive / 4).max(1) as u8;
let test_indices = [0_usize, 4, 8, 12];
let mut bright_count_quick = 0_u8;
let mut dark_count_quick = 0_u8;
for &idx in &test_indices {
let (dr, dc) = circle[idx];
let nr = (row as isize + dr) as usize;
let nc = (col as isize + dc) as usize;
let val = img.get_pixel(nr, nc, 0).unwrap_or(center);
if val > bright_thresh {
bright_count_quick += 1;
} else if val < dark_thresh {
dark_count_quick += 1;
}
}
if bright_count_quick < min_cardinal && dark_count_quick < min_cardinal {
continue;
}
let mut brighter = [false; 32];
let mut darker = [false; 32];
for p in 0..16 {
let (dr, dc) = circle[p];
let nr = (row as isize + dr) as usize;
let nc = (col as isize + dc) as usize;
let val = img.get_pixel(nr, nc, 0).unwrap_or(center);
if val > bright_thresh {
brighter[p] = true;
brighter[p + 16] = true;
}
if val < dark_thresh {
darker[p] = true;
darker[p + 16] = true;
}
}
let is_bright_corner = has_n_contiguous(&brighter, n_consecutive);
let is_dark_corner = has_n_contiguous(&darker, n_consecutive);
if is_bright_corner || is_dark_corner {
let mut response = 0.0;
for p in 0..16 {
let (dr, dc) = circle[p];
let nr = (row as isize + dr) as usize;
let nc = (col as isize + dc) as usize;
let val = img.get_pixel(nr, nc, 0).unwrap_or(center);
response += (val - center).abs();
}
keypoints.push(Keypoint::new(row, col, response));
}
}
}
keypoints.sort_by(|a, b| {
b.response
.partial_cmp(&a.response)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(keypoints)
}
fn has_n_contiguous(arr: &[bool], n: usize) -> bool {
if n == 0 {
return true;
}
let len = arr.len();
let check_len = len.min(len / 2 + n);
let mut consecutive = 0_usize;
for i in 0..check_len {
if arr[i] {
consecutive += 1;
if consecutive >= n {
return true;
}
} else {
consecutive = 0;
}
}
false
}
pub fn non_maximum_suppression(keypoints: &[Keypoint], radius: f64) -> Vec<Keypoint> {
if keypoints.is_empty() {
return Vec::new();
}
let radius_sq = radius * radius;
let mut suppressed = vec![false; keypoints.len()];
let mut result = Vec::new();
for i in 0..keypoints.len() {
if suppressed[i] {
continue;
}
result.push(keypoints[i].clone());
for j in (i + 1)..keypoints.len() {
if suppressed[j] {
continue;
}
let dr = keypoints[i].row as f64 - keypoints[j].row as f64;
let dc = keypoints[i].col as f64 - keypoints[j].col as f64;
let dist_sq = dr * dr + dc * dc;
if dist_sq < radius_sq {
suppressed[j] = true;
}
}
}
result
}
pub fn simple_descriptor(
img: &Image,
keypoints: &[Keypoint],
patch_size: usize,
) -> Result<Vec<FeatureDescriptor>, NumRs2Error> {
if img.color_space() != ColorSpace::Grayscale {
return Err(CvError::RequiresGrayscale.into());
}
if patch_size == 0 {
return Err(
CvError::InvalidParameter("patch_size must be greater than zero".to_string()).into(),
);
}
let h = img.height();
let w = img.width();
let half = patch_size / 2;
let n_bins = 8_usize;
let cells_per_side = 4_usize;
let cell_size = patch_size / cells_per_side;
if cell_size == 0 {
return Err(CvError::InvalidParameter(
"patch_size too small for 4x4 cell grid".to_string(),
)
.into());
}
let gx = sobel_x(img)?;
let gy = sobel_y(img)?;
let desc_len = cells_per_side * cells_per_side * n_bins;
let mut descriptors = Vec::with_capacity(keypoints.len());
for kp in keypoints {
if kp.row < half || kp.row + half >= h || kp.col < half || kp.col + half >= w {
continue;
}
let mut descriptor = vec![0.0_f64; desc_len];
for ci in 0..cells_per_side {
for cj in 0..cells_per_side {
let cell_start_row = kp.row - half + ci * cell_size;
let cell_start_col = kp.col - half + cj * cell_size;
for di in 0..cell_size {
for dj in 0..cell_size {
let r = cell_start_row + di;
let c = cell_start_col + dj;
let ix = gx.get_pixel(r, c, 0).unwrap_or(0.0);
let iy = gy.get_pixel(r, c, 0).unwrap_or(0.0);
let magnitude = (ix * ix + iy * iy).sqrt();
let angle = iy.atan2(ix);
let normalized_angle =
(angle + std::f64::consts::PI) / (2.0 * std::f64::consts::PI);
let bin = (normalized_angle * n_bins as f64).floor() as usize;
let bin = bin.min(n_bins - 1);
let cell_idx = ci * cells_per_side + cj;
let desc_idx = cell_idx * n_bins + bin;
if desc_idx < desc_len {
descriptor[desc_idx] += magnitude;
}
}
}
}
}
let norm: f64 = descriptor.iter().map(|v| v * v).sum::<f64>().sqrt();
if norm > 1e-10 {
for v in &mut descriptor {
*v /= norm;
}
}
let clamp_val = 0.2;
for v in &mut descriptor {
if *v > clamp_val {
*v = clamp_val;
}
}
let norm2: f64 = descriptor.iter().map(|v| v * v).sum::<f64>().sqrt();
if norm2 > 1e-10 {
for v in &mut descriptor {
*v /= norm2;
}
}
descriptors.push(FeatureDescriptor {
keypoint: kp.clone(),
descriptor,
});
}
Ok(descriptors)
}
fn l2_distance(a: &[f64], b: &[f64]) -> f64 {
let max_len = a.len().max(b.len());
let mut sum = 0.0;
for i in 0..max_len {
let va = if i < a.len() { a[i] } else { 0.0 };
let vb = if i < b.len() { b[i] } else { 0.0 };
let diff = va - vb;
sum += diff * diff;
}
sum.sqrt()
}
pub fn brute_force_match(
descriptors1: &[FeatureDescriptor],
descriptors2: &[FeatureDescriptor],
max_distance: f64,
) -> Vec<FeatureMatch> {
if descriptors1.is_empty() || descriptors2.is_empty() {
return Vec::new();
}
let mut matches = Vec::new();
for (qi, q) in descriptors1.iter().enumerate() {
let mut best_dist = f64::INFINITY;
let mut best_idx = 0_usize;
for (ti, t) in descriptors2.iter().enumerate() {
let dist = l2_distance(&q.descriptor, &t.descriptor);
if dist < best_dist {
best_dist = dist;
best_idx = ti;
}
}
if best_dist <= max_distance && best_dist.is_finite() {
matches.push(FeatureMatch {
query_idx: qi,
train_idx: best_idx,
distance: best_dist,
});
}
}
matches.sort_by(|a, b| {
a.distance
.partial_cmp(&b.distance)
.unwrap_or(std::cmp::Ordering::Equal)
});
matches
}
#[cfg(test)]
mod tests {
use super::*;
fn make_corner_image(size: usize) -> Image {
let mut data = vec![0.0; size * size];
let center = size / 2;
for row in center..size {
for col in 0..=center {
data[row * size + col] = 1.0;
}
}
Image::from_grayscale(size, size, &data).expect("test: image creation should succeed")
}
fn make_checkerboard(size: usize, cell_size: usize) -> Image {
let mut data = vec![0.0; size * size];
for row in 0..size {
for col in 0..size {
let cell_row = row / cell_size;
let cell_col = col / cell_size;
if (cell_row + cell_col).is_multiple_of(2) {
data[row * size + col] = 1.0;
}
}
}
Image::from_grayscale(size, size, &data).expect("test: image creation should succeed")
}
fn make_gradient_image(size: usize) -> Image {
let mut data = vec![0.0; size * size];
for row in 0..size {
for col in 0..size {
data[row * size + col] = col as f64 / (size - 1) as f64;
}
}
Image::from_grayscale(size, size, &data).expect("test: image creation should succeed")
}
#[test]
fn test_harris_corner_detect_on_corner_image() {
let img = make_corner_image(32);
let keypoints = harris_corner_detect(&img, 3, 0.04, 0.0)
.expect("test: Harris corner detection should succeed");
assert!(
!keypoints.is_empty(),
"Harris should detect corners in L-shaped image"
);
for window in keypoints.windows(2) {
assert!(
window[0].response >= window[1].response,
"Keypoints should be sorted by descending response"
);
}
}
#[test]
fn test_harris_on_constant_image() {
let data = vec![0.5; 16 * 16];
let img =
Image::from_grayscale(16, 16, &data).expect("test: image creation should succeed");
let keypoints = harris_corner_detect(&img, 3, 0.04, 0.001)
.expect("test: Harris should succeed on constant");
assert!(
keypoints.is_empty(),
"Harris should not detect corners in constant image"
);
}
#[test]
fn test_harris_on_checkerboard() {
let img = make_checkerboard(32, 8);
let keypoints = harris_corner_detect(&img, 3, 0.04, 0.0)
.expect("test: Harris should succeed on checkerboard");
assert!(
keypoints.len() > 2,
"Harris should detect multiple corners on checkerboard: got {}",
keypoints.len()
);
}
#[test]
fn test_harris_requires_grayscale() {
let data = vec![0.5; 8 * 8 * 3];
let img = Image::from_rgb(8, 8, &data).expect("test: RGB image creation should succeed");
let result = harris_corner_detect(&img, 3, 0.04, 0.0);
assert!(result.is_err(), "Harris should reject non-grayscale images");
}
#[test]
fn test_fast_corner_detect_on_checkerboard() {
let img = make_checkerboard(64, 16);
let keypoints =
fast_corner_detect(&img, 0.3, 5).expect("test: FAST should succeed on checkerboard");
assert!(
!keypoints.is_empty(),
"FAST should detect corners on checkerboard"
);
}
#[test]
fn test_fast_corner_detect_on_constant() {
let data = vec![0.5; 16 * 16];
let img =
Image::from_grayscale(16, 16, &data).expect("test: image creation should succeed");
let keypoints =
fast_corner_detect(&img, 0.05, 9).expect("test: FAST should succeed on constant");
assert!(
keypoints.is_empty(),
"FAST should not detect corners in constant image"
);
}
#[test]
fn test_fast_invalid_parameters() {
let img = Image::zeros_grayscale(16, 16);
let result = fast_corner_detect(&img, 0.05, 0);
assert!(result.is_err(), "FAST should reject n_consecutive=0");
let result = fast_corner_detect(&img, 0.05, 17);
assert!(result.is_err(), "FAST should reject n_consecutive=17");
let result = fast_corner_detect(&img, -0.1, 9);
assert!(result.is_err(), "FAST should reject negative threshold");
}
#[test]
fn test_non_maximum_suppression_basic() {
let keypoints = vec![
Keypoint::new(10, 10, 100.0),
Keypoint::new(12, 12, 80.0), Keypoint::new(50, 50, 90.0), Keypoint::new(51, 51, 70.0), ];
let suppressed = non_maximum_suppression(&keypoints, 5.0);
assert_eq!(
suppressed.len(),
2,
"NMS should keep only 2 keypoints: got {}",
suppressed.len()
);
assert!((suppressed[0].response - 100.0).abs() < 1e-10);
assert!((suppressed[1].response - 90.0).abs() < 1e-10);
}
#[test]
fn test_non_maximum_suppression_empty() {
let keypoints: Vec<Keypoint> = vec![];
let suppressed = non_maximum_suppression(&keypoints, 5.0);
assert!(suppressed.is_empty());
}
#[test]
fn test_non_maximum_suppression_single() {
let keypoints = vec![Keypoint::new(5, 5, 42.0)];
let suppressed = non_maximum_suppression(&keypoints, 10.0);
assert_eq!(suppressed.len(), 1);
assert!((suppressed[0].response - 42.0).abs() < 1e-10);
}
#[test]
fn test_simple_descriptor_on_checkerboard() {
let img = make_checkerboard(64, 8);
let keypoints = vec![Keypoint::new(32, 32, 1.0), Keypoint::new(24, 24, 0.8)];
let descs = simple_descriptor(&img, &keypoints, 16)
.expect("test: descriptor computation should succeed");
assert!(
!descs.is_empty(),
"Descriptor extraction should produce results for interior keypoints"
);
for desc in &descs {
assert_eq!(
desc.descriptor.len(),
128,
"Descriptor should be 128-dimensional"
);
let norm: f64 = desc.descriptor.iter().map(|v| v * v).sum::<f64>().sqrt();
assert!(
(norm - 1.0).abs() < 0.01 || norm < 1e-10,
"Descriptor should be normalized: norm = {}",
norm
);
}
}
#[test]
fn test_simple_descriptor_skips_border_keypoints() {
let img = Image::zeros_grayscale(32, 32);
let keypoints = vec![
Keypoint::new(0, 0, 1.0), Keypoint::new(31, 31, 1.0), Keypoint::new(16, 16, 1.0), ];
let descs =
simple_descriptor(&img, &keypoints, 16).expect("test: descriptor should handle border");
assert_eq!(
descs.len(),
1,
"Only interior keypoints should produce descriptors: got {}",
descs.len()
);
}
#[test]
fn test_brute_force_match_identical() {
let desc1 = FeatureDescriptor {
keypoint: Keypoint::new(10, 10, 1.0),
descriptor: vec![1.0, 0.0, 0.0, 0.0],
};
let desc2 = FeatureDescriptor {
keypoint: Keypoint::new(20, 20, 1.0),
descriptor: vec![1.0, 0.0, 0.0, 0.0],
};
let matches = brute_force_match(&[desc1], &[desc2], 1.0);
assert_eq!(matches.len(), 1);
assert!(
matches[0].distance < 1e-10,
"Identical descriptors should match with distance ~0: got {}",
matches[0].distance
);
}
#[test]
fn test_brute_force_match_max_distance_filter() {
let desc1 = FeatureDescriptor {
keypoint: Keypoint::new(10, 10, 1.0),
descriptor: vec![1.0, 0.0, 0.0, 0.0],
};
let desc2 = FeatureDescriptor {
keypoint: Keypoint::new(20, 20, 1.0),
descriptor: vec![0.0, 1.0, 0.0, 0.0],
};
let matches_tight = brute_force_match(
std::slice::from_ref(&desc1),
std::slice::from_ref(&desc2),
1.0,
);
assert!(
matches_tight.is_empty(),
"Should reject match with distance > max_distance"
);
let matches_loose = brute_force_match(&[desc1], &[desc2], 2.0);
assert_eq!(
matches_loose.len(),
1,
"Should accept match within max_distance"
);
}
#[test]
fn test_brute_force_match_empty() {
let matches = brute_force_match(&[], &[], 1.0);
assert!(matches.is_empty());
let desc = FeatureDescriptor {
keypoint: Keypoint::new(5, 5, 1.0),
descriptor: vec![1.0, 0.0],
};
let matches2 = brute_force_match(std::slice::from_ref(&desc), &[], 1.0);
assert!(matches2.is_empty());
let matches3 = brute_force_match(&[], &[desc], 1.0);
assert!(matches3.is_empty());
}
#[test]
fn test_brute_force_match_best_selection() {
let query = vec![FeatureDescriptor {
keypoint: Keypoint::new(10, 10, 1.0),
descriptor: vec![1.0, 0.0, 0.0],
}];
let train = vec![
FeatureDescriptor {
keypoint: Keypoint::new(20, 20, 1.0),
descriptor: vec![0.0, 1.0, 0.0], },
FeatureDescriptor {
keypoint: Keypoint::new(30, 30, 1.0),
descriptor: vec![0.9, 0.1, 0.0], },
];
let matches = brute_force_match(&query, &train, 2.0);
assert_eq!(matches.len(), 1);
assert_eq!(
matches[0].train_idx, 1,
"Should select closest descriptor (index 1)"
);
}
#[test]
fn test_l2_distance_values() {
let a = vec![1.0, 0.0, 0.0];
let b = vec![0.0, 1.0, 0.0];
let dist = l2_distance(&a, &b);
assert!(
(dist - std::f64::consts::SQRT_2).abs() < 1e-10,
"L2 distance between orthogonal unit vectors should be sqrt(2): got {}",
dist
);
let c = vec![1.0, 2.0, 3.0];
let d = vec![1.0, 2.0, 3.0];
assert!(
l2_distance(&c, &d) < 1e-10,
"Identical vectors should have distance 0"
);
let e = vec![0.0];
let f = vec![3.0, 4.0];
let dist2 = l2_distance(&e, &f);
assert!(
(dist2 - 5.0).abs() < 1e-10,
"Zero-padded distance: got {}",
dist2
);
}
#[test]
fn test_has_n_contiguous_wrap_around() {
let mut arr = [false; 32];
arr[14] = true;
arr[15] = true;
arr[16] = true; arr[17] = true; arr[0] = true; arr[1] = true; arr[30] = true; arr[31] = true; assert!(has_n_contiguous(&arr, 4));
assert!(!has_n_contiguous(&arr, 5));
}
#[test]
fn test_keypoint_constructors() {
let kp1 = Keypoint::new(10, 20, 5.0);
assert_eq!(kp1.row, 10);
assert_eq!(kp1.col, 20);
assert!((kp1.response - 5.0).abs() < 1e-10);
assert!((kp1.scale - 1.0).abs() < 1e-10);
assert!((kp1.orientation).abs() < 1e-10);
let kp2 = Keypoint::with_full(5, 15, 3.0, 2.0, 1.5);
assert_eq!(kp2.row, 5);
assert_eq!(kp2.col, 15);
assert!((kp2.response - 3.0).abs() < 1e-10);
assert!((kp2.scale - 2.0).abs() < 1e-10);
assert!((kp2.orientation - 1.5).abs() < 1e-10);
}
#[test]
fn test_descriptor_on_gradient_image() {
let img = make_gradient_image(64);
let keypoints = vec![Keypoint::new(32, 32, 1.0)];
let descs = simple_descriptor(&img, &keypoints, 16)
.expect("test: descriptor on gradient image should succeed");
assert_eq!(descs.len(), 1);
let nonzero_count = descs[0]
.descriptor
.iter()
.filter(|&&v| v.abs() > 1e-6)
.count();
assert!(
nonzero_count > 0,
"Gradient image should produce non-zero descriptor entries"
);
}
}