Skip to main content

libbitsub_core/vobsub/
deband.rs

1//! Debanding filter for VobSub RGBA output.
2//!
3//! Implements a simplified neo_f3kdb-style algorithm to reduce banding artifacts
4//! in subtitle bitmaps. Uses cross-shaped sampling with factor-based blending.
5
6/// Configuration for the debanding filter.
7#[derive(Clone, Debug)]
8pub struct DebandConfig {
9    /// Enable/disable debanding.
10    pub enabled: bool,
11    /// Primary difference threshold (0.0-255.0, default: 64.0).
12    pub threshold: f32,
13    /// Sample range in pixels (default: 15).
14    pub range: u32,
15    /// Random seed for deterministic offset generation.
16    pub seed: u32,
17}
18
19impl Default for DebandConfig {
20    fn default() -> Self {
21        Self {
22            enabled: true,
23            threshold: 64.0,
24            range: 15,
25            seed: 0x1337,
26        }
27    }
28}
29
30/// Apply debanding filter to RGBA buffer.
31///
32/// Pure function: takes immutable input, returns new buffer.
33pub fn apply_deband(rgba: &[u8], width: usize, height: usize, config: &DebandConfig) -> Vec<u8> {
34    if !config.enabled || width == 0 || height == 0 {
35        return rgba.to_vec();
36    }
37
38    let mut output = vec![0u8; rgba.len()];
39    let range = config.range as i32;
40
41    for y in 0..height {
42        for x in 0..width {
43            let idx = (y * width + x) * 4;
44            let src = read_pixel(rgba, idx);
45
46            // Skip fully transparent pixels
47            if src[3] == 0 {
48                write_pixel(&mut output, idx, src);
49                continue;
50            }
51
52            // Get deterministic pseudo-random offset
53            let (ox, oy) = sample_offset(config.seed, x as u32, y as u32, range);
54
55            // Sample 4 cross-shaped reference pixels
56            let refs = sample_cross(rgba, width, height, x, y, ox, oy);
57
58            // Compute blend factor and apply
59            let blended = blend_deband(src, refs, config.threshold);
60            write_pixel(&mut output, idx, blended);
61        }
62    }
63
64    output
65}
66
67/// Read RGBA pixel at byte offset.
68#[inline]
69fn read_pixel(rgba: &[u8], idx: usize) -> [u8; 4] {
70    [rgba[idx], rgba[idx + 1], rgba[idx + 2], rgba[idx + 3]]
71}
72
73/// Write RGBA pixel at byte offset.
74#[inline]
75fn write_pixel(output: &mut [u8], idx: usize, pixel: [u8; 4]) {
76    output[idx..idx + 4].copy_from_slice(&pixel);
77}
78
79/// Generate deterministic pseudo-random offset from seed and position.
80fn sample_offset(seed: u32, x: u32, y: u32, range: i32) -> (i32, i32) {
81    // Simple hash combining seed with position
82    let hash = seed
83        .wrapping_mul(0x9E3779B9)
84        .wrapping_add(x.wrapping_mul(0x85EBCA6B))
85        .wrapping_add(y.wrapping_mul(0xC2B2AE35));
86
87    let ox = ((hash & 0xFFFF) as i32 % (range * 2 + 1)) - range;
88    let oy = (((hash >> 16) & 0xFFFF) as i32 % (range * 2 + 1)) - range;
89
90    (ox.max(1), oy.max(1)) // Ensure non-zero offset
91}
92
93/// Sample 4 cross-shaped reference pixels around (x, y).
94/// Returns [up, down, left, right] pixels.
95fn sample_cross(
96    rgba: &[u8],
97    width: usize,
98    height: usize,
99    x: usize,
100    y: usize,
101    ox: i32,
102    oy: i32,
103) -> [[u8; 4]; 4] {
104    let sample_at = |dx: i32, dy: i32| -> [u8; 4] {
105        let nx = (x as i32 + dx).clamp(0, width as i32 - 1) as usize;
106        let ny = (y as i32 + dy).clamp(0, height as i32 - 1) as usize;
107        let idx = (ny * width + nx) * 4;
108        read_pixel(rgba, idx)
109    };
110
111    [
112        sample_at(0, -oy), // up
113        sample_at(0, oy),  // down
114        sample_at(-ox, 0), // left
115        sample_at(ox, 0),  // right
116    ]
117}
118
119/// Compute deband blend for a single pixel using neo_f3kdb-style algorithm.
120fn blend_deband(src: [u8; 4], refs: [[u8; 4]; 4], threshold: f32) -> [u8; 4] {
121    let mut result = [0u8; 4];
122
123    // Process R, G, B channels (preserve alpha)
124    for c in 0..3 {
125        let s = src[c] as f32;
126        let r: [f32; 4] = [
127            refs[0][c] as f32,
128            refs[1][c] as f32,
129            refs[2][c] as f32,
130            refs[3][c] as f32,
131        ];
132
133        // Average of reference pixels
134        let avg = (r[0] + r[1] + r[2] + r[3]) * 0.25;
135
136        // Difference metrics
137        let avg_dif = (avg - s).abs();
138        let max_dif = r.iter().map(|&v| (v - s).abs()).fold(0.0f32, f32::max);
139        let mid_dif_v = ((r[0] + r[1]) * 0.5 - s).abs(); // vertical midpoint
140        let mid_dif_h = ((r[2] + r[3]) * 0.5 - s).abs(); // horizontal midpoint
141
142        // Compute blend factor
143        let factor = compute_factor(avg_dif, max_dif, mid_dif_v, mid_dif_h, threshold);
144
145        // Blend: src + (avg - src) * factor
146        let blended = s + (avg - s) * factor;
147        result[c] = blended.clamp(0.0, 255.0) as u8;
148    }
149
150    // Preserve original alpha
151    result[3] = src[3];
152    result
153}
154
155/// Compute the blend factor based on difference metrics.
156#[inline]
157fn compute_factor(avg_dif: f32, max_dif: f32, mid_v: f32, mid_h: f32, thresh: f32) -> f32 {
158    let saturate = |x: f32| x.clamp(0.0, 1.0);
159
160    let t1 = thresh;
161    let t2 = thresh * 0.75; // Secondary threshold for midpoint checks
162
163    let f1 = saturate(3.0 * (1.0 - avg_dif / t1));
164    let f2 = saturate(3.0 * (1.0 - max_dif / t1));
165    let f3 = saturate(3.0 * (1.0 - mid_v / t2));
166    let f4 = saturate(3.0 * (1.0 - mid_h / t2));
167
168    // Combined factor with power adjustment for smoother blending
169    (f1 * f2 * f3 * f4).powf(0.1)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_default_config() {
178        let config = DebandConfig::default();
179        assert!(config.enabled);
180        assert_eq!(config.threshold, 64.0);
181        assert_eq!(config.range, 15);
182    }
183
184    #[test]
185    fn test_disabled_passthrough() {
186        let rgba = vec![255, 0, 0, 255, 0, 255, 0, 255];
187        let config = DebandConfig {
188            enabled: false,
189            ..Default::default()
190        };
191        let result = apply_deband(&rgba, 2, 1, &config);
192        assert_eq!(result, rgba);
193    }
194
195    #[test]
196    fn test_transparent_skip() {
197        let rgba = vec![255, 0, 0, 0]; // Fully transparent
198        let config = DebandConfig {
199            enabled: true,
200            ..Default::default()
201        };
202        let result = apply_deband(&rgba, 1, 1, &config);
203        assert_eq!(result[3], 0); // Alpha preserved
204    }
205
206    #[test]
207    fn test_sample_offset_deterministic() {
208        let (ox1, oy1) = sample_offset(0x1337, 10, 20, 15);
209        let (ox2, oy2) = sample_offset(0x1337, 10, 20, 15);
210        assert_eq!((ox1, oy1), (ox2, oy2)); // Same inputs = same outputs
211    }
212}