Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,7 @@
<option value="SIERRA">Sierra</option>
<option value="SIERRA_LITE">Sierra Lite</option>
<option value="JARVIS_JUDICE_NINKE">Jarvis-Judice-Ninke</option>
<option value="DIZZY">Dizzy</option>
</select>
</div>

Expand Down Expand Up @@ -738,6 +739,7 @@
SIERRA: DitherMode.SIERRA,
SIERRA_LITE: DitherMode.SIERRA_LITE,
JARVIS_JUDICE_NINKE: DitherMode.JARVIS_JUDICE_NINKE,
DIZZY: DitherMode.DIZZY,
};

// ── State ────────────────────────────────────────────────────────
Expand Down
3 changes: 2 additions & 1 deletion packages/javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/javascript/demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@
<option value="SIERRA">Sierra</option>
<option value="SIERRA_LITE">Sierra Lite</option>
<option value="JARVIS_JUDICE_NINKE">Jarvis-Judice-Ninke</option>
<option value="DIZZY">Dizzy</option>
</select>
</div>

Expand Down Expand Up @@ -762,6 +763,7 @@
SIERRA: DitherMode.SIERRA,
SIERRA_LITE: DitherMode.SIERRA_LITE,
JARVIS_JUDICE_NINKE: DitherMode.JARVIS_JUDICE_NINKE,
DIZZY: DitherMode.DIZZY,
};

// ── State ────────────────────────────────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions packages/javascript/dev.html
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@
<option value="SIERRA">Sierra</option>
<option value="SIERRA_LITE">Sierra Lite</option>
<option value="JARVIS_JUDICE_NINKE">Jarvis-Judice-Ninke</option>
<option value="DIZZY">Dizzy</option>
</select>
</div>

Expand Down Expand Up @@ -762,6 +763,7 @@
SIERRA: DitherMode.SIERRA,
SIERRA_LITE: DitherMode.SIERRA_LITE,
JARVIS_JUDICE_NINKE: DitherMode.JARVIS_JUDICE_NINKE,
DIZZY: DitherMode.DIZZY,
};

// ── State ────────────────────────────────────────────────────────
Expand Down
8 changes: 7 additions & 1 deletion packages/javascript/src/enums.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Dithering algorithm modes
* Values match firmware conventions (0-8)
* Values match firmware conventions (0-9)
*/
export enum DitherMode {
/**
Expand All @@ -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,
}
26 changes: 26 additions & 0 deletions packages/javascript/tests/dithering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
});
3 changes: 2 additions & 1 deletion packages/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions packages/python/src/epaper_dithering/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions packages/python/tests/test_dithering.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
10 changes: 10 additions & 0 deletions packages/rust/core/benches/dithering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion packages/rust/core/examples/dither.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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); }
};

Expand Down
122 changes: 122 additions & 0 deletions packages/rust/core/examples/dizzy_compare.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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<u8>, &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);
}
}
4 changes: 2 additions & 2 deletions packages/rust/core/src/algorithms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<u8> {
pub(crate) fn exact_palette_index(rgb: &[u8], palette: &Palette) -> Option<u8> {
palette
.colors
.iter()
Expand Down
Loading
Loading