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