Skip to main content

oximedia_align/
markers.rs

1//! Synchronization marker detection.
2//!
3//! This module provides tools for detecting visual and audio sync markers:
4//!
5//! - Clapper board detection
6//! - Flash detection
7//! - LED marker patterns
8//! - Audio spike detection
9//! - Timecode display recognition
10
11use crate::{AlignError, AlignResult, Point2D};
12
13/// Detected synchronization marker
14#[derive(Debug, Clone)]
15pub struct SyncMarker {
16    /// Frame index where marker was detected
17    pub frame: usize,
18    /// Type of marker
19    pub marker_type: MarkerType,
20    /// Confidence score (0.0 to 1.0)
21    pub confidence: f32,
22    /// Optional location in frame
23    pub location: Option<Point2D>,
24}
25
26/// Types of synchronization markers
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum MarkerType {
29    /// Clapper board closure
30    ClapperClosure,
31    /// Camera flash
32    Flash,
33    /// LED marker
34    LedMarker,
35    /// Audio spike/clap
36    AudioSpike,
37    /// Timecode display
38    TimecodeDisplay,
39}
40
41impl SyncMarker {
42    /// Create a new sync marker
43    #[must_use]
44    pub fn new(
45        frame: usize,
46        marker_type: MarkerType,
47        confidence: f32,
48        location: Option<Point2D>,
49    ) -> Self {
50        Self {
51            frame,
52            marker_type,
53            confidence,
54            location,
55        }
56    }
57}
58
59/// Flash detector for bright, sudden changes in luminance
60pub struct FlashDetector {
61    /// Brightness threshold (0.0 to 1.0)
62    pub threshold: f32,
63    /// Minimum flash duration in frames
64    pub min_duration: usize,
65    /// Maximum flash duration in frames
66    pub max_duration: usize,
67}
68
69impl Default for FlashDetector {
70    fn default() -> Self {
71        Self {
72            threshold: 0.8,
73            min_duration: 1,
74            max_duration: 3,
75        }
76    }
77}
78
79impl FlashDetector {
80    /// Create a new flash detector
81    #[must_use]
82    pub fn new(threshold: f32, min_duration: usize, max_duration: usize) -> Self {
83        Self {
84            threshold,
85            min_duration,
86            max_duration,
87        }
88    }
89
90    /// Detect flashes in a sequence of frames
91    #[must_use]
92    pub fn detect(&self, frames: &[&[u8]], width: usize, height: usize) -> Vec<SyncMarker> {
93        let mut markers = Vec::new();
94        let brightness_values: Vec<f32> = frames
95            .iter()
96            .map(|frame| self.compute_brightness(frame, width, height))
97            .collect();
98
99        let mut in_flash = false;
100        let mut flash_start = 0;
101
102        for (i, &brightness) in brightness_values.iter().enumerate() {
103            if !in_flash && brightness > self.threshold {
104                in_flash = true;
105                flash_start = i;
106            } else if in_flash && brightness <= self.threshold {
107                let duration = i - flash_start;
108                if duration >= self.min_duration && duration <= self.max_duration {
109                    let confidence = brightness_values[flash_start];
110                    markers.push(SyncMarker::new(
111                        flash_start,
112                        MarkerType::Flash,
113                        confidence,
114                        None,
115                    ));
116                }
117                in_flash = false;
118            }
119        }
120
121        markers
122    }
123
124    /// Compute average brightness from RGB frame
125    fn compute_brightness(&self, rgb: &[u8], width: usize, height: usize) -> f32 {
126        if rgb.len() != width * height * 3 {
127            return 0.0;
128        }
129
130        let sum: u32 = rgb
131            .chunks_exact(3)
132            .map(|pixel| {
133                let r = u32::from(pixel[0]);
134                let g = u32::from(pixel[1]);
135                let b = u32::from(pixel[2]);
136                (299 * r + 587 * g + 114 * b) / 1000
137            })
138            .sum();
139
140        (sum as f32 / (width * height) as f32) / 255.0
141    }
142
143    /// Detect local flashes in specific regions
144    #[must_use]
145    pub fn detect_local(
146        &self,
147        frames: &[&[u8]],
148        width: usize,
149        _height: usize,
150        region: &Region,
151    ) -> Vec<SyncMarker> {
152        let mut markers = Vec::new();
153
154        for (frame_idx, frame) in frames.iter().enumerate() {
155            let brightness = self.compute_region_brightness(frame, width, region);
156
157            if brightness > self.threshold {
158                let center = Point2D::new(
159                    (region.x + region.width / 2) as f64,
160                    (region.y + region.height / 2) as f64,
161                );
162
163                markers.push(SyncMarker::new(
164                    frame_idx,
165                    MarkerType::Flash,
166                    brightness,
167                    Some(center),
168                ));
169            }
170        }
171
172        markers
173    }
174
175    /// Compute brightness in a specific region
176    fn compute_region_brightness(&self, rgb: &[u8], width: usize, region: &Region) -> f32 {
177        let mut sum = 0u32;
178        let mut count = 0u32;
179
180        for y in region.y..region.y + region.height {
181            for x in region.x..region.x + region.width {
182                let idx = (y * width + x) * 3;
183                if idx + 2 < rgb.len() {
184                    let r = u32::from(rgb[idx]);
185                    let g = u32::from(rgb[idx + 1]);
186                    let b = u32::from(rgb[idx + 2]);
187                    sum += (299 * r + 587 * g + 114 * b) / 1000;
188                    count += 1;
189                }
190            }
191        }
192
193        if count > 0 {
194            (sum as f32 / count as f32) / 255.0
195        } else {
196            0.0
197        }
198    }
199}
200
201/// Region of interest
202#[derive(Debug, Clone, Copy)]
203pub struct Region {
204    /// X coordinate
205    pub x: usize,
206    /// Y coordinate
207    pub y: usize,
208    /// Width
209    pub width: usize,
210    /// Height
211    pub height: usize,
212}
213
214impl Region {
215    /// Create a new region
216    #[must_use]
217    pub fn new(x: usize, y: usize, width: usize, height: usize) -> Self {
218        Self {
219            x,
220            y,
221            width,
222            height,
223        }
224    }
225}
226
227/// Clapper board detector using motion detection
228pub struct ClapperDetector {
229    /// Motion threshold
230    pub motion_threshold: f32,
231    /// Minimum motion area (fraction of frame)
232    pub min_motion_area: f32,
233}
234
235impl Default for ClapperDetector {
236    fn default() -> Self {
237        Self {
238            motion_threshold: 30.0,
239            min_motion_area: 0.1,
240        }
241    }
242}
243
244impl ClapperDetector {
245    /// Create a new clapper detector
246    #[must_use]
247    pub fn new(motion_threshold: f32, min_motion_area: f32) -> Self {
248        Self {
249            motion_threshold,
250            min_motion_area,
251        }
252    }
253
254    /// Detect clapper closure by analyzing motion between frames
255    ///
256    /// # Errors
257    /// Returns error if frames are invalid
258    pub fn detect(
259        &self,
260        frames: &[&[u8]],
261        width: usize,
262        height: usize,
263    ) -> AlignResult<Vec<SyncMarker>> {
264        if frames.len() < 2 {
265            return Err(AlignError::InsufficientData(
266                "Need at least 2 frames".to_string(),
267            ));
268        }
269
270        let mut markers = Vec::new();
271
272        for i in 1..frames.len() {
273            let motion = self.compute_motion(frames[i - 1], frames[i], width, height);
274
275            if motion > self.min_motion_area {
276                markers.push(SyncMarker::new(i, MarkerType::ClapperClosure, motion, None));
277            }
278        }
279
280        Ok(markers)
281    }
282
283    /// Compute motion between two frames
284    fn compute_motion(&self, frame1: &[u8], frame2: &[u8], width: usize, height: usize) -> f32 {
285        let mut motion_pixels = 0;
286        let total_pixels = width * height;
287
288        for i in 0..total_pixels {
289            let idx = i * 3;
290            if idx + 2 < frame1.len() && idx + 2 < frame2.len() {
291                let diff_r = (i16::from(frame1[idx]) - i16::from(frame2[idx])).abs();
292                let diff_g = (i16::from(frame1[idx + 1]) - i16::from(frame2[idx + 1])).abs();
293                let diff_b = (i16::from(frame1[idx + 2]) - i16::from(frame2[idx + 2])).abs();
294
295                let diff = (diff_r + diff_g + diff_b) / 3;
296
297                if f32::from(diff) > self.motion_threshold {
298                    motion_pixels += 1;
299                }
300            }
301        }
302
303        motion_pixels as f32 / total_pixels as f32
304    }
305}
306
307/// LED marker detector for coded light patterns
308pub struct LedMarkerDetector {
309    /// Expected LED color (RGB, 0-1 range)
310    pub expected_color: [f32; 3],
311    /// Color tolerance
312    pub color_tolerance: f32,
313    /// Minimum blob size (pixels)
314    pub min_blob_size: usize,
315}
316
317impl Default for LedMarkerDetector {
318    fn default() -> Self {
319        Self {
320            expected_color: [1.0, 0.0, 0.0], // Red LED
321            color_tolerance: 0.2,
322            min_blob_size: 10,
323        }
324    }
325}
326
327impl LedMarkerDetector {
328    /// Create a new LED marker detector
329    #[must_use]
330    pub fn new(color: [f32; 3], tolerance: f32) -> Self {
331        Self {
332            expected_color: color,
333            color_tolerance: tolerance,
334            min_blob_size: 10,
335        }
336    }
337
338    /// Detect LED markers in frame
339    #[must_use]
340    pub fn detect(&self, frame: &[u8], width: usize, height: usize) -> Vec<SyncMarker> {
341        let mut markers = Vec::new();
342
343        // Simple blob detection
344        let mut visited = vec![false; width * height];
345
346        for y in 0..height {
347            for x in 0..width {
348                let idx = y * width + x;
349                if !visited[idx] && self.is_led_color(frame, width, x, y) {
350                    let blob = self.flood_fill(frame, width, height, x, y, &mut visited);
351
352                    if blob.len() >= self.min_blob_size {
353                        let center = self.compute_centroid(&blob);
354                        markers.push(SyncMarker::new(0, MarkerType::LedMarker, 1.0, Some(center)));
355                    }
356                }
357            }
358        }
359
360        markers
361    }
362
363    /// Check if pixel matches expected LED color
364    fn is_led_color(&self, frame: &[u8], width: usize, x: usize, y: usize) -> bool {
365        let idx = (y * width + x) * 3;
366        if idx + 2 >= frame.len() {
367            return false;
368        }
369
370        let r = f32::from(frame[idx]) / 255.0;
371        let g = f32::from(frame[idx + 1]) / 255.0;
372        let b = f32::from(frame[idx + 2]) / 255.0;
373
374        let diff_r = (r - self.expected_color[0]).abs();
375        let diff_g = (g - self.expected_color[1]).abs();
376        let diff_b = (b - self.expected_color[2]).abs();
377
378        diff_r < self.color_tolerance
379            && diff_g < self.color_tolerance
380            && diff_b < self.color_tolerance
381    }
382
383    /// Flood fill to find connected component
384    fn flood_fill(
385        &self,
386        frame: &[u8],
387        width: usize,
388        height: usize,
389        start_x: usize,
390        start_y: usize,
391        visited: &mut [bool],
392    ) -> Vec<Point2D> {
393        let mut blob = Vec::new();
394        let mut stack = vec![(start_x, start_y)];
395
396        while let Some((x, y)) = stack.pop() {
397            let idx = y * width + x;
398
399            if visited[idx] {
400                continue;
401            }
402
403            if !self.is_led_color(frame, width, x, y) {
404                continue;
405            }
406
407            visited[idx] = true;
408            blob.push(Point2D::new(x as f64, y as f64));
409
410            // Add neighbors
411            if x > 0 {
412                stack.push((x - 1, y));
413            }
414            if x + 1 < width {
415                stack.push((x + 1, y));
416            }
417            if y > 0 {
418                stack.push((x, y - 1));
419            }
420            if y + 1 < height {
421                stack.push((x, y + 1));
422            }
423        }
424
425        blob
426    }
427
428    /// Compute centroid of blob
429    fn compute_centroid(&self, blob: &[Point2D]) -> Point2D {
430        let n = blob.len() as f64;
431        let sum_x: f64 = blob.iter().map(|p| p.x).sum();
432        let sum_y: f64 = blob.iter().map(|p| p.y).sum();
433
434        Point2D::new(sum_x / n, sum_y / n)
435    }
436}
437
438/// Audio spike detector for sharp transients
439pub struct AudioSpikeDetector {
440    /// Threshold for spike detection (0.0 to 1.0)
441    pub threshold: f32,
442    /// Window size for analysis
443    pub window_size: usize,
444}
445
446impl Default for AudioSpikeDetector {
447    fn default() -> Self {
448        Self {
449            threshold: 0.8,
450            window_size: 512,
451        }
452    }
453}
454
455impl AudioSpikeDetector {
456    /// Create a new audio spike detector
457    #[must_use]
458    pub fn new(threshold: f32, window_size: usize) -> Self {
459        Self {
460            threshold,
461            window_size,
462        }
463    }
464
465    /// Detect audio spikes in signal
466    #[must_use]
467    pub fn detect(&self, audio: &[f32], sample_rate: u32) -> Vec<SyncMarker> {
468        let mut markers = Vec::new();
469
470        // Compute envelope
471        let envelope = self.compute_envelope(audio);
472
473        // Find peaks
474        for i in 1..envelope.len().saturating_sub(1) {
475            if envelope[i] > self.threshold
476                && envelope[i] > envelope[i - 1]
477                && envelope[i] > envelope[i + 1]
478            {
479                // Convert sample to frame (assuming 24fps)
480                let frame = (i * 24) / sample_rate as usize;
481
482                markers.push(SyncMarker::new(
483                    frame,
484                    MarkerType::AudioSpike,
485                    envelope[i],
486                    None,
487                ));
488            }
489        }
490
491        markers
492    }
493
494    /// Compute audio envelope
495    fn compute_envelope(&self, audio: &[f32]) -> Vec<f32> {
496        let mut envelope = Vec::new();
497
498        for chunk in audio.chunks(self.window_size) {
499            let max = chunk.iter().map(|&x| x.abs()).fold(0.0f32, f32::max);
500            envelope.push(max);
501        }
502
503        envelope
504    }
505}
506
507/// Timecode display detector (OCR-based)
508#[derive(Default)]
509pub struct TimecodeDetector {
510    /// Region where timecode is expected
511    pub region: Option<Region>,
512}
513
514impl TimecodeDetector {
515    /// Create a new timecode detector
516    #[must_use]
517    pub fn new(region: Option<Region>) -> Self {
518        Self { region }
519    }
520
521    /// Detect timecode in frame (simplified - just checks for presence)
522    #[must_use]
523    pub fn detect(&self, frame: &[u8], width: usize, height: usize) -> Option<SyncMarker> {
524        let region = self
525            .region
526            .unwrap_or_else(|| Region::new(0, height.saturating_sub(100), width, 100));
527
528        // Simplified: check for high contrast in expected region
529        let contrast = self.compute_contrast(frame, width, &region);
530
531        if contrast > 0.5 {
532            Some(SyncMarker::new(
533                0,
534                MarkerType::TimecodeDisplay,
535                contrast,
536                Some(Point2D::new(
537                    (region.x + region.width / 2) as f64,
538                    (region.y + region.height / 2) as f64,
539                )),
540            ))
541        } else {
542            None
543        }
544    }
545
546    /// Compute contrast in region
547    fn compute_contrast(&self, frame: &[u8], width: usize, region: &Region) -> f32 {
548        let mut min_val = 255u8;
549        let mut max_val = 0u8;
550
551        for y in region.y..region.y + region.height {
552            for x in region.x..region.x + region.width {
553                let idx = (y * width + x) * 3;
554                if idx < frame.len() {
555                    let gray = ((u16::from(frame[idx])
556                        + u16::from(frame[idx + 1])
557                        + u16::from(frame[idx + 2]))
558                        / 3) as u8;
559                    min_val = min_val.min(gray);
560                    max_val = max_val.max(gray);
561                }
562            }
563        }
564
565        f32::from(max_val - min_val) / 255.0
566    }
567}
568
569// ─────────────────────────────────────────────────────────────────────────────
570// Automatic marker interpolation
571// ─────────────────────────────────────────────────────────────────────────────
572
573/// Method used for interpolating frame positions between known markers.
574#[derive(Debug, Clone, Copy, PartialEq, Eq)]
575pub enum InterpolationMethod {
576    /// Linear interpolation between neighbouring markers
577    Linear,
578    /// Cubic Hermite (Catmull-Rom) spline – needs ≥ 4 control points for full
579    /// smoothness; falls back to linear at the ends
580    Cubic,
581    /// Cubic Bézier with automatic control-point placement (smooth tangents)
582    Bezier,
583}
584
585/// Interpolate virtual marker positions between a set of known markers.
586///
587/// The returned `Vec<SyncMarker>` contains one synthetic marker for each frame
588/// in `[first_frame, last_frame]`.  Confidence values are linearly blended
589/// between the anchor points.
590///
591/// # Panics
592/// Does not panic, but returns an empty vector when fewer than 2 markers are
593/// supplied or when the frame range is empty.
594#[allow(dead_code)]
595#[must_use]
596pub fn interpolate_markers(anchors: &[SyncMarker], method: InterpolationMethod) -> Vec<SyncMarker> {
597    if anchors.len() < 2 {
598        return Vec::new();
599    }
600
601    // Sort anchors by frame
602    let mut sorted = anchors.to_vec();
603    sorted.sort_by_key(|m| m.frame);
604
605    let first = sorted[0].frame;
606    let last = sorted[sorted.len() - 1].frame;
607    if first >= last {
608        return Vec::new();
609    }
610
611    let total = last - first + 1;
612    let mut result = Vec::with_capacity(total);
613
614    // Build frame positions (f64) and confidence values for interpolation
615    let xs: Vec<f64> = sorted.iter().map(|m| m.frame as f64).collect();
616    let cs: Vec<f64> = sorted.iter().map(|m| f64::from(m.confidence)).collect();
617    // Location x/y (use NaN when absent)
618    let lx: Vec<f64> = sorted
619        .iter()
620        .map(|m| m.location.map_or(f64::NAN, |p| p.x))
621        .collect();
622    let ly: Vec<f64> = sorted
623        .iter()
624        .map(|m| m.location.map_or(f64::NAN, |p| p.y))
625        .collect();
626
627    let marker_type = sorted[0].marker_type;
628
629    for frame in first..=last {
630        let t = frame as f64;
631
632        let (conf, px, py) = match method {
633            InterpolationMethod::Linear => {
634                let (c, x, y) = interpolate_linear(&xs, &cs, &lx, &ly, t);
635                (c, x, y)
636            }
637            InterpolationMethod::Cubic => {
638                let (c, x, y) = interpolate_cubic(&xs, &cs, &lx, &ly, t);
639                (c, x, y)
640            }
641            InterpolationMethod::Bezier => {
642                let (c, x, y) = interpolate_bezier(&xs, &cs, &lx, &ly, t);
643                (c, x, y)
644            }
645        };
646
647        let location = if px.is_finite() && py.is_finite() {
648            Some(Point2D::new(px, py))
649        } else {
650            None
651        };
652
653        result.push(SyncMarker::new(frame, marker_type, conf as f32, location));
654    }
655
656    result
657}
658
659/// Linear piecewise interpolation helper.
660fn interpolate_linear(xs: &[f64], cs: &[f64], lx: &[f64], ly: &[f64], t: f64) -> (f64, f64, f64) {
661    // Find surrounding segment
662    for i in 0..xs.len().saturating_sub(1) {
663        if t >= xs[i] && t <= xs[i + 1] {
664            let alpha = (t - xs[i]) / (xs[i + 1] - xs[i]);
665            let c = cs[i] + alpha * (cs[i + 1] - cs[i]);
666            let x = lerp_nan(lx[i], lx[i + 1], alpha);
667            let y = lerp_nan(ly[i], ly[i + 1], alpha);
668            return (c, x, y);
669        }
670    }
671    (cs[cs.len() - 1], lx[lx.len() - 1], ly[ly.len() - 1])
672}
673
674/// Catmull-Rom cubic spline helper.
675fn interpolate_cubic(xs: &[f64], cs: &[f64], lx: &[f64], ly: &[f64], t: f64) -> (f64, f64, f64) {
676    if xs.len() < 4 {
677        return interpolate_linear(xs, cs, lx, ly, t);
678    }
679
680    for i in 0..xs.len().saturating_sub(1) {
681        if t >= xs[i] && t <= xs[i + 1] {
682            let alpha = (t - xs[i]) / (xs[i + 1] - xs[i]);
683
684            let i0 = i.saturating_sub(1);
685            let i1 = i;
686            let i2 = (i + 1).min(xs.len() - 1);
687            let i3 = (i + 2).min(xs.len() - 1);
688
689            let c = catmull_rom(cs[i0], cs[i1], cs[i2], cs[i3], alpha);
690            let x = catmull_rom_nan(lx[i0], lx[i1], lx[i2], lx[i3], alpha);
691            let y = catmull_rom_nan(ly[i0], ly[i1], ly[i2], ly[i3], alpha);
692            return (c.clamp(0.0, 1.0), x, y);
693        }
694    }
695    (cs[cs.len() - 1], lx[lx.len() - 1], ly[ly.len() - 1])
696}
697
698/// Bézier-based interpolation using automatic control points (Catmull-Rom
699/// tangents mapped into Bézier form).  For 2 anchors this degenerates to
700/// linear.
701fn interpolate_bezier(xs: &[f64], cs: &[f64], lx: &[f64], ly: &[f64], t: f64) -> (f64, f64, f64) {
702    if xs.len() < 3 {
703        return interpolate_linear(xs, cs, lx, ly, t);
704    }
705
706    for i in 0..xs.len().saturating_sub(1) {
707        if t >= xs[i] && t <= xs[i + 1] {
708            let alpha = (t - xs[i]) / (xs[i + 1] - xs[i]);
709
710            // Control-point tangents (Catmull-Rom → Bézier conversion)
711            let prev_c = if i == 0 { cs[i] } else { cs[i - 1] };
712            let next_c = if i + 2 < cs.len() {
713                cs[i + 2]
714            } else {
715                cs[i + 1]
716            };
717            let cp1_c = cs[i] + (cs[i + 1] - prev_c) / 6.0;
718            let cp2_c = cs[i + 1] - (next_c - cs[i]) / 6.0;
719            let c = cubic_bezier(cs[i], cp1_c, cp2_c, cs[i + 1], alpha).clamp(0.0, 1.0);
720
721            let x = bezier_nan(lx, i, alpha);
722            let y = bezier_nan(ly, i, alpha);
723            return (c, x, y);
724        }
725    }
726    (cs[cs.len() - 1], lx[lx.len() - 1], ly[ly.len() - 1])
727}
728
729// Helpers
730
731fn lerp_nan(a: f64, b: f64, t: f64) -> f64 {
732    if a.is_nan() || b.is_nan() {
733        f64::NAN
734    } else {
735        a + t * (b - a)
736    }
737}
738
739fn catmull_rom(p0: f64, p1: f64, p2: f64, p3: f64, t: f64) -> f64 {
740    0.5 * ((2.0 * p1)
741        + (-p0 + p2) * t
742        + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t * t
743        + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t * t * t)
744}
745
746fn catmull_rom_nan(p0: f64, p1: f64, p2: f64, p3: f64, t: f64) -> f64 {
747    if p0.is_nan() || p1.is_nan() || p2.is_nan() || p3.is_nan() {
748        f64::NAN
749    } else {
750        catmull_rom(p0, p1, p2, p3, t)
751    }
752}
753
754fn cubic_bezier(p0: f64, p1: f64, p2: f64, p3: f64, t: f64) -> f64 {
755    let mt = 1.0 - t;
756    mt * mt * mt * p0 + 3.0 * mt * mt * t * p1 + 3.0 * mt * t * t * p2 + t * t * t * p3
757}
758
759fn bezier_nan(vals: &[f64], i: usize, t: f64) -> f64 {
760    if vals[i].is_nan() || vals[i + 1].is_nan() {
761        f64::NAN
762    } else {
763        let prev = if i == 0 { vals[i] } else { vals[i - 1] };
764        let next = if i + 2 < vals.len() {
765            vals[i + 2]
766        } else {
767            vals[i + 1]
768        };
769        let cp1 = vals[i] + (vals[i + 1] - prev) / 6.0;
770        let cp2 = vals[i + 1] - (next - vals[i]) / 6.0;
771        cubic_bezier(vals[i], cp1, cp2, vals[i + 1], t)
772    }
773}
774
775// ─────────────────────────────────────────────────────────────────────────────
776// Marker clustering
777// ─────────────────────────────────────────────────────────────────────────────
778
779/// Cluster nearby markers into groups based on temporal proximity.
780///
781/// Markers within `max_gap_frames` of each other are placed in the same
782/// cluster.  Returns a `Vec` of clusters, each cluster being a `Vec<SyncMarker>`.
783#[allow(dead_code)]
784#[must_use]
785pub fn cluster_markers(markers: &[SyncMarker], max_gap_frames: usize) -> Vec<Vec<SyncMarker>> {
786    if markers.is_empty() {
787        return Vec::new();
788    }
789
790    let mut sorted = markers.to_vec();
791    sorted.sort_by_key(|m| m.frame);
792
793    let mut clusters: Vec<Vec<SyncMarker>> = Vec::new();
794    let mut current: Vec<SyncMarker> = vec![sorted[0].clone()];
795
796    for m in sorted.into_iter().skip(1) {
797        // SAFETY: current always contains at least one element (seeded with sorted[0] above)
798        let last_frame = current
799            .last()
800            .expect("current cluster is always non-empty")
801            .frame;
802        if m.frame.saturating_sub(last_frame) <= max_gap_frames {
803            current.push(m);
804        } else {
805            clusters.push(current);
806            current = vec![m];
807        }
808    }
809    clusters.push(current);
810
811    clusters
812}
813
814/// Return the best marker from each cluster (highest confidence).
815#[allow(dead_code)]
816#[must_use]
817pub fn cluster_best_markers(markers: &[SyncMarker], max_gap_frames: usize) -> Vec<SyncMarker> {
818    cluster_markers(markers, max_gap_frames)
819        .into_iter()
820        .filter_map(|cluster| {
821            cluster.into_iter().max_by(|a, b| {
822                a.confidence
823                    .partial_cmp(&b.confidence)
824                    .unwrap_or(std::cmp::Ordering::Equal)
825            })
826        })
827        .collect()
828}
829
830// ─────────────────────────────────────────────────────────────────────────────
831// Temporal marker alignment
832// ─────────────────────────────────────────────────────────────────────────────
833
834/// The result of aligning two marker sequences temporally.
835#[derive(Debug, Clone)]
836pub struct TemporalAlignment {
837    /// Frame offset to apply to the second stream (add to stream-B frames)
838    pub frame_offset: i64,
839    /// Confidence of the alignment (average pairwise confidence)
840    pub confidence: f32,
841    /// Number of matched marker pairs used
842    pub matched_pairs: usize,
843}
844
845/// Align two sets of markers temporally by finding the frame offset that
846/// maximises the number of close marker pairs.
847///
848/// `tolerance_frames` is the maximum frame difference for two markers to be
849/// considered a match.
850///
851/// Returns `None` when no alignment can be found (e.g., empty inputs).
852#[allow(dead_code)]
853#[must_use]
854pub fn align_markers_temporal(
855    reference: &[SyncMarker],
856    target: &[SyncMarker],
857    tolerance_frames: usize,
858    search_range: i64,
859) -> Option<TemporalAlignment> {
860    if reference.is_empty() || target.is_empty() {
861        return None;
862    }
863
864    let mut best_offset = 0i64;
865    let mut best_matches = 0usize;
866    let mut best_conf = 0.0f32;
867
868    let mut best_total_dist = u64::MAX;
869
870    for delta in -search_range..=search_range {
871        let mut matches = 0usize;
872        let mut conf_sum = 0.0f32;
873        let mut total_dist = 0u64;
874
875        for ref_marker in reference {
876            let shifted_frame = ref_marker.frame as i64 - delta;
877            // Find closest target marker to shifted_frame
878            if let Some(closest) = target
879                .iter()
880                .min_by_key(|m| (m.frame as i64 - shifted_frame).unsigned_abs())
881            {
882                let dist = (closest.frame as i64 - shifted_frame).unsigned_abs() as usize;
883                if dist <= tolerance_frames {
884                    matches += 1;
885                    conf_sum += ref_marker.confidence * closest.confidence;
886                    total_dist += dist as u64;
887                }
888            }
889        }
890
891        let is_better = matches > best_matches
892            || (matches == best_matches && conf_sum > best_conf)
893            || (matches == best_matches
894                && (conf_sum - best_conf).abs() < 1e-6
895                && total_dist < best_total_dist);
896
897        if is_better {
898            best_matches = matches;
899            best_conf = conf_sum;
900            best_total_dist = total_dist;
901            best_offset = delta;
902        }
903    }
904
905    if best_matches == 0 {
906        return None;
907    }
908
909    let avg_conf = best_conf / best_matches as f32;
910
911    Some(TemporalAlignment {
912        frame_offset: best_offset,
913        confidence: avg_conf,
914        matched_pairs: best_matches,
915    })
916}
917
918/// Multi-marker synchronizer that combines different marker types
919pub struct MultiMarkerSync {
920    /// Flash detector
921    flash: FlashDetector,
922    /// Clapper detector
923    clapper: ClapperDetector,
924    /// Audio spike detector
925    audio: AudioSpikeDetector,
926}
927
928impl Default for MultiMarkerSync {
929    fn default() -> Self {
930        Self::new()
931    }
932}
933
934impl MultiMarkerSync {
935    /// Create a new multi-marker synchronizer
936    #[must_use]
937    pub fn new() -> Self {
938        Self {
939            flash: FlashDetector::default(),
940            clapper: ClapperDetector::default(),
941            audio: AudioSpikeDetector::default(),
942        }
943    }
944
945    /// Detect all marker types
946    ///
947    /// # Errors
948    /// Returns error if detection fails
949    pub fn detect_all(
950        &self,
951        video_frames: &[&[u8]],
952        width: usize,
953        height: usize,
954        audio: &[f32],
955        sample_rate: u32,
956    ) -> AlignResult<Vec<SyncMarker>> {
957        let mut markers = Vec::new();
958
959        // Detect visual markers
960        markers.extend(self.flash.detect(video_frames, width, height));
961        markers.extend(self.clapper.detect(video_frames, width, height)?);
962
963        // Detect audio markers
964        markers.extend(self.audio.detect(audio, sample_rate));
965
966        // Sort by frame and confidence
967        markers.sort_by(|a, b| {
968            a.frame.cmp(&b.frame).then_with(|| {
969                b.confidence
970                    .partial_cmp(&a.confidence)
971                    .unwrap_or(std::cmp::Ordering::Equal)
972            })
973        });
974
975        Ok(markers)
976    }
977
978    /// Find the best sync marker (highest confidence)
979    #[must_use]
980    pub fn find_best_marker<'a>(&self, markers: &'a [SyncMarker]) -> Option<&'a SyncMarker> {
981        markers.iter().max_by(|a, b| {
982            a.confidence
983                .partial_cmp(&b.confidence)
984                .unwrap_or(std::cmp::Ordering::Equal)
985        })
986    }
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992
993    // ── Interpolation tests ───────────────────────────────────────────────
994
995    #[test]
996    fn test_interpolate_linear_count() {
997        let anchors = vec![
998            SyncMarker::new(0, MarkerType::Flash, 0.8, None),
999            SyncMarker::new(10, MarkerType::Flash, 0.6, None),
1000        ];
1001        let result = interpolate_markers(&anchors, InterpolationMethod::Linear);
1002        assert_eq!(result.len(), 11); // frames 0..=10
1003    }
1004
1005    #[test]
1006    fn test_interpolate_linear_endpoints() {
1007        let anchors = vec![
1008            SyncMarker::new(0, MarkerType::Flash, 1.0, None),
1009            SyncMarker::new(10, MarkerType::Flash, 0.0, None),
1010        ];
1011        let result = interpolate_markers(&anchors, InterpolationMethod::Linear);
1012        // First confidence should be ≈ 1.0
1013        assert!((result[0].confidence - 1.0).abs() < 1e-5);
1014        // Last confidence should be ≈ 0.0
1015        assert!((result[10].confidence).abs() < 1e-5);
1016    }
1017
1018    #[test]
1019    fn test_interpolate_cubic_count() {
1020        let anchors = vec![
1021            SyncMarker::new(0, MarkerType::Flash, 1.0, None),
1022            SyncMarker::new(5, MarkerType::Flash, 0.8, None),
1023            SyncMarker::new(10, MarkerType::Flash, 0.9, None),
1024            SyncMarker::new(15, MarkerType::Flash, 0.6, None),
1025        ];
1026        let result = interpolate_markers(&anchors, InterpolationMethod::Cubic);
1027        assert_eq!(result.len(), 16);
1028    }
1029
1030    #[test]
1031    fn test_interpolate_bezier_count() {
1032        let anchors = vec![
1033            SyncMarker::new(0, MarkerType::Flash, 0.9, None),
1034            SyncMarker::new(4, MarkerType::Flash, 0.7, None),
1035            SyncMarker::new(8, MarkerType::Flash, 0.8, None),
1036        ];
1037        let result = interpolate_markers(&anchors, InterpolationMethod::Bezier);
1038        assert_eq!(result.len(), 9);
1039    }
1040
1041    #[test]
1042    fn test_interpolate_empty_returns_empty() {
1043        let result = interpolate_markers(&[], InterpolationMethod::Linear);
1044        assert!(result.is_empty());
1045    }
1046
1047    #[test]
1048    fn test_interpolate_single_returns_empty() {
1049        let result = interpolate_markers(
1050            &[SyncMarker::new(5, MarkerType::Flash, 0.9, None)],
1051            InterpolationMethod::Linear,
1052        );
1053        assert!(result.is_empty());
1054    }
1055
1056    #[test]
1057    fn test_interpolate_with_locations() {
1058        let loc_a = Some(Point2D::new(0.0, 0.0));
1059        let loc_b = Some(Point2D::new(10.0, 20.0));
1060        let anchors = vec![
1061            SyncMarker::new(0, MarkerType::Flash, 1.0, loc_a),
1062            SyncMarker::new(10, MarkerType::Flash, 1.0, loc_b),
1063        ];
1064        let result = interpolate_markers(&anchors, InterpolationMethod::Linear);
1065        let mid = &result[5];
1066        let loc = mid.location.expect("loc should be valid");
1067        assert!((loc.x - 5.0).abs() < 0.5);
1068        assert!((loc.y - 10.0).abs() < 1.0);
1069    }
1070
1071    // ── Clustering tests ──────────────────────────────────────────────────
1072
1073    #[test]
1074    fn test_cluster_markers_two_clusters() {
1075        let markers = vec![
1076            SyncMarker::new(0, MarkerType::Flash, 0.9, None),
1077            SyncMarker::new(2, MarkerType::Flash, 0.8, None),
1078            SyncMarker::new(100, MarkerType::Flash, 0.7, None),
1079            SyncMarker::new(102, MarkerType::Flash, 0.6, None),
1080        ];
1081        let clusters = cluster_markers(&markers, 5);
1082        assert_eq!(clusters.len(), 2);
1083    }
1084
1085    #[test]
1086    fn test_cluster_markers_one_cluster() {
1087        let markers = vec![
1088            SyncMarker::new(0, MarkerType::Flash, 0.9, None),
1089            SyncMarker::new(3, MarkerType::Flash, 0.8, None),
1090            SyncMarker::new(6, MarkerType::Flash, 0.7, None),
1091        ];
1092        let clusters = cluster_markers(&markers, 5);
1093        assert_eq!(clusters.len(), 1);
1094    }
1095
1096    #[test]
1097    fn test_cluster_markers_empty() {
1098        let clusters = cluster_markers(&[], 5);
1099        assert!(clusters.is_empty());
1100    }
1101
1102    #[test]
1103    fn test_cluster_best_markers_picks_highest_confidence() {
1104        let markers = vec![
1105            SyncMarker::new(0, MarkerType::Flash, 0.5, None),
1106            SyncMarker::new(2, MarkerType::Flash, 0.9, None),
1107            SyncMarker::new(100, MarkerType::Flash, 0.3, None),
1108        ];
1109        let best = cluster_best_markers(&markers, 5);
1110        assert_eq!(best.len(), 2);
1111        // First cluster best should be the 0.9 marker
1112        assert!((best[0].confidence - 0.9).abs() < 1e-5);
1113    }
1114
1115    #[test]
1116    fn test_cluster_markers_single() {
1117        let markers = vec![SyncMarker::new(42, MarkerType::AudioSpike, 1.0, None)];
1118        let clusters = cluster_markers(&markers, 5);
1119        assert_eq!(clusters.len(), 1);
1120        assert_eq!(clusters[0].len(), 1);
1121    }
1122
1123    // ── Temporal alignment tests ──────────────────────────────────────────
1124
1125    #[test]
1126    fn test_align_markers_perfect_match() {
1127        let reference = vec![
1128            SyncMarker::new(10, MarkerType::Flash, 1.0, None),
1129            SyncMarker::new(50, MarkerType::Flash, 1.0, None),
1130        ];
1131        // Target is shifted by +5 frames
1132        let target = vec![
1133            SyncMarker::new(15, MarkerType::Flash, 1.0, None),
1134            SyncMarker::new(55, MarkerType::Flash, 1.0, None),
1135        ];
1136        let alignment =
1137            align_markers_temporal(&reference, &target, 2, 20).expect("alignment should be valid");
1138        assert_eq!(alignment.frame_offset, -5);
1139        assert_eq!(alignment.matched_pairs, 2);
1140    }
1141
1142    #[test]
1143    fn test_align_markers_no_match() {
1144        let reference = vec![SyncMarker::new(0, MarkerType::Flash, 1.0, None)];
1145        let target = vec![SyncMarker::new(1000, MarkerType::Flash, 1.0, None)];
1146        // Search range too small to find the match
1147        let result = align_markers_temporal(&reference, &target, 2, 10);
1148        assert!(result.is_none());
1149    }
1150
1151    #[test]
1152    fn test_align_markers_empty_inputs() {
1153        let reference: Vec<SyncMarker> = vec![];
1154        let target = vec![SyncMarker::new(10, MarkerType::Flash, 1.0, None)];
1155        assert!(align_markers_temporal(&reference, &target, 5, 20).is_none());
1156    }
1157
1158    #[test]
1159    fn test_align_markers_confidence_nonzero() {
1160        let reference = vec![SyncMarker::new(5, MarkerType::Flash, 0.8, None)];
1161        let target = vec![SyncMarker::new(5, MarkerType::Flash, 0.9, None)];
1162        let result =
1163            align_markers_temporal(&reference, &target, 1, 5).expect("result should be valid");
1164        assert!(result.confidence > 0.0);
1165    }
1166
1167    #[test]
1168    fn test_temporal_alignment_fields() {
1169        let reference = vec![SyncMarker::new(0, MarkerType::Flash, 1.0, None)];
1170        let target = vec![SyncMarker::new(3, MarkerType::Flash, 1.0, None)];
1171        let result =
1172            align_markers_temporal(&reference, &target, 5, 10).expect("result should be valid");
1173        assert_eq!(result.matched_pairs, 1);
1174        assert!(result.confidence > 0.0);
1175    }
1176
1177    // ── Original tests ────────────────────────────────────────────────────
1178
1179    #[test]
1180    fn test_sync_marker_creation() {
1181        let marker = SyncMarker::new(100, MarkerType::Flash, 0.95, None);
1182        assert_eq!(marker.frame, 100);
1183        assert_eq!(marker.marker_type, MarkerType::Flash);
1184        assert_eq!(marker.confidence, 0.95);
1185    }
1186
1187    #[test]
1188    fn test_flash_detector() {
1189        let detector = FlashDetector::default();
1190        assert_eq!(detector.threshold, 0.8);
1191        assert_eq!(detector.min_duration, 1);
1192    }
1193
1194    #[test]
1195    fn test_brightness_computation() {
1196        let detector = FlashDetector::default();
1197        let frame = vec![255u8; 300]; // White frame
1198        let brightness = detector.compute_brightness(&frame, 10, 10);
1199        assert!((brightness - 1.0).abs() < 0.01);
1200    }
1201
1202    #[test]
1203    fn test_region_creation() {
1204        let region = Region::new(10, 20, 100, 200);
1205        assert_eq!(region.x, 10);
1206        assert_eq!(region.y, 20);
1207        assert_eq!(region.width, 100);
1208        assert_eq!(region.height, 200);
1209    }
1210
1211    #[test]
1212    fn test_clapper_detector() {
1213        let detector = ClapperDetector::default();
1214        let frame1 = vec![100u8; 300];
1215        let frame2 = vec![200u8; 300];
1216        let motion = detector.compute_motion(&frame1, &frame2, 10, 10);
1217        assert!(motion > 0.0);
1218    }
1219
1220    #[test]
1221    fn test_led_marker_detector() {
1222        let detector = LedMarkerDetector::new([1.0, 0.0, 0.0], 0.2);
1223        assert_eq!(detector.expected_color[0], 1.0);
1224        assert_eq!(detector.color_tolerance, 0.2);
1225    }
1226
1227    #[test]
1228    fn test_audio_spike_detector() {
1229        // Use a small window_size so the envelope has enough elements for peak detection
1230        let detector = AudioSpikeDetector::new(0.8, 50);
1231        let mut audio = vec![0.0f32; 1000];
1232        audio[500] = 1.0; // Spike at sample 500
1233        let markers = detector.detect(&audio, 48000);
1234        assert!(!markers.is_empty());
1235    }
1236
1237    #[test]
1238    fn test_audio_envelope() {
1239        let detector = AudioSpikeDetector::new(0.5, 100);
1240        let audio = vec![0.5f32; 1000];
1241        let envelope = detector.compute_envelope(&audio);
1242        assert!(!envelope.is_empty());
1243        assert!((envelope[0] - 0.5).abs() < 0.01);
1244    }
1245
1246    #[test]
1247    fn test_multi_marker_sync() {
1248        let sync = MultiMarkerSync::new();
1249        let marker1 = SyncMarker::new(100, MarkerType::Flash, 0.8, None);
1250        let marker2 = SyncMarker::new(101, MarkerType::AudioSpike, 0.9, None);
1251        let markers = vec![marker1, marker2];
1252
1253        let best = sync.find_best_marker(&markers);
1254        assert!(best.is_some());
1255        assert_eq!(best.expect("test expectation failed").confidence, 0.9);
1256    }
1257}