Skip to main content

oximedia_align/
frame_matcher.rs

1//! Frame-level descriptor matching for video alignment in `OxiMedia`.
2//!
3//! Uses perceptual hashing (binary descriptors) and Hamming distance to
4//! find the best corresponding frame between two video streams.
5
6#![allow(dead_code)]
7
8/// A binary feature descriptor stored as a fixed-width bit array.
9///
10/// Uses 256 bits (32 bytes) – compatible with ORB / BRIEF descriptors.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct FrameDescriptor {
13    /// Raw binary descriptor bytes.
14    pub data: [u8; 32],
15    /// Frame presentation timestamp in milliseconds.
16    pub pts_ms: i64,
17}
18
19impl FrameDescriptor {
20    /// Create a descriptor from raw bytes and a PTS.
21    #[must_use]
22    pub fn new(data: [u8; 32], pts_ms: i64) -> Self {
23        Self { data, pts_ms }
24    }
25
26    /// Compute the Hamming distance to another descriptor.
27    ///
28    /// Returns a value in `[0, 256]`; lower is more similar.
29    #[must_use]
30    pub fn hamming_distance(&self, other: &Self) -> u32 {
31        self.data
32            .iter()
33            .zip(other.data.iter())
34            .map(|(a, b)| (a ^ b).count_ones())
35            .sum()
36    }
37
38    /// Return `true` if descriptors are identical.
39    #[must_use]
40    pub fn is_identical(&self, other: &Self) -> bool {
41        self.data == other.data
42    }
43}
44
45/// A candidate match between a query frame and a database frame.
46#[derive(Debug, Clone)]
47pub struct MatchCandidate {
48    /// Hamming distance between the two descriptors.
49    pub hamming: u32,
50    /// PTS of the matched frame in the reference stream (ms).
51    pub ref_pts_ms: i64,
52    /// PTS of the query frame (ms).
53    pub query_pts_ms: i64,
54    /// Normalised confidence `[0.0, 1.0]`; derived from Hamming distance.
55    pub confidence: f64,
56}
57
58impl MatchCandidate {
59    /// Create a match candidate.
60    ///
61    /// `max_bits` is the descriptor width in bits used to normalise confidence.
62    #[allow(clippy::cast_precision_loss)]
63    #[must_use]
64    pub fn new(hamming: u32, ref_pts_ms: i64, query_pts_ms: i64, max_bits: u32) -> Self {
65        let confidence = if max_bits == 0 {
66            0.0
67        } else {
68            1.0 - (f64::from(hamming) / f64::from(max_bits))
69        };
70        Self {
71            hamming,
72            ref_pts_ms,
73            query_pts_ms,
74            confidence,
75        }
76    }
77
78    /// Return `true` when confidence meets the given threshold.
79    #[must_use]
80    pub fn confidence_ok(&self, threshold: f64) -> bool {
81        self.confidence >= threshold
82    }
83
84    /// Signed temporal offset: reference PTS minus query PTS, in ms.
85    #[must_use]
86    pub fn offset_ms(&self) -> i64 {
87        self.ref_pts_ms - self.query_pts_ms
88    }
89}
90
91/// Configuration for [`FrameMatcher`].
92#[derive(Debug, Clone)]
93pub struct FrameMatcherConfig {
94    /// Maximum Hamming distance to consider a valid match.
95    pub max_hamming: u32,
96    /// Minimum confidence threshold for [`MatchCandidate::confidence_ok`].
97    pub min_confidence: f64,
98    /// Descriptor bit width (default 256 for 32-byte descriptors).
99    pub descriptor_bits: u32,
100}
101
102impl Default for FrameMatcherConfig {
103    fn default() -> Self {
104        Self {
105            max_hamming: 64,
106            min_confidence: 0.75,
107            descriptor_bits: 256,
108        }
109    }
110}
111
112/// Matches a query frame descriptor against a reference database.
113#[derive(Debug)]
114pub struct FrameMatcher {
115    config: FrameMatcherConfig,
116    reference: Vec<FrameDescriptor>,
117}
118
119impl FrameMatcher {
120    /// Create a new matcher with the given configuration.
121    #[must_use]
122    pub fn new(config: FrameMatcherConfig) -> Self {
123        Self {
124            config,
125            reference: Vec::new(),
126        }
127    }
128
129    /// Create a matcher with default configuration.
130    #[must_use]
131    pub fn default_matcher() -> Self {
132        Self::new(FrameMatcherConfig::default())
133    }
134
135    /// Add a reference frame descriptor.
136    pub fn add_reference(&mut self, desc: FrameDescriptor) {
137        self.reference.push(desc);
138    }
139
140    /// Load a collection of reference descriptors.
141    pub fn load_reference(&mut self, descs: Vec<FrameDescriptor>) {
142        self.reference = descs;
143    }
144
145    /// Find all candidates within the configured Hamming threshold.
146    #[must_use]
147    pub fn find_match(&self, query: &FrameDescriptor) -> Vec<MatchCandidate> {
148        let bits = self.config.descriptor_bits;
149        let max_h = self.config.max_hamming;
150        self.reference
151            .iter()
152            .filter_map(|r| {
153                let h = query.hamming_distance(r);
154                if h <= max_h {
155                    Some(MatchCandidate::new(h, r.pts_ms, query.pts_ms, bits))
156                } else {
157                    None
158                }
159            })
160            .collect()
161    }
162
163    /// Return the single best (lowest Hamming distance) match, if any.
164    #[must_use]
165    pub fn best_match(&self, query: &FrameDescriptor) -> Option<MatchCandidate> {
166        let bits = self.config.descriptor_bits;
167        let max_h = self.config.max_hamming;
168        self.reference
169            .iter()
170            .filter_map(|r| {
171                let h = query.hamming_distance(r);
172                if h <= max_h {
173                    Some(MatchCandidate::new(h, r.pts_ms, query.pts_ms, bits))
174                } else {
175                    None
176                }
177            })
178            .min_by_key(|c| c.hamming)
179    }
180
181    /// Number of loaded reference descriptors.
182    #[must_use]
183    pub fn reference_count(&self) -> usize {
184        self.reference.len()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    fn zeros() -> [u8; 32] {
193        [0u8; 32]
194    }
195    fn ones() -> [u8; 32] {
196        [0xFFu8; 32]
197    }
198    fn half() -> [u8; 32] {
199        let mut d = [0u8; 32];
200        for i in 0..16 {
201            d[i] = 0xFF;
202        }
203        d
204    }
205
206    // ── FrameDescriptor ──────────────────────────────────────────────────────
207
208    #[test]
209    fn test_hamming_identical() {
210        let d = FrameDescriptor::new(zeros(), 0);
211        assert_eq!(d.hamming_distance(&d), 0);
212    }
213
214    #[test]
215    fn test_hamming_all_different() {
216        let a = FrameDescriptor::new(zeros(), 0);
217        let b = FrameDescriptor::new(ones(), 0);
218        assert_eq!(a.hamming_distance(&b), 256);
219    }
220
221    #[test]
222    fn test_hamming_half() {
223        let a = FrameDescriptor::new(zeros(), 0);
224        let b = FrameDescriptor::new(half(), 0);
225        assert_eq!(a.hamming_distance(&b), 128);
226    }
227
228    #[test]
229    fn test_is_identical_true() {
230        let a = FrameDescriptor::new(zeros(), 100);
231        let b = FrameDescriptor::new(zeros(), 200);
232        assert!(a.is_identical(&b));
233    }
234
235    #[test]
236    fn test_is_identical_false() {
237        let a = FrameDescriptor::new(zeros(), 0);
238        let b = FrameDescriptor::new(ones(), 0);
239        assert!(!a.is_identical(&b));
240    }
241
242    // ── MatchCandidate ───────────────────────────────────────────────────────
243
244    #[test]
245    fn test_candidate_perfect_confidence() {
246        let c = MatchCandidate::new(0, 1000, 1000, 256);
247        assert!((c.confidence - 1.0).abs() < f64::EPSILON);
248    }
249
250    #[test]
251    fn test_candidate_zero_confidence() {
252        let c = MatchCandidate::new(256, 0, 0, 256);
253        assert!(c.confidence < f64::EPSILON);
254    }
255
256    #[test]
257    fn test_candidate_confidence_ok() {
258        let c = MatchCandidate::new(32, 0, 0, 256);
259        assert!(c.confidence_ok(0.75));
260        assert!(!c.confidence_ok(0.999));
261    }
262
263    #[test]
264    fn test_candidate_offset_ms() {
265        let c = MatchCandidate::new(10, 2000, 1500, 256);
266        assert_eq!(c.offset_ms(), 500);
267    }
268
269    #[test]
270    fn test_candidate_zero_max_bits() {
271        let c = MatchCandidate::new(10, 0, 0, 0);
272        assert!((c.confidence).abs() < f64::EPSILON);
273    }
274
275    // ── FrameMatcher ─────────────────────────────────────────────────────────
276
277    #[test]
278    fn test_matcher_empty_reference() {
279        let matcher = FrameMatcher::default_matcher();
280        let query = FrameDescriptor::new(zeros(), 0);
281        assert!(matcher.find_match(&query).is_empty());
282        assert!(matcher.best_match(&query).is_none());
283    }
284
285    #[test]
286    fn test_matcher_finds_exact() {
287        let mut matcher = FrameMatcher::default_matcher();
288        matcher.add_reference(FrameDescriptor::new(zeros(), 1000));
289        let query = FrameDescriptor::new(zeros(), 500);
290        let candidates = matcher.find_match(&query);
291        assert_eq!(candidates.len(), 1);
292        assert_eq!(candidates[0].hamming, 0);
293    }
294
295    #[test]
296    fn test_matcher_best_match_closest() {
297        let mut matcher = FrameMatcher::default_matcher();
298        let mut near = zeros();
299        near[0] = 0x01; // Hamming = 1
300        let mut far = zeros();
301        far[0] = 0x0F; // Hamming = 4
302        matcher.add_reference(FrameDescriptor::new(near, 1000));
303        matcher.add_reference(FrameDescriptor::new(far, 2000));
304        let query = FrameDescriptor::new(zeros(), 0);
305        let best = matcher.best_match(&query).expect("best should be valid");
306        assert_eq!(best.hamming, 1);
307        assert_eq!(best.ref_pts_ms, 1000);
308    }
309
310    #[test]
311    fn test_matcher_rejects_beyond_threshold() {
312        let cfg = FrameMatcherConfig {
313            max_hamming: 10,
314            ..Default::default()
315        };
316        let mut matcher = FrameMatcher::new(cfg);
317        matcher.add_reference(FrameDescriptor::new(ones(), 500));
318        let query = FrameDescriptor::new(zeros(), 0);
319        // Hamming = 256 > 10 → no match
320        assert!(matcher.find_match(&query).is_empty());
321    }
322
323    #[test]
324    fn test_matcher_reference_count() {
325        let mut matcher = FrameMatcher::default_matcher();
326        assert_eq!(matcher.reference_count(), 0);
327        matcher.add_reference(FrameDescriptor::new(zeros(), 0));
328        matcher.add_reference(FrameDescriptor::new(ones(), 1));
329        assert_eq!(matcher.reference_count(), 2);
330    }
331
332    #[test]
333    fn test_load_reference_replaces() {
334        let mut matcher = FrameMatcher::default_matcher();
335        matcher.add_reference(FrameDescriptor::new(zeros(), 0));
336        let new_refs = vec![
337            FrameDescriptor::new(ones(), 100),
338            FrameDescriptor::new(half(), 200),
339        ];
340        matcher.load_reference(new_refs);
341        assert_eq!(matcher.reference_count(), 2);
342    }
343}