Skip to main content

oximedia_codec/
error_concealment.rs

1//! Error concealment for lost or corrupted video frames.
2//!
3//! When a video frame is missing due to packet loss, decoding error, or
4//! transmission failure, the decoder must produce a substitute frame to
5//! maintain temporal continuity.  This module provides simple and effective
6//! concealment strategies based on reference frame data.
7//!
8//! ## Strategies
9//!
10//! - **Frame copy** (`conceal_missing_frame`): Copies the previous reference
11//!   frame verbatim.  This is the simplest and fastest strategy; effective
12//!   when motion between frames is small.
13//! - **Temporal blend** (`conceal_blend`): Blends between the previous and a
14//!   fallback (e.g. grey or next available) frame at a configurable ratio.
15//! - **Motion-compensated** (`conceal_motion_compensated`): Applies a simple
16//!   global translation offset to the previous frame, useful when a dominant
17//!   camera-pan motion is known.
18
19#![allow(dead_code)]
20#![allow(clippy::cast_precision_loss)]
21#![allow(clippy::cast_possible_truncation)]
22#![allow(clippy::cast_sign_loss)]
23
24// ---------------------------------------------------------------------------
25// Frame copy (primary API)
26// ---------------------------------------------------------------------------
27
28/// Conceal a missing frame by copying the previous reference frame.
29///
30/// This is the simplest error-concealment strategy: the decoder re-uses the
31/// most recently decoded frame as a substitute for the lost frame.  For low-
32/// motion content the result is indistinguishable; for high-motion content
33/// it may produce a brief freeze.
34///
35/// # Parameters
36/// - `prev`  – pixel buffer of the previous (reference) frame, interleaved
37///             YUV or RGB u8, length `3 * w * h`.
38/// - `w`     – frame width in pixels.
39/// - `h`     – frame height in pixels.
40///
41/// # Returns
42/// A `Vec<u8>` containing the concealed frame (identical to `prev`).
43///
44/// # Panics
45/// Panics if `prev.len() != 3 * w * h`.
46#[must_use]
47pub fn conceal_missing_frame(prev: &[u8], w: u32, h: u32) -> Vec<u8> {
48    let expected = 3 * (w as usize) * (h as usize);
49    assert_eq!(
50        prev.len(),
51        expected,
52        "conceal_missing_frame: prev length {len} != 3*w*h {expected}",
53        len = prev.len()
54    );
55    prev.to_vec()
56}
57
58// ---------------------------------------------------------------------------
59// Temporal blend
60// ---------------------------------------------------------------------------
61
62/// Conceal a missing frame by blending `prev` with a background colour.
63///
64/// When no future reference frame is available this replaces the missing
65/// frame with a fade toward `bg_color`:
66/// ```text
67/// out[i] = alpha * prev[i] + (1 - alpha) * bg_color
68/// ```
69///
70/// # Parameters
71/// - `prev`     – previous frame pixel buffer, interleaved RGB u8, length
72///               `3 * w * h`.
73/// - `w`        – frame width in pixels.
74/// - `h`        – frame height in pixels.
75/// - `alpha`    – blending weight for `prev` in [0.0, 1.0].  1.0 = pure copy;
76///               0.0 = pure `bg_color`.
77/// - `bg_color` – background RGB colour to blend toward, as `[R, G, B]`.
78///
79/// # Panics
80/// Panics if `prev.len() != 3 * w * h`.
81#[must_use]
82pub fn conceal_blend(prev: &[u8], w: u32, h: u32, alpha: f32, bg_color: [u8; 3]) -> Vec<u8> {
83    let expected = 3 * (w as usize) * (h as usize);
84    assert_eq!(
85        prev.len(),
86        expected,
87        "conceal_blend: prev length {len} != 3*w*h {expected}",
88        len = prev.len()
89    );
90
91    let alpha = alpha.clamp(0.0, 1.0);
92    let one_minus_alpha = 1.0 - alpha;
93
94    let mut out = Vec::with_capacity(expected);
95    let n_pixels = (w * h) as usize;
96
97    for i in 0..n_pixels {
98        for ch in 0..3usize {
99            let pv = prev[i * 3 + ch] as f32;
100            let bg = bg_color[ch] as f32;
101            let blended = alpha * pv + one_minus_alpha * bg;
102            out.push(blended.round().clamp(0.0, 255.0) as u8);
103        }
104    }
105
106    out
107}
108
109// ---------------------------------------------------------------------------
110// Motion-compensated concealment
111// ---------------------------------------------------------------------------
112
113/// Conceal a missing frame using a constant global motion offset.
114///
115/// Applies a pixel-level translation `(dx, dy)` to `prev` to approximate
116/// camera-pan motion.  Pixels that are shifted out-of-bounds are filled from
117/// `prev` at the clamped border (clamp-to-edge).
118///
119/// # Parameters
120/// - `prev` – previous frame pixel buffer, interleaved RGB u8, length
121///            `3 * w * h`.
122/// - `w`    – frame width in pixels.
123/// - `h`    – frame height in pixels.
124/// - `dx`   – horizontal translation (positive = shift right).
125/// - `dy`   – vertical translation (positive = shift down).
126///
127/// # Panics
128/// Panics if `prev.len() != 3 * w * h`.
129#[must_use]
130pub fn conceal_motion_compensated(prev: &[u8], w: u32, h: u32, dx: i32, dy: i32) -> Vec<u8> {
131    let expected = 3 * (w as usize) * (h as usize);
132    assert_eq!(
133        prev.len(),
134        expected,
135        "conceal_motion_compensated: prev length {len} != 3*w*h {expected}",
136        len = prev.len()
137    );
138
139    let w_i = w as i32;
140    let h_i = h as i32;
141    let mut out = vec![0u8; expected];
142
143    for y in 0..h as i32 {
144        for x in 0..w_i {
145            // Source coordinate in previous frame
146            let src_x = (x - dx).clamp(0, w_i - 1) as usize;
147            let src_y = (y - dy).clamp(0, h_i - 1) as usize;
148
149            let src_idx = (src_y * w as usize + src_x) * 3;
150            let dst_idx = (y as usize * w as usize + x as usize) * 3;
151
152            out[dst_idx] = prev[src_idx];
153            out[dst_idx + 1] = prev[src_idx + 1];
154            out[dst_idx + 2] = prev[src_idx + 2];
155        }
156    }
157
158    out
159}
160
161// ---------------------------------------------------------------------------
162// Tests
163// ---------------------------------------------------------------------------
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    fn make_frame(w: u32, h: u32, fill: u8) -> Vec<u8> {
170        vec![fill; (3 * w * h) as usize]
171    }
172
173    #[test]
174    fn conceal_missing_frame_copies_prev() {
175        let prev = vec![42u8; 3 * 8 * 6];
176        let out = conceal_missing_frame(&prev, 8, 6);
177        assert_eq!(out, prev);
178    }
179
180    #[test]
181    fn conceal_missing_frame_correct_length() {
182        let prev = make_frame(16, 9, 100);
183        let out = conceal_missing_frame(&prev, 16, 9);
184        assert_eq!(out.len(), prev.len());
185    }
186
187    #[test]
188    #[should_panic(expected = "3*w*h")]
189    fn conceal_missing_frame_panics_on_wrong_size() {
190        let _ = conceal_missing_frame(&[0u8; 10], 4, 4);
191    }
192
193    #[test]
194    fn conceal_blend_alpha_one_returns_prev() {
195        let prev = vec![200u8; 3 * 4 * 4];
196        let out = conceal_blend(&prev, 4, 4, 1.0, [0, 0, 0]);
197        for (&p, &o) in prev.iter().zip(out.iter()) {
198            assert_eq!(p, o, "alpha=1.0 should return prev unchanged");
199        }
200    }
201
202    #[test]
203    fn conceal_blend_alpha_zero_returns_bg() {
204        let prev = vec![255u8; 3 * 4 * 4];
205        let bg = [128u8, 64, 32];
206        let out = conceal_blend(&prev, 4, 4, 0.0, bg);
207        for i in 0..(4 * 4) as usize {
208            assert_eq!(out[i * 3], bg[0]);
209            assert_eq!(out[i * 3 + 1], bg[1]);
210            assert_eq!(out[i * 3 + 2], bg[2]);
211        }
212    }
213
214    #[test]
215    fn conceal_blend_half_alpha_midpoint() {
216        let prev = vec![200u8; 3 * 2 * 2];
217        let out = conceal_blend(&prev, 2, 2, 0.5, [0, 0, 0]);
218        for &v in &out {
219            assert_eq!(v, 100, "half blend of 200 and 0 should be 100");
220        }
221    }
222
223    #[test]
224    fn conceal_blend_all_values_in_range() {
225        let prev: Vec<u8> = (0..3 * 8 * 8).map(|i| (i * 3 % 256) as u8).collect();
226        let out = conceal_blend(&prev, 8, 8, 0.7, [128, 128, 128]);
227        // All values are u8, so they are always in [0, 255].
228        assert!(!out.is_empty());
229    }
230
231    #[test]
232    fn conceal_motion_compensated_zero_offset_copies_prev() {
233        let prev: Vec<u8> = (0..3 * 8 * 8).map(|i| (i % 256) as u8).collect();
234        let out = conceal_motion_compensated(&prev, 8, 8, 0, 0);
235        assert_eq!(out, prev);
236    }
237
238    #[test]
239    fn conceal_motion_compensated_correct_length() {
240        let prev = make_frame(10, 10, 50);
241        let out = conceal_motion_compensated(&prev, 10, 10, 2, -1);
242        assert_eq!(out.len(), prev.len());
243    }
244
245    #[test]
246    fn conceal_motion_compensated_all_values_valid() {
247        let prev = make_frame(8, 6, 77);
248        let out = conceal_motion_compensated(&prev, 8, 6, 3, 2);
249        // All values are u8, so they are always in [0, 255].
250        assert!(!out.is_empty());
251    }
252
253    #[test]
254    fn conceal_motion_compensated_shift_right_fills_left_edge() {
255        // A frame with gradient 0..w-1 in the horizontal direction
256        let w = 8u32;
257        let h = 4u32;
258        let mut prev = vec![0u8; (3 * w * h) as usize];
259        for y in 0..h as usize {
260            for x in 0..w as usize {
261                let v = x as u8;
262                prev[(y * w as usize + x) * 3] = v;
263                prev[(y * w as usize + x) * 3 + 1] = v;
264                prev[(y * w as usize + x) * 3 + 2] = v;
265            }
266        }
267
268        let out = conceal_motion_compensated(&prev, w, h, 2, 0);
269
270        // After shifting right by 2, the leftmost 2 columns should be clamped
271        // to the source x=0 value (0)
272        assert_eq!(out[0], 0, "left-edge clamped pixel should be 0");
273        assert_eq!(out[3], 0, "second-pixel clamped should be 0");
274    }
275}