diff --git a/README.md b/README.md index c7ff2ae..0a5b0a4 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ See [`packages/javascript/README.md`](packages/javascript/README.md) for full do ## Features - **Rust Core**: All dithering logic in `packages/rust/core/` — shared by both packages -- **9 Dithering Algorithms**: NONE, ORDERED, BURKES, FLOYD_STEINBERG, ATKINSON, STUCKI, SIERRA, SIERRA_LITE, JARVIS_JUDICE_NINKE +- **10 Dithering Algorithms**: NONE, ORDERED, BURKES, FLOYD_STEINBERG, ATKINSON, STUCKI, SIERRA, SIERRA_LITE, JARVIS_JUDICE_NINKE, DIZZY - **9 Color Schemes**: MONO, BWR, BWY, BWRY, BWGBRY (Spectra 6), GRAYSCALE_4, GRAYSCALE_16, SEVEN_COLOR (Spectra/ACeP 7), BWGBRY_SPLIT - **Measured Palettes**: Calibrated RGB values for real displays, linked to their canonical firmware palette - **OKLab Color Matching**: Weighted Cartesian OKLab — preserves hue without the achromatic-attractor bug of LCH-weighted approaches diff --git a/docs/index.html b/docs/index.html index a12c84f..55951d4 100644 --- a/docs/index.html +++ b/docs/index.html @@ -591,6 +591,7 @@ + @@ -738,6 +739,7 @@ SIERRA: DitherMode.SIERRA, SIERRA_LITE: DitherMode.SIERRA_LITE, JARVIS_JUDICE_NINKE: DitherMode.JARVIS_JUDICE_NINKE, + DIZZY: DitherMode.DIZZY, }; // ── State ──────────────────────────────────────────────────────── diff --git a/packages/javascript/README.md b/packages/javascript/README.md index 53a9439..7e6b27a 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -7,7 +7,7 @@ High-quality dithering algorithms for e-paper/e-ink displays, powered by a Rust/ ## Features - **Rust/WASM Core**: Compiled Rust logic bundled inline — no async init, no external files, works everywhere -- **9 Dithering Algorithms**: From fast ordered dithering to high-quality error diffusion +- **10 Dithering Algorithms**: From fast ordered dithering to high-quality error diffusion - **9 Color Schemes**: MONO, BWR, BWY, BWRY, BWGBRY (Spectra 6), GRAYSCALE\_4, GRAYSCALE\_16, SEVEN\_COLOR (Spectra/ACeP 7), BWGBRY\_SPLIT - **Measured Palettes**: Use real display-calibrated colors for accurate dithering (SPECTRA\_7\_3\_6COLOR\_V2, BWRY\_3\_97, and more) - **OKLab Color Matching**: Weighted Cartesian OKLab — preserves hue without the achromatic-attractor bug that plagues LCH-weighted approaches @@ -162,6 +162,7 @@ enum ColorScheme { | `SIERRA` | High | Medium | — | | `STUCKI` | Very high | Slow | — | | `JARVIS_JUDICE_NINKE` | Highest | Slowest | — | +| `DIZZY` | Good | Medium | Error diffusion with pseudo-random traversal — no directional structure. Ignores `serpentine`. Algorithm by [Liam Appelbe](https://liamappelbe.medium.com/dizzy-dithering-2ae76dbceba1) | ### Types diff --git a/packages/javascript/demo.html b/packages/javascript/demo.html index c7d07ac..cf8e6fe 100644 --- a/packages/javascript/demo.html +++ b/packages/javascript/demo.html @@ -590,6 +590,7 @@ + @@ -762,6 +763,7 @@ SIERRA: DitherMode.SIERRA, SIERRA_LITE: DitherMode.SIERRA_LITE, JARVIS_JUDICE_NINKE: DitherMode.JARVIS_JUDICE_NINKE, + DIZZY: DitherMode.DIZZY, }; // ── State ──────────────────────────────────────────────────────── diff --git a/packages/javascript/dev.html b/packages/javascript/dev.html index f3903bb..ea90f4c 100644 --- a/packages/javascript/dev.html +++ b/packages/javascript/dev.html @@ -590,6 +590,7 @@ + @@ -762,6 +763,7 @@ SIERRA: DitherMode.SIERRA, SIERRA_LITE: DitherMode.SIERRA_LITE, JARVIS_JUDICE_NINKE: DitherMode.JARVIS_JUDICE_NINKE, + DIZZY: DitherMode.DIZZY, }; // ── State ──────────────────────────────────────────────────────── diff --git a/packages/javascript/src/enums.ts b/packages/javascript/src/enums.ts index 951e8c7..3583b3f 100644 --- a/packages/javascript/src/enums.ts +++ b/packages/javascript/src/enums.ts @@ -1,6 +1,6 @@ /** * Dithering algorithm modes - * Values match firmware conventions (0-8) + * Values match firmware conventions (0-9) */ export enum DitherMode { /** @@ -19,4 +19,10 @@ export enum DitherMode { SIERRA = 6, SIERRA_LITE = 7, JARVIS_JUDICE_NINKE = 8, + /** + * Error diffusion with pseudo-random traversal instead of a raster scan, + * diffusing error only to not-yet-quantized neighbours. Produces no + * directional structure. `serpentine` is ignored: there is no scan direction. + */ + DIZZY = 9, } \ No newline at end of file diff --git a/packages/javascript/tests/dithering.test.ts b/packages/javascript/tests/dithering.test.ts index 745cf21..3998b32 100644 --- a/packages/javascript/tests/dithering.test.ts +++ b/packages/javascript/tests/dithering.test.ts @@ -503,3 +503,29 @@ describe('RGBA compositing (shared core implementation)', () => { expect(Array.from(compositeRgba(new Uint8Array(0)))).toEqual([]); }); }); + +describe('Dizzy dithering', () => { + const rampImage = () => { + const data = new Uint8ClampedArray(4 * 4 * 4); + for (let y = 0; y < 4; y++) { + for (let x = 0; x < 4; x++) { + const v = x * 60 + y * 5; + const o = (y * 4 + x) * 4; + data[o] = v; data[o + 1] = v; data[o + 2] = v; data[o + 3] = 255; + } + } + return { data, width: 4, height: 4 }; + }; + + it('matches the Rust reference vector byte for byte', () => { + const result = ditherImage(rampImage(), ColorScheme.BWR, { mode: DitherMode.DIZZY }); + // Frozen literals, identical to the Rust and Python suites. + expect(Array.from(result.indices)).toEqual([0, 0, 1, 0, 0, 2, 2, 1, 0, 1, 1, 1, 0, 0, 2, 1]); + }); + + it('is deterministic', () => { + const a = ditherImage(rampImage(), ColorScheme.BWR, { mode: DitherMode.DIZZY }); + const b = ditherImage(rampImage(), ColorScheme.BWR, { mode: DitherMode.DIZZY }); + expect(Array.from(a.indices)).toEqual(Array.from(b.indices)); + }); +}); diff --git a/packages/python/README.md b/packages/python/README.md index 3025ff1..44dd3c8 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -24,7 +24,7 @@ pip install epaper-dithering - **Rust Core**: All dithering runs in a compiled Rust extension — fast enough for 800×480 images in ~30ms - **Perceptually Correct**: Weighted Cartesian OKLab color matching — preserves hue without the achromatic-attractor bug that plagues LCH-weighted approaches -- **9 Dithering Algorithms**: From simple ordered dithering to high-quality Jarvis-Judice-Ninke +- **10 Dithering Algorithms**: From simple ordered dithering to high-quality Jarvis-Judice-Ninke - **8 Color Schemes**: Support for mono, 3-color, 4-color, 6-color, and grayscale e-paper displays - **Pre-dither Adjustments**: Per-image exposure, saturation, shadows, highlights, dynamic-range compression, and gamut compression — all orthogonal knobs you can mix freely - **Serpentine Scanning**: Reduces directional artifacts in error diffusion (enabled by default) @@ -88,6 +88,7 @@ dither_image( | ATKINSON | Good | Medium | High contrast, artistic | | STUCKI | Very High | Slow | Maximum quality | | JARVIS_JUDICE_NINKE | Highest | Slowest | Smooth gradients | +| DIZZY | Good | Medium | Error diffusion with pseudo-random traversal — no directional structure. Ignores `serpentine`. Algorithm by [Liam Appelbe](https://liamappelbe.medium.com/dizzy-dithering-2ae76dbceba1) | ## Usage Examples diff --git a/packages/python/src/epaper_dithering/enums.py b/packages/python/src/epaper_dithering/enums.py index 6d170ff..ced7bf3 100644 --- a/packages/python/src/epaper_dithering/enums.py +++ b/packages/python/src/epaper_dithering/enums.py @@ -25,3 +25,7 @@ class DitherMode(IntEnum): SIERRA = 6 SIERRA_LITE = 7 JARVIS_JUDICE_NINKE = 8 + #: Error diffusion with pseudo-random traversal instead of a raster scan, + #: diffusing error only to not-yet-quantized neighbours. Produces no + #: directional structure. ``serpentine`` is ignored: there is no scan direction. + DIZZY = 9 diff --git a/packages/python/tests/test_dithering.py b/packages/python/tests/test_dithering.py index 34e90c7..04b8ddb 100644 --- a/packages/python/tests/test_dithering.py +++ b/packages/python/tests/test_dithering.py @@ -540,3 +540,20 @@ def test_matches_legacy_pil_paste_over_the_whole_channel_alpha_space(self): legacy_bg.paste(image, mask=image.split()[3]) assert _rs.composite_rgba(image.tobytes()) == legacy_bg.tobytes() + + +class TestDizzy: + """Dizzy dithering (DitherMode 9) must match the Rust reference byte for byte.""" + + def test_cross_language_reference_vector(self): + img = Image.new("RGB", (4, 4)) + img.putdata([(x * 60 + y * 5,) * 3 for y in range(4) for x in range(4)]) + out = dither_image(img, ColorScheme.BWR, mode=DitherMode.DIZZY) + # Frozen literals, identical to the Rust and JavaScript suites. + assert list(out.getdata()) == [0, 0, 1, 0, 0, 2, 2, 1, 0, 1, 1, 1, 0, 0, 2, 1] + + def test_is_deterministic(self): + img = Image.new("RGB", (16, 16), (128, 128, 128)) + a = dither_image(img, ColorScheme.BWR, mode=DitherMode.DIZZY) + b = dither_image(img, ColorScheme.BWR, mode=DitherMode.DIZZY) + assert list(a.getdata()) == list(b.getdata()) diff --git a/packages/rust/core/benches/dithering.rs b/packages/rust/core/benches/dithering.rs index e3f75e9..94acdb6 100644 --- a/packages/rust/core/benches/dithering.rs +++ b/packages/rust/core/benches/dithering.rs @@ -7,6 +7,7 @@ use epaper_dithering_core::{ color_space::srgb_channel_to_linear, color_space_lab::{PaletteLab, WAB, match_pixel_oklab, rgb_to_oklab}, dither, DitherConfig, + dizzy::dizzy_dither, enums::{DitherMode, GamutCompression, ToneCompression}, measured_palettes::SPECTRA_7_3_6COLOR, palettes::ColorScheme, @@ -82,6 +83,15 @@ fn bench_error_diffusion(c: &mut Criterion) { |b, _| b.iter(|| error_diffusion_dither(&pixels, w, h, palette, kernel, false)), ); } + + // Dizzy has no fixed kernel (pseudo-random traversal instead of raster + // scan), so it doesn't fit the (name, kernel) loop above and calls its + // own entry point directly. + group.bench_with_input( + BenchmarkId::new("dizzy", format!("{w}x{h}")), + &(), + |b, _| b.iter(|| dizzy_dither(&pixels, w, h, palette)), + ); } group.finish(); diff --git a/packages/rust/core/examples/dither.rs b/packages/rust/core/examples/dither.rs index 652f65e..fc2f755 100644 --- a/packages/rust/core/examples/dither.rs +++ b/packages/rust/core/examples/dither.rs @@ -10,7 +10,7 @@ // // Schemes: mono, bwr, bwy, bwry, bwgbry, grayscale4, grayscale16, seven_color, bwgbry_split // spectra, spectra_v2, mono_4_26, bwry_4_2, bwry_3_97, solum_bwr, hanshow_bwr, hanshow_bwy -// Modes: none, ordered, floyd_steinberg, burkes, atkinson, stucki, sierra, sierra_lite, jjn +// Modes: none, ordered, floyd_steinberg, burkes, atkinson, stucki, sierra, sierra_lite, jjn, dizzy // Tone: auto, none, 0.0-1.0 (default: none) // Gamut: none, auto, 0.0-1.0 (default: none) @@ -70,6 +70,7 @@ fn main() { "sierra" => DitherMode::Sierra, "sierra_lite" => DitherMode::SierraLite, "jjn" => DitherMode::JarvisJudiceNinke, + "dizzy" => DitherMode::Dizzy, other => { eprintln!("Unknown mode: {other}"); std::process::exit(1); } }; diff --git a/packages/rust/core/examples/dizzy_compare.rs b/packages/rust/core/examples/dizzy_compare.rs new file mode 100644 index 0000000..5b06a68 --- /dev/null +++ b/packages/rust/core/examples/dizzy_compare.rs @@ -0,0 +1,122 @@ +//! Compare dizzy dithering against Burkes and Floyd-Steinberg. +//! +//! Metric: mean OKLab dE between the source and the dithered result, both +//! averaged over 4x4 blocks. Block-averaging measures whether local average +//! colour is preserved; per-pixel dE would only measure dither noise. +//! +//! cargo run --release --example dizzy_compare + +use epaper_dithering_core::{ + color_space::srgb_channel_to_linear, + color_space_lab::rgb_to_oklab, + dither, dither_with_canonical, + enums::{DitherMode, GamutCompression, ToneCompression}, + measured_palettes::SPECTRA_7_3_6COLOR, + palettes::{ColorScheme, Palette}, + types::ImageBuffer, + DitherConfig, +}; + +const BLOCK: usize = 4; + +/// Mean OKLab dE between two flat RGB buffers, averaged over BLOCK x BLOCK tiles. +fn block_delta_e(a: &[u8], b: &[u8], width: usize, height: usize) -> f64 { + let mut total = 0.0; + let mut blocks = 0usize; + for by in (0..height).step_by(BLOCK) { + for bx in (0..width).step_by(BLOCK) { + let (mut sa, mut sb, mut n) = ([0.0; 3], [0.0; 3], 0.0); + for y in by..(by + BLOCK).min(height) { + for x in bx..(bx + BLOCK).min(width) { + let i = (y * width + x) * 3; + for c in 0..3 { + sa[c] += srgb_channel_to_linear(a[i + c]); + sb[c] += srgb_channel_to_linear(b[i + c]); + } + n += 1.0; + } + } + let la = rgb_to_oklab(sa[0] / n, sa[1] / n, sa[2] / n); + let lb = rgb_to_oklab(sb[0] / n, sb[1] / n, sb[2] / n); + let d = ((la.l - lb.l).powi(2) + (la.a - lb.a).powi(2) + (la.b - lb.b).powi(2)).sqrt(); + total += d; + blocks += 1; + } + } + total / blocks as f64 +} + +/// Expand palette indices back into a flat RGB buffer. +fn to_rgb(indices: &[u8], palette: &Palette) -> Vec { + indices + .iter() + .flat_map(|&i| palette.colors[i as usize]) + .collect() +} + +fn main() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/images"); + let mut images: Vec<_> = std::fs::read_dir(&dir) + .expect("fixtures/images") + .filter_map(|e| { + let e = e.ok()?; + e.file_type().ok()?.is_file().then(|| e.file_name().into_string().ok())? + }) + .filter(|n| n.ends_with(".png") || n.ends_with(".jpg") || n.ends_with(".jpeg")) + .collect(); + images.sort(); + + let modes = [ + ("dizzy", DitherMode::Dizzy), + ("burkes", DitherMode::Burkes), + ("floyd_steinberg", DitherMode::FloydSteinberg), + ]; + + println!("{:<22} {:<10} {:<16} {:>10}", "image", "palette", "mode", "mean dE"); + let mut totals = [(0.0, 0usize); 3]; + + for name in &images { + let img = image::open(dir.join(name)).expect("load").to_rgb8(); + let (w, h) = img.dimensions(); + let (w, h) = (w as usize, h as usize); + let src = img.into_raw(); + let buf = ImageBuffer::new(&src, w); + + for (pal_name, is_measured) in [("spectra6", true), ("mono", false)] { + for (mi, (mode_name, mode)) in modes.iter().enumerate() { + let (indices, out_palette): (Vec, &Palette) = if is_measured { + let cfg = DitherConfig { + mode: *mode, + tone: ToneCompression::Auto, + gamut: GamutCompression::Auto, + ..Default::default() + }; + ( + dither_with_canonical( + &buf, + &SPECTRA_7_3_6COLOR, + ColorScheme::Bwgbry.palette(), + cfg, + ), + &SPECTRA_7_3_6COLOR, + ) + } else { + let cfg = DitherConfig { mode: *mode, ..Default::default() }; + (dither(&buf, ColorScheme::Mono.palette(), cfg), ColorScheme::Mono.palette()) + }; + + let rgb = to_rgb(&indices, out_palette); + let de = block_delta_e(&src, &rgb, w, h); + totals[mi].0 += de; + totals[mi].1 += 1; + println!("{name:<22} {pal_name:<10} {mode_name:<16} {de:>10.4}"); + } + } + } + + println!("\n{:<33} {:<16} {:>10}", "SUMMARY", "mode", "mean dE"); + for (mi, (mode_name, _)) in modes.iter().enumerate() { + let (sum, n) = totals[mi]; + println!("{:<33} {mode_name:<16} {:>10.4}", "", sum / n as f64); + } +} diff --git a/packages/rust/core/src/algorithms.rs b/packages/rust/core/src/algorithms.rs index c503a45..4ae0c31 100644 --- a/packages/rust/core/src/algorithms.rs +++ b/packages/rust/core/src/algorithms.rs @@ -12,7 +12,7 @@ pub use crate::kernels::{ // ── Palette setup helper ───────────────────────────────────────────────────── -fn build_palette_lab(palette: &Palette) -> (Vec<[f64; 3]>, PaletteLab) { +pub(crate) fn build_palette_lab(palette: &Palette) -> (Vec<[f64; 3]>, PaletteLab) { let palette_linear: Vec<[f64; 3]> = palette .colors .iter() @@ -173,7 +173,7 @@ pub fn jarvis_judice_ninke(pixels: &[u8], w: usize, h: usize, palette: &Palette, // ── Direct palette map (no dithering) ──────────────────────────────────────── /// Returns the palette index if the RGB pixel exactly matches a palette color, or None. -fn exact_palette_index(rgb: &[u8], palette: &Palette) -> Option { +pub(crate) fn exact_palette_index(rgb: &[u8], palette: &Palette) -> Option { palette .colors .iter() diff --git a/packages/rust/core/src/dizzy.rs b/packages/rust/core/src/dizzy.rs new file mode 100644 index 0000000..ff42214 --- /dev/null +++ b/packages/rust/core/src/dizzy.rs @@ -0,0 +1,315 @@ +//! Dizzy dithering: error diffusion with pseudo-random traversal. +//! +//! Algorithm devised and described by **Liam Appelbe** in "Dizzy Dithering": +//! +//! +//! Implemented here from that written description — the article contains no code +//! listing, so this is an independent implementation, not a port. The two design +//! choices taken directly from the article are the stateless multiply-and-xor +//! permutation used for the traversal, and the 10:1 orthogonal-to-diagonal ratio +//! when spreading error over the unvisited neighbours. + +use crate::algorithms::{build_palette_lab, exact_palette_index}; +use crate::color_space::srgb_channel_to_linear; +use crate::color_space_lab::{match_pixel_oklab, rgb_to_oklab, WAB}; +use crate::palettes::Palette; + +// ── Traversal ───────────────────────────────────────────────────────────────── +// +// A stateless bijective permutation of `0..2^bits`, so the walk needs no shuffled +// index array. Multiplication by an odd number is invertible modulo 2^k (odd +// numbers are units in that ring) and XOR by a constant is self-inverse, so five +// rounds of (multiply, mask, xor) compose to a bijection. Indices >= n are skipped. +// +// FROZEN: changing either table changes every image this mode has ever produced. +const ODD: [u64; 5] = [0x2545_F491, 0x9E37_79B1, 0x85EB_CA6B, 0xC2B2_AE35, 0x27D4_EB2F]; +const XOR: [u64; 5] = [0x1656_67B1, 0xD3A2_646C, 0xFD70_46C5, 0xB55A_4F09, 0x1B87_3593]; + +/// Smallest `bits` such that `2^bits >= n`. `bits_for(1) == 0`. +fn bits_for(n: usize) -> u32 { + debug_assert!(n > 0, "bits_for requires a non-empty image"); + usize::BITS - (n - 1).leading_zeros() +} + +fn permute(i: u64, mask: u64) -> u64 { + let mut p = i; + for r in 0..ODD.len() { + p = p.wrapping_mul(ODD[r]) & mask; + p ^= XOR[r] & mask; + } + p +} + +/// Yields every index in `0..n` exactly once, in pseudo-random order. +pub(crate) fn dizzy_order(n: usize) -> impl Iterator { + let bits = bits_for(n); + let mask = if bits >= u64::BITS { u64::MAX } else { (1u64 << bits) - 1 }; + (0..=mask).filter_map(move |i| { + let p = permute(i, mask) as usize; + (p < n).then_some(p) + }) +} + +/// 8-neighbourhood with the source article's 10:1 orthogonal:diagonal weighting. +const NEIGHBORS: [(i64, i64, f64); 8] = [ + (0, -1, 1.0), (-1, 0, 1.0), (1, 0, 1.0), (0, 1, 1.0), + (-1, -1, 0.1), (1, -1, 0.1), (-1, 1, 0.1), (1, 1, 0.1), +]; + +/// Dither with pseudo-random traversal, diffusing error only to unquantized neighbours. +pub fn dizzy_dither(pixels: &[u8], width: usize, height: usize, palette: &Palette) -> Vec { + dizzy_dither_impl(pixels, width, height, palette, None) +} + +/// As [`dizzy_dither`], but pixels exactly matching a `canonical` ink pass through +/// unchanged and are excluded from error distribution. +pub fn dizzy_dither_with_canonical( + pixels: &[u8], + width: usize, + height: usize, + palette: &Palette, + canonical: &Palette, +) -> Vec { + dizzy_dither_impl(pixels, width, height, palette, Some(canonical)) +} + +fn dizzy_dither_impl( + pixels: &[u8], + width: usize, + height: usize, + palette: &Palette, + canonical_palette: Option<&Palette>, +) -> Vec { + let (_palette_linear, palette_lab) = build_palette_lab(palette); + let palette_srgb_f: Vec<[f64; 3]> = palette + .colors + .iter() + .map(|&[r, g, b]| [r as f64, g as f64, b as f64]) + .collect(); + + // Working buffer in sRGB float space [0, 255]; accumulates diffused error. + let mut buf: Vec = pixels.iter().map(|&v| v as f64).collect(); + // LUT: u8 sRGB -> linear f64 (avoids powf per pixel in the inner loop) + let lut: Vec = (0u8..=255).map(srgb_channel_to_linear).collect(); + + let n = width * height; + let mut output = vec![0u8; n]; + let mut processed = vec![false; n]; + + // Pre-pass: pin exact canonical pixels and mark them processed BEFORE the walk, + // so no neighbour spends error on a pixel that will ignore it. This differs + // deliberately from the raster implementation, which lets pinned pixels + // accumulate error that is then discarded. + if let Some(canonical) = canonical_palette { + for i in 0..n { + if let Some(exact) = exact_palette_index(&pixels[i * 3..i * 3 + 3], canonical) { + output[i] = exact; + processed[i] = true; + } + } + } + + for i in dizzy_order(n) { + if processed[i] { + continue; + } + let idx = i * 3; + let rs = buf[idx].clamp(0.0, 255.0); + let gs = buf[idx + 1].clamp(0.0, 255.0); + let bs = buf[idx + 2].clamp(0.0, 255.0); + + let pixel_lab = rgb_to_oklab( + lut[rs.round() as usize], + lut[gs.round() as usize], + lut[bs.round() as usize], + ); + let best = match_pixel_oklab(pixel_lab, &palette_lab, WAB); + + output[i] = best as u8; + processed[i] = true; + + let err = [ + rs - palette_srgb_f[best][0], + gs - palette_srgb_f[best][1], + bs - palette_srgb_f[best][2], + ]; + + let x = (i % width) as i64; + let y = (i / width) as i64; + + // Pass 1: total weight of the still-unquantized neighbours. + let mut denom = 0.0; + for (dx, dy, w) in NEIGHBORS { + let (nx, ny) = (x + dx, y + dy); + if nx >= 0 && nx < width as i64 && ny >= 0 && ny < height as i64 { + let ni = ny as usize * width + nx as usize; + if !processed[ni] { + denom += w; + } + } + } + + // Every neighbour is already quantized, so this pixel's error has nowhere to + // go and is dropped. That is inherent to the algorithm: do NOT "fix" it by + // widening the neighbourhood or diffusing back into processed pixels -- + // either changes the output and defeats the point of the traversal order. + if denom == 0.0 { + continue; + } + + // Pass 2: distribute, normalized so the full error is conserved. + for (dx, dy, w) in NEIGHBORS { + let (nx, ny) = (x + dx, y + dy); + if nx >= 0 && nx < width as i64 && ny >= 0 && ny < height as i64 { + let ni = ny as usize * width + nx as usize; + if !processed[ni] { + let share = w / denom; + buf[ni * 3] += err[0] * share; + buf[ni * 3 + 1] += err[1] * share; + buf[ni * 3 + 2] += err[2] * share; + } + } + } + } + + output +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::palettes::ColorScheme; + + fn gray_image(value: u8, count: usize) -> Vec { + std::iter::repeat_n([value, value, value], count).flatten().collect() + } + + #[test] + fn output_is_deterministic() { + let img = gray_image(128, 64); + let p = ColorScheme::Bwr.palette(); + let a = dizzy_dither(&img, 8, 8, p); + let b = dizzy_dither(&img, 8, 8, p); + assert_eq!(a, b, "dizzy must be deterministic for identical input"); + } + + #[test] + fn output_length_and_indices_are_in_range() { + let img = gray_image(90, 96); + let p = ColorScheme::Bwgbry.palette(); + let out = dizzy_dither(&img, 12, 8, p); + assert_eq!(out.len(), 96); + assert!(out.iter().all(|&i| (i as usize) < p.colors.len())); + } + + #[test] + fn flat_midgray_uses_more_than_one_ink() { + // A flat field must dither, not collapse to a single nearest colour. + let img = gray_image(128, 1024); + let out = dizzy_dither(&img, 32, 32, ColorScheme::Mono.palette()); + let black = out.iter().filter(|&&i| i == 0).count(); + assert!(black > 0 && black < 1024, "expected a mix of inks, got {black}/1024 black"); + } + + #[test] + fn flat_midgray_black_white_ratio_is_balanced() { + // Beyond "more than one ink": for a flat mid-gray field on MONO, the + // black/white split should be roughly balanced, not grossly skewed. + // A skew here (e.g. 99/1) would indicate the error normalization is + // wrong even though every other test still passes. + let img = gray_image(128, 1024); + let out = dizzy_dither(&img, 32, 32, ColorScheme::Mono.palette()); + let black = out.iter().filter(|&&i| i == 0).count(); + let ratio = black as f64 / 1024.0; + assert!( + (0.3..=0.7).contains(&ratio), + "expected roughly balanced black/white split, got {ratio:.3} black ({black}/1024)" + ); + } + + #[test] + fn error_is_conserved_while_neighbours_remain() { + // Every pixel's error must be fully handed on (shares sum to 1.0) unless + // every neighbour is already quantized. Verified here on the weighting + // itself: for any non-empty subset of the 8 neighbours, the normalized + // shares must sum to 1. + for mask in 1u32..256 { + let mut denom = 0.0; + for (bit, (_, _, w)) in NEIGHBORS.iter().enumerate() { + if mask & (1 << bit) != 0 { + denom += w; + } + } + let total: f64 = NEIGHBORS + .iter() + .enumerate() + .filter(|(bit, _)| mask & (1 << bit) != 0) + .map(|(_, (_, _, w))| w / denom) + .sum(); + assert!( + (total - 1.0).abs() < 1e-12, + "mask {mask:08b}: shares summed to {total}, expected 1.0" + ); + } + } + + #[test] + fn exact_canonical_pixels_are_pinned() { + // Mirrors algorithms.rs's equivalent test for raster error diffusion. + let mut image = gray_image(128, 8); + image[0..3].copy_from_slice(&[0, 255, 0]); // pure green -> index 5 in BWGBRY + image[9..12].copy_from_slice(&[0, 255, 0]); + let out = dizzy_dither_with_canonical( + &image, 4, 2, + &crate::measured_palettes::SPECTRA_7_3_6COLOR, + ColorScheme::Bwgbry.palette(), + ); + assert_eq!(out[0], 5, "exact green at pixel 0 should be pinned"); + assert_eq!(out[3], 5, "exact green at pixel 3 should be pinned"); + } + + #[test] + fn walk_visits_every_index_exactly_once() { + // Powers of two, 2^k+1 (worst-case rejection), primes, and degenerate shapes. + for n in [1usize, 2, 3, 4, 5, 7, 8, 9, 16, 17, 31, 64, 100, 255, 256, 257, 1000, 4096] { + let mut seen = vec![0u32; n]; + let mut count = 0usize; + for p in dizzy_order(n) { + assert!(p < n, "n={n}: yielded out-of-range index {p}"); + seen[p] += 1; + count += 1; + } + assert_eq!(count, n, "n={n}: walk yielded {count} indices, expected {n}"); + assert!( + seen.iter().all(|&c| c == 1), + "n={n}: some index was visited {:?} times, expected exactly once each", + seen.iter().max() + ); + } + } + + #[test] + fn walk_is_not_the_identity() { + // A permutation that happened to be the identity would pass the bijection + // test while making this mode a plain raster scan. + let order: Vec = dizzy_order(256).collect(); + let identity: Vec = (0..256).collect(); + assert_ne!(order, identity, "walk degenerated into raster order"); + } + + #[test] + fn cross_language_reference_vector() { + // 4x4 horizontal ramp, BWR palette, dizzy. These literals are frozen and + // mirrored verbatim in the Python and JavaScript suites; if any surface + // drifts, exactly one of the three tests fails. + let mut img = Vec::new(); + for y in 0..4u8 { + for x in 0..4u8 { + let v = x * 60 + y * 5; + img.extend_from_slice(&[v, v, v]); + } + } + let out = dizzy_dither(&img, 4, 4, ColorScheme::Bwr.palette()); + assert_eq!(out, vec![0, 0, 1, 0, 0, 2, 2, 1, 0, 1, 1, 1, 0, 0, 2, 1]); + } +} diff --git a/packages/rust/core/src/enums.rs b/packages/rust/core/src/enums.rs index 7f7ab6b..9c4588e 100644 --- a/packages/rust/core/src/enums.rs +++ b/packages/rust/core/src/enums.rs @@ -24,6 +24,10 @@ pub enum DitherMode { Sierra = 6, SierraLite = 7, JarvisJudiceNinke = 8, + /// Error diffusion with pseudo-random traversal instead of a raster scan, + /// diffusing error only to not-yet-quantized neighbours. Produces no + /// directional structure. `serpentine` is ignored: there is no scan direction. + Dizzy = 9, } /// Dynamic range compression applied before dithering. @@ -94,6 +98,7 @@ impl TryFrom for DitherMode { 6 => Ok(DitherMode::Sierra), 7 => Ok(DitherMode::SierraLite), 8 => Ok(DitherMode::JarvisJudiceNinke), + 9 => Ok(DitherMode::Dizzy), _ => Err(DitherError::UnknownDitherMode(v)), } } @@ -102,7 +107,7 @@ impl TryFrom for DitherMode { impl DitherMode { pub fn kernel(self) -> Option<&'static Kernel> { match self { - DitherMode::None | DitherMode::Ordered => None, + DitherMode::None | DitherMode::Ordered | DitherMode::Dizzy => None, DitherMode::FloydSteinberg => Some(&FLOYD_STEINBERG), DitherMode::Burkes => Some(&BURKES), DitherMode::Atkinson => Some(&ATKINSON), @@ -113,3 +118,15 @@ impl DitherMode { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dizzy_is_mode_nine_and_has_no_kernel() { + assert_eq!(DitherMode::Dizzy as u8, 9); + assert_eq!(DitherMode::try_from(9u8), Ok(DitherMode::Dizzy)); + assert!(DitherMode::Dizzy.kernel().is_none(), "dizzy has no fixed kernel"); + } +} diff --git a/packages/rust/core/src/lib.rs b/packages/rust/core/src/lib.rs index 5a2aba8..06d7918 100644 --- a/packages/rust/core/src/lib.rs +++ b/packages/rust/core/src/lib.rs @@ -2,6 +2,7 @@ pub mod algorithms; pub mod color_space; pub mod color_space_lab; pub mod composite; +pub mod dizzy; pub mod enums; pub mod error; pub mod kernels; @@ -75,6 +76,10 @@ fn dispatch( algorithms::ordered_dither_with_canonical(img.data, img.width, p, canonical) } DitherMode::Ordered => algorithms::ordered_dither(img.data, img.width, p), + DitherMode::Dizzy if pin_exact_pixels => { + dizzy::dizzy_dither_with_canonical(img.data, img.width, img.height, p, canonical) + } + DitherMode::Dizzy => dizzy::dizzy_dither(img.data, img.width, img.height, p), DitherMode::FloydSteinberg | DitherMode::Burkes | DitherMode::Atkinson @@ -218,6 +223,24 @@ mod tests { assert_eq!(GamutCompression::default(), config.gamut); } + /// Proves `DitherMode::Dizzy` is actually wired to the dizzy algorithm and not + /// accidentally aliased to another dispatch arm: on identical non-uniform input, + /// its output must differ from Burkes error diffusion. + #[test] + fn dizzy_output_differs_from_burkes() { + let pixels: Vec = (0u8..=255) + .flat_map(|v| [v, v / 2, 255 - v]) + .cycle() + .take(16 * 16 * 3) + .collect(); + let img = ImageBuffer::new(&pixels, 16); + + let burkes = dither(&img, &SPECTRA_7_3_6COLOR, DitherConfig { mode: DitherMode::Burkes, ..Default::default() }); + let dizzy = dither(&img, &SPECTRA_7_3_6COLOR, DitherConfig { mode: DitherMode::Dizzy, ..Default::default() }); + + assert_ne!(dizzy, burkes, "dizzy dithering should differ from Burkes on non-uniform input"); + } + #[test] fn none_uses_canonical_exact_colors_with_measured_palette() { let image = pixels([255, 0, 0], 4); diff --git a/packages/rust/core/tests/fixtures/references/cat__dizzy_mono_raw.bin b/packages/rust/core/tests/fixtures/references/cat__dizzy_mono_raw.bin new file mode 100644 index 0000000..18c0f58 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/cat__dizzy_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/cat__dizzy_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/cat__dizzy_spectra6_auto.bin new file mode 100644 index 0000000..477adb9 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/cat__dizzy_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/cat_orange__dizzy_mono_raw.bin b/packages/rust/core/tests/fixtures/references/cat_orange__dizzy_mono_raw.bin new file mode 100644 index 0000000..60f9983 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/cat_orange__dizzy_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/cat_orange__dizzy_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/cat_orange__dizzy_spectra6_auto.bin new file mode 100644 index 0000000..2ae138e Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/cat_orange__dizzy_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/frankfurt_nacht__dizzy_mono_raw.bin b/packages/rust/core/tests/fixtures/references/frankfurt_nacht__dizzy_mono_raw.bin new file mode 100644 index 0000000..b671099 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/frankfurt_nacht__dizzy_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/frankfurt_nacht__dizzy_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/frankfurt_nacht__dizzy_spectra6_auto.bin new file mode 100644 index 0000000..a14f0a6 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/frankfurt_nacht__dizzy_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/olympiapark__dizzy_mono_raw.bin b/packages/rust/core/tests/fixtures/references/olympiapark__dizzy_mono_raw.bin new file mode 100644 index 0000000..2050108 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/olympiapark__dizzy_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/olympiapark__dizzy_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/olympiapark__dizzy_spectra6_auto.bin new file mode 100644 index 0000000..c0ed66c Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/olympiapark__dizzy_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/river__dizzy_mono_raw.bin b/packages/rust/core/tests/fixtures/references/river__dizzy_mono_raw.bin new file mode 100644 index 0000000..60b5999 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/river__dizzy_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/river__dizzy_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/river__dizzy_spectra6_auto.bin new file mode 100644 index 0000000..5909625 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/river__dizzy_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/seeed_opendisplay__dizzy_mono_raw.bin b/packages/rust/core/tests/fixtures/references/seeed_opendisplay__dizzy_mono_raw.bin new file mode 100644 index 0000000..49d2b6f Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/seeed_opendisplay__dizzy_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/seeed_opendisplay__dizzy_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/seeed_opendisplay__dizzy_spectra6_auto.bin new file mode 100644 index 0000000..9474efb Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/seeed_opendisplay__dizzy_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/ubahn_station__dizzy_mono_raw.bin b/packages/rust/core/tests/fixtures/references/ubahn_station__dizzy_mono_raw.bin new file mode 100644 index 0000000..88bb964 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/ubahn_station__dizzy_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/ubahn_station__dizzy_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/ubahn_station__dizzy_spectra6_auto.bin new file mode 100644 index 0000000..d8898e3 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/ubahn_station__dizzy_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/fixtures/references/unicorn__dizzy_mono_raw.bin b/packages/rust/core/tests/fixtures/references/unicorn__dizzy_mono_raw.bin new file mode 100644 index 0000000..1258405 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/unicorn__dizzy_mono_raw.bin differ diff --git a/packages/rust/core/tests/fixtures/references/unicorn__dizzy_spectra6_auto.bin b/packages/rust/core/tests/fixtures/references/unicorn__dizzy_spectra6_auto.bin new file mode 100644 index 0000000..2c846e1 Binary files /dev/null and b/packages/rust/core/tests/fixtures/references/unicorn__dizzy_spectra6_auto.bin differ diff --git a/packages/rust/core/tests/regression.rs b/packages/rust/core/tests/regression.rs index a25910a..1384268 100644 --- a/packages/rust/core/tests/regression.rs +++ b/packages/rust/core/tests/regression.rs @@ -156,4 +156,36 @@ fn ordered_spectra6_auto() { GamutCompression::Auto, ); } +} + +/// Dizzy + 6-color measured palette + auto preprocessing. +/// Random-traversal family — pins the permutation walk against accidental change. +#[test] +fn dizzy_spectra6_auto() { + for img in discover_images() { + assert_regression( + &img, + "dizzy_spectra6_auto", + DitherMode::Dizzy, + &SPECTRA_7_3_6COLOR, + ToneCompression::Auto, + GamutCompression::Auto, + ); + } +} + +/// Dizzy + monochrome + no preprocessing. +/// Two-color palette makes any traversal-order change highly visible in the diff. +#[test] +fn dizzy_mono_raw() { + for img in discover_images() { + assert_regression( + &img, + "dizzy_mono_raw", + DitherMode::Dizzy, + ColorScheme::Mono, + ToneCompression::Fixed(0.0), + GamutCompression::None, + ); + } } \ No newline at end of file