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