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, zero-mean thresholds in (-0.5, 0.5). Indexed as [y % 4][x % 4].
311// Values = (bayer_entry + 0.5) / 16.0 - 0.5, the standard zero-mean normalization —
312// the +0.5 centering makes ordered dithering brightness-neutral (mean threshold = 0).
313// Written as exact fractions to keep precision (all are odd multiples of 1/32).
314const BAYER_4X4: [[f64; 4]; 4] = [
315    [-0.46875,  0.03125, -0.34375,  0.15625],
316    [ 0.28125, -0.21875,  0.40625, -0.09375],
317    [-0.28125,  0.21875, -0.40625,  0.09375],
318    [ 0.46875, -0.03125,  0.34375, -0.15625],
319];
320
321/// Threshold amplitude (in sRGB-fraction space) for ordered dither on this palette.
322///
323/// Ordered dithering trades quantization error for a fixed threshold pattern; the
324/// threshold should be scaled to the palette's quantization step. For an evenly-spaced
325/// grayscale ramp of `n` levels the step is `1/(n-1)`, so a full ±0.5 threshold is
326/// `2·(n-1)×` too large (e.g. 14× for 16-level gray) and swamps fine detail with noise.
327///
328/// Sparse palettes (mono + color) keep the full ±0.5 spread: their transitions are
329/// dominated by black↔white/ink decisions where the wide threshold is the tuned,
330/// tested behavior (see the issue-#27 property test).
331fn ordered_spread(palette: &Palette) -> f64 {
332    let is_grayscale =
333        palette.colors.len() >= 3 && palette.colors.iter().all(|&[r, g, b]| r == g && g == b);
334    if is_grayscale {
335        1.0 / (palette.colors.len() - 1) as f64
336    } else {
337        1.0
338    }
339}
340
341/// Ordered (Bayer 4×4) dither. Pixels are independent — parallelized with rayon.
342///
343/// The Bayer threshold is added in sRGB-fraction space, not linear. Linear-space
344/// thresholding produces ~3× the perceptual spread in shadows compared to highlights
345/// (the sRGB gamma is convex, so a fixed linear ±0.5 step is huge near 0 and tiny near 1).
346/// sRGB-space thresholding gives uniform perceptual dot density across the tonal range,
347/// matching how error diffusion already accumulates error. See GitHub issue #27.
348///
349/// The threshold amplitude is scaled by `ordered_spread` to the palette's quantization
350/// step so dense grayscale ramps are not swamped by full-range dither noise.
351pub fn ordered_dither(pixels: &[u8], width: usize, palette: &Palette) -> Vec<u8> {
352    ordered_dither_impl(pixels, width, palette, None)
353}
354
355pub fn ordered_dither_with_canonical(
356    pixels: &[u8],
357    width: usize,
358    palette: &Palette,
359    canonical_palette: &Palette,
360) -> Vec<u8> {
361    ordered_dither_impl(pixels, width, palette, Some(canonical_palette))
362}
363
364fn ordered_dither_impl(
365    pixels: &[u8],
366    width: usize,
367    palette: &Palette,
368    canonical_palette: Option<&Palette>,
369) -> Vec<u8> {
370    let (_palette_linear, palette_lab) = build_palette_lab(palette);
371    let spread = ordered_spread(palette);
372
373    // Per-threshold gamma LUT: lut[t][v] = srgb_fraction_to_linear((v/255 + threshold_t).clamp).
374    // Only 16 thresholds × 256 byte values exist, so this removes all three per-pixel powf
375    // calls. Built from the exact same inputs as the scalar path, so results are bit-identical.
376    let lut: Vec<[f64; 256]> = (0..16)
377        .map(|t| {
378            let threshold = BAYER_4X4[t / 4][t % 4] * spread;
379            let mut row = [0.0_f64; 256];
380            for (v, slot) in row.iter_mut().enumerate() {
381                *slot = srgb_fraction_to_linear((v as f64 / 255.0 + threshold).clamp(0.0, 1.0));
382            }
383            row
384        })
385        .collect();
386
387    pixels
388        .par_chunks(3)
389        .enumerate()
390        .map(|(i, rgb)| {
391            if let Some(canonical_palette) = canonical_palette
392                && let Some(idx) = exact_palette_index(rgb, canonical_palette)
393            {
394                return idx;
395            }
396
397            let x = i % width;
398            let y = i / width;
399            let t = (y % 4) * 4 + (x % 4);
400
401            let lab = rgb_to_oklab(
402                lut[t][rgb[0] as usize],
403                lut[t][rgb[1] as usize],
404                lut[t][rgb[2] as usize],
405            );
406            match_pixel_oklab(lab, &palette_lab, WAB) as u8
407        })
408        .collect()
409}
410
411// ── Tests ─────────────────────────────────────────────────────────────────────
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::palettes::ColorScheme;
417
418    fn solid_image(r: u8, g: u8, b: u8, w: usize, h: usize) -> Vec<u8> {
419        vec![r, g, b].into_iter().cycle().take(w * h * 3).collect()
420    }
421
422    #[test]
423    fn solid_white_maps_to_white() {
424        let pixels = solid_image(255, 255, 255, 4, 4);
425        let out = floyd_steinberg(&pixels, 4, 4, ColorScheme::Mono.palette(), true);
426        // White palette index in Mono is 1
427        assert!(out.iter().all(|&v| v == 1));
428    }
429
430    #[test]
431    fn solid_black_maps_to_black() {
432        let pixels = solid_image(0, 0, 0, 4, 4);
433        let out = floyd_steinberg(&pixels, 4, 4, ColorScheme::Mono.palette(), true);
434        assert!(out.iter().all(|&v| v == 0));
435    }
436
437    #[test]
438    fn output_length_matches_pixels() {
439        let pixels = solid_image(128, 64, 200, 10, 7);
440        let out = floyd_steinberg(&pixels, 10, 7, ColorScheme::Bwr.palette(), true);
441        assert_eq!(out.len(), 10 * 7);
442    }
443
444    #[test]
445    fn all_algorithms_produce_correct_length() {
446        let pixels = solid_image(128, 64, 200, 10, 7);
447        let pal = ColorScheme::Bwr.palette();
448        assert_eq!(burkes(&pixels, 10, 7, pal, false).len(), 70);
449        assert_eq!(atkinson(&pixels, 10, 7, pal, false).len(), 70);
450        assert_eq!(stucki(&pixels, 10, 7, pal, false).len(), 70);
451        assert_eq!(sierra(&pixels, 10, 7, pal, false).len(), 70);
452        assert_eq!(sierra_lite(&pixels, 10, 7, pal, false).len(), 70);
453        assert_eq!(jarvis_judice_ninke(&pixels, 10, 7, pal, false).len(), 70);
454        assert_eq!(ordered_dither(&pixels, 10, pal).len(), 70);
455        assert_eq!(direct_map(&pixels, pal, pal).len(), 70);
456    }
457
458    #[test]
459    fn direct_map_pure_red_maps_to_red_in_bwr() {
460        // BWR palette: index 0=black, 1=white, 2=red — pure red should map to red ink
461        let pixels = vec![255u8, 0, 0];
462        let out = direct_map(&pixels, ColorScheme::Bwr.palette(), ColorScheme::Bwr.palette());
463        assert_eq!(out[0], 2, "pure sRGB red should map to red ink (index 2) in BWR");
464    }
465
466    #[test]
467    fn direct_map_pure_blue_maps_to_blue_in_bwgbry() {
468        // BWGBRY palette order: 0=black, 1=white, 2=yellow, 3=red, 4=blue, 5=green
469        let pixels = vec![0u8, 0, 255];
470        let out = direct_map(&pixels, ColorScheme::Bwgbry.palette(), ColorScheme::Bwgbry.palette());
471        assert_eq!(out[0], 4, "pure sRGB blue should map to blue ink (index 4) in BWGBRY");
472    }
473
474    #[test]
475    fn serpentine_differs_from_raster_on_gradient() {
476        // A gradient (non-uniform content) should produce different output
477        // with serpentine=true vs serpentine=false
478        let pixels: Vec<u8> = (0u8..=255)
479            .flat_map(|v| [v, v / 2, 255 - v])
480            .cycle()
481            .take(16 * 16 * 3)
482            .collect();
483        let raster = floyd_steinberg(&pixels, 16, 16, ColorScheme::Mono.palette(), false);
484        let serpentine = floyd_steinberg(&pixels, 16, 16, ColorScheme::Mono.palette(), true);
485        assert_ne!(raster, serpentine, "serpentine and raster should differ on a gradient");
486    }
487
488    /// Property test for issue #27: ordered dither activity should be perceptually uniform
489    /// across the tonal range, not skewed toward shadows.
490    ///
491    /// On a horizontal sRGB ramp (one column per luminance level), each Bayer-period band
492    /// of columns contains the same `x % 4` thresholds and therefore the same *kinds* of
493    /// dithering decisions. Under linear-space thresholding, the perceptual spread of the
494    /// threshold is huge in shadows and tiny in highlights, so transitions cluster heavily
495    /// in the dark bands. Under sRGB-space thresholding, transitions distribute roughly
496    /// evenly across mid-tone bands.
497    ///
498    /// We measure transitions (adjacent columns with differing palette indices) per band
499    /// in the mid-tone region and assert the max/min ratio stays modest. Empirically the
500    /// linear-space implementation produces ratio ≈ 13.5; sRGB-space ≈ 2.2.
501    ///
502    /// The mono decision midpoint sits at OKLab L=0.5 ≈ sRGB 188, so the top two bands
503    /// (sRGB ≳ 192) are already in the highlight roll-off where dither activity legitimately
504    /// tapers; we exclude them along with the pure-black first band.
505    #[test]
506    fn ordered_dither_activity_is_perceptually_uniform() {
507        const W: usize = 256;
508        const H: usize = 16; // 4 full Bayer cells vertically
509        const BANDS: usize = 8;
510        const BAND_W: usize = W / BANDS;
511
512        // Horizontal sRGB ramp 0..255.
513        let mut pixels = Vec::with_capacity(W * H * 3);
514        for _ in 0..H {
515            for x in 0..W {
516                let v = x as u8;
517                pixels.extend_from_slice(&[v, v, v]);
518            }
519        }
520        let out = ordered_dither(&pixels, W, ColorScheme::Mono.palette());
521
522        // Count per-band horizontal transitions (adjacent columns with differing index).
523        let mut transitions = [0usize; BANDS];
524        for y in 0..H {
525            for x in 0..W - 1 {
526                if out[y * W + x] != out[y * W + x + 1] {
527                    transitions[(x / BAND_W).min(BANDS - 1)] += 1;
528                }
529            }
530        }
531
532        // Mid-tone bands: skip the pure-black first band and the top two near-white bands
533        // (past the mono midpoint ≈ sRGB 188) where the threshold clamps and dither
534        // activity legitimately falls off.
535        let mid = &transitions[1..BANDS - 2];
536        let max = *mid.iter().max().unwrap();
537        let min = *mid.iter().min().unwrap();
538        assert!(min > 0, "every mid-tone band should have at least one transition: {transitions:?}");
539        let ratio = max as f64 / min as f64;
540        assert!(
541            ratio < 5.0,
542            "ordered-dither activity is perceptually skewed (max/min = {ratio:.2}); \
543             expected sRGB-space ordered dither to spread roughly uniformly across \
544             mid-tones. Per-band transitions: {transitions:?}"
545        );
546    }
547
548    #[test]
549    fn bayer_matrix_is_zero_mean() {
550        // A biased Bayer matrix systematically darkens (or brightens) every ordered-
551        // dithered image. The standard (v + 0.5)/16 - 0.5 normalization sums to zero.
552        let sum: f64 = BAYER_4X4.iter().flatten().sum();
553        assert!(sum.abs() < 1e-12, "Bayer thresholds must sum to zero, got {sum}");
554    }
555
556    #[test]
557    fn ordered_dither_is_brightness_neutral_at_midpoint() {
558        // Find the mono decision midpoint: the smallest gray value whose threshold-free
559        // OKLab match flips from black (0) to white (1).
560        let pal = ColorScheme::Mono.palette();
561        let (_, pal_lab) = build_palette_lab(pal);
562        let midpoint = (0u16..=255)
563            .find(|&v| {
564                let lin = srgb_channel_to_linear(v as u8);
565                let lab = rgb_to_oklab(lin, lin, lin);
566                match_pixel_oklab(lab, &pal_lab, WAB) == 1
567            })
568            .expect("mono palette must have a black→white transition") as u8;
569
570        // Dither a solid midpoint-gray field. With a zero-mean threshold the white
571        // fraction should sit near 0.5; the old -1/32 bias produced ≈ 7/16.
572        const N: usize = 32;
573        let pixels = solid_image(midpoint, midpoint, midpoint, N, N);
574        let out = ordered_dither(&pixels, N, pal);
575        let white = out.iter().filter(|&&v| v == 1).count() as f64 / out.len() as f64;
576        assert!(
577            (white - 0.5).abs() <= 1.0 / 16.0,
578            "ordered dither at the mono midpoint should be ~50% white, got {white}"
579        );
580    }
581
582    #[test]
583    fn ordered_dither_grayscale16_tracks_ramp_and_uses_many_levels() {
584        // On a horizontal sRGB ramp the full ±0.5 threshold swamps the 17/255 GS16 step
585        // (14× too large). After scaling by ordered_spread, block-averaged output should
586        // track the input ramp and exercise most of the 16 levels.
587        const W: usize = 256;
588        const H: usize = 4;
589        let pal = ColorScheme::Grayscale16.palette();
590
591        let mut pixels = Vec::with_capacity(W * H * 3);
592        for _ in 0..H {
593            for x in 0..W {
594                let v = x as u8;
595                pixels.extend_from_slice(&[v, v, v]);
596            }
597        }
598        let out = ordered_dither(&pixels, W, pal);
599
600        // (a) 4-wide block average of palette level tracks the input level.
601        let levels: Vec<f64> = pal.colors.iter().map(|&[r, _, _]| r as f64).collect();
602        let mut max_err = 0.0_f64;
603        for bx in 0..W / 4 {
604            let mut sum = 0.0;
605            for y in 0..H {
606                for dx in 0..4 {
607                    let x = bx * 4 + dx;
608                    sum += levels[out[y * W + x] as usize];
609                }
610            }
611            let avg = sum / (H * 4) as f64;
612            let input = (bx * 4 + 1) as f64; // center-ish input value
613            max_err = max_err.max((avg - input).abs());
614        }
615        assert!(max_err < 24.0, "block-averaged output should track the ramp, max_err={max_err}");
616
617        // (b) most of the 16 levels are used.
618        let mut seen = [false; 16];
619        for &idx in &out {
620            seen[idx as usize] = true;
621        }
622        let used = seen.iter().filter(|&&s| s).count();
623        assert!(used >= 12, "GS16 ordered dither should use ≥12 levels, used {used}");
624    }
625
626    #[test]
627    fn serpentine_first_row_matches_raster() {
628        // Row 0 is always scanned left-to-right regardless of serpentine flag
629        let pixels: Vec<u8> = (0u8..=255)
630            .flat_map(|v| [v, 128u8.wrapping_add(v), 64u8])
631            .cycle()
632            .take(8 * 4 * 3)
633            .collect();
634        let raster = floyd_steinberg(&pixels, 8, 4, ColorScheme::Mono.palette(), false);
635        let serpentine = floyd_steinberg(&pixels, 8, 4, ColorScheme::Mono.palette(), true);
636        assert_eq!(
637            &raster[0..8],
638            &serpentine[0..8],
639            "first row must be identical (both scan left-to-right)"
640        );
641    }
642}