1use crate::error::{Result, VisionError};
23use scirs2_core::ndarray::Array2;
24use std::f64::consts::PI;
25
26pub(crate) const DESC_WORDS: usize = 8;
28pub const DESC_BITS: usize = DESC_WORDS * 32;
30
31#[derive(Debug, Clone)]
35pub struct OrbKeypoint {
36 pub x: f64,
38 pub y: f64,
40 pub score: f64,
42 pub orientation: f64,
44 pub level: usize,
46}
47
48#[derive(Debug, Clone)]
50pub struct OrbLikeDescriptor {
51 pub keypoint: OrbKeypoint,
53 pub descriptor: [u32; DESC_WORDS],
55}
56
57#[derive(Debug, Clone)]
59pub struct OrbLikeConfig {
60 pub max_features: usize,
62 pub fast_threshold: u8,
64 pub fast_n: usize,
66 pub harris_k: f64,
68 pub harris_sigma: f64,
70 pub nms_radius: usize,
72 pub scale_factor: f64,
74 pub num_levels: usize,
76 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
96pub 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 let pyramid = build_pyramid(image, config)?;
121
122 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 let fast_pts = detect_fast(level_img, config.fast_threshold, config.fast_n)?;
130 if fast_pts.is_empty() {
131 continue;
132 }
133
134 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 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 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
187fn 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
212fn 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 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; }
258
259 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
284fn has_contiguous_run(arc: &[i8], n: usize, target: i8) -> bool {
287 let len = arc.len();
288 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
303fn compute_harris_response(image: &Array2<f64>, k: f64, sigma: f64) -> Result<Array2<f64>> {
310 let (rows, cols) = image.dim();
311
312 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 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 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 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
356fn 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 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
390fn 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; let mut m01 = 0.0f64; 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
426fn generate_brief_pairs(patch_radius: usize) -> Vec<(i32, i32, i32, i32)> {
431 let limit = patch_radius as i32;
434 let total = DESC_BITS; let mut pairs = Vec::with_capacity(total);
436
437 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 let u1 = (*s >> 32) as f64 / u32::MAX as f64;
446 let u2 = (*s & 0xFFFF_FFFF) as f64 / u32::MAX as f64;
447 let g = (u1 - 0.5) * 2.0; let _ = u2;
452 (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 if !(r1 == r2 && c1 == c2) {
463 pairs.push((r1, c1, r2, c2));
464 }
465 }
466
467 pairs
468}
469
470fn 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 let mut words = [0u32; DESC_WORDS];
491
492 for (bit_idx, (dr1, dc1, dr2, dc2)) in pairs.iter().enumerate() {
493 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
517fn 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 let blurred =
533 crate::features::sift_like::gaussian_blur(¤t, 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
541fn 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
570pub 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#[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 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 let mut img = Array2::<f64>::zeros((64, 64));
640 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 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}