Skip to main content

a_sixel/
lib.rs

1//! A sixel library for encoding images.
2//!
3//! ## Basic Usage
4//!
5//! ### Simple Encoding
6//!
7//! ```rust
8//! use a_sixel::PaletteBuilder;
9//! use a_sixel::SixelEncoder;
10//! use a_sixel::dither::Dither;
11//! use image::RgbaImage;
12//!
13//! let img = RgbaImage::new(100, 100);
14//! println!(
15//!     "{}",
16//!     SixelEncoder::new(PaletteBuilder::BitMergeBest, Dither::Sierra).encode(img)
17//! );
18//! ```
19//!
20//! ### Loading and Encoding an Image File
21//!
22//! ```rust
23//! use a_sixel::PaletteBuilder;
24//! use a_sixel::SixelEncoder;
25//! use a_sixel::dither::Dither;
26//!
27//! // Load an image from file
28//! let image = image::open("examples/transparent.png").unwrap().to_rgba8();
29//!
30//! // Encode with 256 colors and Sierra dithering
31//! let encoder = SixelEncoder::new(PaletteBuilder::KMeans, Dither::Sierra);
32//! let sixel_output = encoder.encode(image);
33//! println!("{}", sixel_output);
34//! ```
35//!
36//! ### Custom Palette Size and Dithering
37//!
38//! ```rust
39//! use a_sixel::PaletteBuilder;
40//! use a_sixel::SixelEncoder;
41//! use a_sixel::dither::Dither;
42//!
43//! let image = image::open("examples/transparent.png").unwrap().to_rgba8();
44//!
45//! // Use 16 colors with no dithering for faster encoding
46//! let encoder = SixelEncoder::new(PaletteBuilder::Bit, Dither::None);
47//! let sixel_output = encoder.encode_with_palette_size(image, 16);
48//! println!("{}", sixel_output);
49//! ```
50//!
51//! ## Transparency
52//! By default, `a-sixel` handles transparency by setting any fully-transparent
53//! pixels to all-bits-zero. This translates to a transparent pixel in most
54//! sixel implementations, but some terminals may not support this.
55//!
56//! Sixel does not natively support partial transparency, but this library does
57//! have some support for rendering images as if partial transparency was
58//! supported. If the `partial-transparency` feature is enabled, `a-sixel` will
59//! query the terminal and attempt to determine the background color. Partially
60//! transparent pixels will then be blended with this background color before
61//! encoding. Note that with this approach, changing the terminal background
62//! color will not update partially transparent pixels to match. You will need
63//! to re-encode the image if the background color changes.
64//!
65//!
66//! ## Choosing a `PaletteBuilder`
67//! - I want good quality:
68//!   - Use [`PaletteBuilder::BitMergeBest`] or [`PaletteBuilder::KMeans`].
69//! - I'm time constrained:
70//!   - Use [`PaletteBuilder::BitMergeLow`], [`PaletteBuilder::Bit`], or
71//!     [`PaletteBuilder::Octree`].
72//! - I'm _really_ time constrained and can sacrifice a little quality:
73//!   - Use [`PaletteBuilder::Bit`] with [`Dither::None`](dither::Dither::None).
74//!
75//! For a more detailed breakdown, here's the encoders by average speed and
76//! quality against the test images (speed figures will vary) at 256 colors with
77//! Sierra dithering:
78//!
79//! | Algorithm        |   MSE |  DSSIM | PHash Distance | Mean ΔE | Max ΔE | ΔE >2.3 | ΔE >5.0 | Execution Time (ms) |
80//! | :--------------- | ----: | -----: | -------------: | ------: | -----: | ------: | ------: | ------------------: |
81//! | adu              | 15.04 | 0.0052 |           8.86 |    1.79 |  12.80 |   31.8% |    4.4% |                1448 |
82//! | bit              | 35.82 | 0.0132 |          31.14 |    3.16 |  11.03 |   64.5% |   15.1% |                 468 |
83//! | bit-merge-low    | 10.67 | 0.0038 |          13.97 |    1.95 |   9.98 |   32.4% |    2.2% |                 855 |
84//! | bit-merge        | 10.37 | 0.0037 |          13.48 |    1.89 |  10.03 |   31.0% |    2.2% |                1034 |
85//! | bit-merge-better | 10.30 | 0.0037 |          13.07 |    1.85 |  10.22 |   30.6% |    2.2% |                1301 |
86//! | bit-merge-best   | 10.28 | 0.0037 |          13.59 |    1.83 |  10.20 |   30.5% |    2.2% |                1532 |
87//! | focal            | 31.10 | 0.0091 |          19.72 |    3.34 |   8.41 |   73.9% |   13.1% |                 821 |
88//! | k-means          | 10.07 | 0.0036 |          13.28 |    1.80 |  10.14 |   29.1% |    2.2% |                3175 |
89//! | k-medians        | 17.22 | 0.0067 |          19.10 |    2.56 |   9.98 |   50.8% |    4.7% |                9088 |
90//! | median-cut       | 19.64 | 0.0059 |          16.45 |    2.24 |  10.36 |   42.2% |    5.9% |                 740 |
91//! | octree           | 54.48 | 0.0148 |          26.03 |    3.89 |  12.49 |   78.6% |   25.4% |                 754 |
92//! | wu               | 17.89 | 0.0068 |          21.03 |    2.34 |  10.24 |   46.3% |    5.1% |                1984 |
93//!
94//! **Note:** Execution time _includes_ the time taken to compute error
95//! statistics - this is non-trivial. For example, exclusive of error statistics
96//! computation, bit-no-dither takes <100ms on average. Performance figures will
97//! vary based on machine, etc. They are only useful for comparing algorithms
98//! against each other within this dataset.
99//!
100//! Here's the encoders at 16 colors with Sierra dithering:
101//!
102//! | Algorithm        |    MSE |  DSSIM | PHash Distance | Mean ΔE | Max ΔE | ΔE >2.3 | ΔE >5.0 | Execution Time (ms) |
103//! | :--------------- | -----: | -----: | -------------: | ------: | -----: | ------: | ------: | ------------------: |
104//! | adu              | 118.90 | 0.0364 |          40.86 |    4.04 |  18.38 |   65.7% |   33.8% |                 357 |
105//! | bit              | 178.47 | 0.0490 |          59.79 |    5.53 |  16.61 |   89.0% |   51.4% |                 325 |
106//! | bit-merge-low    |  95.61 | 0.0302 |          41.59 |    3.97 |  16.26 |   67.4% |   31.4% |                 631 |
107//! | bit-merge        |  94.53 | 0.0302 |          41.48 |    3.95 |  16.15 |   67.0% |   31.3% |                 800 |
108//! | bit-merge-better |  96.11 | 0.0299 |          41.55 |    3.96 |  16.17 |   67.9% |   31.4% |                1078 |
109//! | bit-merge-best   |  95.44 | 0.0297 |          41.69 |    3.96 |  16.46 |   67.0% |   31.4% |                1297 |
110//! | focal            | 345.08 | 0.0585 |          56.66 |    7.23 |  16.65 |   95.6% |   74.8% |                 433 |
111//! | k-means          |  99.36 | 0.0309 |          42.83 |    3.99 |  16.39 |   66.9% |   31.4% |                 702 |
112//! | k-medians        | 173.95 | 0.0533 |          59.62 |    5.57 |  16.23 |   90.8% |   49.7% |                7255 |
113//! | median-cut       | 164.52 | 0.0374 |          45.28 |    4.68 |  16.72 |   73.7% |   42.3% |                 395 |
114//! | octree           | 459.37 | 0.0845 |          75.03 |    7.69 |  18.87 |   98.3% |   73.5% |                 477 |
115//! | wu               | 125.84 | 0.0386 |          50.52 |    4.48 |  16.70 |   74.5% |   39.2% |                 929 |
116#![cfg_attr(all(doc, ENABLE_DOC_AUTO_CFG), feature(doc_cfg))]
117
118#[cfg(feature = "adu")]
119pub mod adu;
120pub mod bit;
121#[cfg(feature = "bit-merge")]
122pub mod bitmerge;
123pub mod dither;
124#[cfg(feature = "focal")]
125pub mod focal;
126#[cfg(feature = "k-means")]
127pub mod kmeans;
128#[cfg(feature = "k-medians")]
129pub mod kmedians;
130#[cfg(feature = "median-cut")]
131pub mod median_cut;
132#[cfg(feature = "octree")]
133pub mod octree;
134#[cfg(feature = "wu")]
135pub mod wu;
136
137use image::Rgba;
138use image::RgbaImage;
139use palette::Hsl;
140use palette::IntoColor;
141use palette::Lab;
142use palette::encoding::Srgb;
143use rayon::iter::IndexedParallelIterator;
144use rayon::iter::IntoParallelRefMutIterator;
145use rayon::iter::ParallelIterator;
146use rayon::slice::ParallelSlice;
147#[cfg(feature = "strum")]
148use strum::Display;
149#[cfg(feature = "strum")]
150use strum::EnumString;
151
152/// The palette building algorithm to use for color quantization.
153///
154/// # Choosing an algorithm
155/// - [`PaletteBuilder::BitMergeBest`] or [`PaletteBuilder::KMeans`] for best
156///   quality.
157/// - [`PaletteBuilder::BitMergeLow`], [`PaletteBuilder::Bit`], or
158///   [`PaletteBuilder::Octree`] for speed.
159/// - [`PaletteBuilder::Bit`] combined with
160///   [`Dither::None`](dither::Dither::None) for maximum speed.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162#[cfg_attr(feature = "strum", derive(EnumString, Display))]
163#[cfg_attr(
164    feature = "strum",
165    strum(ascii_case_insensitive, serialize_all = "kebab-case")
166)]
167#[non_exhaustive]
168pub enum PaletteBuilder {
169    /// Adaptive Distributive Units algorithm.
170    #[cfg(feature = "adu")]
171    Adu,
172    /// Fast bit-dilation bucketing.
173    Bit,
174    /// Bit bucketing + k-means + agglomerative merge (low quality preset).
175    #[cfg(feature = "bit-merge")]
176    BitMergeLow,
177    /// Bit bucketing + k-means + agglomerative merge (default preset).
178    #[cfg(feature = "bit-merge")]
179    BitMerge,
180    /// Bit bucketing + k-means + agglomerative merge (better quality preset).
181    #[cfg(feature = "bit-merge")]
182    BitMergeBetter,
183    /// Bit bucketing + k-means + agglomerative merge (best quality preset).
184    #[cfg(feature = "bit-merge")]
185    BitMergeBest,
186    /// Spectral-residual peak isolation algorithm.
187    #[cfg(feature = "focal")]
188    Focal,
189    /// K-means clustering.
190    #[cfg(feature = "k-means")]
191    KMeans,
192    /// K-medians clustering.
193    #[cfg(feature = "k-medians")]
194    KMedians,
195    /// Median cut quantization.
196    #[cfg(feature = "median-cut")]
197    MedianCut,
198    /// Octree quantization (max-heap merging).
199    #[cfg(feature = "octree")]
200    Octree,
201    /// Octree quantization (min-heap merging).
202    #[cfg(feature = "octree")]
203    OctreeMinHeap,
204    /// Wu's PCA-based quantization.
205    #[cfg(feature = "wu")]
206    Wu,
207}
208
209impl PaletteBuilder {
210    #[cfg(feature = "dump-image")]
211    fn name(&self) -> &'static str {
212        match self {
213            #[cfg(feature = "adu")]
214            Self::Adu => "ADU",
215            Self::Bit => "Bit",
216            #[cfg(feature = "bit-merge")]
217            Self::BitMergeLow | Self::BitMerge | Self::BitMergeBetter | Self::BitMergeBest => {
218                "Bit-Merge"
219            }
220            #[cfg(feature = "focal")]
221            Self::Focal => "Focal",
222            #[cfg(feature = "k-means")]
223            Self::KMeans => "K-Means",
224            #[cfg(feature = "k-medians")]
225            Self::KMedians => "K-Medians",
226            #[cfg(feature = "median-cut")]
227            Self::MedianCut => "Median-Cut",
228            #[cfg(feature = "octree")]
229            Self::Octree | Self::OctreeMinHeap => "Octree",
230            #[cfg(feature = "wu")]
231            Self::Wu => "Wu",
232        }
233    }
234
235    fn build_palette(
236        &self,
237        image: &RgbaImage,
238        palette_size: usize,
239    ) -> Vec<Lab> {
240        match self {
241            #[cfg(feature = "adu")]
242            Self::Adu => adu::ADUPaletteBuilder::build_palette(image, palette_size),
243            Self::Bit => bit::BitPaletteBuilder::build_palette(image, palette_size),
244            #[cfg(feature = "bit-merge")]
245            Self::BitMergeLow => {
246                bitmerge::BitMergePaletteBuilder::<{ 1 << 14 }>::build_palette(image, palette_size)
247            }
248            #[cfg(feature = "bit-merge")]
249            Self::BitMerge => {
250                <bitmerge::BitMergePaletteBuilder>::build_palette(image, palette_size)
251            }
252            #[cfg(feature = "bit-merge")]
253            Self::BitMergeBetter => {
254                bitmerge::BitMergePaletteBuilder::<{ 1 << 20 }>::build_palette(image, palette_size)
255            }
256            #[cfg(feature = "bit-merge")]
257            Self::BitMergeBest => {
258                bitmerge::BitMergePaletteBuilder::<{ 1 << 21 }>::build_palette(image, palette_size)
259            }
260            #[cfg(feature = "focal")]
261            Self::Focal => focal::FocalPaletteBuilder::build_palette(image, palette_size),
262            #[cfg(feature = "k-means")]
263            Self::KMeans => kmeans::KMeansPaletteBuilder::build_palette(image, palette_size),
264            #[cfg(feature = "k-medians")]
265            Self::KMedians => kmedians::KMediansPaletteBuilder::build_palette(image, palette_size),
266            #[cfg(feature = "median-cut")]
267            Self::MedianCut => {
268                median_cut::MedianCutPaletteBuilder::build_palette(image, palette_size)
269            }
270            #[cfg(feature = "octree")]
271            Self::Octree => {
272                octree::OctreePaletteBuilder::<false>::build_palette(image, palette_size)
273            }
274            #[cfg(feature = "octree")]
275            Self::OctreeMinHeap => {
276                octree::OctreePaletteBuilder::<true>::build_palette(image, palette_size)
277            }
278            #[cfg(feature = "wu")]
279            Self::Wu => wu::WuPaletteBuilder::build_palette(image, palette_size),
280        }
281    }
282
283    fn build_bucketer(
284        &self,
285        palette: &[Lab],
286        palette_size: usize,
287    ) -> dither::PaletteBucketer {
288        match self {
289            Self::Bit => dither::PaletteBucketer::Bit(bit::BitPaletteBuilder::build_bucketer(
290                palette,
291                palette_size,
292            )),
293            _ => dither::PaletteBucketer::KdTree(dither::KdTreeBucketer::new(palette)),
294        }
295    }
296}
297
298/// The main type for performing sixel encoding.
299///
300/// Combines a [`PaletteBuilder`] to generate a color palette from the input
301/// image (sixel only supports up to 256 colors) with a
302/// [`Dither`](dither::Dither) algorithm to apply dithering to the reduced color
303/// image before encoding it into sixel format.
304///
305/// # Choosing a `PaletteBuilder`
306/// - [`PaletteBuilder::BitMergeBest`] or [`PaletteBuilder::KMeans`] are good
307///   default choices for minimizing the error across the image.
308/// - [`PaletteBuilder::Focal`] is a good choice if the image has highlights and
309///   other details that other encoders might squash, but is experimental. It is
310///   a weighted k-means implementation. Depending on the image, `KMeans` may be
311///   able to capture these highlights already, but it's worth trying if you're
312///   trying to preserve specific image characteristics.
313///
314/// # Choosing a `Dither`
315/// - [`Dither::Sierra`](dither::Dither::Sierra) is a good default choice for
316///   dithering, as it produces high-quality results with minimal artifacts.
317/// - [`Dither::None`](dither::Dither::None) can be used if performance is a
318///   concern.
319#[derive(Debug, Clone, Copy)]
320pub struct SixelEncoder {
321    /// The color quantization algorithm used to reduce the image to a palette.
322    pub algorithm: PaletteBuilder,
323    /// The dithering algorithm applied after quantization.
324    pub dither: dither::Dither,
325}
326
327impl Default for SixelEncoder {
328    fn default() -> Self {
329        Self {
330            algorithm: PaletteBuilder::Bit,
331            dither: dither::Dither::Sierra,
332        }
333    }
334}
335
336impl SixelEncoder {
337    /// Create a new encoder with the given palette-building algorithm and
338    /// dithering mode.
339    pub fn new(
340        algorithm: PaletteBuilder,
341        dither: dither::Dither,
342    ) -> Self {
343        Self { algorithm, dither }
344    }
345
346    /// Encode an RGBA image into sixel format with the default palette size of
347    /// 256 colors.
348    ///
349    /// This is a convenience method that calls
350    /// [`encode_with_palette_size`](Self::encode_with_palette_size)
351    /// with a palette size of 256.
352    ///
353    /// # Arguments
354    ///
355    /// * `rgba` - An RGBA image to encode. The alpha channel is used for
356    ///   transparency handling.
357    ///
358    /// # Returns
359    ///
360    /// A `String` containing the sixel-encoded image data, ready to be printed
361    /// to a sixel-capable terminal.
362    ///
363    /// # Transparency Handling
364    ///
365    /// - **Fully transparent pixels** (alpha = 0): Encoded as transparent sixel
366    ///   pixels
367    /// - **Partially transparent pixels**: If the `partial-transparency`
368    ///   feature is enabled, these are blended with the detected terminal
369    ///   background color. Otherwise, treated as opaque.
370    /// - **Opaque pixels** (alpha = 255): Encoded normally
371    pub fn encode(
372        &self,
373        rgba: RgbaImage,
374    ) -> String {
375        self.encode_with_palette_size(rgba, 256)
376    }
377
378    /// Encode an RGBA image into sixel format with a custom palette size.
379    ///
380    /// This method provides full control over the color quantization process by
381    /// allowing you to specify the exact number of colors in the resulting
382    /// palette. The palette size directly affects both the quality and size
383    /// of the output.
384    ///
385    /// # Arguments
386    ///
387    /// * `rgba` - An RGBA image to encode. The alpha channel is used for
388    ///   transparency handling.
389    /// * `palette_size` - The number of colors to use in the palette. Valid
390    ///   range is 1-256. Will be automatically clamped if the image has fewer
391    ///   unique colors.
392    ///
393    /// # Returns
394    ///
395    /// A `String` containing the sixel-encoded image data, ready to be printed
396    /// to a sixel-capable terminal.
397    ///
398    /// # Transparency Handling
399    ///
400    /// Same as [`encode`](Self::encode) - see that method's documentation for
401    /// details.
402    pub fn encode_with_palette_size(
403        &self,
404        #[allow(unused_mut)] mut image: RgbaImage,
405        palette_size: usize,
406    ) -> String {
407        #[cfg(feature = "partial-transparency")]
408        {
409            use std::sync::LazyLock;
410            use std::time::Duration;
411
412            static BG_COLOR: LazyLock<Rgba<u8>> = LazyLock::new(|| {
413                termbg::rgb(Duration::from_millis(100))
414                    .map(|rgb| {
415                        Rgba([
416                            (rgb.r as f32 / u16::MAX as f32 * u8::MAX as f32) as u8,
417                            (rgb.g as f32 / u16::MAX as f32 * u8::MAX as f32) as u8,
418                            (rgb.b as f32 / u16::MAX as f32 * u8::MAX as f32) as u8,
419                            u8::MAX,
420                        ])
421                    })
422                    .unwrap_or(Rgba([0, 0, 0, u8::MAX]))
423            });
424
425            image.par_pixels_mut().for_each(|pixel| {
426                use image::Pixel;
427                use image::Rgba;
428
429                let mut color = Rgba([BG_COLOR[0], BG_COLOR[1], BG_COLOR[2], pixel[3]]);
430                color.blend(pixel);
431                *pixel = color;
432            });
433        }
434        let palette = if image.width().saturating_mul(image.height()) < palette_size as u32 {
435            image.pixels().copied().map(rgba_to_lab).collect::<Vec<_>>()
436        } else {
437            self.algorithm.build_palette(&image, palette_size)
438        };
439
440        let mut sixel_string = r#"P9;1q"1;1;"#.to_string();
441        push_usize(&mut sixel_string, image.width() as usize);
442        sixel_string.push(';');
443        push_usize(&mut sixel_string, image.height() as usize);
444
445        if image.width() > 0 && image.height() > 0 {
446            for (i, lab) in palette.iter().copied().enumerate() {
447                let hsl: Hsl = lab.into_color();
448                // Sixel hue is offset by 120 degrees from the common hue values.
449                let deg = (hsl.hue.into_positive_degrees().round() as u16 + 120) % 360;
450
451                sixel_string.push('#');
452                push_usize(&mut sixel_string, i);
453                sixel_string.push_str(";1;");
454                push_usize(&mut sixel_string, deg as usize);
455                sixel_string.push(';');
456                push_usize(&mut sixel_string, (hsl.lightness * 100.0).round() as usize);
457                sixel_string.push(';');
458                push_usize(&mut sixel_string, (hsl.saturation * 100.0).round() as usize);
459            }
460
461            let bucketer = self.algorithm.build_bucketer(&palette, palette_size);
462            let paletted_pixels = self
463                .dither
464                .dither_and_palettize(&image, &palette, &bucketer);
465
466            #[cfg(feature = "dump-mse")]
467            {
468                use rayon::iter::IntoParallelRefIterator;
469
470                let dequant = paletted_pixels
471                    .iter()
472                    .map(|&idx| palette[idx])
473                    .collect::<Vec<_>>();
474                let mse = dequant
475                    .par_iter()
476                    .zip(image.par_pixels())
477                    .map(|(l, rgb)| {
478                        use palette::color_difference::EuclideanDistance;
479                        let lab = rgba_to_lab(*rgb);
480                        lab.distance_squared(*l)
481                    })
482                    .sum::<f32>()
483                    / (image.width() * image.height()) as f32;
484
485                println!("MSE: {:.2} ({} colors)", mse, palette_size);
486            }
487
488            #[cfg(feature = "dump-delta-e")]
489            {
490                use rayon::iter::IntoParallelRefIterator;
491
492                let dequant = paletted_pixels
493                    .iter()
494                    .map(|&idx| palette[idx])
495                    .collect::<Vec<_>>();
496                let differences = image
497                    .par_pixels()
498                    .copied()
499                    .zip(dequant.par_iter())
500                    .map(|(rgb, lab)| {
501                        use palette::color_difference::ImprovedCiede2000;
502                        let lab_rgb = rgba_to_lab(rgb);
503                        lab_rgb.improved_difference(*lab)
504                    })
505                    .collect::<Vec<_>>();
506
507                let mean_diff =
508                    differences.iter().sum::<f32>() / (image.width() * image.height()) as f32;
509                let max_diff = differences.iter().copied().fold(0.0, f32::max);
510                let two_three_threshold = differences.iter().copied().filter(|d| *d > 2.3).count()
511                    as f32
512                    / (image.width() * image.height()) as f32;
513                let five_threshold = differences.iter().copied().filter(|d| *d > 5.0).count()
514                    as f32
515                    / (image.width() * image.height()) as f32;
516
517                println!("Mean DeltaE: {:.2} ({} colors)", mean_diff, palette_size);
518                println!("Max DeltaE: {:.2} ({} colors)", max_diff, palette_size);
519                println!(
520                    "DeltaE > 2.3: {:.2} ({} colors)",
521                    two_three_threshold, palette_size
522                );
523                println!(
524                    "DeltaE > 5.0: {:.2} ({} colors)",
525                    five_threshold, palette_size
526                );
527            }
528
529            #[cfg(feature = "dump-dssim")]
530            {
531                use dssim_core::Dssim;
532
533                let dssim = Dssim::new();
534                let image_pixels = image
535                    .pixels()
536                    .copied()
537                    .map(|Rgba([r, g, b, _])| rgb::RGB::new(r, g, b))
538                    .collect::<Vec<_>>();
539                let orig = dssim
540                    .create_image_rgb(
541                        &image_pixels,
542                        image.width() as usize,
543                        image.height() as usize,
544                    )
545                    .unwrap();
546
547                let palette_pixels = paletted_pixels
548                    .iter()
549                    .map(|&idx| {
550                        let lab = palette[idx];
551                        let rgb: palette::Srgb = lab.into_color();
552                        let rgb = rgb.into_format::<u8>();
553                        rgb::RGB::new(rgb.red, rgb.green, rgb.blue)
554                    })
555                    .collect::<Vec<_>>();
556                let new = dssim
557                    .create_image_rgb(
558                        &palette_pixels,
559                        image.width() as usize,
560                        image.height() as usize,
561                    )
562                    .unwrap();
563
564                let (dssim, _) = dssim.compare(&orig, &new);
565
566                println!("DSSIM: {:.4} ({} colors)", dssim, palette_size);
567            }
568
569            #[cfg(feature = "dump-phash")]
570            {
571                use image_hasher::FilterType;
572                use image_hasher::HashAlg;
573                use image_hasher::HasherConfig;
574
575                let mut output_image = image::ImageBuffer::new(image.width(), image.height());
576                for (pixel, &idx) in output_image.pixels_mut().zip(&paletted_pixels) {
577                    let lab = palette[idx];
578                    let rgb: palette::Srgb = lab.into_color();
579                    let rgb = rgb.into_format::<u8>();
580                    *pixel = Rgba([rgb.red, rgb.green, rgb.blue, u8::MAX]);
581                }
582
583                let hasher = HasherConfig::new()
584                    .hash_alg(HashAlg::DoubleGradient)
585                    .resize_filter(FilterType::Lanczos3)
586                    .hash_size(32, 32)
587                    .to_hasher();
588
589                let hash_in = hasher.hash_image(&image);
590                let hash_out = hasher.hash_image(&output_image);
591
592                println!(
593                    "Hash Distance: {} ({} colors)",
594                    hash_in.dist(&hash_out),
595                    palette_size
596                );
597            }
598
599            #[cfg(feature = "dump-image")]
600            {
601                use std::hash::BuildHasher;
602                use std::hash::Hasher;
603                use std::hash::RandomState;
604
605                let mut output_image = image::ImageBuffer::new(image.width(), image.height());
606                for (pixel, &idx) in output_image.pixels_mut().zip(&paletted_pixels) {
607                    let lab = palette[idx];
608                    let rgb: palette::Srgb = lab.into_color();
609                    let rgb = rgb.into_format::<u8>();
610                    *pixel = Rgba([rgb.red, rgb.green, rgb.blue, u8::MAX]);
611                }
612                let rand = BuildHasher::build_hasher(&RandomState::new()).finish();
613
614                output_image
615                    .save(format!("{}-{rand}.png", self.algorithm.name()))
616                    .expect("Failed to save output image");
617            }
618
619            let width = image.width() as usize;
620
621            let num_chunks = (paletted_pixels.len() / width).div_ceil(6);
622            let chunk_capacity = width * 7;
623            let mut strings =
624                Vec::from_iter((0..num_chunks).map(|_| String::with_capacity(chunk_capacity)));
625
626            paletted_pixels
627                .par_chunks(width * 6)
628                .zip(image.into_raw().par_chunks(width * 6 * 4))
629                .zip(&mut strings)
630                .for_each(|((palette_chunk, rgba_chunk), sixel_string)| {
631                    let mut color_bits = vec![0u8; palette_size * width];
632                    let mut color_used = vec![false; palette_size];
633                    let chunk_height = palette_chunk.len() / width;
634
635                    for row in 0..chunk_height {
636                        let bit = 1u8 << row;
637                        let row_offset = row * width;
638                        for col in 0..width {
639                            let pixel_idx = row_offset + col;
640                            if rgba_chunk[pixel_idx * 4 + 3] != 0 {
641                                let color = palette_chunk[pixel_idx];
642                                color_bits[color * width + col] |= bit;
643                                color_used[color] = true;
644                            }
645                        }
646                    }
647
648                    color_bits.par_iter_mut().for_each(|d| {
649                        *d += 0x3f;
650                    });
651                    // SAFETY: 0x3f..=0x7e are valid ASCII bytes
652                    let color_bits = unsafe { String::from_utf8_unchecked(color_bits) };
653
654                    for (color, _) in color_used.iter().enumerate().filter(|(_, u)| **u) {
655                        sixel_string.push('#');
656                        push_usize(sixel_string, color);
657                        let base = color * width;
658                        sixel_string.push_str(&color_bits[base..base + width]);
659                        sixel_string.push('$');
660                    }
661                    sixel_string.push('-');
662                });
663
664            sixel_string.extend(strings);
665        }
666
667        sixel_string.push_str(r#"\"#);
668        sixel_string
669    }
670}
671
672fn rgba_to_lab(Rgba([r, g, b, _]): Rgba<u8>) -> Lab {
673    palette::rgb::Rgb::<Srgb, _>::new(r, g, b)
674        .into_format::<f32>()
675        .into_color()
676}
677
678fn push_usize(
679    s: &mut String,
680    n: usize,
681) {
682    s.push_str(itoa::Buffer::new().format(n));
683}