pixie_anim_lib/quant/
mod.rs

1//! Quantization algorithms for GIF.
2//!
3//! Implements K-Means clustering for optimal palette generation.
4
5pub mod dither;
6pub mod zeng;
7
8use crate::error::Result;
9
10#[cfg(feature = "rayon")]
11use rayon::prelude::*;
12
13/// Representation of an RGB color.
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub struct Rgb {
16    /// Red channel (0-255)
17    pub r: u8,
18    /// Green channel (0-255)
19    pub g: u8,
20    /// Blue channel (0-255)
21    pub b: u8,
22}
23
24/// Dithering algorithms supported by Pixie-Anim.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum DitherType {
27    /// No dithering (sharp edges, potential banding)
28    None,
29    /// Spatial error diffusion (Floyd-Steinberg)
30    FloydSteinberg,
31    /// Deterministic perceptual noise
32    BlueNoise,
33    /// Matrix-based deterministic dithering (Bayer 8x8)
34    Ordered,
35}
36
37/// A collection of colors representing a GIF palette.
38pub struct Palette {
39    /// The colors in the palette.
40    pub colors: Vec<Rgb>,
41}
42
43/// Result of quantization including the palette and a mapping from
44/// the intermediate refinement indices to the final reordered indices.
45pub struct QuantizationResult {
46    /// The final generated palette.
47    pub palette: Palette,
48    /// Mapping from intermediate indices to final reordered indices.
49    pub index_mapping: Vec<u8>,
50}
51
52/// Common trait for color quantizers.
53pub trait Quantizer {
54    /// Quantizes a slice of pixels into a palette of at most `max_colors`.
55    fn quantize(&self, pixels: &[Rgb], max_colors: usize) -> Result<QuantizationResult>;
56}
57
58/// A K-Means++ based quantizer that operates in CIELAB space.
59pub struct KMeansQuantizer {
60    /// Maximum number of iterations for the clustering algorithm.
61    pub max_iterations: usize,
62    /// Pixel sampling rate (e.g. 10 for 10% sampling)
63    pub sample_rate: usize,
64    /// Whether to use dithering during index generation.
65    pub dither: bool,
66}
67
68impl KMeansQuantizer {
69    /// Creates a new KMeansQuantizer with default settings.
70    pub fn new(max_iterations: usize) -> Self {
71        Self {
72            max_iterations,
73            sample_rate: 10,
74            dither: true,
75        }
76    }
77
78    fn distance_sq(c1: crate::color::Lab, c2: crate::color::Lab) -> f32 {
79        crate::color::lab_distance_sq(c1, c2)
80    }
81
82    /// K-Means++ initialization for Lab centroids
83    fn initialize_centroids(
84        pixels: &[crate::color::Lab],
85        max_colors: usize,
86    ) -> Vec<crate::color::Lab> {
87        if pixels.is_empty() {
88            return Vec::new();
89        }
90
91        let mut centroids = Vec::with_capacity(max_colors);
92        centroids.push(pixels[0]);
93
94        let mut min_distances = vec![f32::MAX; pixels.len()];
95
96        while centroids.len() < max_colors {
97            let last_centroid = centroids.last().unwrap();
98            let mut total_dist: f64 = 0.0;
99
100            for (i, &pixel) in pixels.iter().enumerate() {
101                let dist = Self::distance_sq(pixel, *last_centroid);
102                if dist < min_distances[i] {
103                    min_distances[i] = dist;
104                }
105                total_dist += min_distances[i] as f64;
106            }
107
108            if total_dist == 0.0 {
109                break;
110            }
111
112            let mut best_pixel_idx = 0;
113            let mut max_d = -1.0;
114            for (i, &d) in min_distances.iter().enumerate() {
115                if d > max_d {
116                    max_d = d;
117                    best_pixel_idx = i;
118                }
119            }
120            centroids.push(pixels[best_pixel_idx]);
121        }
122
123        centroids
124    }
125}
126
127impl Quantizer for KMeansQuantizer {
128    fn quantize(&self, pixels: &[Rgb], max_colors: usize) -> Result<QuantizationResult> {
129        if pixels.is_empty() {
130            return Ok(QuantizationResult {
131                palette: Palette { colors: Vec::new() },
132                index_mapping: Vec::new(),
133            });
134        }
135
136        // 1. Sub-sample pixels and convert to CIELAB for perceptual refinement
137        let sampled_pixels: Vec<crate::color::Lab> = pixels
138            .iter()
139            .step_by(self.sample_rate)
140            .map(|p| crate::color::rgb_to_lab(p.r, p.g, p.b))
141            .collect();
142
143        // 2. Initialize centroids using K-Means++ logic on sampled Lab pixels
144        let mut centroids = Self::initialize_centroids(&sampled_pixels, max_colors);
145
146        let mut assignments = vec![0usize; sampled_pixels.len()];
147
148        for _ in 0..self.max_iterations {
149            let mut changed = false;
150
151            // 3. Assignment step (Perceptual Distance via CIELAB Planar SIMD)
152            let planar_centroids = crate::simd::PlanarLabPalette::from_lab(&centroids);
153
154            #[cfg(feature = "rayon")]
155            let new_assignments: Vec<usize> = sampled_pixels
156                .par_iter()
157                .map(|&pixel_lab| crate::simd::find_nearest_color_lab(pixel_lab, &planar_centroids))
158                .collect();
159
160            #[cfg(not(feature = "rayon"))]
161            let new_assignments: Vec<usize> = sampled_pixels
162                .iter()
163                .map(|&pixel_lab| crate::simd::find_nearest_color_lab(pixel_lab, &planar_centroids))
164                .collect();
165
166            if assignments != new_assignments {
167                assignments = new_assignments;
168                changed = true;
169            }
170
171            if !changed {
172                break;
173            }
174
175            // 4. Update step
176            let mut sums = vec![(0.0f32, 0.0f32, 0.0f32, 0usize); centroids.len()];
177            for (i, &pixel_lab) in sampled_pixels.iter().enumerate() {
178                let a = assignments[i];
179                sums[a].0 += pixel_lab.l;
180                sums[a].1 += pixel_lab.a;
181                sums[a].2 += pixel_lab.b;
182                sums[a].3 += 1;
183            }
184
185            for (c_idx, sum) in sums.iter().enumerate() {
186                if sum.3 > 0 {
187                    centroids[c_idx] = crate::color::Lab {
188                        l: sum.0 / sum.3 as f32,
189                        a: sum.1 / sum.3 as f32,
190                        b: sum.2 / sum.3 as f32,
191                    };
192                }
193            }
194        }
195
196        // Convert Lab centroids back to RGB for the palette
197        let rgb_centroids: Vec<Rgb> = centroids
198            .iter()
199            .map(|&lab| {
200                let mut min_dist = f32::MAX;
201                let mut best_rgb = Rgb { r: 0, g: 0, b: 0 };
202                // Sample more densely for the final palette back-mapping
203                for &p in pixels.iter().step_by(10) {
204                    let p_lab = crate::color::rgb_to_lab(p.r, p.g, p.b);
205                    let dist = crate::color::lab_distance_sq(lab, p_lab);
206                    if dist < min_dist {
207                        min_dist = dist;
208                        best_rgb = p;
209                    }
210                }
211                best_rgb
212            })
213            .collect();
214
215        // 5. Zeng Palette Reordering for maximum LZW compressibility
216        let (final_palette, index_mapping) = zeng::reorder_palette(&Palette {
217            colors: rgb_centroids,
218        });
219
220        Ok(QuantizationResult {
221            palette: final_palette,
222            index_mapping,
223        })
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_kmeans_basic() {
233        let pixels = vec![
234            Rgb { r: 255, g: 0, b: 0 },
235            Rgb { r: 254, g: 1, b: 0 },
236            Rgb { r: 0, g: 255, b: 0 },
237            Rgb { r: 1, g: 254, b: 0 },
238        ];
239        let mut quantizer = KMeansQuantizer::new(10);
240        quantizer.sample_rate = 1;
241        let result = quantizer.quantize(&pixels, 2).unwrap();
242        assert_eq!(result.palette.colors.len(), 2);
243    }
244}