Skip to main content

oximedia_align/
subframe_interp.rs

1#![allow(dead_code)]
2//! Sub-frame interpolation for precision alignment.
3//!
4//! When alignment accuracy needs to be finer than a single frame,
5//! this module provides interpolation techniques to estimate sub-frame
6//! offsets from cross-correlation peaks and feature match residuals.
7
8/// Interpolation method for sub-frame refinement.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum InterpMethod {
11    /// Parabolic (quadratic) peak interpolation.
12    Parabolic,
13    /// Gaussian peak fitting.
14    Gaussian,
15    /// Sinc interpolation (band-limited).
16    Sinc,
17    /// Linear interpolation (simplest).
18    Linear,
19}
20
21/// Configuration for sub-frame interpolation.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct InterpConfig {
24    /// Interpolation method to use.
25    pub method: InterpMethod,
26    /// Number of neighboring samples to consider on each side.
27    pub radius: usize,
28    /// Frame rate of the source material (fps).
29    pub frame_rate: f64,
30    /// Minimum peak height for valid interpolation.
31    pub min_peak_height: f64,
32}
33
34impl Default for InterpConfig {
35    fn default() -> Self {
36        Self {
37            method: InterpMethod::Parabolic,
38            radius: 1,
39            frame_rate: 30.0,
40            min_peak_height: 0.1,
41        }
42    }
43}
44
45/// Result of sub-frame interpolation.
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct SubFrameOffset {
48    /// Integer frame offset (from peak location).
49    pub frame_offset: i64,
50    /// Fractional offset within the frame (-0.5..+0.5).
51    pub fractional: f64,
52    /// Interpolated peak value at the refined position.
53    pub peak_value: f64,
54    /// Confidence of the interpolation (0.0..1.0).
55    pub confidence: f64,
56}
57
58impl SubFrameOffset {
59    /// Create a new sub-frame offset.
60    #[must_use]
61    pub fn new(frame_offset: i64, fractional: f64, peak_value: f64, confidence: f64) -> Self {
62        Self {
63            frame_offset,
64            fractional,
65            peak_value,
66            confidence: confidence.clamp(0.0, 1.0),
67        }
68    }
69
70    /// Return the total offset in frames (integer + fractional).
71    #[must_use]
72    pub fn total_frames(&self) -> f64 {
73        self.frame_offset as f64 + self.fractional
74    }
75
76    /// Convert the total offset to seconds given a frame rate.
77    #[must_use]
78    pub fn to_seconds(&self, frame_rate: f64) -> f64 {
79        if frame_rate > 0.0 {
80            self.total_frames() / frame_rate
81        } else {
82            0.0
83        }
84    }
85
86    /// Convert the total offset to milliseconds given a frame rate.
87    #[must_use]
88    pub fn to_millis(&self, frame_rate: f64) -> f64 {
89        self.to_seconds(frame_rate) * 1000.0
90    }
91}
92
93/// Perform parabolic (quadratic) interpolation around a peak in a correlation signal.
94///
95/// Given three consecutive samples y_{-1}, `y_0`, y_{+1} where `y_0` is the peak,
96/// the fractional offset is: delta = (y_{-1} - y_{+1}) / (2 * (y_{-1} - 2*`y_0` + y_{+1}))
97#[must_use]
98pub fn parabolic_interpolation(y_minus: f64, y_center: f64, y_plus: f64) -> (f64, f64) {
99    let denom = y_minus - 2.0 * y_center + y_plus;
100    if denom.abs() < 1e-15 {
101        return (0.0, y_center);
102    }
103    let delta = 0.5 * (y_minus - y_plus) / denom;
104    let peak = y_center - 0.25 * (y_minus - y_plus) * delta;
105    (delta.clamp(-0.5, 0.5), peak)
106}
107
108/// Perform Gaussian peak interpolation around a peak.
109///
110/// Uses log-domain fitting: delta = ln(y_{-1}/y_{+1}) / (2 * ln(y_{-1}*y_{+`1}/y_0^2`))
111#[must_use]
112pub fn gaussian_interpolation(y_minus: f64, y_center: f64, y_plus: f64) -> (f64, f64) {
113    // All values must be positive for log-domain fitting
114    if y_minus <= 0.0 || y_center <= 0.0 || y_plus <= 0.0 {
115        return parabolic_interpolation(y_minus, y_center, y_plus);
116    }
117    let ln_minus = y_minus.ln();
118    let ln_center = y_center.ln();
119    let ln_plus = y_plus.ln();
120
121    let denom = 2.0 * (ln_minus - 2.0 * ln_center + ln_plus);
122    if denom.abs() < 1e-15 {
123        return (0.0, y_center);
124    }
125    let delta = (ln_minus - ln_plus) / denom;
126    let clamped = delta.clamp(-0.5, 0.5);
127    let peak = (ln_center
128        - 0.125 * (ln_minus - ln_plus).powi(2) / (ln_minus - 2.0 * ln_center + ln_plus))
129        .exp();
130    (clamped, peak)
131}
132
133/// Perform windowed sinc interpolation at a fractional position.
134#[allow(clippy::cast_precision_loss)]
135#[must_use]
136pub fn sinc_interpolation(
137    samples: &[f64],
138    center_idx: usize,
139    fractional: f64,
140    radius: usize,
141) -> f64 {
142    if samples.is_empty() {
143        return 0.0;
144    }
145    let mut sum = 0.0;
146    let mut weight_sum = 0.0;
147
148    let start = center_idx.saturating_sub(radius);
149    let end = (center_idx + radius + 1).min(samples.len());
150
151    for i in start..end {
152        let x = i as f64 - center_idx as f64 - fractional;
153        let w = sinc(x) * hann_window(x, radius as f64);
154        sum += samples[i] * w;
155        weight_sum += w;
156    }
157
158    if weight_sum.abs() > 1e-15 {
159        sum / weight_sum
160    } else {
161        samples.get(center_idx).copied().unwrap_or(0.0)
162    }
163}
164
165/// Normalized sinc function: sin(pi*x) / (pi*x).
166#[must_use]
167fn sinc(x: f64) -> f64 {
168    if x.abs() < 1e-15 {
169        1.0
170    } else {
171        let px = std::f64::consts::PI * x;
172        px.sin() / px
173    }
174}
175
176/// Hann window function for windowed sinc.
177#[must_use]
178fn hann_window(x: f64, radius: f64) -> f64 {
179    if x.abs() > radius {
180        0.0
181    } else {
182        0.5 * (1.0 + (std::f64::consts::PI * x / radius).cos())
183    }
184}
185
186/// Find the peak in a correlation signal and refine with sub-frame interpolation.
187#[allow(clippy::cast_precision_loss)]
188#[must_use]
189pub fn find_subframe_peak(correlation: &[f64], config: &InterpConfig) -> Option<SubFrameOffset> {
190    if correlation.len() < 3 {
191        return None;
192    }
193
194    // Find the integer peak
195    let (peak_idx, &peak_val) = correlation
196        .iter()
197        .enumerate()
198        .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))?;
199
200    if peak_val < config.min_peak_height {
201        return None;
202    }
203
204    // Cannot interpolate at boundaries
205    if peak_idx == 0 || peak_idx == correlation.len() - 1 {
206        let conf = compute_peak_confidence(correlation, peak_idx);
207        return Some(SubFrameOffset::new(peak_idx as i64, 0.0, peak_val, conf));
208    }
209
210    let y_minus = correlation[peak_idx - 1];
211    let y_center = correlation[peak_idx];
212    let y_plus = correlation[peak_idx + 1];
213
214    let (delta, interp_peak) = match config.method {
215        InterpMethod::Parabolic => parabolic_interpolation(y_minus, y_center, y_plus),
216        InterpMethod::Gaussian => gaussian_interpolation(y_minus, y_center, y_plus),
217        InterpMethod::Sinc => {
218            let (para_delta, _) = parabolic_interpolation(y_minus, y_center, y_plus);
219            let val = sinc_interpolation(correlation, peak_idx, para_delta, config.radius);
220            (para_delta, val)
221        }
222        InterpMethod::Linear => {
223            // Simple linear between the two highest neighbors
224            if y_minus > y_plus {
225                let d = -0.5 * (y_center - y_minus) / (y_center - y_minus + f64::EPSILON);
226                (d.clamp(-0.5, 0.0), y_center)
227            } else {
228                let d = 0.5 * (y_center - y_plus) / (y_center - y_plus + f64::EPSILON);
229                (d.clamp(0.0, 0.5), y_center)
230            }
231        }
232    };
233
234    let confidence = compute_peak_confidence(correlation, peak_idx);
235    Some(SubFrameOffset::new(
236        peak_idx as i64,
237        delta,
238        interp_peak,
239        confidence,
240    ))
241}
242
243/// Compute a confidence measure for the peak (ratio of peak to second highest).
244fn compute_peak_confidence(correlation: &[f64], peak_idx: usize) -> f64 {
245    let peak_val = correlation[peak_idx];
246    if peak_val.abs() < 1e-15 {
247        return 0.0;
248    }
249
250    // Find the second highest local maximum
251    let mut second_max = 0.0_f64;
252    for (i, &val) in correlation.iter().enumerate() {
253        if i != peak_idx && val > second_max {
254            // Only consider if it's a local peak or boundary
255            let is_local = i == 0
256                || i == correlation.len() - 1
257                || (val >= correlation[i.saturating_sub(1)]
258                    && val >= correlation[(i + 1).min(correlation.len() - 1)]);
259            if is_local {
260                second_max = val;
261            }
262        }
263    }
264
265    if second_max.abs() < 1e-15 {
266        return 1.0;
267    }
268    let ratio = 1.0 - (second_max / peak_val);
269    ratio.clamp(0.0, 1.0)
270}
271
272/// Linear interpolation between two values.
273#[must_use]
274pub fn lerp(a: f64, b: f64, t: f64) -> f64 {
275    a + (b - a) * t
276}
277
278/// Cubic Hermite interpolation for smooth sub-frame values.
279#[must_use]
280pub fn cubic_hermite(y0: f64, y1: f64, y2: f64, y3: f64, t: f64) -> f64 {
281    let a = -0.5 * y0 + 1.5 * y1 - 1.5 * y2 + 0.5 * y3;
282    let b = y0 - 2.5 * y1 + 2.0 * y2 - 0.5 * y3;
283    let c = -0.5 * y0 + 0.5 * y2;
284    let d = y1;
285    ((a * t + b) * t + c) * t + d
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn test_parabolic_symmetric_peak() {
294        // Symmetric peak => delta should be ~0
295        let (delta, peak) = parabolic_interpolation(0.5, 1.0, 0.5);
296        assert!(delta.abs() < 1e-10);
297        assert!((peak - 1.0).abs() < 1e-10);
298    }
299
300    #[test]
301    fn test_parabolic_asymmetric_peak() {
302        // y_minus > y_plus => peak shifts toward minus direction (negative delta)
303        let (delta, _) = parabolic_interpolation(0.8, 1.0, 0.4);
304        assert!(delta < 0.0); // peak is between center and y_minus
305    }
306
307    #[test]
308    fn test_parabolic_flat() {
309        let (delta, peak) = parabolic_interpolation(1.0, 1.0, 1.0);
310        assert!((delta).abs() < 1e-10);
311        assert!((peak - 1.0).abs() < 1e-10);
312    }
313
314    #[test]
315    fn test_gaussian_symmetric() {
316        let (delta, _) = gaussian_interpolation(0.5, 1.0, 0.5);
317        assert!(delta.abs() < 1e-10);
318    }
319
320    #[test]
321    fn test_gaussian_fallback_on_negative() {
322        let (delta, _) = gaussian_interpolation(-0.5, 1.0, 0.5);
323        // Should fall back to parabolic
324        assert!(delta.abs() < 1.0);
325    }
326
327    #[test]
328    fn test_sinc_at_integer() {
329        let samples = vec![0.0, 0.0, 1.0, 0.0, 0.0];
330        let val = sinc_interpolation(&samples, 2, 0.0, 2);
331        assert!((val - 1.0).abs() < 0.01);
332    }
333
334    #[test]
335    fn test_sinc_empty() {
336        let val = sinc_interpolation(&[], 0, 0.0, 2);
337        assert!((val).abs() < f64::EPSILON);
338    }
339
340    #[test]
341    fn test_subframe_offset_total() {
342        let offset = SubFrameOffset::new(10, 0.25, 0.9, 0.95);
343        assert!((offset.total_frames() - 10.25).abs() < 1e-10);
344    }
345
346    #[test]
347    fn test_subframe_offset_to_seconds() {
348        let offset = SubFrameOffset::new(30, 0.0, 1.0, 0.9);
349        assert!((offset.to_seconds(30.0) - 1.0).abs() < 1e-10);
350    }
351
352    #[test]
353    fn test_subframe_offset_to_millis() {
354        let offset = SubFrameOffset::new(30, 0.0, 1.0, 0.9);
355        assert!((offset.to_millis(30.0) - 1000.0).abs() < 1e-10);
356    }
357
358    #[test]
359    fn test_subframe_offset_zero_fps() {
360        let offset = SubFrameOffset::new(10, 0.5, 1.0, 0.9);
361        assert!((offset.to_seconds(0.0)).abs() < f64::EPSILON);
362    }
363
364    #[test]
365    fn test_find_subframe_peak_parabolic() {
366        let correlation = vec![0.1, 0.3, 0.5, 0.9, 0.5, 0.3, 0.1];
367        let config = InterpConfig {
368            method: InterpMethod::Parabolic,
369            ..InterpConfig::default()
370        };
371        let result = find_subframe_peak(&correlation, &config);
372        assert!(result.is_some());
373        let r = result.expect("r should be valid");
374        assert_eq!(r.frame_offset, 3);
375        assert!(r.fractional.abs() < 0.5);
376    }
377
378    #[test]
379    fn test_find_subframe_peak_gaussian() {
380        let correlation = vec![0.2, 0.5, 1.0, 0.5, 0.2];
381        let config = InterpConfig {
382            method: InterpMethod::Gaussian,
383            ..InterpConfig::default()
384        };
385        let result = find_subframe_peak(&correlation, &config);
386        assert!(result.is_some());
387        let r = result.expect("r should be valid");
388        assert_eq!(r.frame_offset, 2);
389    }
390
391    #[test]
392    fn test_find_subframe_peak_too_short() {
393        let correlation = vec![0.5, 0.9];
394        let config = InterpConfig::default();
395        assert!(find_subframe_peak(&correlation, &config).is_none());
396    }
397
398    #[test]
399    fn test_find_subframe_peak_below_threshold() {
400        let correlation = vec![0.01, 0.02, 0.01];
401        let config = InterpConfig {
402            min_peak_height: 0.5,
403            ..InterpConfig::default()
404        };
405        assert!(find_subframe_peak(&correlation, &config).is_none());
406    }
407
408    #[test]
409    fn test_lerp() {
410        assert!((lerp(0.0, 1.0, 0.5) - 0.5).abs() < 1e-10);
411        assert!((lerp(2.0, 4.0, 0.25) - 2.5).abs() < 1e-10);
412    }
413
414    #[test]
415    fn test_cubic_hermite_at_endpoints() {
416        let v = cubic_hermite(0.0, 1.0, 2.0, 3.0, 0.0);
417        assert!((v - 1.0).abs() < 1e-10);
418    }
419
420    #[test]
421    fn test_subframe_confidence_clamped() {
422        let offset = SubFrameOffset::new(0, 0.0, 1.0, 1.5);
423        assert!((offset.confidence - 1.0).abs() < f64::EPSILON);
424
425        let offset2 = SubFrameOffset::new(0, 0.0, 1.0, -0.5);
426        assert!((offset2.confidence).abs() < f64::EPSILON);
427    }
428}