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