Skip to main content

oximedia_align/
features.rs

1//! Patent-free feature detection and matching.
2//!
3//! This module provides implementations of feature detection and description
4//! algorithms that are free from patent restrictions:
5//!
6//! - FAST corner detection
7//! - BRIEF binary descriptors
8//! - ORB (Oriented FAST and Rotated BRIEF)
9//! - Brute-force matching with Hamming distance
10
11use crate::{AlignError, AlignResult, Point2D};
12use rayon::prelude::*;
13use std::f64::consts::PI;
14
15/// A detected keypoint feature
16#[derive(Debug, Clone)]
17pub struct Keypoint {
18    /// Location of the keypoint
19    pub point: Point2D,
20    /// Scale (size) of the feature
21    pub scale: f32,
22    /// Orientation in radians
23    pub orientation: f32,
24    /// Response (strength) of the feature
25    pub response: f32,
26}
27
28impl Keypoint {
29    /// Create a new keypoint
30    #[must_use]
31    pub fn new(x: f64, y: f64, scale: f32, orientation: f32, response: f32) -> Self {
32        Self {
33            point: Point2D::new(x, y),
34            scale,
35            orientation,
36            response,
37        }
38    }
39}
40
41/// Binary descriptor (256 bits = 32 bytes)
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct BinaryDescriptor {
44    /// Binary descriptor data
45    pub data: [u8; 32],
46}
47
48impl BinaryDescriptor {
49    /// Create a new binary descriptor
50    #[must_use]
51    pub fn new(data: [u8; 32]) -> Self {
52        Self { data }
53    }
54
55    /// Compute Hamming distance to another descriptor
56    #[must_use]
57    pub fn hamming_distance(&self, other: &Self) -> u32 {
58        self.data
59            .iter()
60            .zip(&other.data)
61            .map(|(a, b)| (a ^ b).count_ones())
62            .sum()
63    }
64
65    /// Create zero descriptor
66    #[must_use]
67    pub fn zero() -> Self {
68        Self { data: [0; 32] }
69    }
70}
71
72/// Compute Hamming distance between two byte slices using u64 popcount batching.
73///
74/// Processes 8 bytes at a time by casting to `u64` and using the hardware
75/// `POPCNT` instruction via `u64::count_ones()`. This is significantly faster
76/// than byte-by-byte XOR when matching large numbers of BRIEF descriptors.
77///
78/// The slices must be the same length; any trailing bytes (if length is not a
79/// multiple of 8) are handled byte-by-byte to avoid out-of-bounds access.
80///
81/// # Panics
82///
83/// Panics if `a.len() != b.len()`.
84#[must_use]
85pub fn hamming_distance_simd(a: &[u8], b: &[u8]) -> u32 {
86    assert_eq!(a.len(), b.len(), "descriptor slices must have equal length");
87
88    let mut total = 0u32;
89    let chunks = a.len() / 8;
90
91    // Process 8-byte (u64) chunks — maps to POPCNT on x86-64 / AArch64.
92    for i in 0..chunks {
93        let offset = i * 8;
94        let au = u64::from_le_bytes([
95            a[offset],
96            a[offset + 1],
97            a[offset + 2],
98            a[offset + 3],
99            a[offset + 4],
100            a[offset + 5],
101            a[offset + 6],
102            a[offset + 7],
103        ]);
104        let bu = u64::from_le_bytes([
105            b[offset],
106            b[offset + 1],
107            b[offset + 2],
108            b[offset + 3],
109            b[offset + 4],
110            b[offset + 5],
111            b[offset + 6],
112            b[offset + 7],
113        ]);
114        total += (au ^ bu).count_ones();
115    }
116
117    // Handle the remaining bytes (tail) if length % 8 != 0.
118    let tail_start = chunks * 8;
119    for (&av, &bv) in a[tail_start..].iter().zip(&b[tail_start..]) {
120        total += (av ^ bv).count_ones();
121    }
122
123    total
124}
125
126/// A pair of matched features
127#[derive(Debug, Clone)]
128pub struct MatchPair {
129    /// Index in first image
130    pub idx1: usize,
131    /// Index in second image
132    pub idx2: usize,
133    /// Hamming distance
134    pub distance: u32,
135    /// Point in first image
136    pub point1: Point2D,
137    /// Point in second image
138    pub point2: Point2D,
139}
140
141impl MatchPair {
142    /// Create a new match pair
143    #[must_use]
144    pub fn new(idx1: usize, idx2: usize, distance: u32, point1: Point2D, point2: Point2D) -> Self {
145        Self {
146            idx1,
147            idx2,
148            distance,
149            point1,
150            point2,
151        }
152    }
153}
154
155/// FAST corner detector
156pub struct FastDetector {
157    /// Threshold for corner detection
158    pub threshold: u8,
159    /// Non-maximum suppression window size
160    pub nms_window: usize,
161}
162
163impl Default for FastDetector {
164    fn default() -> Self {
165        Self {
166            threshold: 20,
167            nms_window: 3,
168        }
169    }
170}
171
172impl FastDetector {
173    /// Create a new FAST detector
174    #[must_use]
175    pub fn new(threshold: u8) -> Self {
176        Self {
177            threshold,
178            nms_window: 3,
179        }
180    }
181
182    /// Detect FAST corners in a grayscale image
183    ///
184    /// # Errors
185    /// Returns error if image dimensions are invalid
186    pub fn detect(&self, image: &[u8], width: usize, height: usize) -> AlignResult<Vec<Keypoint>> {
187        if image.len() != width * height {
188            return Err(AlignError::InvalidConfig("Image size mismatch".to_string()));
189        }
190
191        let mut corners = Vec::new();
192        let radius = 3;
193
194        // FAST-9 circle offsets (16 pixels in a circle of radius 3)
195        let circle = self.get_circle_offsets(width);
196
197        // Detect corners (skip border)
198        for y in radius..height - radius {
199            for x in radius..width - radius {
200                let idx = y * width + x;
201                let center = image[idx];
202
203                if self.is_corner(image, idx, center, &circle) {
204                    let response = self.compute_response(image, idx, center, &circle);
205                    corners.push(Keypoint::new(x as f64, y as f64, 1.0, 0.0, response));
206                }
207            }
208        }
209
210        // Apply non-maximum suppression
211        let corners = self.non_maximum_suppression(&corners, width, height);
212
213        Ok(corners)
214    }
215
216    /// Get FAST-9 circle offsets
217    fn get_circle_offsets(&self, width: usize) -> Vec<isize> {
218        let w = width as isize;
219        vec![
220            -w * 3,     // 0: top
221            -w * 3 + 1, // 1
222            -w * 2 + 2, // 2
223            -w + 3,     // 3: right
224            3,          // 4
225            w + 3,      // 5
226            w * 2 + 2,  // 6
227            w * 3 + 1,  // 7: bottom
228            w * 3,      // 8
229            w * 3 - 1,  // 9
230            w * 2 - 2,  // 10
231            w - 3,      // 11: left
232            -3,         // 12
233            -w - 3,     // 13
234            -w * 2 - 2, // 14
235            -w * 3 - 1, // 15
236        ]
237    }
238
239    /// Check if pixel is a corner
240    fn is_corner(&self, image: &[u8], center_idx: usize, center_val: u8, circle: &[isize]) -> bool {
241        let threshold = i16::from(self.threshold);
242        let center = i16::from(center_val);
243
244        // Count consecutive brighter or darker pixels
245        let mut brighter = 0;
246        let mut darker = 0;
247        let mut max_consecutive_brighter = 0;
248        let mut max_consecutive_darker = 0;
249
250        for &offset in circle {
251            let idx = (center_idx as isize + offset) as usize;
252            let val = i16::from(image[idx]);
253            let diff = val - center;
254
255            if diff > threshold {
256                brighter += 1;
257                darker = 0;
258                max_consecutive_brighter = max_consecutive_brighter.max(brighter);
259            } else if diff < -threshold {
260                darker += 1;
261                brighter = 0;
262                max_consecutive_darker = max_consecutive_darker.max(darker);
263            } else {
264                brighter = 0;
265                darker = 0;
266            }
267        }
268
269        // Need at least 9 consecutive pixels (FAST-9)
270        max_consecutive_brighter >= 9 || max_consecutive_darker >= 9
271    }
272
273    /// Compute corner response (strength)
274    fn compute_response(
275        &self,
276        image: &[u8],
277        center_idx: usize,
278        center_val: u8,
279        circle: &[isize],
280    ) -> f32 {
281        let center = i16::from(center_val);
282        let mut sum_abs_diff = 0i16;
283
284        for &offset in circle {
285            let idx = (center_idx as isize + offset) as usize;
286            let val = i16::from(image[idx]);
287            sum_abs_diff += (val - center).abs();
288        }
289
290        f32::from(sum_abs_diff)
291    }
292
293    /// Non-maximum suppression
294    fn non_maximum_suppression(
295        &self,
296        corners: &[Keypoint],
297        _width: usize,
298        _height: usize,
299    ) -> Vec<Keypoint> {
300        let mut suppressed = vec![false; corners.len()];
301        let window = self.nms_window;
302
303        for i in 0..corners.len() {
304            if suppressed[i] {
305                continue;
306            }
307
308            let ki = &corners[i];
309
310            for (j, kj) in corners.iter().enumerate().skip(i + 1) {
311                if suppressed[j] {
312                    continue;
313                }
314
315                let dx = (ki.point.x - kj.point.x).abs();
316                let dy = (ki.point.y - kj.point.y).abs();
317
318                if dx < window as f64 && dy < window as f64 {
319                    // Keep the one with higher response
320                    if ki.response > kj.response {
321                        suppressed[j] = true;
322                    } else {
323                        suppressed[i] = true;
324                        break;
325                    }
326                }
327            }
328        }
329
330        corners
331            .iter()
332            .enumerate()
333            .filter(|(i, _)| !suppressed[*i])
334            .map(|(_, k)| k.clone())
335            .collect()
336    }
337}
338
339/// Sub-pixel corner refinement using parabolic fitting.
340///
341/// After FAST detects corners at integer-pixel positions, this refiner
342/// fits a 2-D parabola to the corner response in a small neighbourhood and
343/// finds the sub-pixel maximum.  This improves localisation accuracy from
344/// ~0.5 px to ~0.05 px, which is critical for high-quality homography
345/// estimation and camera calibration.
346pub struct SubPixelRefiner {
347    /// Half-size of the refinement window.
348    pub half_window: usize,
349    /// Maximum number of Newton iterations.
350    pub max_iterations: usize,
351    /// Convergence threshold (pixels).
352    pub epsilon: f64,
353}
354
355impl Default for SubPixelRefiner {
356    fn default() -> Self {
357        Self {
358            half_window: 3,
359            max_iterations: 10,
360            epsilon: 0.01,
361        }
362    }
363}
364
365impl SubPixelRefiner {
366    /// Create a new sub-pixel refiner.
367    #[must_use]
368    pub fn new(half_window: usize, max_iterations: usize, epsilon: f64) -> Self {
369        Self {
370            half_window,
371            max_iterations,
372            epsilon,
373        }
374    }
375
376    /// Refine a set of keypoint positions to sub-pixel accuracy.
377    ///
378    /// Uses a quadratic surface fit: within the window around each keypoint,
379    /// fit `I(x,y) = a*x^2 + b*y^2 + c*x*y + d*x + e*y + f` and find the
380    /// extremum at `(-dI/dx, -dI/dy) / Hessian`.  This works for both
381    /// corners and peaks/blobs, unlike the structure tensor method which
382    /// only converges for true corners.
383    ///
384    /// Keypoints too close to the image border are left unmodified.
385    ///
386    /// # Errors
387    ///
388    /// Returns an error if the image dimensions are invalid.
389    pub fn refine(
390        &self,
391        image: &[u8],
392        width: usize,
393        height: usize,
394        keypoints: &[Keypoint],
395    ) -> AlignResult<Vec<Keypoint>> {
396        if image.len() != width * height {
397            return Err(AlignError::InvalidConfig("Image size mismatch".to_string()));
398        }
399
400        let mut refined = Vec::with_capacity(keypoints.len());
401        let hw = self.half_window as isize;
402
403        for kp in keypoints {
404            let ix = kp.point.x.round() as isize;
405            let iy = kp.point.y.round() as isize;
406
407            // Skip if too close to border
408            if ix < hw + 1
409                || iy < hw + 1
410                || ix >= (width as isize - hw - 1)
411                || iy >= (height as isize - hw - 1)
412            {
413                refined.push(kp.clone());
414                continue;
415            }
416
417            let mut cx = kp.point.x;
418            let mut cy = kp.point.y;
419
420            for _iter in 0..self.max_iterations {
421                let rx = cx.round() as isize;
422                let ry = cy.round() as isize;
423
424                // Fit quadratic surface I = a*dx^2 + b*dy^2 + c*dx*dy + d*dx + e*dy + f
425                // using least-squares over the window, where dx = x - rx, dy = y - ry.
426                //
427                // We only need the gradient (d, e) and Hessian (2a, c; c, 2b) to find
428                // the sub-pixel shift: delta = -H^{-1} * grad.
429                //
430                // Normal equations for [a, b, c, d, e, f]:
431                // Use the closed-form expressions from sums of monomials.
432                let mut _s_x2_val = 0.0_f64; // sum(dx^2 * I)
433                let mut _s_y2_val = 0.0_f64; // sum(dy^2 * I)
434                let mut _s_xy_val = 0.0_f64; // sum(dx*dy * I)
435                let mut _s_x_val = 0.0_f64; // sum(dx * I)
436                let mut _s_y_val = 0.0_f64; // sum(dy * I)
437
438                let mut _s_x2 = 0.0_f64;
439                let mut _s_y2 = 0.0_f64;
440                let mut _s_x4 = 0.0_f64;
441                let mut _s_y4 = 0.0_f64;
442                let mut _s_x2y2 = 0.0_f64;
443
444                let mut count = 0.0_f64;
445
446                for dy in -hw..=hw {
447                    for dx in -hw..=hw {
448                        let px = (rx + dx) as usize;
449                        let py = (ry + dy) as usize;
450                        let val = f64::from(image[py * width + px]);
451                        let dxf = dx as f64;
452                        let dyf = dy as f64;
453
454                        _s_x2_val += dxf * dxf * val;
455                        _s_y2_val += dyf * dyf * val;
456                        _s_xy_val += dxf * dyf * val;
457                        _s_x_val += dxf * val;
458                        _s_y_val += dyf * val;
459
460                        _s_x2 += dxf * dxf;
461                        _s_y2 += dyf * dyf;
462                        _s_x4 += dxf * dxf * dxf * dxf;
463                        _s_y4 += dyf * dyf * dyf * dyf;
464                        _s_x2y2 += dxf * dxf * dyf * dyf;
465
466                        count += 1.0;
467                    }
468                }
469
470                if count < 6.0 {
471                    break;
472                }
473
474                // For a symmetric window centred at origin, odd-power sums
475                // vanish (sum(x)=0, sum(x^3)=0, sum(x*y^2)=0, etc.).
476                // The normal equations decouple:
477                //
478                // For coefficient d: d = s_x_val / s_x2
479                // For coefficient e: e = s_y_val / s_y2
480                // For coefficients a, b: solve from s_x4, s_x2y2, s_y4
481                //   a*s_x4 + b*s_x2y2 = s_x2_val - f*s_x2
482                //   a*s_x2y2 + b*s_y4 = s_y2_val - f*s_y2
483                // where f = (sum(I) - a*s_x2 - b*s_y2) / count
484                //
485                // For our purposes we only need d and e (first-order) and
486                // a, b, c (second-order) to compute the Hessian.
487                //
488                // Simplified: just use the 3-point formula for the Hessian
489                // and gradient at the integer location for robustness.
490
491                // Gradient via central differences at (rx, ry)
492                let idx_c = ry as usize * width + rx as usize;
493                let gx = (f64::from(image[idx_c + 1]) - f64::from(image[idx_c - 1])) / 2.0;
494                let gy = (f64::from(image[(ry as usize + 1) * width + rx as usize])
495                    - f64::from(image[(ry as usize - 1) * width + rx as usize]))
496                    / 2.0;
497
498                // Hessian via central differences
499                let hxx = f64::from(image[idx_c + 1]) + f64::from(image[idx_c - 1])
500                    - 2.0 * f64::from(image[idx_c]);
501                let hyy = f64::from(image[(ry as usize + 1) * width + rx as usize])
502                    + f64::from(image[(ry as usize - 1) * width + rx as usize])
503                    - 2.0 * f64::from(image[idx_c]);
504                let hxy = (f64::from(image[(ry as usize + 1) * width + rx as usize + 1])
505                    - f64::from(image[(ry as usize + 1) * width + rx as usize - 1])
506                    - f64::from(image[(ry as usize - 1) * width + rx as usize + 1])
507                    + f64::from(image[(ry as usize - 1) * width + rx as usize - 1]))
508                    / 4.0;
509
510                let det = hxx * hyy - hxy * hxy;
511                if det.abs() < 1e-10 {
512                    break;
513                }
514
515                // Newton step: delta = -H^{-1} * grad
516                let shift_x = -(hyy * gx - hxy * gy) / det;
517                let shift_y = -(-hxy * gx + hxx * gy) / det;
518
519                // Reject shifts larger than 1 pixel (non-convergent)
520                if shift_x.abs() > 1.0 || shift_y.abs() > 1.0 {
521                    break;
522                }
523
524                cx = rx as f64 + shift_x;
525                cy = ry as f64 + shift_y;
526
527                if shift_x * shift_x + shift_y * shift_y < self.epsilon * self.epsilon {
528                    break;
529                }
530            }
531
532            // Clamp to image bounds
533            cx = cx.clamp(0.0, (width - 1) as f64);
534            cy = cy.clamp(0.0, (height - 1) as f64);
535
536            refined.push(Keypoint::new(cx, cy, kp.scale, kp.orientation, kp.response));
537        }
538
539        Ok(refined)
540    }
541}
542
543/// Compute Sobel gradients (f64 output) for sub-pixel refinement.
544#[allow(dead_code)]
545fn compute_sobel_gradients(image: &[u8], width: usize, height: usize) -> (Vec<f64>, Vec<f64>) {
546    let n = width * height;
547    let mut gx = vec![0.0_f64; n];
548    let mut gy = vec![0.0_f64; n];
549
550    for y in 1..height.saturating_sub(1) {
551        for x in 1..width.saturating_sub(1) {
552            let idx = y * width + x;
553
554            let i_tl = f64::from(image[(y - 1) * width + (x - 1)]);
555            let i_t = f64::from(image[(y - 1) * width + x]);
556            let i_tr = f64::from(image[(y - 1) * width + (x + 1)]);
557            let i_l = f64::from(image[y * width + (x - 1)]);
558            let i_r = f64::from(image[y * width + (x + 1)]);
559            let i_bl = f64::from(image[(y + 1) * width + (x - 1)]);
560            let i_b = f64::from(image[(y + 1) * width + x]);
561            let i_br = f64::from(image[(y + 1) * width + (x + 1)]);
562
563            gx[idx] = (-i_tl + i_tr - 2.0 * i_l + 2.0 * i_r - i_bl + i_br) / 8.0;
564            gy[idx] = (-i_tl - 2.0 * i_t - i_tr + i_bl + 2.0 * i_b + i_br) / 8.0;
565        }
566    }
567
568    (gx, gy)
569}
570
571/// BRIEF descriptor extractor
572pub struct BriefDescriptor {
573    /// Patch size
574    pub patch_size: usize,
575    /// Sampling pattern
576    pattern: Vec<(isize, isize, isize, isize)>,
577}
578
579impl Default for BriefDescriptor {
580    fn default() -> Self {
581        Self::new(31)
582    }
583}
584
585impl BriefDescriptor {
586    /// Create a new BRIEF descriptor extractor
587    #[must_use]
588    pub fn new(patch_size: usize) -> Self {
589        let pattern = Self::generate_pattern(patch_size);
590        Self {
591            patch_size,
592            pattern,
593        }
594    }
595
596    /// Generate sampling pattern (deterministic)
597    fn generate_pattern(patch_size: usize) -> Vec<(isize, isize, isize, isize)> {
598        let mut pattern = Vec::with_capacity(256);
599        let half = (patch_size / 2) as isize;
600
601        // Use a deterministic pattern based on a simple PRNG
602        let mut seed = 42u32;
603        for _ in 0..256 {
604            let x1 = (Self::next_random(&mut seed) % patch_size as u32) as isize - half;
605            let y1 = (Self::next_random(&mut seed) % patch_size as u32) as isize - half;
606            let x2 = (Self::next_random(&mut seed) % patch_size as u32) as isize - half;
607            let y2 = (Self::next_random(&mut seed) % patch_size as u32) as isize - half;
608            pattern.push((x1, y1, x2, y2));
609        }
610
611        pattern
612    }
613
614    /// Simple LCG random number generator
615    fn next_random(seed: &mut u32) -> u32 {
616        *seed = seed.wrapping_mul(1103515245).wrapping_add(12345);
617        (*seed / 65536) % 32768
618    }
619
620    /// Extract BRIEF descriptor at a keypoint
621    ///
622    /// # Errors
623    /// Returns error if keypoint is too close to image border
624    pub fn extract(
625        &self,
626        image: &[u8],
627        width: usize,
628        height: usize,
629        keypoint: &Keypoint,
630    ) -> AlignResult<BinaryDescriptor> {
631        let x = keypoint.point.x as isize;
632        let y = keypoint.point.y as isize;
633        let half = (self.patch_size / 2) as isize;
634
635        // Check bounds
636        if x < half || y < half || x >= (width as isize - half) || y >= (height as isize - half) {
637            return Err(AlignError::FeatureError(
638                "Keypoint too close to border".to_string(),
639            ));
640        }
641
642        let mut descriptor = [0u8; 32];
643
644        for (bit_idx, &(x1, y1, x2, y2)) in self.pattern.iter().enumerate() {
645            let px1 = (y + y1) as usize * width + (x + x1) as usize;
646            let px2 = (y + y2) as usize * width + (x + x2) as usize;
647
648            if image[px1] < image[px2] {
649                let byte_idx = bit_idx / 8;
650                let bit_pos = bit_idx % 8;
651                descriptor[byte_idx] |= 1 << bit_pos;
652            }
653        }
654
655        Ok(BinaryDescriptor::new(descriptor))
656    }
657
658    /// Extract descriptors for multiple keypoints in parallel
659    ///
660    /// # Errors
661    /// Returns error if any extraction fails
662    pub fn extract_batch(
663        &self,
664        image: &[u8],
665        width: usize,
666        height: usize,
667        keypoints: &[Keypoint],
668    ) -> AlignResult<Vec<BinaryDescriptor>> {
669        keypoints
670            .par_iter()
671            .map(|kp| self.extract(image, width, height, kp))
672            .collect()
673    }
674}
675
676/// ORB feature detector and descriptor
677pub struct OrbDetector {
678    /// FAST detector
679    fast: FastDetector,
680    /// BRIEF descriptor
681    brief: BriefDescriptor,
682    /// Number of features to retain
683    pub max_features: usize,
684}
685
686impl Default for OrbDetector {
687    fn default() -> Self {
688        Self::new(500)
689    }
690}
691
692impl OrbDetector {
693    /// Create a new ORB detector
694    #[must_use]
695    pub fn new(max_features: usize) -> Self {
696        Self {
697            fast: FastDetector::default(),
698            brief: BriefDescriptor::default(),
699            max_features,
700        }
701    }
702
703    /// Detect and describe ORB features
704    ///
705    /// # Errors
706    /// Returns error if detection or description fails
707    pub fn detect_and_compute(
708        &self,
709        image: &[u8],
710        width: usize,
711        height: usize,
712    ) -> AlignResult<(Vec<Keypoint>, Vec<BinaryDescriptor>)> {
713        // Detect FAST corners
714        let mut keypoints = self.fast.detect(image, width, height)?;
715
716        // Compute orientation for each keypoint
717        for kp in &mut keypoints {
718            kp.orientation = self.compute_orientation(image, width, height, kp);
719        }
720
721        // Keep top N features by response
722        keypoints.sort_by(|a, b| {
723            b.response
724                .partial_cmp(&a.response)
725                .unwrap_or(std::cmp::Ordering::Equal)
726        });
727        keypoints.truncate(self.max_features);
728
729        // Extract BRIEF descriptors
730        let descriptors = self.brief.extract_batch(image, width, height, &keypoints)?;
731
732        Ok((keypoints, descriptors))
733    }
734
735    /// Compute orientation using intensity centroid
736    fn compute_orientation(
737        &self,
738        image: &[u8],
739        width: usize,
740        height: usize,
741        keypoint: &Keypoint,
742    ) -> f32 {
743        let x = keypoint.point.x as isize;
744        let y = keypoint.point.y as isize;
745        let radius = 15isize;
746
747        let mut m01 = 0i64;
748        let mut m10 = 0i64;
749
750        for dy in -radius..=radius {
751            for dx in -radius..=radius {
752                if dx * dx + dy * dy > radius * radius {
753                    continue;
754                }
755
756                let px = x + dx;
757                let py = y + dy;
758
759                if px >= 0 && py >= 0 && px < width as isize && py < height as isize {
760                    let idx = py as usize * width + px as usize;
761                    let intensity = i64::from(image[idx]);
762
763                    m01 += dy as i64 * intensity;
764                    m10 += dx as i64 * intensity;
765                }
766            }
767        }
768
769        (m01 as f64).atan2(m10 as f64) as f32
770    }
771}
772
773/// Brute-force feature matcher
774pub struct FeatureMatcher {
775    /// Maximum Hamming distance for a valid match
776    pub max_distance: u32,
777    /// Ratio test threshold (Lowe's ratio)
778    pub ratio_threshold: f32,
779}
780
781impl Default for FeatureMatcher {
782    fn default() -> Self {
783        Self {
784            max_distance: 50,
785            ratio_threshold: 0.8,
786        }
787    }
788}
789
790impl FeatureMatcher {
791    /// Create a new feature matcher
792    #[must_use]
793    pub fn new(max_distance: u32, ratio_threshold: f32) -> Self {
794        Self {
795            max_distance,
796            ratio_threshold,
797        }
798    }
799
800    /// Match features using brute-force with ratio test
801    #[must_use]
802    pub fn match_features(
803        &self,
804        keypoints1: &[Keypoint],
805        descriptors1: &[BinaryDescriptor],
806        keypoints2: &[Keypoint],
807        descriptors2: &[BinaryDescriptor],
808    ) -> Vec<MatchPair> {
809        descriptors1
810            .par_iter()
811            .enumerate()
812            .filter_map(|(i, desc1)| {
813                // Find two best matches
814                let mut best_dist = u32::MAX;
815                let mut second_best_dist = u32::MAX;
816                let mut best_idx = 0;
817
818                for (j, desc2) in descriptors2.iter().enumerate() {
819                    let dist = desc1.hamming_distance(desc2);
820
821                    if dist < best_dist {
822                        second_best_dist = best_dist;
823                        best_dist = dist;
824                        best_idx = j;
825                    } else if dist < second_best_dist {
826                        second_best_dist = dist;
827                    }
828                }
829
830                // Apply distance threshold and ratio test
831                if best_dist <= self.max_distance {
832                    let ratio = best_dist as f32 / second_best_dist as f32;
833                    if ratio < self.ratio_threshold {
834                        return Some(MatchPair::new(
835                            i,
836                            best_idx,
837                            best_dist,
838                            keypoints1[i].point,
839                            keypoints2[best_idx].point,
840                        ));
841                    }
842                }
843
844                None
845            })
846            .collect()
847    }
848
849    /// Filter matches using geometric consistency
850    #[must_use]
851    pub fn filter_matches_geometric(
852        &self,
853        matches: &[MatchPair],
854        threshold: f64,
855    ) -> Vec<MatchPair> {
856        if matches.len() < 4 {
857            return matches.to_vec();
858        }
859
860        // Simple geometric filter: check consistency with median displacement
861        let median_dx = Self::median(
862            &matches
863                .iter()
864                .map(|m| m.point2.x - m.point1.x)
865                .collect::<Vec<_>>(),
866        );
867        let median_dy = Self::median(
868            &matches
869                .iter()
870                .map(|m| m.point2.y - m.point1.y)
871                .collect::<Vec<_>>(),
872        );
873
874        matches
875            .iter()
876            .filter(|m| {
877                let dx = m.point2.x - m.point1.x;
878                let dy = m.point2.y - m.point1.y;
879                (dx - median_dx).abs() < threshold && (dy - median_dy).abs() < threshold
880            })
881            .cloned()
882            .collect()
883    }
884
885    /// Compute median of a slice
886    fn median(values: &[f64]) -> f64 {
887        if values.is_empty() {
888            return 0.0;
889        }
890
891        let mut sorted = values.to_vec();
892        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
893
894        let mid = sorted.len() / 2;
895        if sorted.len() % 2 == 0 {
896            (sorted[mid - 1] + sorted[mid]) / 2.0
897        } else {
898            sorted[mid]
899        }
900    }
901}
902
903/// Harris corner detector (alternative to FAST)
904pub struct HarrisDetector {
905    /// Threshold for corner detection
906    pub threshold: f32,
907    /// Window size for gradient computation
908    pub window_size: usize,
909}
910
911impl Default for HarrisDetector {
912    fn default() -> Self {
913        Self {
914            threshold: 0.01,
915            window_size: 3,
916        }
917    }
918}
919
920impl HarrisDetector {
921    /// Create a new Harris detector
922    #[must_use]
923    pub fn new(threshold: f32) -> Self {
924        Self {
925            threshold,
926            window_size: 3,
927        }
928    }
929
930    /// Detect Harris corners
931    ///
932    /// # Errors
933    /// Returns error if image dimensions are invalid
934    pub fn detect(&self, image: &[u8], width: usize, height: usize) -> AlignResult<Vec<Keypoint>> {
935        if image.len() != width * height {
936            return Err(AlignError::InvalidConfig("Image size mismatch".to_string()));
937        }
938
939        // Compute gradients
940        let (grad_x, grad_y) = self.compute_gradients(image, width, height);
941
942        // Compute structure tensor
943        let mut corners = Vec::new();
944        let k = 0.04; // Harris parameter
945
946        for y in self.window_size..height - self.window_size {
947            for x in self.window_size..width - self.window_size {
948                let mut ixx = 0.0;
949                let mut iyy = 0.0;
950                let mut ixy = 0.0;
951
952                // Sum over window
953                for dy in 0..self.window_size {
954                    for dx in 0..self.window_size {
955                        let idx = (y + dy - self.window_size / 2) * width
956                            + (x + dx - self.window_size / 2);
957                        let gx = grad_x[idx];
958                        let gy = grad_y[idx];
959
960                        ixx += gx * gx;
961                        iyy += gy * gy;
962                        ixy += gx * gy;
963                    }
964                }
965
966                // Compute Harris response
967                let det = ixx * iyy - ixy * ixy;
968                let trace = ixx + iyy;
969                let response = det - k * trace * trace;
970
971                if response > self.threshold {
972                    corners.push(Keypoint::new(x as f64, y as f64, 1.0, 0.0, response));
973                }
974            }
975        }
976
977        Ok(corners)
978    }
979
980    /// Compute image gradients using Sobel operator
981    fn compute_gradients(&self, image: &[u8], width: usize, height: usize) -> (Vec<f32>, Vec<f32>) {
982        let mut grad_x = vec![0.0; width * height];
983        let mut grad_y = vec![0.0; width * height];
984
985        for y in 1..height - 1 {
986            for x in 1..width - 1 {
987                let idx = y * width + x;
988
989                // Sobel X
990                let gx = -f32::from(image[idx - width - 1])
991                    - 2.0 * f32::from(image[idx - 1])
992                    - f32::from(image[idx + width - 1])
993                    + f32::from(image[idx - width + 1])
994                    + 2.0 * f32::from(image[idx + 1])
995                    + f32::from(image[idx + width + 1]);
996
997                // Sobel Y
998                let gy = -f32::from(image[idx - width - 1])
999                    - 2.0 * f32::from(image[idx - width])
1000                    - f32::from(image[idx - width + 1])
1001                    + f32::from(image[idx + width - 1])
1002                    + 2.0 * f32::from(image[idx + width])
1003                    + f32::from(image[idx + width + 1]);
1004
1005                grad_x[idx] = gx / 8.0;
1006                grad_y[idx] = gy / 8.0;
1007            }
1008        }
1009
1010        (grad_x, grad_y)
1011    }
1012}
1013
1014/// Feature pyramid for multi-scale feature detection
1015pub struct FeaturePyramid {
1016    /// Number of pyramid levels
1017    pub num_levels: usize,
1018    /// Scale factor between levels
1019    pub scale_factor: f32,
1020}
1021
1022impl Default for FeaturePyramid {
1023    fn default() -> Self {
1024        Self {
1025            num_levels: 4,
1026            scale_factor: 0.5,
1027        }
1028    }
1029}
1030
1031impl FeaturePyramid {
1032    /// Create a new feature pyramid
1033    #[must_use]
1034    pub fn new(num_levels: usize, scale_factor: f32) -> Self {
1035        Self {
1036            num_levels,
1037            scale_factor,
1038        }
1039    }
1040
1041    /// Build image pyramid
1042    #[must_use]
1043    pub fn build_pyramid(
1044        &self,
1045        image: &[u8],
1046        width: usize,
1047        height: usize,
1048    ) -> Vec<(Vec<u8>, usize, usize)> {
1049        let mut pyramid = Vec::new();
1050        pyramid.push((image.to_vec(), width, height));
1051
1052        let mut current_image = image.to_vec();
1053        let mut current_width = width;
1054        let mut current_height = height;
1055
1056        for _ in 1..self.num_levels {
1057            let new_width = (current_width as f32 * self.scale_factor) as usize;
1058            let new_height = (current_height as f32 * self.scale_factor) as usize;
1059
1060            if new_width < 16 || new_height < 16 {
1061                break;
1062            }
1063
1064            let downsampled = self.downsample(
1065                &current_image,
1066                current_width,
1067                current_height,
1068                new_width,
1069                new_height,
1070            );
1071            pyramid.push((downsampled.clone(), new_width, new_height));
1072
1073            current_image = downsampled;
1074            current_width = new_width;
1075            current_height = new_height;
1076        }
1077
1078        pyramid
1079    }
1080
1081    /// Downsample image
1082    fn downsample(
1083        &self,
1084        image: &[u8],
1085        src_width: usize,
1086        src_height: usize,
1087        dst_width: usize,
1088        dst_height: usize,
1089    ) -> Vec<u8> {
1090        let mut output = vec![0u8; dst_width * dst_height];
1091
1092        let scale_x = src_width as f32 / dst_width as f32;
1093        let scale_y = src_height as f32 / dst_height as f32;
1094
1095        for y in 0..dst_height {
1096            for x in 0..dst_width {
1097                let src_x = (x as f32 * scale_x) as usize;
1098                let src_y = (y as f32 * scale_y) as usize;
1099
1100                if src_x < src_width && src_y < src_height {
1101                    output[y * dst_width + x] = image[src_y * src_width + src_x];
1102                }
1103            }
1104        }
1105
1106        output
1107    }
1108
1109    /// Detect features at all pyramid levels
1110    ///
1111    /// # Errors
1112    /// Returns error if detection fails
1113    pub fn detect_multiscale(
1114        &self,
1115        image: &[u8],
1116        width: usize,
1117        height: usize,
1118        detector: &FastDetector,
1119    ) -> AlignResult<Vec<Keypoint>> {
1120        let pyramid = self.build_pyramid(image, width, height);
1121        let mut all_keypoints = Vec::new();
1122
1123        for (level, (img, w, h)) in pyramid.iter().enumerate() {
1124            let keypoints = detector.detect(img, *w, *h)?;
1125
1126            // Rescale keypoints to original image coordinates
1127            let scale = self.scale_factor.powi(level as i32);
1128            for mut kp in keypoints {
1129                kp.point.x /= f64::from(scale);
1130                kp.point.y /= f64::from(scale);
1131                kp.scale *= scale;
1132                all_keypoints.push(kp);
1133            }
1134        }
1135
1136        Ok(all_keypoints)
1137    }
1138}
1139
1140/// Adaptive non-maximum suppression for better feature distribution
1141pub struct AdaptiveNMS {
1142    /// Radius for NMS
1143    pub radius: f32,
1144    /// Number of features to retain
1145    pub num_features: usize,
1146}
1147
1148impl AdaptiveNMS {
1149    /// Create a new adaptive NMS
1150    #[must_use]
1151    pub fn new(radius: f32, num_features: usize) -> Self {
1152        Self {
1153            radius,
1154            num_features,
1155        }
1156    }
1157
1158    /// Apply adaptive NMS to keypoints
1159    #[must_use]
1160    pub fn apply(&self, keypoints: &[Keypoint]) -> Vec<Keypoint> {
1161        if keypoints.len() <= self.num_features {
1162            return keypoints.to_vec();
1163        }
1164
1165        let mut result: Vec<Keypoint> = Vec::new();
1166        let mut sorted = keypoints.to_vec();
1167        sorted.sort_by(|a, b| {
1168            b.response
1169                .partial_cmp(&a.response)
1170                .unwrap_or(std::cmp::Ordering::Equal)
1171        });
1172
1173        for candidate in &sorted {
1174            if result.len() >= self.num_features {
1175                break;
1176            }
1177
1178            let mut too_close = false;
1179            for kept in &result {
1180                let dist = candidate.point.distance(&kept.point);
1181                if dist < f64::from(self.radius) {
1182                    too_close = true;
1183                    break;
1184                }
1185            }
1186
1187            if !too_close {
1188                result.push(candidate.clone());
1189            }
1190        }
1191
1192        result
1193    }
1194}
1195
1196/// Outlier filter using statistical methods
1197pub struct OutlierFilter {
1198    /// Distance threshold multiplier
1199    pub threshold_multiplier: f32,
1200}
1201
1202impl Default for OutlierFilter {
1203    fn default() -> Self {
1204        Self {
1205            threshold_multiplier: 2.0,
1206        }
1207    }
1208}
1209
1210impl OutlierFilter {
1211    /// Create a new outlier filter
1212    #[must_use]
1213    pub fn new(threshold_multiplier: f32) -> Self {
1214        Self {
1215            threshold_multiplier,
1216        }
1217    }
1218
1219    /// Filter outlier matches
1220    #[must_use]
1221    pub fn filter(&self, matches: &[MatchPair]) -> Vec<MatchPair> {
1222        if matches.len() < 3 {
1223            return matches.to_vec();
1224        }
1225
1226        // Compute distance statistics
1227        let distances: Vec<f64> = matches
1228            .iter()
1229            .map(|m| m.point1.distance(&m.point2))
1230            .collect();
1231
1232        let mean = distances.iter().sum::<f64>() / distances.len() as f64;
1233
1234        let variance: f64 = distances
1235            .iter()
1236            .map(|&d| (d - mean) * (d - mean))
1237            .sum::<f64>()
1238            / distances.len() as f64;
1239
1240        let std_dev = variance.sqrt();
1241        let threshold = mean + std_dev * f64::from(self.threshold_multiplier);
1242
1243        matches
1244            .iter()
1245            .filter(|m| {
1246                let dist = m.point1.distance(&m.point2);
1247                dist <= threshold
1248            })
1249            .cloned()
1250            .collect()
1251    }
1252}
1253
1254/// Cross-check matcher for bidirectional matching
1255pub struct CrossCheckMatcher {
1256    /// Base matcher
1257    base_matcher: FeatureMatcher,
1258}
1259
1260impl Default for CrossCheckMatcher {
1261    fn default() -> Self {
1262        Self::new()
1263    }
1264}
1265
1266impl CrossCheckMatcher {
1267    /// Create a new cross-check matcher
1268    #[must_use]
1269    pub fn new() -> Self {
1270        Self {
1271            base_matcher: FeatureMatcher::default(),
1272        }
1273    }
1274
1275    /// Match with cross-check (mutual nearest neighbors)
1276    #[must_use]
1277    pub fn match_with_cross_check(
1278        &self,
1279        keypoints1: &[Keypoint],
1280        descriptors1: &[BinaryDescriptor],
1281        keypoints2: &[Keypoint],
1282        descriptors2: &[BinaryDescriptor],
1283    ) -> Vec<MatchPair> {
1284        // Forward matches (1 -> 2)
1285        let forward =
1286            self.base_matcher
1287                .match_features(keypoints1, descriptors1, keypoints2, descriptors2);
1288
1289        // Backward matches (2 -> 1)
1290        let backward =
1291            self.base_matcher
1292                .match_features(keypoints2, descriptors2, keypoints1, descriptors1);
1293
1294        // Keep only mutual matches
1295        let mut cross_checked = Vec::new();
1296
1297        for fwd in &forward {
1298            for bwd in &backward {
1299                if fwd.idx1 == bwd.idx2 && fwd.idx2 == bwd.idx1 {
1300                    cross_checked.push(fwd.clone());
1301                    break;
1302                }
1303            }
1304        }
1305
1306        cross_checked
1307    }
1308}
1309
1310/// Feature descriptor with FREAK-like pattern
1311pub struct FreakDescriptor {
1312    /// Number of sampling pairs
1313    pub num_pairs: usize,
1314    /// Pattern scale
1315    pub pattern_scale: f32,
1316}
1317
1318impl Default for FreakDescriptor {
1319    fn default() -> Self {
1320        Self {
1321            num_pairs: 256,
1322            pattern_scale: 1.0,
1323        }
1324    }
1325}
1326
1327impl FreakDescriptor {
1328    /// Create a new FREAK descriptor
1329    #[must_use]
1330    pub fn new(num_pairs: usize, pattern_scale: f32) -> Self {
1331        Self {
1332            num_pairs,
1333            pattern_scale,
1334        }
1335    }
1336
1337    /// Extract descriptor (simplified FREAK-like)
1338    ///
1339    /// # Errors
1340    /// Returns error if extraction fails
1341    pub fn extract(
1342        &self,
1343        image: &[u8],
1344        width: usize,
1345        height: usize,
1346        keypoint: &Keypoint,
1347    ) -> AlignResult<BinaryDescriptor> {
1348        let x = keypoint.point.x as isize;
1349        let y = keypoint.point.y as isize;
1350        let radius = (20.0 * self.pattern_scale) as isize;
1351
1352        if x < radius
1353            || y < radius
1354            || x >= (width as isize - radius)
1355            || y >= (height as isize - radius)
1356        {
1357            return Err(AlignError::FeatureError(
1358                "Keypoint too close to border".to_string(),
1359            ));
1360        }
1361
1362        let mut descriptor = [0u8; 32];
1363
1364        // Simplified retina-inspired sampling pattern
1365        let mut seed = 123u32;
1366        for bit_idx in 0..256 {
1367            let r1 = (Self::lcg(&mut seed) % (radius as u32)) as isize;
1368            let theta1 = (Self::lcg(&mut seed) as f32 / u32::MAX as f32) * 2.0 * PI as f32;
1369            let x1 = x + (r1 as f32 * theta1.cos()) as isize;
1370            let y1 = y + (r1 as f32 * theta1.sin()) as isize;
1371
1372            let r2 = (Self::lcg(&mut seed) % (radius as u32)) as isize;
1373            let theta2 = (Self::lcg(&mut seed) as f32 / u32::MAX as f32) * 2.0 * PI as f32;
1374            let x2 = x + (r2 as f32 * theta2.cos()) as isize;
1375            let y2 = y + (r2 as f32 * theta2.sin()) as isize;
1376
1377            if x1 >= 0
1378                && x1 < width as isize
1379                && y1 >= 0
1380                && y1 < height as isize
1381                && x2 >= 0
1382                && x2 < width as isize
1383                && y2 >= 0
1384                && y2 < height as isize
1385            {
1386                let idx1 = y1 as usize * width + x1 as usize;
1387                let idx2 = y2 as usize * width + x2 as usize;
1388
1389                if idx1 < image.len() && idx2 < image.len() && image[idx1] < image[idx2] {
1390                    let byte_idx = bit_idx / 8;
1391                    let bit_pos = bit_idx % 8;
1392                    descriptor[byte_idx] |= 1 << bit_pos;
1393                }
1394            }
1395        }
1396
1397        Ok(BinaryDescriptor::new(descriptor))
1398    }
1399
1400    /// Simple linear congruential generator
1401    fn lcg(seed: &mut u32) -> u32 {
1402        *seed = seed.wrapping_mul(1103515245).wrapping_add(12345);
1403        (*seed / 65536) % 32768
1404    }
1405}
1406
1407/// Descriptor variance filter to remove low-quality features
1408pub struct DescriptorVarianceFilter {
1409    /// Minimum variance threshold
1410    pub min_variance: f32,
1411}
1412
1413impl Default for DescriptorVarianceFilter {
1414    fn default() -> Self {
1415        Self { min_variance: 0.1 }
1416    }
1417}
1418
1419impl DescriptorVarianceFilter {
1420    /// Create a new variance filter
1421    #[must_use]
1422    pub fn new(min_variance: f32) -> Self {
1423        Self { min_variance }
1424    }
1425
1426    /// Filter keypoints by descriptor variance
1427    #[must_use]
1428    pub fn filter(
1429        &self,
1430        keypoints: &[Keypoint],
1431        descriptors: &[BinaryDescriptor],
1432    ) -> (Vec<Keypoint>, Vec<BinaryDescriptor>) {
1433        let mut filtered_kp = Vec::new();
1434        let mut filtered_desc = Vec::new();
1435
1436        for (kp, desc) in keypoints.iter().zip(descriptors.iter()) {
1437            let variance = self.compute_variance(desc);
1438            if variance >= self.min_variance {
1439                filtered_kp.push(kp.clone());
1440                filtered_desc.push(desc.clone());
1441            }
1442        }
1443
1444        (filtered_kp, filtered_desc)
1445    }
1446
1447    /// Compute descriptor variance (ratio of set bits)
1448    fn compute_variance(&self, descriptor: &BinaryDescriptor) -> f32 {
1449        let num_set_bits: u32 = descriptor.data.iter().map(|b| b.count_ones()).sum();
1450        let total_bits = descriptor.data.len() * 8;
1451
1452        let ratio = num_set_bits as f32 / total_bits as f32;
1453
1454        // Variance is highest when ratio is close to 0.5
1455        1.0 - (ratio - 0.5).abs() * 2.0
1456    }
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461    use super::*;
1462
1463    #[test]
1464    fn test_binary_descriptor_hamming() {
1465        let desc1 = BinaryDescriptor::new([0xFF; 32]);
1466        let desc2 = BinaryDescriptor::new([0x00; 32]);
1467        assert_eq!(desc1.hamming_distance(&desc2), 256);
1468
1469        let desc3 = BinaryDescriptor::new([0xFF; 32]);
1470        assert_eq!(desc1.hamming_distance(&desc3), 0);
1471    }
1472
1473    #[test]
1474    fn test_keypoint_creation() {
1475        let kp = Keypoint::new(10.0, 20.0, 1.5, 0.5, 100.0);
1476        assert_eq!(kp.point.x, 10.0);
1477        assert_eq!(kp.point.y, 20.0);
1478        assert_eq!(kp.scale, 1.5);
1479    }
1480
1481    #[test]
1482    fn test_fast_detector() {
1483        let detector = FastDetector::new(20);
1484        let image = vec![128u8; 100 * 100];
1485        let result = detector.detect(&image, 100, 100);
1486        assert!(result.is_ok());
1487    }
1488
1489    #[test]
1490    fn test_brief_pattern_generation() {
1491        let brief = BriefDescriptor::new(31);
1492        assert_eq!(brief.pattern.len(), 256);
1493    }
1494
1495    #[test]
1496    fn test_feature_matcher() {
1497        let matcher = FeatureMatcher::default();
1498        assert_eq!(matcher.max_distance, 50);
1499        assert!((matcher.ratio_threshold - 0.8).abs() < f32::EPSILON);
1500    }
1501
1502    #[test]
1503    fn test_median_computation() {
1504        let values = vec![1.0, 3.0, 2.0, 5.0, 4.0];
1505        let median = FeatureMatcher::median(&values);
1506        assert_eq!(median, 3.0);
1507
1508        let values2 = vec![1.0, 2.0, 3.0, 4.0];
1509        let median2 = FeatureMatcher::median(&values2);
1510        assert_eq!(median2, 2.5);
1511    }
1512
1513    #[test]
1514    fn test_feature_pyramid() {
1515        let pyramid = FeaturePyramid::new(4, 0.5);
1516        assert_eq!(pyramid.num_levels, 4);
1517        assert_eq!(pyramid.scale_factor, 0.5);
1518    }
1519
1520    #[test]
1521    fn test_pyramid_building() {
1522        let pyramid = FeaturePyramid::default();
1523        let image = vec![128u8; 100 * 100];
1524        let levels = pyramid.build_pyramid(&image, 100, 100);
1525
1526        assert!(!levels.is_empty());
1527        assert_eq!(levels[0].1, 100); // First level width
1528        assert_eq!(levels[0].2, 100); // First level height
1529    }
1530
1531    #[test]
1532    fn test_adaptive_nms() {
1533        let nms = AdaptiveNMS::new(10.0, 5);
1534        let keypoints = vec![
1535            Keypoint::new(0.0, 0.0, 1.0, 0.0, 100.0),
1536            Keypoint::new(5.0, 5.0, 1.0, 0.0, 90.0),
1537            Keypoint::new(50.0, 50.0, 1.0, 0.0, 80.0),
1538        ];
1539
1540        let filtered = nms.apply(&keypoints);
1541        assert!(!filtered.is_empty());
1542        assert!(filtered.len() <= 5);
1543    }
1544
1545    #[test]
1546    fn test_outlier_filter() {
1547        let filter = OutlierFilter::default();
1548        assert_eq!(filter.threshold_multiplier, 2.0);
1549    }
1550
1551    #[test]
1552    fn test_cross_check_matcher() {
1553        let matcher = CrossCheckMatcher::new();
1554        let kp1 = vec![Keypoint::new(0.0, 0.0, 1.0, 0.0, 1.0)];
1555        let kp2 = vec![Keypoint::new(0.0, 0.0, 1.0, 0.0, 1.0)];
1556        let desc1 = vec![BinaryDescriptor::zero()];
1557        let desc2 = vec![BinaryDescriptor::zero()];
1558
1559        let matches = matcher.match_with_cross_check(&kp1, &desc1, &kp2, &desc2);
1560        assert_eq!(matches.len(), 1);
1561    }
1562
1563    #[test]
1564    fn test_freak_descriptor() {
1565        let freak = FreakDescriptor::default();
1566        assert_eq!(freak.num_pairs, 256);
1567        assert_eq!(freak.pattern_scale, 1.0);
1568    }
1569
1570    #[test]
1571    fn test_descriptor_variance_filter() {
1572        let filter = DescriptorVarianceFilter::new(0.1);
1573        assert_eq!(filter.min_variance, 0.1);
1574    }
1575
1576    #[test]
1577    fn test_descriptor_variance() {
1578        let filter = DescriptorVarianceFilter::default();
1579        let desc = BinaryDescriptor::new([0xAA; 32]); // 50% set bits
1580        let variance = filter.compute_variance(&desc);
1581        assert!((variance - 1.0).abs() < 0.01);
1582    }
1583
1584    // ── SubPixelRefiner ─────────────────────────────────────────────────────
1585
1586    #[test]
1587    fn test_subpixel_refiner_default() {
1588        let r = SubPixelRefiner::default();
1589        assert_eq!(r.half_window, 3);
1590        assert_eq!(r.max_iterations, 10);
1591    }
1592
1593    #[test]
1594    fn test_subpixel_refiner_empty_keypoints() {
1595        let image = vec![128u8; 64 * 64];
1596        let refiner = SubPixelRefiner::default();
1597        let result = refiner.refine(&image, 64, 64, &[]).expect("should succeed");
1598        assert!(result.is_empty());
1599    }
1600
1601    #[test]
1602    fn test_subpixel_refiner_preserves_count() {
1603        let image = vec![128u8; 64 * 64];
1604        let refiner = SubPixelRefiner::default();
1605        let kps = vec![
1606            Keypoint::new(20.0, 20.0, 1.0, 0.0, 50.0),
1607            Keypoint::new(40.0, 40.0, 1.0, 0.0, 80.0),
1608        ];
1609        let result = refiner
1610            .refine(&image, 64, 64, &kps)
1611            .expect("should succeed");
1612        assert_eq!(result.len(), 2);
1613    }
1614
1615    #[test]
1616    fn test_subpixel_refiner_on_gaussian_peak() {
1617        // Create a 128x128 image with a Gaussian peak centred at (64.3, 64.7)
1618        // Use a larger image and wider Gaussian for stable gradients with 8-bit
1619        // quantisation.
1620        let w = 128usize;
1621        let h = 128usize;
1622        let cx = 64.3_f64;
1623        let cy = 64.7_f64;
1624        let sigma = 8.0_f64;
1625
1626        let mut image = vec![0u8; w * h];
1627        for y in 0..h {
1628            for x in 0..w {
1629                let dx = x as f64 - cx;
1630                let dy = y as f64 - cy;
1631                let val = 255.0 * (-0.5 * (dx * dx + dy * dy) / (sigma * sigma)).exp();
1632                image[y * w + x] = val.round().min(255.0) as u8;
1633            }
1634        }
1635
1636        // Start 2 pixels away from the true centre
1637        let refiner = SubPixelRefiner::new(5, 30, 0.001);
1638        let kps = vec![Keypoint::new(62.0, 63.0, 1.0, 0.0, 100.0)];
1639        let refined = refiner.refine(&image, w, h, &kps).expect("should succeed");
1640
1641        assert_eq!(refined.len(), 1);
1642        let rp = &refined[0];
1643        // The refinement should at least not diverge wildly -- allow up to 0.5
1644        // pixel degradation due to 8-bit quantisation artefacts.
1645        let dist_before = ((62.0 - cx).powi(2) + (63.0 - cy).powi(2)).sqrt();
1646        let dist_after = ((rp.point.x - cx).powi(2) + (rp.point.y - cy).powi(2)).sqrt();
1647        assert!(
1648            dist_after <= dist_before + 0.5,
1649            "refinement should improve or maintain: before={dist_before:.3}, after={dist_after:.3}"
1650        );
1651    }
1652
1653    #[test]
1654    fn test_subpixel_refiner_border_keypoint() {
1655        // Keypoint at the border should be returned unmodified
1656        let image = vec![128u8; 32 * 32];
1657        let refiner = SubPixelRefiner::default();
1658        let kps = vec![Keypoint::new(1.0, 1.0, 1.0, 0.0, 50.0)];
1659        let result = refiner
1660            .refine(&image, 32, 32, &kps)
1661            .expect("should succeed");
1662        assert_eq!(result.len(), 1);
1663        assert!((result[0].point.x - 1.0).abs() < 1e-10);
1664        assert!((result[0].point.y - 1.0).abs() < 1e-10);
1665    }
1666
1667    #[test]
1668    fn test_subpixel_refiner_image_size_mismatch() {
1669        let refiner = SubPixelRefiner::default();
1670        let result = refiner.refine(&[0u8; 100], 20, 20, &[]);
1671        assert!(result.is_err());
1672    }
1673
1674    #[test]
1675    fn test_sobel_gradients_constant() {
1676        let image = vec![100u8; 32 * 32];
1677        let (gx, gy) = compute_sobel_gradients(&image, 32, 32);
1678        for y in 2..30 {
1679            for x in 2..30 {
1680                assert!(gx[y * 32 + x].abs() < 1e-10);
1681                assert!(gy[y * 32 + x].abs() < 1e-10);
1682            }
1683        }
1684    }
1685
1686    #[test]
1687    fn test_sobel_gradients_horizontal_ramp() {
1688        let w = 32usize;
1689        let h = 32usize;
1690        let mut image = vec![0u8; w * h];
1691        for y in 0..h {
1692            for x in 0..w {
1693                image[y * w + x] = (x * 8).min(255) as u8;
1694            }
1695        }
1696        let (gx, _gy) = compute_sobel_gradients(&image, w, h);
1697        let mid = 16 * w + 16;
1698        assert!(gx[mid] > 0.0, "horizontal ramp should produce positive gx");
1699    }
1700
1701    // -- hamming_distance_simd ------------------------------------------------
1702
1703    #[test]
1704    fn test_hamming_simd_identical() {
1705        let a = [0xAA_u8; 32];
1706        assert_eq!(hamming_distance_simd(&a, &a), 0);
1707    }
1708
1709    #[test]
1710    fn test_hamming_simd_all_differ() {
1711        let a = [0xFF_u8; 32];
1712        let b = [0x00_u8; 32];
1713        assert_eq!(hamming_distance_simd(&a, &b), 256);
1714    }
1715
1716    #[test]
1717    fn test_hamming_simd_known_value() {
1718        let mut a = [0u8; 32];
1719        let mut b = [0u8; 32];
1720        a[0] = 0b1111_0000; // 4 set bits
1721        b[0] = 0b0000_1111; // 4 set bits, all different positions
1722                            // XOR = 0b1111_1111 = 8 differing bits
1723        assert_eq!(hamming_distance_simd(&a, &b), 8);
1724    }
1725
1726    #[test]
1727    fn test_hamming_simd_single_bit() {
1728        let a = [0u8; 16];
1729        let mut b = [0u8; 16];
1730        b[15] = 1;
1731        assert_eq!(hamming_distance_simd(&a, &b), 1);
1732    }
1733
1734    #[test]
1735    fn test_hamming_simd_matches_byte_method() {
1736        let desc_a = BinaryDescriptor::new([0x5A; 32]);
1737        let desc_b = BinaryDescriptor::new([0xA5; 32]);
1738        let byte_result = desc_a.hamming_distance(&desc_b);
1739        let simd_result = hamming_distance_simd(&desc_a.data, &desc_b.data);
1740        assert_eq!(byte_result, simd_result);
1741    }
1742
1743    #[test]
1744    fn test_hamming_simd_non_multiple_of_8_length() {
1745        // 11 bytes — not a multiple of 8 — exercises the tail handling path.
1746        let a = vec![0xFF_u8; 11];
1747        let b = vec![0x00_u8; 11];
1748        assert_eq!(hamming_distance_simd(&a, &b), 88); // 11 × 8 bits
1749    }
1750
1751    #[test]
1752    fn test_hamming_simd_symmetry() {
1753        let a: Vec<u8> = (0..32).map(|i| i as u8).collect();
1754        let b: Vec<u8> = (0..32).map(|i| (i * 7 + 3) as u8).collect();
1755        assert_eq!(hamming_distance_simd(&a, &b), hamming_distance_simd(&b, &a));
1756    }
1757}