pixie_anim_lib/
delta.rs

1//! Inter-frame Delta Compression.
2
3use crate::quant::{DitherType, Rgb};
4
5/// Represents the difference between two consecutive frames.
6#[derive(Debug, Default)]
7pub struct FrameDelta {
8    /// X coordinate of the delta bounding box.
9    pub x: u16,
10    /// Y coordinate of the delta bounding box.
11    pub y: u16,
12    /// Width of the delta bounding box.
13    pub width: u16,
14    /// Height of the delta bounding box.
15    pub height: u16,
16    /// Palette indices for the pixels within the bounding box.
17    pub indices: Vec<u8>,
18}
19
20/// Fast RGB distance for fuzzy delta matching
21#[inline]
22fn rgb_dist_sq(c1: Rgb, c2: Rgb) -> u32 {
23    let dr = c1.r as i32 - c2.r as i32;
24    let dg = c1.g as i32 - c2.g as i32;
25    let db = c1.b as i32 - c2.b as i32;
26    (dr * dr + dg * dg + db * db) as u32
27}
28
29/// Options for delta compression.
30/// Options for configuring the delta compression engine.
31pub struct DeltaOptions<'a> {
32    /// Width of the frame.
33    pub width: u16,
34    /// Height of the frame.
35    pub height: u16,
36    /// The global palette to use for indexing.
37    pub palette: &'a [Rgb],
38    /// The index to use for transparent pixels.
39    pub transparent_idx: u8,
40    /// Squared perceptual threshold for "fuzzy" equality.
41    pub fuzz_threshold: u32,
42    /// Type of dithering to apply to opaque pixels.
43    pub dither: DitherType,
44    /// Strength of the dithering effect (0.0 to 1.0).
45    pub dither_strength: f32,
46}
47
48/// Finds the smallest bounding box and maps pixels to transparent if they are "close enough"
49/// to the previous frame's color.
50///
51/// # Arguments
52/// * `curr_pixels` - Pixels of the current frame
53/// * `prev_pixels` - Pixels of the previous frame
54/// * `prev_indices` - Palette indices used in the previous frame at these coordinates
55/// * `options` - Delta compression configuration
56pub fn find_delta_fuzzy(
57    curr_pixels: &[Rgb],
58    prev_pixels: &[Rgb],
59    prev_indices: Option<&[u8]>,
60    options: &DeltaOptions,
61) -> Option<FrameDelta> {
62    if curr_pixels.len() != prev_pixels.len() {
63        return None;
64    }
65
66    let mut min_x = options.width;
67    let mut max_x = 0;
68    let mut min_y = options.height;
69    let mut max_y = 0;
70    let mut changed = false;
71
72    // 1. Find bounding box using fuzzy equality
73    for y in 0..options.height {
74        for x in 0..options.width {
75            let idx = (y as usize * options.width as usize) + x as usize;
76            if rgb_dist_sq(curr_pixels[idx], prev_pixels[idx]) > options.fuzz_threshold {
77                if x < min_x {
78                    min_x = x;
79                }
80                if x > max_x {
81                    max_x = x;
82                }
83                if y < min_y {
84                    min_y = y;
85                }
86                if y > max_y {
87                    max_y = y;
88                }
89                changed = true;
90            }
91        }
92    }
93
94    if !changed {
95        return None;
96    }
97
98    let delta_width = max_x - min_x + 1;
99    let delta_height = max_y - min_y + 1;
100
101    // 2. Map pixels to indices
102
103    // Prepare Blue Noise if needed
104    let lab_palette = if options.dither == DitherType::BlueNoise {
105        let lp: Vec<crate::color::Lab> = options
106            .palette
107            .iter()
108            .map(|p| crate::color::rgb_to_lab(p.r, p.g, p.b))
109            .collect();
110        let pp = crate::simd::PlanarLabPalette::from_lab(&lp);
111        Some((lp, pp))
112    } else {
113        None
114    };
115
116    #[cfg(feature = "rayon")]
117    use rayon::prelude::*;
118
119    #[cfg(feature = "rayon")]
120    let delta_indices: Vec<u8> = (min_y..=max_y)
121        .into_par_iter()
122        .flat_map(|y| {
123            let lab_palette_ref = lab_palette.as_ref();
124            (min_x..=max_x).into_par_iter().map(move |x| {
125                let idx = (y as usize * options.width as usize) + x as usize;
126                // If it's close enough to the previous pixel, make it transparent
127                if rgb_dist_sq(curr_pixels[idx], prev_pixels[idx]) <= options.fuzz_threshold {
128                    options.transparent_idx
129                } else {
130                    // TEMPORAL DENOISING:
131                    // If we used an index in the previous frame, check if that same color
132                    // is "close enough" to our current pixel. Reusing indices is LZW-friendly.
133                    if let Some(prev_idx_buffer) = prev_indices {
134                        let prev_idx = prev_idx_buffer[idx];
135                        if prev_idx != options.transparent_idx {
136                            let prev_color = options.palette[prev_idx as usize];
137                            // Use a tighter threshold for index-reuse than for transparency
138                            if rgb_dist_sq(curr_pixels[idx], prev_color)
139                                <= options.fuzz_threshold / 2
140                            {
141                                return prev_idx;
142                            }
143                        }
144                    }
145
146                    match options.dither {
147                        DitherType::BlueNoise => {
148                            if let Some((_, pp)) = lab_palette_ref {
149                                let (ol, oa, ob) = crate::quant::dither::get_blue_noise_offset(
150                                    x,
151                                    y,
152                                    options.dither_strength,
153                                );
154                                let p = curr_pixels[idx];
155                                let mut lab = crate::color::rgb_to_lab(p.r, p.g, p.b);
156                                lab.l = (lab.l + ol).clamp(0.0, 100.0);
157                                lab.a = (lab.a + oa).clamp(-128.0, 127.0);
158                                lab.b = (lab.b + ob).clamp(-128.0, 127.0);
159                                crate::simd::find_nearest_color_lab(lab, pp) as u8
160                            } else {
161                                crate::simd::find_nearest_color(curr_pixels[idx], options.palette)
162                                    as u8
163                            }
164                        }
165                        _ => {
166                            crate::simd::find_nearest_color(curr_pixels[idx], options.palette) as u8
167                        }
168                    }
169                }
170            })
171        })
172        .collect();
173
174    #[cfg(not(feature = "rayon"))]
175    let delta_indices: Vec<u8> = (min_y..=max_y)
176        .flat_map(|y| {
177            let lab_palette_ref = lab_palette.as_ref();
178            (min_x..=max_x).map(move |x| {
179                let idx = (y as usize * options.width as usize) + x as usize;
180                if rgb_dist_sq(curr_pixels[idx], prev_pixels[idx]) <= options.fuzz_threshold {
181                    options.transparent_idx
182                } else {
183                    // TEMPORAL DENOISING:
184                    if let Some(prev_idx_buffer) = prev_indices {
185                        let prev_idx = prev_idx_buffer[idx];
186                        if prev_idx != options.transparent_idx {
187                            let prev_color = options.palette[prev_idx as usize];
188                            if rgb_dist_sq(curr_pixels[idx], prev_color)
189                                <= options.fuzz_threshold / 2
190                            {
191                                return prev_idx;
192                            }
193                        }
194                    }
195
196                    match options.dither {
197                        DitherType::BlueNoise => {
198                            if let Some((_, pp)) = lab_palette_ref {
199                                let (ol, oa, ob) = crate::quant::dither::get_blue_noise_offset(
200                                    x,
201                                    y,
202                                    options.dither_strength,
203                                );
204                                let p = curr_pixels[idx];
205                                let mut lab = crate::color::rgb_to_lab(p.r, p.g, p.b);
206                                lab.l = (lab.l + ol).clamp(0.0, 100.0);
207                                lab.a = (lab.a + oa).clamp(-128.0, 127.0);
208                                lab.b = (lab.b + ob).clamp(-128.0, 127.0);
209                                crate::simd::find_nearest_color_lab(lab, pp) as u8
210                            } else {
211                                crate::simd::find_nearest_color(curr_pixels[idx], options.palette)
212                                    as u8
213                            }
214                        }
215                        _ => {
216                            crate::simd::find_nearest_color(curr_pixels[idx], options.palette) as u8
217                        }
218                    }
219                }
220            })
221        })
222        .collect();
223    Some(FrameDelta {
224        x: min_x,
225        y: min_y,
226        width: delta_width,
227        height: delta_height,
228        indices: delta_indices,
229    })
230}