Skip to main content

scirs2_vision/feature/
corners.rs

1//! Unified corner detection API
2//!
3//! Provides a single entry point for corner detection using multiple methods
4//! (Harris, Shi-Tomasi, FAST). Each method has its own strengths:
5//!
6//! - **Harris**: Good for detecting corners with a well-defined mathematical
7//!   response based on the structure tensor eigenvalues.
8//! - **Shi-Tomasi**: An improvement over Harris that uses the minimum eigenvalue
9//!   criterion, often yielding more robust corners.
10//! - **FAST**: Very fast corner detection using a segment test on a circle of
11//!   pixels around a candidate. Best for real-time applications.
12
13use crate::error::{Result, VisionError};
14use image::DynamicImage;
15
16/// A detected corner point with position and score
17#[derive(Debug, Clone, Copy)]
18pub struct CornerPoint {
19    /// X coordinate (column)
20    pub x: f32,
21    /// Y coordinate (row)
22    pub y: f32,
23    /// Corner response score (interpretation depends on method)
24    pub score: f32,
25}
26
27/// Corner detection method selection
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum CornerMethod {
30    /// Harris corner detector (uses structure tensor determinant/trace ratio)
31    Harris,
32    /// Shi-Tomasi (Good Features to Track, uses minimum eigenvalue)
33    ShiTomasi,
34    /// FAST (Features from Accelerated Segment Test)
35    Fast,
36}
37
38/// Parameters for unified corner detection
39#[derive(Debug, Clone)]
40pub struct CornerDetectParams {
41    /// Corner detection method
42    pub method: CornerMethod,
43    /// Detection threshold (method-specific interpretation)
44    /// - Harris: Harris response threshold (typical: 0.0001 to 0.01)
45    /// - Shi-Tomasi: minimum eigenvalue threshold (typical: 0.01 to 0.1)
46    /// - FAST: intensity difference threshold (typical: 10.0 to 50.0)
47    pub threshold: f32,
48    /// Maximum number of corners to return (0 for unlimited)
49    pub max_corners: usize,
50    /// Minimum distance between detected corners
51    pub min_distance: usize,
52    /// Block/window size for corner computation (Harris and Shi-Tomasi, must be odd >= 3)
53    pub block_size: usize,
54    /// Harris detector free parameter k (only for Harris, typical: 0.04-0.06)
55    pub harris_k: f32,
56    /// Number of consecutive pixels for FAST (typically 9 or 12)
57    pub fast_n: usize,
58    /// Apply non-maximum suppression
59    pub non_max_suppression: bool,
60}
61
62impl Default for CornerDetectParams {
63    fn default() -> Self {
64        Self {
65            method: CornerMethod::Harris,
66            threshold: 0.01,
67            max_corners: 500,
68            min_distance: 10,
69            block_size: 3,
70            harris_k: 0.04,
71            fast_n: 9,
72            non_max_suppression: true,
73        }
74    }
75}
76
77/// Detect corners in an image using the specified method
78///
79/// This is the unified API for corner detection. It dispatches to the
80/// appropriate detector based on the `params.method` field.
81///
82/// # Arguments
83///
84/// * `img` - Input image
85/// * `params` - Corner detection parameters including method selection
86///
87/// # Returns
88///
89/// * Vector of detected corner points, sorted by descending score
90///
91/// # Example
92///
93/// ```rust
94/// use scirs2_vision::feature::corners::{detect_corners, CornerDetectParams, CornerMethod};
95/// use image::{DynamicImage, GrayImage, Luma};
96///
97/// # fn main() -> scirs2_vision::error::Result<()> {
98/// // Create a test image with some structure
99/// let mut buf = GrayImage::new(32, 32);
100/// for y in 0..32u32 {
101///     for x in 0..32u32 {
102///         let val = if x > 10 && x < 22 && y > 10 && y < 22 { 200u8 } else { 50u8 };
103///         buf.put_pixel(x, y, Luma([val]));
104///     }
105/// }
106/// let img = DynamicImage::ImageLuma8(buf);
107///
108/// let params = CornerDetectParams {
109///     method: CornerMethod::Harris,
110///     threshold: 0.0001,
111///     ..CornerDetectParams::default()
112/// };
113/// let corners = detect_corners(&img, &params)?;
114/// # Ok(())
115/// # }
116/// ```
117pub fn detect_corners(img: &DynamicImage, params: &CornerDetectParams) -> Result<Vec<CornerPoint>> {
118    match params.method {
119        CornerMethod::Harris => detect_harris(img, params),
120        CornerMethod::ShiTomasi => detect_shi_tomasi(img, params),
121        CornerMethod::Fast => detect_fast(img, params),
122    }
123}
124
125/// Harris corner detection returning CornerPoints
126fn detect_harris(img: &DynamicImage, params: &CornerDetectParams) -> Result<Vec<CornerPoint>> {
127    let array = crate::feature::image_to_array(img)?;
128    let (height, width) = array.dim();
129
130    // Ensure block_size is odd and >= 3
131    let block_size = ensure_odd_block_size(params.block_size);
132    let radius = block_size / 2;
133    let k = params.harris_k;
134
135    // Compute gradients
136    let mut ix2 = scirs2_core::ndarray::Array2::<f32>::zeros((height, width));
137    let mut iy2 = scirs2_core::ndarray::Array2::<f32>::zeros((height, width));
138    let mut ixy = scirs2_core::ndarray::Array2::<f32>::zeros((height, width));
139
140    for y in 1..height - 1 {
141        for x in 1..width - 1 {
142            let dx = (array[[y, x + 1]] - array[[y, x - 1]]) / 2.0;
143            let dy = (array[[y + 1, x]] - array[[y - 1, x]]) / 2.0;
144            ix2[[y, x]] = dx * dx;
145            iy2[[y, x]] = dy * dy;
146            ixy[[y, x]] = dx * dy;
147        }
148    }
149
150    // Apply box filter and compute Harris response
151    let mut response = scirs2_core::ndarray::Array2::<f32>::zeros((height, width));
152
153    for y in radius..height.saturating_sub(radius) {
154        for x in radius..width.saturating_sub(radius) {
155            let mut sum_ix2 = 0.0f32;
156            let mut sum_iy2 = 0.0f32;
157            let mut sum_ixy = 0.0f32;
158
159            for dy in y.saturating_sub(radius)..=(y + radius).min(height - 1) {
160                for dx in x.saturating_sub(radius)..=(x + radius).min(width - 1) {
161                    sum_ix2 += ix2[[dy, dx]];
162                    sum_iy2 += iy2[[dy, dx]];
163                    sum_ixy += ixy[[dy, dx]];
164                }
165            }
166
167            let det = sum_ix2 * sum_iy2 - sum_ixy * sum_ixy;
168            let trace = sum_ix2 + sum_iy2;
169            response[[y, x]] = det - k * trace * trace;
170        }
171    }
172
173    // Extract corners above threshold with NMS
174    extract_corners_from_response(&response, params)
175}
176
177/// Shi-Tomasi corner detection using good_features_to_track
178fn detect_shi_tomasi(img: &DynamicImage, params: &CornerDetectParams) -> Result<Vec<CornerPoint>> {
179    let block_size = ensure_odd_block_size(params.block_size);
180
181    let features = crate::feature::shi_tomasi::good_features_to_track(
182        img,
183        block_size,
184        params.threshold,
185        params.max_corners,
186        params.min_distance,
187    )?;
188
189    let corners: Vec<CornerPoint> = features
190        .iter()
191        .map(|&(x, y, score)| CornerPoint { x, y, score })
192        .collect();
193
194    Ok(corners)
195}
196
197/// FAST corner detection returning CornerPoints
198fn detect_fast(img: &DynamicImage, params: &CornerDetectParams) -> Result<Vec<CornerPoint>> {
199    // FAST returns a GrayImage; we extract corner coordinates from it
200    let corner_img = crate::feature::fast::fast_corners(
201        img,
202        params.threshold,
203        params.fast_n,
204        params.non_max_suppression,
205    )?;
206
207    let (width, height) = corner_img.dimensions();
208    let mut corners = Vec::new();
209
210    for y in 0..height {
211        for x in 0..width {
212            let val = corner_img.get_pixel(x, y)[0];
213            if val > 0 {
214                corners.push(CornerPoint {
215                    x: x as f32,
216                    y: y as f32,
217                    score: val as f32,
218                });
219            }
220        }
221    }
222
223    // Sort by score descending
224    corners.sort_by(|a, b| {
225        b.score
226            .partial_cmp(&a.score)
227            .unwrap_or(std::cmp::Ordering::Equal)
228    });
229
230    // Apply max_corners limit
231    if params.max_corners > 0 && corners.len() > params.max_corners {
232        corners.truncate(params.max_corners);
233    }
234
235    // Apply minimum distance filtering
236    if params.min_distance > 0 {
237        corners = filter_by_distance(&corners, params.min_distance as f32);
238    }
239
240    Ok(corners)
241}
242
243/// Extract corner points from a response map with non-maximum suppression
244fn extract_corners_from_response(
245    response: &scirs2_core::ndarray::Array2<f32>,
246    params: &CornerDetectParams,
247) -> Result<Vec<CornerPoint>> {
248    let (height, width) = response.dim();
249    let nms_radius = (params.block_size / 2).max(1);
250    let mut corners = Vec::new();
251
252    for y in nms_radius..height.saturating_sub(nms_radius) {
253        for x in nms_radius..width.saturating_sub(nms_radius) {
254            let r = response[[y, x]];
255            if r <= params.threshold {
256                continue;
257            }
258
259            // Non-maximum suppression in local neighborhood
260            if params.non_max_suppression {
261                let mut is_max = true;
262
263                'nms: for dy in y.saturating_sub(nms_radius)..=(y + nms_radius).min(height - 1) {
264                    for dx in x.saturating_sub(nms_radius)..=(x + nms_radius).min(width - 1) {
265                        if dy == y && dx == x {
266                            continue;
267                        }
268                        if response[[dy, dx]] >= r {
269                            is_max = false;
270                            break 'nms;
271                        }
272                    }
273                }
274
275                if !is_max {
276                    continue;
277                }
278            }
279
280            corners.push(CornerPoint {
281                x: x as f32,
282                y: y as f32,
283                score: r,
284            });
285        }
286    }
287
288    // Sort by score descending
289    corners.sort_by(|a, b| {
290        b.score
291            .partial_cmp(&a.score)
292            .unwrap_or(std::cmp::Ordering::Equal)
293    });
294
295    // Apply max corners limit
296    if params.max_corners > 0 && corners.len() > params.max_corners {
297        corners.truncate(params.max_corners);
298    }
299
300    // Apply minimum distance filtering
301    if params.min_distance > 0 {
302        corners = filter_by_distance(&corners, params.min_distance as f32);
303    }
304
305    Ok(corners)
306}
307
308/// Filter corners to maintain minimum distance between them
309fn filter_by_distance(corners: &[CornerPoint], min_dist: f32) -> Vec<CornerPoint> {
310    let min_dist_sq = min_dist * min_dist;
311    let mut filtered = Vec::new();
312
313    for corner in corners {
314        let too_close = filtered.iter().any(|existing: &CornerPoint| {
315            let dx = corner.x - existing.x;
316            let dy = corner.y - existing.y;
317            dx * dx + dy * dy < min_dist_sq
318        });
319
320        if !too_close {
321            filtered.push(*corner);
322        }
323    }
324
325    filtered
326}
327
328/// Ensure block_size is odd and >= 3
329fn ensure_odd_block_size(block_size: usize) -> usize {
330    let bs = block_size.max(3);
331    if bs.is_multiple_of(2) {
332        bs + 1
333    } else {
334        bs
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use image::{GrayImage, Luma};
342
343    fn create_corner_image() -> DynamicImage {
344        // Create an image with sharp corners (a bright square on dark background)
345        let mut buf = GrayImage::new(40, 40);
346        for y in 0..40u32 {
347            for x in 0..40u32 {
348                let val = if (10..30).contains(&x) && (10..30).contains(&y) {
349                    200u8
350                } else {
351                    30u8
352                };
353                buf.put_pixel(x, y, Luma([val]));
354            }
355        }
356        DynamicImage::ImageLuma8(buf)
357    }
358
359    #[test]
360    fn test_detect_corners_harris() {
361        let img = create_corner_image();
362        let params = CornerDetectParams {
363            method: CornerMethod::Harris,
364            threshold: 0.00001,
365            max_corners: 100,
366            min_distance: 3,
367            block_size: 3,
368            harris_k: 0.04,
369            non_max_suppression: true,
370            ..CornerDetectParams::default()
371        };
372
373        let corners = detect_corners(&img, &params).expect("Harris detection failed");
374        assert!(
375            !corners.is_empty(),
376            "Harris should detect corners of the square"
377        );
378    }
379
380    #[test]
381    fn test_detect_corners_shi_tomasi() {
382        let img = create_corner_image();
383        let params = CornerDetectParams {
384            method: CornerMethod::ShiTomasi,
385            threshold: 0.001,
386            max_corners: 50,
387            min_distance: 5,
388            block_size: 3,
389            ..CornerDetectParams::default()
390        };
391
392        let corners = detect_corners(&img, &params).expect("Shi-Tomasi detection failed");
393        assert!(
394            !corners.is_empty(),
395            "Shi-Tomasi should detect corners of the square"
396        );
397    }
398
399    #[test]
400    fn test_detect_corners_fast() {
401        let img = create_corner_image();
402        let params = CornerDetectParams {
403            method: CornerMethod::Fast,
404            threshold: 20.0,
405            max_corners: 50,
406            min_distance: 5,
407            fast_n: 9,
408            non_max_suppression: true,
409            ..CornerDetectParams::default()
410        };
411
412        let corners = detect_corners(&img, &params).expect("FAST detection failed");
413        // Just verify it runs without error
414        assert!(corners.len() <= 50, "Should respect max_corners limit");
415    }
416
417    #[test]
418    fn test_detect_corners_no_corners_uniform() {
419        // Uniform image should have no corners
420        let img = DynamicImage::new_luma8(30, 30);
421        let params = CornerDetectParams {
422            method: CornerMethod::Harris,
423            threshold: 0.01,
424            ..CornerDetectParams::default()
425        };
426
427        let corners = detect_corners(&img, &params).expect("Harris on uniform failed");
428        assert!(corners.is_empty(), "Uniform image should have no corners");
429    }
430
431    #[test]
432    fn test_corner_point_scores_sorted() {
433        let img = create_corner_image();
434        let params = CornerDetectParams {
435            method: CornerMethod::Harris,
436            threshold: 0.000001,
437            max_corners: 0,
438            min_distance: 0,
439            non_max_suppression: false,
440            ..CornerDetectParams::default()
441        };
442
443        let corners = detect_corners(&img, &params).expect("Harris detection failed");
444        // Verify sorted by descending score
445        for window in corners.windows(2) {
446            assert!(
447                window[0].score >= window[1].score,
448                "Corners should be sorted by descending score"
449            );
450        }
451    }
452
453    #[test]
454    fn test_min_distance_filtering() {
455        let corners = vec![
456            CornerPoint {
457                x: 0.0,
458                y: 0.0,
459                score: 1.0,
460            },
461            CornerPoint {
462                x: 1.0,
463                y: 0.0,
464                score: 0.9,
465            },
466            CornerPoint {
467                x: 10.0,
468                y: 10.0,
469                score: 0.8,
470            },
471            CornerPoint {
472                x: 11.0,
473                y: 10.0,
474                score: 0.7,
475            },
476        ];
477
478        let filtered = filter_by_distance(&corners, 5.0);
479        assert_eq!(filtered.len(), 2, "Should keep 2 clusters");
480        assert!((filtered[0].x - 0.0).abs() < 0.01);
481        assert!((filtered[1].x - 10.0).abs() < 0.01);
482    }
483
484    #[test]
485    fn test_corner_detect_params_default() {
486        let params = CornerDetectParams::default();
487        assert_eq!(params.method, CornerMethod::Harris);
488        assert!(params.threshold > 0.0);
489        assert!(params.max_corners > 0);
490        assert!(params.block_size >= 3);
491    }
492
493    #[test]
494    fn test_ensure_odd_block_size() {
495        assert_eq!(ensure_odd_block_size(3), 3);
496        assert_eq!(ensure_odd_block_size(4), 5);
497        assert_eq!(ensure_odd_block_size(5), 5);
498        assert_eq!(ensure_odd_block_size(1), 3);
499        assert_eq!(ensure_odd_block_size(2), 3);
500    }
501}