Skip to main content

epaper_dithering_core/
algorithms.rs

1//! Error diffusion and ordered dithering on raw RGB pixel buffers.
2
3use crate::color_space::{srgb_channel_to_linear, srgb_fraction_to_linear};
4use crate::color_space_lab::{PaletteLab, match_pixel_oklab, rgb_to_oklab, WAB};
5use crate::palettes::Palette;
6use rayon::prelude::*;
7
8// ── Kernel definition ─────────────────────────────────────────────────────────
9
10/// Pixel offset + pre-divided weight for one kernel entry.
11pub struct KernelOffset {
12    pub dx: i32,
13    pub dy: i32,
14    pub weight: f64,
15}
16
17pub struct Kernel {
18    pub offsets: &'static [KernelOffset],
19}
20
21// ── Kernel constants ──────────────────────────────────────────────────────────
22
23pub static FLOYD_STEINBERG: Kernel = Kernel {
24    offsets: &[
25        KernelOffset { dx:  1, dy: 0, weight: 7.0 / 16.0 },
26        KernelOffset { dx: -1, dy: 1, weight: 3.0 / 16.0 },
27        KernelOffset { dx:  0, dy: 1, weight: 5.0 / 16.0 },
28        KernelOffset { dx:  1, dy: 1, weight: 1.0 / 16.0 },
29    ],
30};
31
32pub static ATKINSON: Kernel = Kernel {
33    offsets: &[
34        KernelOffset { dx:  1, dy: 0, weight: 1.0 / 8.0 },
35        KernelOffset { dx:  2, dy: 0, weight: 1.0 / 8.0 },
36        KernelOffset { dx: -1, dy: 1, weight: 1.0 / 8.0 },
37        KernelOffset { dx:  0, dy: 1, weight: 1.0 / 8.0 },
38        KernelOffset { dx:  1, dy: 1, weight: 1.0 / 8.0 },
39        KernelOffset { dx:  0, dy: 2, weight: 1.0 / 8.0 },
40    ],
41};
42
43pub static BURKES: Kernel = Kernel {
44    offsets: &[
45        KernelOffset { dx:  1, dy: 0, weight: 8.0 / 32.0 },
46        KernelOffset { dx:  2, dy: 0, weight: 4.0 / 32.0 },
47        KernelOffset { dx: -2, dy: 1, weight: 2.0 / 32.0 },
48        KernelOffset { dx: -1, dy: 1, weight: 4.0 / 32.0 },
49        KernelOffset { dx:  0, dy: 1, weight: 8.0 / 32.0 },
50        KernelOffset { dx:  1, dy: 1, weight: 4.0 / 32.0 },
51        KernelOffset { dx:  2, dy: 1, weight: 2.0 / 32.0 },
52    ],
53};
54
55pub static STUCKI: Kernel = Kernel {
56    offsets: &[
57        KernelOffset { dx:  1, dy: 0, weight: 8.0 / 42.0 },
58        KernelOffset { dx:  2, dy: 0, weight: 4.0 / 42.0 },
59        KernelOffset { dx: -2, dy: 1, weight: 2.0 / 42.0 },
60        KernelOffset { dx: -1, dy: 1, weight: 4.0 / 42.0 },
61        KernelOffset { dx:  0, dy: 1, weight: 8.0 / 42.0 },
62        KernelOffset { dx:  1, dy: 1, weight: 4.0 / 42.0 },
63        KernelOffset { dx:  2, dy: 1, weight: 2.0 / 42.0 },
64        KernelOffset { dx: -2, dy: 2, weight: 1.0 / 42.0 },
65        KernelOffset { dx: -1, dy: 2, weight: 2.0 / 42.0 },
66        KernelOffset { dx:  0, dy: 2, weight: 4.0 / 42.0 },
67        KernelOffset { dx:  1, dy: 2, weight: 2.0 / 42.0 },
68        KernelOffset { dx:  2, dy: 2, weight: 1.0 / 42.0 },
69    ],
70};
71
72pub static SIERRA: Kernel = Kernel {
73    offsets: &[
74        KernelOffset { dx:  1, dy: 0, weight: 5.0 / 32.0 },
75        KernelOffset { dx:  2, dy: 0, weight: 3.0 / 32.0 },
76        KernelOffset { dx: -2, dy: 1, weight: 2.0 / 32.0 },
77        KernelOffset { dx: -1, dy: 1, weight: 4.0 / 32.0 },
78        KernelOffset { dx:  0, dy: 1, weight: 5.0 / 32.0 },
79        KernelOffset { dx:  1, dy: 1, weight: 4.0 / 32.0 },
80        KernelOffset { dx:  2, dy: 1, weight: 2.0 / 32.0 },
81        KernelOffset { dx: -1, dy: 2, weight: 2.0 / 32.0 },
82        KernelOffset { dx:  0, dy: 2, weight: 3.0 / 32.0 },
83        KernelOffset { dx:  1, dy: 2, weight: 2.0 / 32.0 },
84    ],
85};
86
87pub static SIERRA_LITE: Kernel = Kernel {
88    offsets: &[
89        KernelOffset { dx:  1, dy: 0, weight: 2.0 / 4.0 },
90        KernelOffset { dx: -1, dy: 1, weight: 1.0 / 4.0 },
91        KernelOffset { dx:  0, dy: 1, weight: 1.0 / 4.0 },
92    ],
93};
94
95pub static JARVIS_JUDICE_NINKE: Kernel = Kernel {
96    offsets: &[
97        KernelOffset { dx:  1, dy: 0, weight: 7.0 / 48.0 },
98        KernelOffset { dx:  2, dy: 0, weight: 5.0 / 48.0 },
99        KernelOffset { dx: -2, dy: 1, weight: 3.0 / 48.0 },
100        KernelOffset { dx: -1, dy: 1, weight: 5.0 / 48.0 },
101        KernelOffset { dx:  0, dy: 1, weight: 7.0 / 48.0 },
102        KernelOffset { dx:  1, dy: 1, weight: 5.0 / 48.0 },
103        KernelOffset { dx:  2, dy: 1, weight: 3.0 / 48.0 },
104        KernelOffset { dx: -2, dy: 2, weight: 1.0 / 48.0 },
105        KernelOffset { dx: -1, dy: 2, weight: 3.0 / 48.0 },
106        KernelOffset { dx:  0, dy: 2, weight: 5.0 / 48.0 },
107        KernelOffset { dx:  1, dy: 2, weight: 3.0 / 48.0 },
108        KernelOffset { dx:  2, dy: 2, weight: 1.0 / 48.0 },
109    ],
110};
111
112// ── Palette setup helper ─────────────────────────────────────────────────────
113
114fn build_palette_lab(palette: &Palette) -> (Vec<[f64; 3]>, PaletteLab) {
115    let palette_linear: Vec<[f64; 3]> = palette
116        .colors
117        .iter()
118        .map(|&[r, g, b]| {
119            [
120                srgb_channel_to_linear(r),
121                srgb_channel_to_linear(g),
122                srgb_channel_to_linear(b),
123            ]
124        })
125        .collect();
126    let palette_lab = PaletteLab::from_linear_rgb(&palette_linear);
127    (palette_linear, palette_lab)
128}
129
130// ── Main function ─────────────────────────────────────────────────────────────
131
132/// Error diffusion dither. Returns palette indices (len = width × height).
133pub fn error_diffusion_dither(
134    pixels: &[u8],
135    width: usize,
136    height: usize,
137    palette: &Palette,
138    kernel: &Kernel,
139    serpentine: bool,
140) -> Vec<u8> {
141    error_diffusion_dither_impl(pixels, width, height, palette, None, kernel, serpentine)
142}
143
144/// Error diffusion with exact canonical display-color pixels pinned.
145///
146/// Exact canonical pixels are already displayable, so they emit their firmware
147/// palette index directly and absorb any accumulated error instead of diffusing
148/// it into neighboring pixels.
149pub fn error_diffusion_dither_with_canonical(
150    pixels: &[u8],
151    width: usize,
152    height: usize,
153    palette: &Palette,
154    canonical_palette: &Palette,
155    kernel: &Kernel,
156    serpentine: bool,
157) -> Vec<u8> {
158    error_diffusion_dither_impl(
159        pixels,
160        width,
161        height,
162        palette,
163        Some(canonical_palette),
164        kernel,
165        serpentine,
166    )
167}
168
169fn error_diffusion_dither_impl(
170    pixels: &[u8],
171    width: usize,
172    height: usize,
173    palette: &Palette,
174    canonical_palette: Option<&Palette>,
175    kernel: &Kernel,
176    serpentine: bool,
177) -> Vec<u8> {
178    let (_palette_linear, palette_lab) = build_palette_lab(palette);
179
180    // Palette sRGB as f64 for error computation
181    let palette_srgb_f: Vec<[f64; 3]> = palette
182        .colors
183        .iter()
184        .map(|&[r, g, b]| [r as f64, g as f64, b as f64])
185        .collect();
186
187    // Working buffer in sRGB float space [0, 255]; accumulates diffused error.
188    let mut buf: Vec<f64> = pixels.iter().map(|&v| v as f64).collect();
189
190    // LUT: u8 sRGB -> linear f64 (avoids powf per pixel in the inner loop)
191    let lut: Vec<f64> = (0u8..=255).map(srgb_channel_to_linear).collect();
192
193    let mut output = vec![0u8; width * height];
194
195    for y in 0..height {
196        // Serpentine: odd rows scan right-to-left
197        let reverse = serpentine && y % 2 == 1;
198
199        for xi in 0..width {
200            let x = if reverse { width - 1 - xi } else { xi };
201            let idx = (y * width + x) * 3;
202
203            if let Some(canonical_palette) = canonical_palette
204                && let Some(exact_idx) =
205                    exact_palette_index(&pixels[idx..idx + 3], canonical_palette)
206            {
207                output[y * width + x] = exact_idx;
208                continue;
209            }
210
211            let rs = buf[idx].clamp(0.0, 255.0);
212            let gs = buf[idx + 1].clamp(0.0, 255.0);
213            let bs = buf[idx + 2].clamp(0.0, 255.0);
214
215            let r_lin = lut[rs.round() as usize];
216            let g_lin = lut[gs.round() as usize];
217            let b_lin = lut[bs.round() as usize];
218
219            let pixel_lab = rgb_to_oklab(r_lin, g_lin, b_lin);
220            let best_idx = match_pixel_oklab(pixel_lab, &palette_lab, WAB);
221
222            output[y * width + x] = best_idx as u8;
223
224            // Quantization error in sRGB space
225            let err_r = rs - palette_srgb_f[best_idx][0];
226            let err_g = gs - palette_srgb_f[best_idx][1];
227            let err_b = bs - palette_srgb_f[best_idx][2];
228
229            for offset in kernel.offsets {
230                let effective_dx = if reverse { -offset.dx } else { offset.dx };
231
232                let nx = x as i64 + effective_dx as i64;
233                let ny = y as i64 + offset.dy as i64;
234
235                if nx >= 0 && nx < width as i64 && ny >= 0 && ny < height as i64 {
236                    let ni = (ny as usize * width + nx as usize) * 3;
237                    buf[ni] += err_r * offset.weight;
238                    buf[ni + 1] += err_g * offset.weight;
239                    buf[ni + 2] += err_b * offset.weight;
240                }
241            }
242        }
243    }
244
245    output
246}
247
248// ── Thin wrappers ─────────────────────────────────────────────────────────────
249
250pub fn floyd_steinberg(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
251    error_diffusion_dither(pixels, w, h, palette, &FLOYD_STEINBERG, serpentine)
252}
253pub fn atkinson(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
254    error_diffusion_dither(pixels, w, h, palette, &ATKINSON, serpentine)
255}
256pub fn burkes(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
257    error_diffusion_dither(pixels, w, h, palette, &BURKES, serpentine)
258}
259pub fn stucki(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
260    error_diffusion_dither(pixels, w, h, palette, &STUCKI, serpentine)
261}
262pub fn sierra(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
263    error_diffusion_dither(pixels, w, h, palette, &SIERRA, serpentine)
264}
265pub fn sierra_lite(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
266    error_diffusion_dither(pixels, w, h, palette, &SIERRA_LITE, serpentine)
267}
268pub fn jarvis_judice_ninke(pixels: &[u8], w: usize, h: usize, palette: &Palette, serpentine: bool) -> Vec<u8> {
269    error_diffusion_dither(pixels, w, h, palette, &JARVIS_JUDICE_NINKE, serpentine)
270}
271
272// ── Direct palette map (no dithering) ────────────────────────────────────────
273
274/// Nearest-color mapping with no dithering. Each pixel maps independently.
275fn exact_palette_index(rgb: &[u8], palette: &Palette) -> Option<u8> {
276    palette
277        .colors
278        .iter()
279        .position(|&color| rgb == color)
280        .and_then(|idx| u8::try_from(idx).ok())
281}
282
283pub fn try_exact_palette_map(pixels: &[u8], canonical_palette: &Palette) -> Option<Vec<u8>> {
284    pixels
285        .par_chunks(3)
286        .map(|rgb| exact_palette_index(rgb, canonical_palette))
287        .collect()
288}
289
290pub fn direct_map(pixels: &[u8], palette: &Palette, canonical_palette: &Palette) -> Vec<u8> {
291    let (_, palette_lab) = build_palette_lab(palette);
292    pixels
293        .par_chunks(3)
294        .map(|rgb| {
295            if let Some(idx) = exact_palette_index(rgb, canonical_palette) {
296                return idx;
297            }
298
299            let r = srgb_channel_to_linear(rgb[0]);
300            let g = srgb_channel_to_linear(rgb[1]);
301            let b = srgb_channel_to_linear(rgb[2]);
302            let lab = rgb_to_oklab(r, g, b);
303            match_pixel_oklab(lab, &palette_lab, WAB) as u8
304        })
305        .collect()
306}
307
308// ── Ordered (Bayer) dithering ─────────────────────────────────────────────────
309
310// 4×4 Bayer matrix, normalized to [-0.5, 0.5]. Indexed as [y % 4][x % 4].
311// Values = (bayer_entry / 16.0) - 0.5 for the standard ordered Bayer matrix.
312// Written as exact fractions to keep precision (all are multiples of 1/16).
313const BAYER_4X4: [[f64; 4]; 4] = [
314    [-0.5000,  0.0000, -0.3750,  0.1250],
315    [ 0.2500, -0.2500,  0.3750, -0.1250],
316    [-0.3125,  0.1875, -0.4375,  0.0625],
317    [ 0.4375, -0.0625,  0.3125, -0.1875],
318];
319
320/// Ordered (Bayer 4×4) dither. Pixels are independent — parallelized with rayon.
321///
322/// The Bayer threshold is added in sRGB-fraction space, not linear. Linear-space
323/// thresholding produces ~3× the perceptual spread in shadows compared to highlights
324/// (the sRGB gamma is convex, so a fixed linear ±0.5 step is huge near 0 and tiny near 1).
325/// sRGB-space thresholding gives uniform perceptual dot density across the tonal range,
326/// matching how error diffusion already accumulates error. See GitHub issue #27.
327pub fn ordered_dither(pixels: &[u8], width: usize, palette: &Palette) -> Vec<u8> {
328    ordered_dither_impl(pixels, width, palette, None)
329}
330
331pub fn ordered_dither_with_canonical(
332    pixels: &[u8],
333    width: usize,
334    palette: &Palette,
335    canonical_palette: &Palette,
336) -> Vec<u8> {
337    ordered_dither_impl(pixels, width, palette, Some(canonical_palette))
338}
339
340fn ordered_dither_impl(
341    pixels: &[u8],
342    width: usize,
343    palette: &Palette,
344    canonical_palette: Option<&Palette>,
345) -> Vec<u8> {
346    let (_palette_linear, palette_lab) = build_palette_lab(palette);
347
348    pixels
349        .par_chunks(3)
350        .enumerate()
351        .map(|(i, rgb)| {
352            if let Some(canonical_palette) = canonical_palette
353                && let Some(idx) = exact_palette_index(rgb, canonical_palette)
354            {
355                return idx;
356            }
357
358            let x = i % width;
359            let y = i / width;
360
361            let threshold = BAYER_4X4[y % 4][x % 4];
362
363            // Add threshold in sRGB-fraction space, then convert to linear for OKLab match.
364            let r_srgb = (rgb[0] as f64 / 255.0 + threshold).clamp(0.0, 1.0);
365            let g_srgb = (rgb[1] as f64 / 255.0 + threshold).clamp(0.0, 1.0);
366            let b_srgb = (rgb[2] as f64 / 255.0 + threshold).clamp(0.0, 1.0);
367
368            let lab = rgb_to_oklab(
369                srgb_fraction_to_linear(r_srgb),
370                srgb_fraction_to_linear(g_srgb),
371                srgb_fraction_to_linear(b_srgb),
372            );
373            match_pixel_oklab(lab, &palette_lab, WAB) as u8
374        })
375        .collect()
376}
377
378// ── Tests ─────────────────────────────────────────────────────────────────────
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::palettes::ColorScheme;
384
385    fn solid_image(r: u8, g: u8, b: u8, w: usize, h: usize) -> Vec<u8> {
386        vec![r, g, b].into_iter().cycle().take(w * h * 3).collect()
387    }
388
389    #[test]
390    fn solid_white_maps_to_white() {
391        let pixels = solid_image(255, 255, 255, 4, 4);
392        let out = floyd_steinberg(&pixels, 4, 4, ColorScheme::Mono.palette(), true);
393        // White palette index in Mono is 1
394        assert!(out.iter().all(|&v| v == 1));
395    }
396
397    #[test]
398    fn solid_black_maps_to_black() {
399        let pixels = solid_image(0, 0, 0, 4, 4);
400        let out = floyd_steinberg(&pixels, 4, 4, ColorScheme::Mono.palette(), true);
401        assert!(out.iter().all(|&v| v == 0));
402    }
403
404    #[test]
405    fn output_length_matches_pixels() {
406        let pixels = solid_image(128, 64, 200, 10, 7);
407        let out = floyd_steinberg(&pixels, 10, 7, ColorScheme::Bwr.palette(), true);
408        assert_eq!(out.len(), 10 * 7);
409    }
410
411    #[test]
412    fn all_algorithms_produce_correct_length() {
413        let pixels = solid_image(128, 64, 200, 10, 7);
414        let pal = ColorScheme::Bwr.palette();
415        assert_eq!(burkes(&pixels, 10, 7, pal, false).len(), 70);
416        assert_eq!(atkinson(&pixels, 10, 7, pal, false).len(), 70);
417        assert_eq!(stucki(&pixels, 10, 7, pal, false).len(), 70);
418        assert_eq!(sierra(&pixels, 10, 7, pal, false).len(), 70);
419        assert_eq!(sierra_lite(&pixels, 10, 7, pal, false).len(), 70);
420        assert_eq!(jarvis_judice_ninke(&pixels, 10, 7, pal, false).len(), 70);
421        assert_eq!(ordered_dither(&pixels, 10, pal).len(), 70);
422        assert_eq!(direct_map(&pixels, pal, pal).len(), 70);
423    }
424
425    #[test]
426    fn direct_map_pure_red_maps_to_red_in_bwr() {
427        // BWR palette: index 0=black, 1=white, 2=red — pure red should map to red ink
428        let pixels = vec![255u8, 0, 0];
429        let out = direct_map(&pixels, ColorScheme::Bwr.palette(), ColorScheme::Bwr.palette());
430        assert_eq!(out[0], 2, "pure sRGB red should map to red ink (index 2) in BWR");
431    }
432
433    #[test]
434    fn direct_map_pure_blue_maps_to_blue_in_bwgbry() {
435        // BWGBRY palette order: 0=black, 1=white, 2=yellow, 3=red, 4=blue, 5=green
436        let pixels = vec![0u8, 0, 255];
437        let out = direct_map(&pixels, ColorScheme::Bwgbry.palette(), ColorScheme::Bwgbry.palette());
438        assert_eq!(out[0], 4, "pure sRGB blue should map to blue ink (index 4) in BWGBRY");
439    }
440
441    #[test]
442    fn serpentine_differs_from_raster_on_gradient() {
443        // A gradient (non-uniform content) should produce different output
444        // with serpentine=true vs serpentine=false
445        let pixels: Vec<u8> = (0u8..=255)
446            .flat_map(|v| [v, v / 2, 255 - v])
447            .cycle()
448            .take(16 * 16 * 3)
449            .collect();
450        let raster = floyd_steinberg(&pixels, 16, 16, ColorScheme::Mono.palette(), false);
451        let serpentine = floyd_steinberg(&pixels, 16, 16, ColorScheme::Mono.palette(), true);
452        assert_ne!(raster, serpentine, "serpentine and raster should differ on a gradient");
453    }
454
455    /// Property test for issue #27: ordered dither activity should be perceptually uniform
456    /// across the tonal range, not skewed toward shadows.
457    ///
458    /// On a horizontal sRGB ramp (one column per luminance level), each Bayer-period band
459    /// of columns contains the same `x % 4` thresholds and therefore the same *kinds* of
460    /// dithering decisions. Under linear-space thresholding, the perceptual spread of the
461    /// threshold is huge in shadows and tiny in highlights, so transitions cluster heavily
462    /// in the dark bands. Under sRGB-space thresholding, transitions distribute roughly
463    /// evenly across mid-tone bands.
464    ///
465    /// We measure transitions (adjacent columns with differing palette indices) per band
466    /// in the mid-tone region and assert the max/min ratio stays modest. Empirically the
467    /// linear-space implementation produces ratio ≈ 13.5; sRGB-space ≈ 4.3.
468    #[test]
469    fn ordered_dither_activity_is_perceptually_uniform() {
470        const W: usize = 256;
471        const H: usize = 16; // 4 full Bayer cells vertically
472        const BANDS: usize = 8;
473        const BAND_W: usize = W / BANDS;
474
475        // Horizontal sRGB ramp 0..255.
476        let mut pixels = Vec::with_capacity(W * H * 3);
477        for _ in 0..H {
478            for x in 0..W {
479                let v = x as u8;
480                pixels.extend_from_slice(&[v, v, v]);
481            }
482        }
483        let out = ordered_dither(&pixels, W, ColorScheme::Mono.palette());
484
485        // Count per-band horizontal transitions (adjacent columns with differing index).
486        let mut transitions = [0usize; BANDS];
487        for y in 0..H {
488            for x in 0..W - 1 {
489                if out[y * W + x] != out[y * W + x + 1] {
490                    transitions[(x / BAND_W).min(BANDS - 1)] += 1;
491                }
492            }
493        }
494
495        // Mid-tone bands (skip the first and last — they're near pure black/white where
496        // the threshold is clamped and dither activity legitimately falls off).
497        let mid = &transitions[1..BANDS - 1];
498        let max = *mid.iter().max().unwrap();
499        let min = *mid.iter().min().unwrap();
500        assert!(min > 0, "every mid-tone band should have at least one transition: {transitions:?}");
501        let ratio = max as f64 / min as f64;
502        assert!(
503            ratio < 5.0,
504            "ordered-dither activity is perceptually skewed (max/min = {ratio:.2}); \
505             expected sRGB-space ordered dither to spread roughly uniformly across \
506             mid-tones. Per-band transitions: {transitions:?}"
507        );
508    }
509
510    #[test]
511    fn serpentine_first_row_matches_raster() {
512        // Row 0 is always scanned left-to-right regardless of serpentine flag
513        let pixels: Vec<u8> = (0u8..=255)
514            .flat_map(|v| [v, 128u8.wrapping_add(v), 64u8])
515            .cycle()
516            .take(8 * 4 * 3)
517            .collect();
518        let raster = floyd_steinberg(&pixels, 8, 4, ColorScheme::Mono.palette(), false);
519        let serpentine = floyd_steinberg(&pixels, 8, 4, ColorScheme::Mono.palette(), true);
520        assert_eq!(
521            &raster[0..8],
522            &serpentine[0..8],
523            "first row must be identical (both scan left-to-right)"
524        );
525    }
526}