Skip to main content

epaper_dithering_core/
lib.rs

1pub mod algorithms;
2pub mod color_space;
3pub mod color_space_lab;
4pub mod enums;
5pub mod error;
6pub mod measured_palettes;
7pub mod palettes;
8pub mod tone_map;
9pub mod types;
10
11use crate::color_space::{linear_channel_to_srgb, srgb_channel_to_linear};
12use crate::enums::{DitherMode, GamutCompression, ToneCompression};
13use crate::palettes::Palette;
14use crate::types::ImageBuffer;
15
16/// Configuration for `dither()`. All fields have sensible defaults.
17///
18/// Construct with struct-update syntax:
19/// ```ignore
20/// dither(&img, palette, DitherConfig { mode: DitherMode::Burkes, ..Default::default() });
21/// ```
22///
23/// Pre-processing pipeline (applied in order, each step is a no-op at its identity value):
24/// `exposure → saturation → shadows/highlights → tone → gamut → dither`.
25#[derive(Debug, Clone, PartialEq)]
26pub struct DitherConfig {
27    /// Dithering algorithm.
28    pub mode: DitherMode,
29    /// Use serpentine scanning for error diffusion (alternates row direction). Ignored for
30    /// `None` and `Ordered`.
31    pub serpentine: bool,
32    /// Linear-RGB exposure multiplier. 1.0 = no change, 2.0 = +1 stop, 0.5 = -1 stop.
33    pub exposure: f64,
34    /// OKLab saturation multiplier. 1.0 = no change, 0.0 = grayscale, >1.0 = boost.
35    /// Hue-preserving.
36    pub saturation: f64,
37    /// Shadow lift strength (lower-half S-curve). 0.0 = identity, 1.0 = strong lift.
38    pub shadows: f64,
39    /// Highlight compression strength (upper-half S-curve). 0.0 = identity, 1.0 = strong.
40    pub highlights: f64,
41    /// Dynamic-range compression. Auto = histogram-based; Fixed(s) = manual blend strength.
42    pub tone: ToneCompression,
43    /// Gamut compression. Auto = full strength on out-of-gamut; Fixed(s) = manual.
44    pub gamut: GamutCompression,
45}
46
47impl Default for DitherConfig {
48    fn default() -> Self {
49        Self {
50            mode:       DitherMode::Burkes,
51            serpentine: true,
52            exposure:   1.0,
53            saturation: 1.0,
54            shadows:    0.0,
55            highlights: 0.0,
56            tone:       ToneCompression::Fixed(0.0),
57            gamut:      GamutCompression::None,
58        }
59    }
60}
61
62fn dispatch(
63    img: &ImageBuffer,
64    p: &Palette,
65    canonical: &Palette,
66    mode: DitherMode,
67    serpentine: bool,
68    pin_exact_pixels: bool,
69) -> Vec<u8> {
70    match mode {
71        DitherMode::None => algorithms::direct_map(img.data, p, canonical),
72        DitherMode::Ordered if pin_exact_pixels => {
73            algorithms::ordered_dither_with_canonical(img.data, img.width, p, canonical)
74        }
75        DitherMode::Ordered => algorithms::ordered_dither(img.data, img.width, p),
76        DitherMode::FloydSteinberg if pin_exact_pixels => algorithms::error_diffusion_dither_with_canonical(
77            img.data,
78            img.width,
79            img.height,
80            p,
81            canonical,
82            &algorithms::FLOYD_STEINBERG,
83            serpentine,
84        ),
85        DitherMode::Burkes if pin_exact_pixels => algorithms::error_diffusion_dither_with_canonical(
86            img.data,
87            img.width,
88            img.height,
89            p,
90            canonical,
91            &algorithms::BURKES,
92            serpentine,
93        ),
94        DitherMode::Atkinson if pin_exact_pixels => algorithms::error_diffusion_dither_with_canonical(
95            img.data,
96            img.width,
97            img.height,
98            p,
99            canonical,
100            &algorithms::ATKINSON,
101            serpentine,
102        ),
103        DitherMode::Stucki if pin_exact_pixels => algorithms::error_diffusion_dither_with_canonical(
104            img.data,
105            img.width,
106            img.height,
107            p,
108            canonical,
109            &algorithms::STUCKI,
110            serpentine,
111        ),
112        DitherMode::Sierra if pin_exact_pixels => algorithms::error_diffusion_dither_with_canonical(
113            img.data,
114            img.width,
115            img.height,
116            p,
117            canonical,
118            &algorithms::SIERRA,
119            serpentine,
120        ),
121        DitherMode::SierraLite if pin_exact_pixels => algorithms::error_diffusion_dither_with_canonical(
122            img.data,
123            img.width,
124            img.height,
125            p,
126            canonical,
127            &algorithms::SIERRA_LITE,
128            serpentine,
129        ),
130        DitherMode::JarvisJudiceNinke if pin_exact_pixels => {
131            algorithms::error_diffusion_dither_with_canonical(
132                img.data,
133                img.width,
134                img.height,
135                p,
136                canonical,
137                &algorithms::JARVIS_JUDICE_NINKE,
138                serpentine,
139            )
140        }
141        DitherMode::FloydSteinberg => algorithms::error_diffusion_dither(
142            img.data,
143            img.width,
144            img.height,
145            p,
146            &algorithms::FLOYD_STEINBERG,
147            serpentine,
148        ),
149        DitherMode::Burkes => algorithms::error_diffusion_dither(
150            img.data,
151            img.width,
152            img.height,
153            p,
154            &algorithms::BURKES,
155            serpentine,
156        ),
157        DitherMode::Atkinson => algorithms::error_diffusion_dither(
158            img.data,
159            img.width,
160            img.height,
161            p,
162            &algorithms::ATKINSON,
163            serpentine,
164        ),
165        DitherMode::Stucki => algorithms::error_diffusion_dither(
166            img.data,
167            img.width,
168            img.height,
169            p,
170            &algorithms::STUCKI,
171            serpentine,
172        ),
173        DitherMode::Sierra => algorithms::error_diffusion_dither(
174            img.data,
175            img.width,
176            img.height,
177            p,
178            &algorithms::SIERRA,
179            serpentine,
180        ),
181        DitherMode::SierraLite => algorithms::error_diffusion_dither(
182            img.data,
183            img.width,
184            img.height,
185            p,
186            &algorithms::SIERRA_LITE,
187            serpentine,
188        ),
189        DitherMode::JarvisJudiceNinke => algorithms::error_diffusion_dither(
190            img.data,
191            img.width,
192            img.height,
193            p,
194            &algorithms::JARVIS_JUDICE_NINKE,
195            serpentine,
196        ),
197    }
198}
199
200fn needs_preprocess(c: &DitherConfig) -> bool {
201    (c.exposure - 1.0).abs() > 1e-9
202        || (c.saturation - 1.0).abs() > 1e-9
203        || c.shadows > 0.0
204        || c.highlights > 0.0
205        || !matches!(c.tone, ToneCompression::Fixed(s) if s <= 0.0)
206        || !matches!(c.gamut, GamutCompression::None)
207}
208
209/// Dither an image for e-paper display.
210///
211/// Returns palette indices (one `u8` per pixel, length = `width × height`).
212pub fn dither(img: &ImageBuffer, palette: impl AsRef<Palette>, config: DitherConfig) -> Vec<u8> {
213    let p = palette.as_ref();
214    dither_impl(img, p, p, config, false)
215}
216
217/// Dither an image using one palette for color matching and another for exact
218/// already-displayable RGB passthrough.
219///
220/// Measured palettes should be supplied as `matching_palette`; the ideal display
221/// color scheme should be supplied as `canonical_palette`.
222pub fn dither_with_canonical(
223    img: &ImageBuffer,
224    matching_palette: impl AsRef<Palette>,
225    canonical_palette: impl AsRef<Palette>,
226    config: DitherConfig,
227) -> Vec<u8> {
228    let p = matching_palette.as_ref();
229    let canonical = canonical_palette.as_ref();
230    dither_impl(img, p, canonical, config, true)
231}
232
233fn dither_impl(
234    img: &ImageBuffer,
235    p: &Palette,
236    canonical: &Palette,
237    config: DitherConfig,
238    pin_exact_pixels: bool,
239) -> Vec<u8> {
240    if !needs_preprocess(&config) {
241        if config.mode != DitherMode::None
242            && let Some(indices) = algorithms::try_exact_palette_map(img.data, canonical)
243        {
244            return indices;
245        }
246        return dispatch(img, p, canonical, config.mode, config.serpentine, pin_exact_pixels);
247    }
248
249    // Convert sRGB bytes → linear, apply pre-processing pipeline, convert back.
250    let mut linear: Vec<[f64; 3]> = img
251        .data
252        .chunks_exact(3)
253        .map(|c| [
254            srgb_channel_to_linear(c[0]),
255            srgb_channel_to_linear(c[1]),
256            srgb_channel_to_linear(c[2]),
257        ])
258        .collect();
259
260    tone_map::apply_exposure(&mut linear, config.exposure);
261    tone_map::adjust_saturation(&mut linear, config.saturation);
262    tone_map::apply_shadows_highlights(&mut linear, config.shadows, config.highlights);
263    config.tone.apply(&mut linear, p);
264    config.gamut.apply(&mut linear, p);
265
266    let processed: Vec<u8> = linear
267        .iter()
268        .flat_map(|&[r, g, b]| [
269            linear_channel_to_srgb(r),
270            linear_channel_to_srgb(g),
271            linear_channel_to_srgb(b),
272        ])
273        .collect();
274
275    let processed_img = ImageBuffer::new(&processed, img.width);
276    dispatch(&processed_img, p, canonical, config.mode, config.serpentine, pin_exact_pixels)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::measured_palettes::SPECTRA_7_3_6COLOR;
283    use crate::palettes::ColorScheme;
284
285    fn pixels(rgb: [u8; 3], count: usize) -> Vec<u8> {
286        std::iter::repeat_n(rgb, count).flatten().collect()
287    }
288
289    #[test]
290    fn default_config_has_preprocessing_off() {
291        let config = DitherConfig::default();
292        assert!(matches!(config.tone, ToneCompression::Fixed(s) if s == 0.0));
293        assert_eq!(config.gamut, GamutCompression::None);
294        assert!(!needs_preprocess(&config));
295    }
296
297    #[test]
298    fn none_uses_canonical_exact_colors_with_measured_palette() {
299        let image = pixels([255, 0, 0], 4);
300        let img = ImageBuffer::new(&image, 2);
301        let output = dither_with_canonical(
302            &img,
303            &SPECTRA_7_3_6COLOR,
304            ColorScheme::Bwgbry.palette(),
305            DitherConfig { mode: DitherMode::None, ..Default::default() },
306        );
307        assert_eq!(output, vec![3, 3, 3, 3]);
308    }
309
310    #[test]
311    fn exact_canonical_colors_bypass_error_diffusion() {
312        let image = [
313            [0, 0, 0],
314            [255, 255, 255],
315            [255, 255, 0],
316            [255, 0, 0],
317        ]
318        .into_iter()
319        .flatten()
320        .collect::<Vec<_>>();
321        let img = ImageBuffer::new(&image, 2);
322        let output = dither_with_canonical(
323            &img,
324            &SPECTRA_7_3_6COLOR,
325            ColorScheme::Bwry.palette(),
326            DitherConfig { mode: DitherMode::Burkes, ..Default::default() },
327        );
328        assert_eq!(output, vec![0, 1, 2, 3]);
329    }
330
331    #[test]
332    fn exact_canonical_pixels_are_pinned_inside_mixed_error_diffusion_image() {
333        let mut image = pixels([128, 128, 128], 8);
334        image[0..3].copy_from_slice(&[0, 255, 0]);
335        image[9..12].copy_from_slice(&[0, 255, 0]);
336        let img = ImageBuffer::new(&image, 4);
337
338        for mode in [
339            DitherMode::Burkes,
340            DitherMode::FloydSteinberg,
341            DitherMode::Atkinson,
342            DitherMode::Stucki,
343            DitherMode::Sierra,
344            DitherMode::SierraLite,
345            DitherMode::JarvisJudiceNinke,
346        ] {
347            let output = dither_with_canonical(
348                &img,
349                &SPECTRA_7_3_6COLOR,
350                ColorScheme::Bwgbry.palette(),
351                DitherConfig { mode, ..Default::default() },
352            );
353            assert_eq!(output[0], 5, "{mode:?} should pin exact green at pixel 0");
354            assert_eq!(output[3], 5, "{mode:?} should pin exact green at pixel 3");
355        }
356    }
357
358    #[test]
359    fn exact_canonical_pixels_are_pinned_inside_mixed_ordered_image() {
360        let mut image = pixels([128, 128, 128], 8);
361        image[0..3].copy_from_slice(&[0, 255, 0]);
362        image[9..12].copy_from_slice(&[0, 255, 0]);
363        let img = ImageBuffer::new(&image, 4);
364
365        let output = dither_with_canonical(
366            &img,
367            &SPECTRA_7_3_6COLOR,
368            ColorScheme::Bwgbry.palette(),
369            DitherConfig { mode: DitherMode::Ordered, ..Default::default() },
370        );
371        assert_eq!(output[0], 5);
372        assert_eq!(output[3], 5);
373    }
374
375    #[test]
376    fn exact_bypass_is_skipped_when_preprocessing_is_enabled() {
377        let image = pixels([255, 0, 0], 4);
378        let img = ImageBuffer::new(&image, 2);
379        let output = dither_with_canonical(
380            &img,
381            &SPECTRA_7_3_6COLOR,
382            ColorScheme::Bwgbry.palette(),
383            DitherConfig {
384                mode: DitherMode::Burkes,
385                tone: ToneCompression::Fixed(1.0),
386                ..Default::default()
387            },
388        );
389        assert_eq!(output.len(), 4);
390    }
391}