Skip to main content

scirs2_vision/features/
orb_like.rs

1//! ORB-like feature detector with FAST corners and binary BRIEF descriptor
2//!
3//! This module implements an ORB-inspired (Oriented FAST and Rotated BRIEF) algorithm:
4//!
5//! 1. **FAST corner detection** – pixel-ring threshold test with adaptive score
6//! 2. **Harris score** – refines FAST corners by curvature response
7//! 3. **Orientation assignment** – intensity centroid in a circular patch
8//! 4. **BRIEF-like binary descriptor** – 256 pixel-pair tests drawn from a
9//!    2-D Gaussian distribution, rotated to the keypoint orientation (rBRIEF)
10//!
11//! Binary descriptors are stored as packed `u32` words (8 × `u32` = 256 bits).
12//!
13//! # References
14//!
15//! - Rosten, E. & Drummond, T. (2006). Machine learning for high-speed corner detection.
16//!   ECCV 2006.
17//! - Calonder, M. et al. (2010). BRIEF: Binary Robust Independent Elementary Features.
18//!   ECCV 2010.
19//! - Rublee, E. et al. (2011). ORB: An efficient alternative to SIFT or SURF.
20//!   ICCV 2011.
21
22use crate::error::{Result, VisionError};
23use scirs2_core::ndarray::Array2;
24use std::f64::consts::PI;
25
26// Number of u32 words for 256-bit descriptor
27pub(crate) const DESC_WORDS: usize = 8;
28/// Total descriptor bits
29pub const DESC_BITS: usize = DESC_WORDS * 32;
30
31// ─── Public types ────────────────────────────────────────────────────────────
32
33/// A keypoint detected by the ORB-like detector.
34#[derive(Debug, Clone)]
35pub struct OrbKeypoint {
36    /// Column (x) coordinate in the image
37    pub x: f64,
38    /// Row (y) coordinate in the image
39    pub y: f64,
40    /// Harris corner response score
41    pub score: f64,
42    /// Intensity centroid orientation in radians \[−π, π\]
43    pub orientation: f64,
44    /// Scale-pyramid level (0 = full resolution)
45    pub level: usize,
46}
47
48/// An ORB-like descriptor: keypoint + 256-bit binary descriptor.
49#[derive(Debug, Clone)]
50pub struct OrbLikeDescriptor {
51    /// Associated keypoint
52    pub keypoint: OrbKeypoint,
53    /// 256-bit binary descriptor packed as 8 × u32
54    pub descriptor: [u32; DESC_WORDS],
55}
56
57/// Configuration for the ORB-like detector.
58#[derive(Debug, Clone)]
59pub struct OrbLikeConfig {
60    /// Maximum number of features to detect (0 = unlimited)
61    pub max_features: usize,
62    /// FAST intensity-difference threshold (0–255 range)
63    pub fast_threshold: u8,
64    /// Minimum number of contiguous Bresenham-circle pixels that must pass the test
65    pub fast_n: usize,
66    /// Harris window half-size for scoring
67    pub harris_k: f64,
68    /// Harris window radius (σ for Gaussian weighting)
69    pub harris_sigma: f64,
70    /// NMS radius (pixels): keeps the single strongest response in this neighbourhood
71    pub nms_radius: usize,
72    /// Scale factor between pyramid levels
73    pub scale_factor: f64,
74    /// Number of pyramid levels
75    pub num_levels: usize,
76    /// Half-patch radius for orientation centroid (should cover the descriptor patch)
77    pub patch_radius: usize,
78}
79
80impl Default for OrbLikeConfig {
81    fn default() -> Self {
82        Self {
83            max_features: 500,
84            fast_threshold: 20,
85            fast_n: 9,
86            harris_k: 0.04,
87            harris_sigma: 3.0,
88            nms_radius: 5,
89            scale_factor: 1.2,
90            num_levels: 4,
91            patch_radius: 15,
92        }
93    }
94}
95
96// ─── Entry point ─────────────────────────────────────────────────────────────
97
98/// Detect ORB-like keypoints and compute 256-bit binary descriptors.
99///
100/// # Arguments
101///
102/// * `image` – Grayscale image, values in \[0, 1\]
103/// * `config` – Detector / descriptor parameters
104///
105/// # Returns
106///
107/// Vector of [`OrbLikeDescriptor`] sorted by score (descending).
108pub fn detect_and_describe_orb(
109    image: &Array2<f64>,
110    config: &OrbLikeConfig,
111) -> Result<Vec<OrbLikeDescriptor>> {
112    let (h, w) = image.dim();
113    if h < 16 || w < 16 {
114        return Err(VisionError::InvalidParameter(
115            "Image must be at least 16×16 pixels for ORB detection".to_string(),
116        ));
117    }
118
119    // Build image pyramid
120    let pyramid = build_pyramid(image, config)?;
121
122    // Per-level detection
123    let mut all_descs: Vec<OrbLikeDescriptor> = Vec::new();
124
125    for (level, level_img) in pyramid.iter().enumerate() {
126        let scale = config.scale_factor.powi(level as i32);
127
128        // 1. FAST corners
129        let fast_pts = detect_fast(level_img, config.fast_threshold, config.fast_n)?;
130        if fast_pts.is_empty() {
131            continue;
132        }
133
134        // 2. Harris score + NMS
135        let harris = compute_harris_response(level_img, config.harris_k, config.harris_sigma)?;
136        let scored: Vec<(usize, usize, f64)> = fast_pts
137            .into_iter()
138            .map(|(r, c)| {
139                let score = harris.get([r, c]).copied().unwrap_or(0.0);
140                (r, c, score)
141            })
142            .collect();
143
144        let nms_pts = non_max_suppression(&scored, config.nms_radius, level_img.dim());
145
146        // 3. Orientation via intensity centroid
147        // 4. Compute rBRIEF descriptors
148        let border = config.patch_radius + 2;
149        let (lrows, lcols) = level_img.dim();
150
151        for (r, c, score) in nms_pts {
152            if r < border || r + border >= lrows || c < border || c + border >= lcols {
153                continue;
154            }
155
156            let orientation = intensity_centroid_orientation(level_img, r, c, config.patch_radius);
157            let descriptor = brief_descriptor(level_img, r, c, orientation, config.patch_radius)?;
158
159            all_descs.push(OrbLikeDescriptor {
160                keypoint: OrbKeypoint {
161                    x: c as f64 * scale,
162                    y: r as f64 * scale,
163                    score,
164                    orientation,
165                    level,
166                },
167                descriptor,
168            });
169        }
170    }
171
172    // Sort by score
173    all_descs.sort_unstable_by(|a, b| {
174        b.keypoint
175            .score
176            .partial_cmp(&a.keypoint.score)
177            .unwrap_or(std::cmp::Ordering::Equal)
178    });
179
180    if config.max_features > 0 && all_descs.len() > config.max_features {
181        all_descs.truncate(config.max_features);
182    }
183
184    Ok(all_descs)
185}
186
187// ─── FAST detector ────────────────────────────────────────────────────────────
188
189/// Bresenham circle offsets for FAST-9/12/16 detector (16-pixel ring).
190/// Returns (Δrow, Δcol) pairs for the 16 ring positions.
191fn bresenham_circle_16() -> [(i32, i32); 16] {
192    [
193        (-3, 0),
194        (-3, 1),
195        (-2, 2),
196        (-1, 3),
197        (0, 3),
198        (1, 3),
199        (2, 2),
200        (3, 1),
201        (3, 0),
202        (3, -1),
203        (2, -2),
204        (1, -3),
205        (0, -3),
206        (-1, -3),
207        (-2, -2),
208        (-3, -1),
209    ]
210}
211
212/// Detect FAST corners.
213///
214/// For each candidate pixel p, check whether at least `n` contiguous pixels
215/// on the 16-pixel Bresenham ring are all brighter than p + t or all darker
216/// than p − t.
217fn detect_fast(image: &Array2<f64>, threshold: u8, n: usize) -> Result<Vec<(usize, usize)>> {
218    let (rows, cols) = image.dim();
219    let t = threshold as f64 / 255.0;
220    let ring = bresenham_circle_16();
221    let ring_len = ring.len();
222    let border = 4usize;
223
224    let mut corners = Vec::new();
225
226    for r in border..(rows - border) {
227        for c in border..(cols - border) {
228            let p = image[[r, c]];
229            let high = p + t;
230            let low = p - t;
231
232            // Fast pre-test: pixels 1, 5, 9, 13 (0-indexed: 0, 4, 8, 12)
233            let vals: [f64; 4] = [
234                image[[
235                    (r as i32 + ring[0].0) as usize,
236                    (c as i32 + ring[0].1) as usize,
237                ]],
238                image[[
239                    (r as i32 + ring[4].0) as usize,
240                    (c as i32 + ring[4].1) as usize,
241                ]],
242                image[[
243                    (r as i32 + ring[8].0) as usize,
244                    (c as i32 + ring[8].1) as usize,
245                ]],
246                image[[
247                    (r as i32 + ring[12].0) as usize,
248                    (c as i32 + ring[12].1) as usize,
249                ]],
250            ];
251
252            let bright_count = vals.iter().filter(|&&v| v > high).count();
253            let dark_count = vals.iter().filter(|&&v| v < low).count();
254
255            if bright_count < 2 && dark_count < 2 {
256                continue; // fails pre-test: cannot have ≥9 contiguous
257            }
258
259            // Full ring check – look for n contiguous above or below
260            // Build arc classification: +1 bright, -1 dark, 0 similar
261            let mut arc: Vec<i8> = Vec::with_capacity(ring_len);
262            for (dr, dc) in ring.iter() {
263                let nr = (r as i32 + dr) as usize;
264                let nc = (c as i32 + dc) as usize;
265                let v = image[[nr, nc]];
266                arc.push(if v > high {
267                    1
268                } else if v < low {
269                    -1
270                } else {
271                    0
272                });
273            }
274
275            if has_contiguous_run(&arc, n, 1) || has_contiguous_run(&arc, n, -1) {
276                corners.push((r, c));
277            }
278        }
279    }
280
281    Ok(corners)
282}
283
284/// Returns true if `arc` (treated as circular) has ≥ `n` contiguous pixels
285/// with value `target`.
286fn has_contiguous_run(arc: &[i8], n: usize, target: i8) -> bool {
287    let len = arc.len();
288    // Unroll circle for easier scanning
289    let mut count = 0usize;
290    for i in 0..(2 * len) {
291        if arc[i % len] == target {
292            count += 1;
293            if count >= n {
294                return true;
295            }
296        } else {
297            count = 0;
298        }
299    }
300    false
301}
302
303// ─── Harris response ──────────────────────────────────────────────────────────
304
305/// Compute Harris corner response at every pixel.
306///
307/// R = det(M) − k · trace²(M)   where M is the gradient structure tensor,
308/// computed using a Gaussian-weighted window.
309fn compute_harris_response(image: &Array2<f64>, k: f64, sigma: f64) -> Result<Array2<f64>> {
310    let (rows, cols) = image.dim();
311
312    // Compute image gradients (Sobel-like)
313    let mut ix = Array2::<f64>::zeros((rows, cols));
314    let mut iy = Array2::<f64>::zeros((rows, cols));
315
316    for r in 1..(rows - 1) {
317        for c in 1..(cols - 1) {
318            ix[[r, c]] = (image[[r, c + 1]] - image[[r, c - 1]]) * 0.5;
319            iy[[r, c]] = (image[[r + 1, c]] - image[[r - 1, c]]) * 0.5;
320        }
321    }
322
323    // Compute products
324    let mut ixx = Array2::<f64>::zeros((rows, cols));
325    let mut iyy = Array2::<f64>::zeros((rows, cols));
326    let mut ixy = Array2::<f64>::zeros((rows, cols));
327    for r in 0..rows {
328        for c in 0..cols {
329            ixx[[r, c]] = ix[[r, c]] * ix[[r, c]];
330            iyy[[r, c]] = iy[[r, c]] * iy[[r, c]];
331            ixy[[r, c]] = ix[[r, c]] * iy[[r, c]];
332        }
333    }
334
335    // Gaussian smoothing of structure tensor components
336    let ixx_s = crate::features::sift_like::gaussian_blur(&ixx, sigma)?;
337    let iyy_s = crate::features::sift_like::gaussian_blur(&iyy, sigma)?;
338    let ixy_s = crate::features::sift_like::gaussian_blur(&ixy, sigma)?;
339
340    // Harris response
341    let mut response = Array2::<f64>::zeros((rows, cols));
342    for r in 0..rows {
343        for c in 0..cols {
344            let a = ixx_s[[r, c]];
345            let b = ixy_s[[r, c]];
346            let d = iyy_s[[r, c]];
347            let det = a * d - b * b;
348            let trace = a + d;
349            response[[r, c]] = det - k * trace * trace;
350        }
351    }
352
353    Ok(response)
354}
355
356// ─── Non-maximum suppression ──────────────────────────────────────────────────
357
358/// Keep only the locally maximal point within radius `r` (in a grid sense).
359fn non_max_suppression(
360    pts: &[(usize, usize, f64)],
361    radius: usize,
362    dims: (usize, usize),
363) -> Vec<(usize, usize, f64)> {
364    let (rows, cols) = dims;
365    let cell = (radius * 2 + 1).max(1);
366    let grid_rows = rows.div_ceil(cell);
367    let grid_cols = cols.div_ceil(cell);
368
369    // Place each point into a grid cell, keeping the best score
370    let mut grid: Vec<Vec<Option<(usize, usize, f64)>>> = vec![vec![None; grid_cols]; grid_rows];
371
372    for &(r, c, score) in pts {
373        let gr = r / cell;
374        let gc = c / cell;
375        if gr < grid_rows && gc < grid_cols {
376            let cell_val = &mut grid[gr][gc];
377            if cell_val.is_none_or(|(_, _, s)| score > s) {
378                *cell_val = Some((r, c, score));
379            }
380        }
381    }
382
383    grid.into_iter()
384        .flatten()
385        .flatten()
386        .filter(|(_, _, s)| *s > 0.0)
387        .collect()
388}
389
390// ─── Orientation via intensity centroid ──────────────────────────────────────
391
392/// Compute the intensity centroid direction over a circular patch of given radius.
393///
394/// Returns angle in radians in \[−π, π\].
395fn intensity_centroid_orientation(
396    image: &Array2<f64>,
397    row: usize,
398    col: usize,
399    radius: usize,
400) -> f64 {
401    let (rows, cols) = image.dim();
402    let r = radius as i64;
403
404    let mut m10 = 0.0f64; // first moment in x
405    let mut m01 = 0.0f64; // first moment in y
406
407    for dy in -r..=r {
408        for dx in -r..=r {
409            if dx * dx + dy * dy > r * r {
410                continue;
411            }
412            let nr = row as i64 + dy;
413            let nc = col as i64 + dx;
414            if nr < 0 || nr >= rows as i64 || nc < 0 || nc >= cols as i64 {
415                continue;
416            }
417            let v = image[[nr as usize, nc as usize]];
418            m10 += dx as f64 * v;
419            m01 += dy as f64 * v;
420        }
421    }
422
423    m01.atan2(m10)
424}
425
426// ─── Steered BRIEF descriptor ─────────────────────────────────────────────────
427
428/// Pre-computed Gaussian-sampled BRIEF pair offsets (generated deterministically).
429/// Each pair is (Δr1, Δc1, Δr2, Δc2).
430fn generate_brief_pairs(patch_radius: usize) -> Vec<(i32, i32, i32, i32)> {
431    // Generate a deterministic pseudo-random sequence using LCG
432    // seeded at 0xDEADBEEF to reproduce the same pairs every time.
433    let limit = patch_radius as i32;
434    let total = DESC_BITS; // 256 pairs
435    let mut pairs = Vec::with_capacity(total);
436
437    // Use LCG to generate reproducible Gaussian-like samples within [-limit, limit]
438    let mut state: u64 = 0xDEAD_BEEF_CAFE_BABE;
439
440    let next_i32 = |s: &mut u64| -> i32 {
441        *s = s
442            .wrapping_mul(6_364_136_223_846_793_005)
443            .wrapping_add(1_442_695_040_888_963_407);
444        // Box-Muller to approximate Gaussian, then scale
445        let u1 = (*s >> 32) as f64 / u32::MAX as f64;
446        let u2 = (*s & 0xFFFF_FFFF) as f64 / u32::MAX as f64;
447        // Box-Muller: approximate by linear mapping of U(0,1) → approximated Gaussian
448        // Use: G ≈ (u – 0.5) × 2 × 3σ / limit clipped to [-1,1]
449        let g = (u1 - 0.5) * 2.0; // uniform [-1, 1]
450                                  // Use 2nd sample for another dimension
451        let _ = u2;
452        // Quantise to patch
453        (g * limit as f64).round() as i32
454    };
455
456    while pairs.len() < total {
457        let r1 = next_i32(&mut state).clamp(-limit, limit);
458        let c1 = next_i32(&mut state).clamp(-limit, limit);
459        let r2 = next_i32(&mut state).clamp(-limit, limit);
460        let c2 = next_i32(&mut state).clamp(-limit, limit);
461        // Avoid identical pairs
462        if !(r1 == r2 && c1 == c2) {
463            pairs.push((r1, c1, r2, c2));
464        }
465    }
466
467    pairs
468}
469
470/// Compute 256-bit rBRIEF descriptor at the given keypoint location.
471///
472/// The test pattern is rotated by `orientation` to achieve rotation invariance.
473fn brief_descriptor(
474    image: &Array2<f64>,
475    row: usize,
476    col: usize,
477    orientation: f64,
478    patch_radius: usize,
479) -> Result<[u32; DESC_WORDS]> {
480    let (rows, cols) = image.dim();
481    let pairs = generate_brief_pairs(patch_radius);
482
483    let cos_a = orientation.cos();
484    let sin_a = orientation.sin();
485
486    // Apply smoothing before sampling (as in original ORB)
487    // We'll compute the descriptor directly with a small blur weight per sample
488    // (simplified: use the image directly; production code would pre-smooth)
489
490    let mut words = [0u32; DESC_WORDS];
491
492    for (bit_idx, (dr1, dc1, dr2, dc2)) in pairs.iter().enumerate() {
493        // Rotate the offsets into the image frame
494        let rot_r1 = (cos_a * *dr1 as f64 - sin_a * *dc1 as f64).round() as i64;
495        let rot_c1 = (sin_a * *dr1 as f64 + cos_a * *dc1 as f64).round() as i64;
496        let rot_r2 = (cos_a * *dr2 as f64 - sin_a * *dc2 as f64).round() as i64;
497        let rot_c2 = (sin_a * *dr2 as f64 + cos_a * *dc2 as f64).round() as i64;
498
499        let nr1 = (row as i64 + rot_r1).clamp(0, rows as i64 - 1) as usize;
500        let nc1 = (col as i64 + rot_c1).clamp(0, cols as i64 - 1) as usize;
501        let nr2 = (row as i64 + rot_r2).clamp(0, rows as i64 - 1) as usize;
502        let nc2 = (col as i64 + rot_c2).clamp(0, cols as i64 - 1) as usize;
503
504        let p1 = image[[nr1, nc1]];
505        let p2 = image[[nr2, nc2]];
506
507        if p1 < p2 {
508            let word_idx = bit_idx / 32;
509            let bit_pos = bit_idx % 32;
510            words[word_idx] |= 1u32 << bit_pos;
511        }
512    }
513
514    Ok(words)
515}
516
517// ─── Pyramid builder ──────────────────────────────────────────────────────────
518
519fn build_pyramid(image: &Array2<f64>, config: &OrbLikeConfig) -> Result<Vec<Array2<f64>>> {
520    let mut pyramid = Vec::with_capacity(config.num_levels);
521    let mut current = image.to_owned();
522    pyramid.push(current.clone());
523
524    for _ in 1..config.num_levels {
525        let (rows, cols) = current.dim();
526        let new_rows = ((rows as f64 / config.scale_factor).round() as usize).max(8);
527        let new_cols = ((cols as f64 / config.scale_factor).round() as usize).max(8);
528        if new_rows < 16 || new_cols < 16 {
529            break;
530        }
531        // Gaussian blur before downsampling to avoid aliasing
532        let blurred =
533            crate::features::sift_like::gaussian_blur(&current, config.scale_factor.ln())?;
534        current = resize_bilinear(&blurred, new_rows, new_cols);
535        pyramid.push(current.clone());
536    }
537
538    Ok(pyramid)
539}
540
541/// Bilinear resize to target (rows, cols).
542fn resize_bilinear(src: &Array2<f64>, dst_rows: usize, dst_cols: usize) -> Array2<f64> {
543    let (src_rows, src_cols) = src.dim();
544    let mut dst = Array2::<f64>::zeros((dst_rows, dst_cols));
545
546    let row_scale = (src_rows - 1) as f64 / (dst_rows - 1).max(1) as f64;
547    let col_scale = (src_cols - 1) as f64 / (dst_cols - 1).max(1) as f64;
548
549    for r in 0..dst_rows {
550        let src_r = r as f64 * row_scale;
551        let r0 = src_r.floor() as usize;
552        let r1 = (r0 + 1).min(src_rows - 1);
553        let alpha_r = src_r - r0 as f64;
554
555        for c in 0..dst_cols {
556            let src_c = c as f64 * col_scale;
557            let c0 = src_c.floor() as usize;
558            let c1 = (c0 + 1).min(src_cols - 1);
559            let alpha_c = src_c - c0 as f64;
560
561            let top = src[[r0, c0]] * (1.0 - alpha_c) + src[[r0, c1]] * alpha_c;
562            let bot = src[[r1, c0]] * (1.0 - alpha_c) + src[[r1, c1]] * alpha_c;
563            dst[[r, c]] = top * (1.0 - alpha_r) + bot * alpha_r;
564        }
565    }
566
567    dst
568}
569
570// ─── Hamming distance ─────────────────────────────────────────────────────────
571
572/// Compute Hamming distance between two 256-bit descriptors.
573pub fn hamming_distance(a: &[u32; DESC_WORDS], b: &[u32; DESC_WORDS]) -> u32 {
574    a.iter()
575        .zip(b.iter())
576        .map(|(&x, &y)| (x ^ y).count_ones())
577        .sum()
578}
579
580// ─── Tests ────────────────────────────────────────────────────────────────────
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use scirs2_core::ndarray::Array2;
586
587    fn checkerboard(size: usize) -> Array2<f64> {
588        Array2::from_shape_fn((size, size), |(r, c)| {
589            if (r / 8 + c / 8) % 2 == 0 {
590                1.0
591            } else {
592                0.0
593            }
594        })
595    }
596
597    #[test]
598    fn test_orb_detect_runs() {
599        let img = checkerboard(128);
600        let config = OrbLikeConfig {
601            max_features: 50,
602            fast_threshold: 10,
603            fast_n: 9,
604            num_levels: 2,
605            ..Default::default()
606        };
607        let descs = detect_and_describe_orb(&img, &config)
608            .expect("detect_and_describe_orb should succeed on valid image");
609        // Should find corners at checkerboard transitions
610        assert!(!descs.is_empty(), "Expected ORB keypoints on checkerboard");
611        for d in &descs {
612            assert_eq!(d.descriptor.len(), DESC_WORDS);
613        }
614    }
615
616    #[test]
617    fn test_hamming_distance_identical() {
618        let desc = [0xABCD1234u32; DESC_WORDS];
619        assert_eq!(hamming_distance(&desc, &desc), 0);
620    }
621
622    #[test]
623    fn test_hamming_distance_complement() {
624        let a = [0u32; DESC_WORDS];
625        let b = [u32::MAX; DESC_WORDS];
626        assert_eq!(hamming_distance(&a, &b), (DESC_WORDS * 32) as u32);
627    }
628
629    #[test]
630    fn test_too_small_image() {
631        let img = Array2::<f64>::zeros((8, 8));
632        let result = detect_and_describe_orb(&img, &OrbLikeConfig::default());
633        assert!(result.is_err());
634    }
635
636    #[test]
637    fn test_fast_corner_detection() {
638        // Create a simple corner pattern
639        let mut img = Array2::<f64>::zeros((64, 64));
640        // Bright square in top-left quadrant
641        for r in 10..30 {
642            for c in 10..30 {
643                img[[r, c]] = 1.0;
644            }
645        }
646        let corners = detect_fast(&img, 30, 9).expect("detect_fast should succeed on valid image");
647        // Should detect corners at ~(10,10), (10,30), (30,10), (30,30)
648        assert!(
649            !corners.is_empty(),
650            "Expected FAST corners on bright square"
651        );
652    }
653
654    #[test]
655    fn test_orientation_range() {
656        let img = checkerboard(64);
657        let angle = intensity_centroid_orientation(&img, 32, 32, 8);
658        assert!((-PI..=PI).contains(&angle));
659    }
660}