Skip to main content

oximedia_align/
multitrack_align.rs

1//! Multi-track alignment for synchronizing multiple audio/video tracks.
2//!
3//! This module provides tools for aligning multiple tracks to a common reference,
4//! using cross-correlation of feature vectors to find the best time offset.
5
6#![allow(dead_code)]
7
8/// Anchor point tying a frame to a feature vector for alignment purposes.
9#[derive(Debug, Clone)]
10pub struct AlignmentAnchor {
11    /// ID of the track this anchor belongs to.
12    pub track_id: String,
13    /// Frame index within the track.
14    pub frame_idx: u64,
15    /// Feature vector extracted from this frame.
16    pub feature_vector: Vec<f32>,
17}
18
19impl AlignmentAnchor {
20    /// Create a new alignment anchor.
21    #[must_use]
22    pub fn new(track_id: impl Into<String>, frame_idx: u64, feature_vector: Vec<f32>) -> Self {
23        Self {
24            track_id: track_id.into(),
25            frame_idx,
26            feature_vector,
27        }
28    }
29}
30
31/// Method used to align two tracks.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum AlignMethod {
34    /// Cross-correlation of audio waveforms.
35    AudioCorrelation,
36    /// Matching of visual feature vectors.
37    VisualFeature,
38    /// Explicit sync markers (clapperboard, flash, …).
39    Marker,
40    /// LTC/VITC timecode comparison.
41    Timecode,
42    /// Manually specified offset.
43    Manual,
44}
45
46/// Result of aligning one track to a reference track.
47#[derive(Debug, Clone)]
48pub struct TrackAlignment {
49    /// ID of the reference (anchor) track.
50    pub reference_id: String,
51    /// ID of the track that was aligned.
52    pub aligned_id: String,
53    /// Frame offset to apply: `aligned_frame + offset_frames = reference_frame`.
54    pub offset_frames: i64,
55    /// Confidence of the alignment in `[0.0, 1.0]`.
56    pub confidence: f32,
57    /// Method that produced the alignment.
58    pub method: AlignMethod,
59}
60
61impl TrackAlignment {
62    /// Create a new track alignment result.
63    #[must_use]
64    pub fn new(
65        reference_id: impl Into<String>,
66        aligned_id: impl Into<String>,
67        offset_frames: i64,
68        confidence: f32,
69        method: AlignMethod,
70    ) -> Self {
71        Self {
72            reference_id: reference_id.into(),
73            aligned_id: aligned_id.into(),
74            offset_frames,
75            confidence,
76            method,
77        }
78    }
79}
80
81/// Aligns multiple tracks to a designated reference track using feature
82/// cross-correlation.
83#[derive(Debug, Default)]
84pub struct MultitrackAligner {
85    /// ID of the reference track.
86    reference_id: Option<String>,
87}
88
89impl MultitrackAligner {
90    /// Create a new aligner (no reference set yet).
91    #[must_use]
92    pub fn new() -> Self {
93        Self { reference_id: None }
94    }
95
96    /// Set the reference track ID.
97    pub fn set_reference(&mut self, track_id: &str) {
98        self.reference_id = Some(track_id.to_owned());
99    }
100
101    /// Align `track_id` to the reference using the supplied anchors.
102    ///
103    /// For each anchor belonging to `track_id`, the method searches for the
104    /// anchor from the reference track with the matching frame index (or the
105    /// closest one) and accumulates cross-correlation evidence.  The offset
106    /// with the highest aggregate correlation peak is returned.
107    ///
108    /// # Panics
109    /// Panics if no reference track has been set via [`Self::set_reference`].
110    #[must_use]
111    pub fn align_track(&self, track_id: &str, anchors: &[AlignmentAnchor]) -> TrackAlignment {
112        let reference_id = self
113            .reference_id
114            .as_deref()
115            .expect("Reference track must be set before calling align_track");
116
117        // Separate anchors by track.
118        let ref_anchors: Vec<&AlignmentAnchor> = anchors
119            .iter()
120            .filter(|a| a.track_id == reference_id)
121            .collect();
122        let tgt_anchors: Vec<&AlignmentAnchor> =
123            anchors.iter().filter(|a| a.track_id == track_id).collect();
124
125        if ref_anchors.is_empty() || tgt_anchors.is_empty() {
126            return TrackAlignment::new(reference_id, track_id, 0, 0.0, AlignMethod::VisualFeature);
127        }
128
129        // Compute cross-correlation for each reference/target pair and vote.
130        let mut best_offset: i64 = 0;
131        let mut best_score: f64 = f64::NEG_INFINITY;
132        let mut total_pairs = 0usize;
133
134        for ref_anchor in &ref_anchors {
135            for tgt_anchor in &tgt_anchors {
136                let corr = cross_correlate(&ref_anchor.feature_vector, &tgt_anchor.feature_vector);
137                // The peak position in the cross-correlation array gives the lag.
138                if let Some((peak_lag, peak_val)) = corr
139                    .iter()
140                    .enumerate()
141                    .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
142                {
143                    // Convert cross-correlation lag to frame offset.
144                    let n = ref_anchor.feature_vector.len();
145                    let signed_lag = peak_lag as i64 - (n as i64 - 1);
146                    let frame_offset =
147                        tgt_anchor.frame_idx as i64 - ref_anchor.frame_idx as i64 + signed_lag;
148
149                    if f64::from(*peak_val) > best_score {
150                        best_score = f64::from(*peak_val);
151                        best_offset = frame_offset;
152                    }
153                    total_pairs += 1;
154                }
155            }
156        }
157
158        // Confidence is based on peak value normalised to [0, 1].
159        let confidence = if total_pairs > 0 {
160            (best_score as f32).clamp(0.0, 1.0)
161        } else {
162            0.0
163        };
164
165        TrackAlignment::new(
166            reference_id,
167            track_id,
168            best_offset,
169            confidence,
170            AlignMethod::VisualFeature,
171        )
172    }
173}
174
175/// Compute the normalised cross-correlation of two equal-length (or different-
176/// length) sequences.
177///
178/// The output has length `len(a) + len(b) - 1`.  Each value is the
179/// zero-lag-normalised dot product at that lag, clamped to `[-1, 1]`.
180#[must_use]
181pub fn cross_correlate(a: &[f32], b: &[f32]) -> Vec<f32> {
182    if a.is_empty() || b.is_empty() {
183        return Vec::new();
184    }
185
186    let na = a.len();
187    let nb = b.len();
188    let out_len = na + nb - 1;
189
190    // Precompute norms for normalisation.
191    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
192    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
193    let denom = norm_a * norm_b;
194
195    let mut result = vec![0.0f32; out_len];
196
197    for lag in 0..out_len {
198        let mut sum = 0.0f32;
199        // lag = 0 → b starts at position -(nb-1) relative to a.
200        // At lag `lag`, b[j] aligns with a[lag + j - (nb - 1)].
201        for j in 0..nb {
202            let a_idx = lag + j;
203            if a_idx >= nb - 1 && a_idx - (nb - 1) < na {
204                let ai = a_idx - (nb - 1);
205                sum += a[ai] * b[j];
206            }
207        }
208        result[lag] = if denom > 1e-10 {
209            (sum / denom).clamp(-1.0, 1.0)
210        } else {
211            0.0
212        };
213    }
214
215    result
216}
217
218/// Pairwise frame-offset matrix between a set of tracks.
219#[derive(Debug, Clone)]
220pub struct AlignmentMatrix {
221    /// Track IDs (row/column labels).
222    pub tracks: Vec<String>,
223    /// `offsets[i][j]` = frame offset of track `j` relative to track `i`.
224    pub offsets: Vec<Vec<i64>>,
225}
226
227impl AlignmentMatrix {
228    /// Create a new alignment matrix from a list of pairwise [`TrackAlignment`]s.
229    ///
230    /// The matrix is filled symmetrically: if `alignment.offset_frames` is the
231    /// offset from `reference_id` to `aligned_id`, then `[ref_idx][aln_idx] =
232    /// offset` and `[aln_idx][ref_idx] = -offset`.
233    #[must_use]
234    pub fn from_alignments(track_ids: &[&str], alignments: &[TrackAlignment]) -> Self {
235        let n = track_ids.len();
236        let tracks: Vec<String> = track_ids.iter().map(|s| (*s).to_string()).collect();
237        let mut offsets = vec![vec![0i64; n]; n];
238
239        let idx = |id: &str| tracks.iter().position(|t| t == id);
240
241        for aln in alignments {
242            if let (Some(ri), Some(ai)) = (idx(&aln.reference_id), idx(&aln.aligned_id)) {
243                offsets[ri][ai] = aln.offset_frames;
244                offsets[ai][ri] = -aln.offset_frames;
245            }
246        }
247
248        Self { tracks, offsets }
249    }
250
251    /// Compute a single global offset for each track (relative to track 0)
252    /// using a least-squares approach.
253    ///
254    /// The method averages all available pairwise constraints for each track,
255    /// propagating offsets transitively through the matrix.  Track 0 is fixed
256    /// at offset 0.
257    #[must_use]
258    pub fn compute_global_offsets(&self) -> Vec<i64> {
259        let n = self.tracks.len();
260        if n == 0 {
261            return Vec::new();
262        }
263
264        // Initialise with direct offsets from track 0.
265        let mut global: Vec<f64> = (0..n).map(|j| self.offsets[0][j] as f64).collect();
266        global[0] = 0.0;
267
268        // Iteratively refine: for each track i, average all constraints
269        // `global[j] + offsets[j][i]` over all j != i.
270        // Two passes is enough for a dense matrix; more for sparse.
271        for _ in 0..3 {
272            let prev = global.clone();
273            for i in 1..n {
274                let mut sum = 0.0f64;
275                let mut count = 0usize;
276                for j in 0..n {
277                    if j != i && self.offsets[j][i] != 0 {
278                        sum += prev[j] + self.offsets[j][i] as f64;
279                        count += 1;
280                    }
281                }
282                if count > 0 {
283                    global[i] = sum / count as f64;
284                }
285            }
286        }
287
288        global.iter().map(|&v| v.round() as i64).collect()
289    }
290}
291
292// ─────────────────────────────────────────────────────────────────────────────
293// Unit tests
294// ─────────────────────────────────────────────────────────────────────────────
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    // ── cross_correlate ───────────────────────────────────────────────────────
301
302    #[test]
303    fn test_cross_correlate_empty() {
304        assert!(cross_correlate(&[], &[1.0]).is_empty());
305        assert!(cross_correlate(&[1.0], &[]).is_empty());
306    }
307
308    #[test]
309    fn test_cross_correlate_identical() {
310        let a = vec![0.0, 0.0, 1.0, 0.0, 0.0];
311        let corr = cross_correlate(&a, &a);
312        // Peak should be at the centre lag (zero lag).
313        let peak_idx = corr
314            .iter()
315            .enumerate()
316            .max_by(|x, y| x.1.partial_cmp(y.1).expect("max_by should succeed"))
317            .map(|(i, _)| i)
318            .expect("test expectation failed");
319        let zero_lag = a.len() - 1; // centre lag index
320        assert_eq!(peak_idx, zero_lag, "peak should be at zero lag");
321    }
322
323    #[test]
324    fn test_cross_correlate_output_length() {
325        let a = vec![1.0f32; 5];
326        let b = vec![1.0f32; 3];
327        let corr = cross_correlate(&a, &b);
328        assert_eq!(corr.len(), a.len() + b.len() - 1);
329    }
330
331    #[test]
332    fn test_cross_correlate_values_in_range() {
333        let a: Vec<f32> = (0..8).map(|i| i as f32).collect();
334        let b: Vec<f32> = (0..8).map(|i| (8 - i) as f32).collect();
335        let corr = cross_correlate(&a, &b);
336        for &v in &corr {
337            assert!(v >= -1.0 && v <= 1.0, "value {v} out of [-1, 1]");
338        }
339    }
340
341    #[test]
342    fn test_cross_correlate_zero_signal() {
343        let a = vec![0.0f32; 4];
344        let b = vec![1.0f32; 4];
345        let corr = cross_correlate(&a, &b);
346        assert!(
347            corr.iter().all(|&v| v == 0.0),
348            "zero signal should yield zero correlation"
349        );
350    }
351
352    // ── AlignmentAnchor ───────────────────────────────────────────────────────
353
354    #[test]
355    fn test_anchor_creation() {
356        let anchor = AlignmentAnchor::new("cam_a", 42, vec![1.0, 2.0, 3.0]);
357        assert_eq!(anchor.track_id, "cam_a");
358        assert_eq!(anchor.frame_idx, 42);
359        assert_eq!(anchor.feature_vector.len(), 3);
360    }
361
362    // ── TrackAlignment ────────────────────────────────────────────────────────
363
364    #[test]
365    fn test_track_alignment_fields() {
366        let aln = TrackAlignment::new("ref", "tgt", -5, 0.9, AlignMethod::AudioCorrelation);
367        assert_eq!(aln.reference_id, "ref");
368        assert_eq!(aln.aligned_id, "tgt");
369        assert_eq!(aln.offset_frames, -5);
370        assert!((aln.confidence - 0.9).abs() < f32::EPSILON);
371        assert_eq!(aln.method, AlignMethod::AudioCorrelation);
372    }
373
374    // ── MultitrackAligner ─────────────────────────────────────────────────────
375
376    #[test]
377    fn test_aligner_no_anchors() {
378        let mut aligner = MultitrackAligner::new();
379        aligner.set_reference("ref");
380        let anchors: Vec<AlignmentAnchor> = vec![];
381        let result = aligner.align_track("tgt", &anchors);
382        assert_eq!(result.offset_frames, 0);
383        assert_eq!(result.confidence, 0.0);
384    }
385
386    #[test]
387    fn test_aligner_identical_features() {
388        let mut aligner = MultitrackAligner::new();
389        aligner.set_reference("ref");
390
391        let fv = vec![0.0, 0.0, 1.0, 0.0, 0.0];
392        let anchors = vec![
393            AlignmentAnchor::new("ref", 10, fv.clone()),
394            AlignmentAnchor::new("tgt", 10, fv.clone()),
395        ];
396        let result = aligner.align_track("tgt", &anchors);
397        // With identical features at the same frame, offset should be near 0.
398        assert!(result.confidence > 0.0);
399    }
400
401    #[test]
402    fn test_aligner_sets_reference() {
403        let mut aligner = MultitrackAligner::new();
404        assert!(aligner.reference_id.is_none());
405        aligner.set_reference("master");
406        assert_eq!(aligner.reference_id.as_deref(), Some("master"));
407    }
408
409    // ── AlignmentMatrix ───────────────────────────────────────────────────────
410
411    #[test]
412    fn test_alignment_matrix_identity() {
413        let tracks = vec!["a", "b", "c"];
414        let alignments: Vec<TrackAlignment> = vec![];
415        let matrix = AlignmentMatrix::from_alignments(&tracks, &alignments);
416        assert_eq!(matrix.tracks.len(), 3);
417        // All offsets default to 0.
418        for row in &matrix.offsets {
419            for &v in row {
420                assert_eq!(v, 0);
421            }
422        }
423    }
424
425    #[test]
426    fn test_alignment_matrix_symmetric() {
427        let tracks = vec!["a", "b"];
428        let alignments = vec![TrackAlignment::new("a", "b", 10, 0.9, AlignMethod::Manual)];
429        let matrix = AlignmentMatrix::from_alignments(&tracks, &alignments);
430        assert_eq!(matrix.offsets[0][1], 10);
431        assert_eq!(matrix.offsets[1][0], -10);
432    }
433
434    #[test]
435    fn test_compute_global_offsets_empty() {
436        let matrix = AlignmentMatrix {
437            tracks: vec![],
438            offsets: vec![],
439        };
440        assert!(matrix.compute_global_offsets().is_empty());
441    }
442
443    #[test]
444    fn test_compute_global_offsets_single() {
445        let matrix = AlignmentMatrix {
446            tracks: vec!["a".to_string()],
447            offsets: vec![vec![0]],
448        };
449        let offsets = matrix.compute_global_offsets();
450        assert_eq!(offsets, vec![0]);
451    }
452
453    #[test]
454    fn test_compute_global_offsets_two_tracks() {
455        let tracks = vec!["a", "b"];
456        let alignments = vec![TrackAlignment::new("a", "b", 5, 0.9, AlignMethod::Manual)];
457        let matrix = AlignmentMatrix::from_alignments(&tracks, &alignments);
458        let global = matrix.compute_global_offsets();
459        // Track 0 (a) is reference → offset 0; track 1 (b) should be +5.
460        assert_eq!(global[0], 0);
461        assert_eq!(global[1], 5);
462    }
463}