Skip to main content

scirs2_vision/features/
matching.rs

1//! Feature descriptor matching algorithms
2//!
3//! Provides multiple matching strategies for both floating-point (SIFT-like)
4//! and binary (ORB-like) descriptors:
5//!
6//! - **`BruteForce`** – exact O(n·m) exhaustive search
7//! - **`FlannLike`** – approximate nearest-neighbour via a randomised
8//!   kd-tree forest (float descriptors) or multi-probe LSH (binary descriptors)
9//! - **`RatioTest`** – Lowe's ratio test: a match is retained only when the
10//!   best-match distance is < `ratio × second-best-match distance`.
11//!
12//! All three methods return `(idx1, idx2, distance)` triples.
13
14use crate::error::{Result, VisionError};
15use crate::features::orb_like::{hamming_distance, OrbLikeDescriptor, DESC_WORDS};
16use crate::features::sift_like::SIFTDescriptor;
17
18// ─── Match method enum ────────────────────────────────────────────────────────
19
20/// Strategy used to match feature descriptors.
21#[derive(Debug, Clone)]
22pub enum MatchMethod {
23    /// Exhaustive brute-force matching (exact nearest neighbour).
24    BruteForce,
25    /// Approximate nearest-neighbour with a randomised forest / LSH index.
26    FlannLike {
27        /// Number of kd-trees (float) or hash tables (binary)
28        num_trees: usize,
29        /// Number of candidate checks per query
30        checks: usize,
31    },
32    /// Lowe's ratio test: keep matches where `d_best / d_second < ratio`.
33    RatioTest {
34        /// Ratio threshold (typical value: 0.75)
35        ratio: f64,
36    },
37}
38
39impl Default for MatchMethod {
40    fn default() -> Self {
41        MatchMethod::RatioTest { ratio: 0.75 }
42    }
43}
44
45// ─── Float descriptor matching (SIFT-like) ───────────────────────────────────
46
47/// Match two sets of SIFT-like descriptors.
48///
49/// # Arguments
50///
51/// * `desc1` – Query descriptors
52/// * `desc2` – Train descriptors
53/// * `method` – Matching strategy
54///
55/// # Returns
56///
57/// Vector of `(idx1, idx2, distance)` triples sorted by distance ascending.
58pub fn match_descriptors(
59    desc1: &[SIFTDescriptor],
60    desc2: &[SIFTDescriptor],
61    method: &MatchMethod,
62) -> Result<Vec<(usize, usize, f64)>> {
63    if desc1.is_empty() || desc2.is_empty() {
64        return Ok(Vec::new());
65    }
66
67    // Extract raw float vectors for matching
68    let vecs1: Vec<&[f32]> = desc1.iter().map(|d| d.descriptor.as_slice()).collect();
69    let vecs2: Vec<&[f32]> = desc2.iter().map(|d| d.descriptor.as_slice()).collect();
70
71    let dim = vecs1[0].len();
72    for v in vecs1.iter().chain(vecs2.iter()) {
73        if v.len() != dim {
74            return Err(VisionError::InvalidParameter(format!(
75                "Descriptor dimension mismatch: expected {dim}, got {}",
76                v.len()
77            )));
78        }
79    }
80
81    let matches = match method {
82        MatchMethod::BruteForce => brute_force_float(&vecs1, &vecs2),
83        MatchMethod::FlannLike { num_trees, checks } => {
84            flann_like_float(&vecs1, &vecs2, *num_trees, *checks)
85        }
86        MatchMethod::RatioTest { ratio } => ratio_test_float(&vecs1, &vecs2, *ratio),
87    };
88
89    Ok(matches)
90}
91
92/// Exhaustive L2 nearest-neighbour.
93fn brute_force_float(q: &[&[f32]], t: &[&[f32]]) -> Vec<(usize, usize, f64)> {
94    let mut out = Vec::with_capacity(q.len());
95    for (i, qi) in q.iter().enumerate() {
96        let mut best_dist = f64::MAX;
97        let mut best_j = 0usize;
98        for (j, tj) in t.iter().enumerate() {
99            let d = l2_distance_f32(qi, tj);
100            if d < best_dist {
101                best_dist = d;
102                best_j = j;
103            }
104        }
105        out.push((i, best_j, best_dist));
106    }
107    out.sort_unstable_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
108    out
109}
110
111/// Approximate nearest-neighbour using a lightweight randomised kd-tree forest.
112///
113/// For each query, traversal with `checks` node evaluations is performed.
114/// Falls back to brute-force for small descriptor sets.
115fn flann_like_float(
116    q: &[&[f32]],
117    t: &[&[f32]],
118    num_trees: usize,
119    checks: usize,
120) -> Vec<(usize, usize, f64)> {
121    if t.len() < 16 {
122        return brute_force_float(q, t);
123    }
124
125    // Build multiple randomised kd-trees
126    let trees: Vec<KdNode> = (0..num_trees.max(1))
127        .map(|seed| build_kdtree(t, seed as u64))
128        .collect();
129
130    let mut out = Vec::with_capacity(q.len());
131    for (i, qi) in q.iter().enumerate() {
132        let mut best_dist = f64::MAX;
133        let mut best_j = 0usize;
134
135        for tree in &trees {
136            let (j, d) = search_kdtree(tree, qi, t, checks);
137            if d < best_dist {
138                best_dist = d;
139                best_j = j;
140            }
141        }
142        out.push((i, best_j, best_dist));
143    }
144    out.sort_unstable_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
145    out
146}
147
148/// Lowe's ratio test: keep a match only if d1 / d2 < ratio.
149fn ratio_test_float(q: &[&[f32]], t: &[&[f32]], ratio: f64) -> Vec<(usize, usize, f64)> {
150    let mut out = Vec::new();
151    for (i, qi) in q.iter().enumerate() {
152        let mut first = (f64::MAX, 0usize);
153        let mut second = f64::MAX;
154
155        for (j, tj) in t.iter().enumerate() {
156            let d = l2_distance_f32(qi, tj);
157            if d < first.0 {
158                second = first.0;
159                first = (d, j);
160            } else if d < second {
161                second = d;
162            }
163        }
164
165        if second > 0.0 && first.0 / second < ratio {
166            out.push((i, first.1, first.0));
167        }
168    }
169    out.sort_unstable_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
170    out
171}
172
173// ─── Binary descriptor matching (ORB-like) ───────────────────────────────────
174
175/// Match two sets of ORB-like binary descriptors using Hamming distance.
176///
177/// # Arguments
178///
179/// * `desc1` – Query descriptors
180/// * `desc2` – Train descriptors
181/// * `method` – Matching strategy
182///
183/// # Returns
184///
185/// Vector of `(idx1, idx2, distance)` where distance is the Hamming distance
186/// (number of differing bits, 0–256).
187pub fn match_binary_descriptors(
188    desc1: &[OrbLikeDescriptor],
189    desc2: &[OrbLikeDescriptor],
190    method: &MatchMethod,
191) -> Result<Vec<(usize, usize, f64)>> {
192    if desc1.is_empty() || desc2.is_empty() {
193        return Ok(Vec::new());
194    }
195
196    let refs1: Vec<&[u32; DESC_WORDS]> = desc1.iter().map(|d| &d.descriptor).collect();
197    let refs2: Vec<&[u32; DESC_WORDS]> = desc2.iter().map(|d| &d.descriptor).collect();
198
199    let matches = match method {
200        MatchMethod::BruteForce => brute_force_binary(&refs1, &refs2),
201        MatchMethod::FlannLike { checks, .. } => {
202            // For binary: approximate via multi-table LSH
203            lsh_binary(&refs1, &refs2, *checks)
204        }
205        MatchMethod::RatioTest { ratio } => ratio_test_binary(&refs1, &refs2, *ratio),
206    };
207
208    Ok(matches)
209}
210
211/// Exhaustive Hamming nearest-neighbour for binary descriptors.
212fn brute_force_binary(
213    q: &[&[u32; DESC_WORDS]],
214    t: &[&[u32; DESC_WORDS]],
215) -> Vec<(usize, usize, f64)> {
216    let mut out = Vec::with_capacity(q.len());
217    for (i, qi) in q.iter().enumerate() {
218        let mut best = (u32::MAX, 0usize);
219        for (j, tj) in t.iter().enumerate() {
220            let d = hamming_distance(qi, tj);
221            if d < best.0 {
222                best = (d, j);
223            }
224        }
225        out.push((i, best.1, best.0 as f64));
226    }
227    out.sort_unstable_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
228    out
229}
230
231/// Approximate Hamming matching via a simple multi-probe approach:
232/// for each query, randomly sub-sample `checks` train descriptors plus a
233/// guided sweep of the top hash-bucket.
234fn lsh_binary(
235    q: &[&[u32; DESC_WORDS]],
236    t: &[&[u32; DESC_WORDS]],
237    checks: usize,
238) -> Vec<(usize, usize, f64)> {
239    if t.len() <= checks {
240        return brute_force_binary(q, t);
241    }
242
243    // Build a simple random-projection table for approximate bucketing
244    // Hash = XOR of selected descriptor words
245    let bucket_count = (t.len() / 4).next_power_of_two().max(64);
246    let mask = bucket_count - 1;
247
248    // Assign train descriptors to buckets (based on first word XOR hash)
249    let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); bucket_count];
250    for (j, tj) in t.iter().enumerate() {
251        let hash = (tj[0] ^ tj[1].rotate_left(8) ^ tj[2].rotate_left(16)) as usize & mask;
252        buckets[hash].push(j);
253    }
254
255    let mut out = Vec::with_capacity(q.len());
256    for (i, qi) in q.iter().enumerate() {
257        let query_hash = (qi[0] ^ qi[1].rotate_left(8) ^ qi[2].rotate_left(16)) as usize & mask;
258
259        let mut candidates: Vec<usize> = buckets[query_hash].clone();
260        // Also probe neighbouring buckets
261        let nb1 = (query_hash + 1) & mask;
262        let nb2 = (query_hash + bucket_count - 1) & mask;
263        candidates.extend_from_slice(&buckets[nb1]);
264        candidates.extend_from_slice(&buckets[nb2]);
265
266        // Supplement with uniformly spaced samples if we have too few candidates
267        if candidates.len() < checks {
268            let step = t.len() / (checks - candidates.len()).max(1);
269            for k in (0..t.len()).step_by(step.max(1)) {
270                candidates.push(k);
271            }
272        }
273
274        candidates.sort_unstable();
275        candidates.dedup();
276
277        let mut best = (u32::MAX, 0usize);
278        for j in candidates {
279            if j < t.len() {
280                let d = hamming_distance(qi, t[j]);
281                if d < best.0 {
282                    best = (d, j);
283                }
284            }
285        }
286
287        out.push((i, best.1, best.0 as f64));
288    }
289
290    out.sort_unstable_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
291    out
292}
293
294/// Lowe's ratio test for binary (Hamming) descriptors.
295fn ratio_test_binary(
296    q: &[&[u32; DESC_WORDS]],
297    t: &[&[u32; DESC_WORDS]],
298    ratio: f64,
299) -> Vec<(usize, usize, f64)> {
300    let mut out = Vec::new();
301    for (i, qi) in q.iter().enumerate() {
302        let mut first = (u32::MAX, 0usize);
303        let mut second = u32::MAX;
304
305        for (j, tj) in t.iter().enumerate() {
306            let d = hamming_distance(qi, tj);
307            if d < first.0 {
308                second = first.0;
309                first = (d, j);
310            } else if d < second {
311                second = d;
312            }
313        }
314
315        if second > 0 {
316            let r = first.0 as f64 / second as f64;
317            if r < ratio {
318                out.push((i, first.1, first.0 as f64));
319            }
320        }
321    }
322    out.sort_unstable_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
323    out
324}
325
326// ─── Symmetric / cross-check filtering ───────────────────────────────────────
327
328/// Filter a set of SIFT-like matches using symmetric (cross-check) validation.
329///
330/// Keeps only matches (i → j) where the reverse match (j → i) is also i.
331/// This removes many spurious correspondences at the cost of slightly
332/// fewer total matches.
333pub fn symmetric_filter(
334    desc1: &[SIFTDescriptor],
335    desc2: &[SIFTDescriptor],
336    method: &MatchMethod,
337) -> Result<Vec<(usize, usize, f64)>> {
338    let fwd = match_descriptors(desc1, desc2, method)?;
339    let rev = match_descriptors(desc2, desc1, method)?;
340
341    // Build reverse lookup: j → i
342    let mut rev_map: std::collections::HashMap<usize, usize> =
343        std::collections::HashMap::with_capacity(rev.len());
344    for (j, i, _) in &rev {
345        rev_map.insert(*j, *i);
346    }
347
348    let symmetric: Vec<(usize, usize, f64)> = fwd
349        .into_iter()
350        .filter(|(i, j, _)| rev_map.get(j).is_some_and(|&ri| ri == *i))
351        .collect();
352
353    Ok(symmetric)
354}
355
356// ─── kd-tree (float, randomised) ─────────────────────────────────────────────
357
358/// Node in a randomised kd-tree.
359enum KdNode {
360    Leaf {
361        indices: Vec<usize>,
362    },
363    Internal {
364        axis: usize,
365        split_val: f32,
366        left: Box<KdNode>,
367        right: Box<KdNode>,
368    },
369}
370
371/// Build a randomised kd-tree over `vecs` (indexed by position).
372fn build_kdtree(vecs: &[&[f32]], seed: u64) -> KdNode {
373    let indices: Vec<usize> = (0..vecs.len()).collect();
374    build_kdtree_rec(&indices, vecs, seed, 0)
375}
376
377fn build_kdtree_rec(indices: &[usize], vecs: &[&[f32]], seed: u64, depth: usize) -> KdNode {
378    const LEAF_SIZE: usize = 8;
379    if indices.len() <= LEAF_SIZE {
380        return KdNode::Leaf {
381            indices: indices.to_vec(),
382        };
383    }
384
385    let dim = vecs[0].len();
386    // Randomised axis selection: pick the axis with highest variance among a
387    // random subset of 5 dimensions.
388    let axis = choose_split_axis(indices, vecs, seed, depth, dim);
389
390    // Split at the median
391    let mut vals: Vec<f32> = indices.iter().map(|&i| vecs[i][axis]).collect();
392    vals.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
393    let split_val = vals[vals.len() / 2];
394
395    let left_idx: Vec<usize> = indices
396        .iter()
397        .copied()
398        .filter(|&i| vecs[i][axis] < split_val)
399        .collect();
400    let right_idx: Vec<usize> = indices
401        .iter()
402        .copied()
403        .filter(|&i| vecs[i][axis] >= split_val)
404        .collect();
405
406    // Guard against degenerate splits
407    if left_idx.is_empty() || right_idx.is_empty() {
408        return KdNode::Leaf {
409            indices: indices.to_vec(),
410        };
411    }
412
413    KdNode::Internal {
414        axis,
415        split_val,
416        left: Box::new(build_kdtree_rec(&left_idx, vecs, seed, depth + 1)),
417        right: Box::new(build_kdtree_rec(&right_idx, vecs, seed, depth + 1)),
418    }
419}
420
421fn choose_split_axis(
422    indices: &[usize],
423    vecs: &[&[f32]],
424    seed: u64,
425    depth: usize,
426    dim: usize,
427) -> usize {
428    // Sample up to 5 random dimensions and pick the one with highest variance
429    let n_sample = 5usize.min(dim);
430    let mut rng_state = seed.wrapping_add(depth as u64 * 6_364_136_223_846_793_005);
431
432    let sample_n = indices.len().min(32);
433
434    let mut best_axis = 0usize;
435    let mut best_var = f64::NEG_INFINITY;
436
437    for _ in 0..n_sample {
438        rng_state = rng_state
439            .wrapping_mul(6_364_136_223_846_793_005)
440            .wrapping_add(1_442_695_040_888_963_407);
441        let axis = (rng_state >> 33) as usize % dim;
442
443        // Compute variance of this axis over a subset
444        let step = indices.len() / sample_n + 1;
445        let sampled: Vec<f64> = indices
446            .iter()
447            .step_by(step)
448            .take(sample_n)
449            .map(|&i| vecs[i][axis] as f64)
450            .collect();
451
452        let n = sampled.len() as f64;
453        if n < 2.0 {
454            continue;
455        }
456        let mean = sampled.iter().sum::<f64>() / n;
457        let var = sampled.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n;
458
459        if var > best_var {
460            best_var = var;
461            best_axis = axis;
462        }
463    }
464
465    best_axis
466}
467
468/// Approximate nearest-neighbour search in a kd-tree with `checks` leaf evaluations.
469fn search_kdtree(root: &KdNode, query: &[f32], vecs: &[&[f32]], checks: usize) -> (usize, f64) {
470    let mut best = (f64::MAX, 0usize);
471    let mut evals = 0usize;
472    search_kdtree_rec(root, query, vecs, &mut best, &mut evals, checks);
473    (best.1, best.0)
474}
475
476fn search_kdtree_rec(
477    node: &KdNode,
478    query: &[f32],
479    vecs: &[&[f32]],
480    best: &mut (f64, usize),
481    evals: &mut usize,
482    max_evals: usize,
483) {
484    if *evals >= max_evals {
485        return;
486    }
487
488    match node {
489        KdNode::Leaf { indices } => {
490            for &i in indices {
491                *evals += 1;
492                let d = l2_distance_f32(query, vecs[i]);
493                if d < best.0 {
494                    *best = (d, i);
495                }
496                if *evals >= max_evals {
497                    return;
498                }
499            }
500        }
501        KdNode::Internal {
502            axis,
503            split_val,
504            left,
505            right,
506        } => {
507            let q_val = query[*axis];
508            let (near, far) = if q_val < *split_val {
509                (left.as_ref(), right.as_ref())
510            } else {
511                (right.as_ref(), left.as_ref())
512            };
513
514            search_kdtree_rec(near, query, vecs, best, evals, max_evals);
515
516            // Backtrack to far side if potentially useful
517            let plane_dist = (q_val - split_val).powi(2) as f64;
518            if plane_dist < best.0 && *evals < max_evals {
519                search_kdtree_rec(far, query, vecs, best, evals, max_evals);
520            }
521        }
522    }
523}
524
525// ─── Distance helpers ─────────────────────────────────────────────────────────
526
527/// Squared L2 distance between two float descriptor vectors.
528fn l2_distance_f32(a: &[f32], b: &[f32]) -> f64 {
529    a.iter()
530        .zip(b.iter())
531        .map(|(&x, &y)| {
532            let d = x - y;
533            (d * d) as f64
534        })
535        .sum::<f64>()
536        .sqrt()
537}
538
539// ─── Tests ────────────────────────────────────────────────────────────────────
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use crate::features::sift_like::{Keypoint, SIFTDescriptor};
545
546    fn make_sift_desc(id: usize, perturb: f32) -> SIFTDescriptor {
547        let mut desc = vec![0.0f32; 128];
548        desc[id % 128] = 1.0;
549        // tiny perturbation so ratio test can differentiate
550        desc[(id + 1) % 128] = perturb;
551        // normalise
552        let norm: f32 = desc.iter().map(|v| v * v).sum::<f32>().sqrt();
553        for v in &mut desc {
554            *v /= norm;
555        }
556        SIFTDescriptor {
557            keypoint: Keypoint {
558                x: id as f64,
559                y: id as f64,
560                scale: 1.0,
561                orientation: 0.0,
562                response: 1.0,
563                octave: 0,
564            },
565            descriptor: desc,
566        }
567    }
568
569    fn make_orb_desc(id: usize) -> OrbLikeDescriptor {
570        let mut words = [0u32; DESC_WORDS];
571        words[id % DESC_WORDS] = (id as u32).wrapping_mul(0x12345678);
572        OrbLikeDescriptor {
573            keypoint: crate::features::orb_like::OrbKeypoint {
574                x: id as f64,
575                y: id as f64,
576                score: 1.0,
577                orientation: 0.0,
578                level: 0,
579            },
580            descriptor: words,
581        }
582    }
583
584    #[test]
585    fn test_brute_force_exact_match() {
586        let descs: Vec<SIFTDescriptor> = (0..5).map(|i| make_sift_desc(i, 0.0)).collect();
587        let matches = match_descriptors(&descs, &descs, &MatchMethod::BruteForce)
588            .expect("match_descriptors should succeed");
589        // Each descriptor should match itself (distance 0)
590        for (i, j, d) in &matches {
591            assert_eq!(i, j, "Self-match expected at index {i}");
592            assert!(*d < 1e-6, "Self-match distance should be ~0, got {d}");
593        }
594    }
595
596    #[test]
597    fn test_ratio_test_returns_matches() {
598        let q: Vec<SIFTDescriptor> = (0..4).map(|i| make_sift_desc(i, 0.01)).collect();
599        let t: Vec<SIFTDescriptor> = (0..8).map(|i| make_sift_desc(i, 0.01)).collect();
600        let m = match_descriptors(&q, &t, &MatchMethod::RatioTest { ratio: 0.9 })
601            .expect("match_descriptors with ratio test should succeed");
602        // Should return at least some matches
603        assert!(!m.is_empty());
604    }
605
606    #[test]
607    fn test_flann_like_consistent_with_brute_force_small() {
608        // For a tiny set, FLANN should fall back to brute-force and give the same result
609        let descs: Vec<SIFTDescriptor> = (0..5).map(|i| make_sift_desc(i, 0.0)).collect();
610        let bf = match_descriptors(&descs, &descs, &MatchMethod::BruteForce)
611            .expect("brute force match should succeed");
612        let fl = match_descriptors(
613            &descs,
614            &descs,
615            &MatchMethod::FlannLike {
616                num_trees: 2,
617                checks: 50,
618            },
619        )
620        .expect("flann-like match should succeed");
621        // Both should match each descriptor to itself
622        assert_eq!(bf.len(), fl.len());
623    }
624
625    #[test]
626    fn test_binary_brute_force() {
627        let d: Vec<OrbLikeDescriptor> = (0..4).map(make_orb_desc).collect();
628        let matches = match_binary_descriptors(&d, &d, &MatchMethod::BruteForce)
629            .expect("match_binary_descriptors should succeed");
630        for (i, j, dist) in &matches {
631            assert_eq!(i, j, "Binary self-match expected");
632            assert_eq!(*dist, 0.0, "Hamming self-distance should be 0");
633        }
634    }
635
636    #[test]
637    fn test_empty_match_set() {
638        let empty: Vec<SIFTDescriptor> = Vec::new();
639        let q: Vec<SIFTDescriptor> = (0..3).map(|i| make_sift_desc(i, 0.0)).collect();
640        let m1 = match_descriptors(&empty, &q, &MatchMethod::BruteForce)
641            .expect("match_descriptors should succeed with empty query");
642        let m2 = match_descriptors(&q, &empty, &MatchMethod::BruteForce)
643            .expect("match_descriptors should succeed with empty target");
644        assert!(m1.is_empty());
645        assert!(m2.is_empty());
646    }
647
648    #[test]
649    fn test_symmetric_filter() {
650        let descs: Vec<SIFTDescriptor> = (0..6).map(|i| make_sift_desc(i, 0.0)).collect();
651        let sym = symmetric_filter(&descs, &descs, &MatchMethod::BruteForce)
652            .expect("symmetric_filter should succeed");
653        // All self-matches are symmetric
654        for (i, j, d) in &sym {
655            assert_eq!(i, j);
656            assert!(*d < 1e-6);
657        }
658    }
659}