diff --git a/.gitignore b/.gitignore index c419eea..d35eedb 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ yarn.lock # Project specific /fixtures/expected/ *.png +!docs/examples/*.png *.jpg *.jpeg *.gif diff --git a/README.md b/README.md index 545fc22..304b0e3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ Dithering algorithms for e-paper/e-ink displays. Rust core with Python and JavaS ![All color schemes compared](docs/examples/color_schemes_grid.png) +**DBS refinement** (Spectra 6-color, auto tone + gamut). Direct Binary Search optimizes the dithered pixels against a model of the eye at a given viewing distance: here it recovers the blue sky that plain error diffusion washes out to grey. The zoomed crops show the trade-off up close, including a few colored specks where it mixes inks. It is opt-in and slower (about 0.3 s per 800×480 panel on a multi-core machine, vs. ~30 ms). + +![Original vs Burkes vs Burkes + DBS, full image and 4× crops](docs/examples/dbs_comparison.png) + ## Packages ### Python (`packages/python/`) @@ -44,6 +48,9 @@ dithered = dither_image(img, SPECTRA_7_3_6COLOR_V2) # Opt in to automatic tone + gamut compression for photos dithered = dither_image(img, SPECTRA_7_3_6COLOR_V2, tone="auto", gamut="auto") + +# Opt in to DBS refinement (slower; optimizes for how the image is perceived) +dithered = dither_image(img, SPECTRA_7_3_6COLOR_V2, dbs=True) ``` See [`packages/python/README.md`](packages/python/README.md) for full documentation. @@ -67,6 +74,9 @@ const dithered = ditherImage(imageBuffer, SPECTRA_7_3_6COLOR_V2); // Opt in to automatic tone + gamut compression for photos const compressed = ditherImage(imageBuffer, SPECTRA_7_3_6COLOR_V2, { tone: 'auto', gamut: 'auto' }); + +// Opt in to DBS refinement (slower; run it in a Web Worker in the browser) +const refined = ditherImage(imageBuffer, SPECTRA_7_3_6COLOR_V2, { dbs: true }); ``` See [`packages/javascript/README.md`](packages/javascript/README.md) for full documentation. @@ -78,7 +88,8 @@ See [`packages/javascript/README.md`](packages/javascript/README.md) for full do - **10 Color Schemes**: MONO, BWR, BWY, BWRY, BWGBRY (Spectra 6), GRAYSCALE_4, GRAYSCALE_16, SEVEN_COLOR (Spectra/ACeP 7), BWGBRY_SPLIT, GRAYSCALE_8 (library-local, e.g. Inkplate 10 — not a firmware wire value) - **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 -- **Pre-dither Knobs**: Per-image exposure, saturation, shadows, highlights, and gamut compression — all orthogonal +- **Pre-dither Knobs**: Per-image exposure, saturation, shadows, highlights, dynamic-range (tone) compression, and gamut compression — all orthogonal +- **DBS Refinement**: Optional Direct Binary Search pass that optimizes the dithered pixels for how they are perceived at a given viewing distance and panel resolution — opt-in, slower ## Repository Structure @@ -103,7 +114,8 @@ uv sync --all-extras uv run maturin develop --release uv run pytest tests/ -v -# JavaScript +# JavaScript (requires wasm-pack and the wasm32-unknown-unknown target) +wasm-pack build packages/rust/wasm --target web --out-dir ../../javascript/src/wasm-core cd packages/javascript bun install bun run test diff --git a/docs/examples/algorithms_grid.png b/docs/examples/algorithms_grid.png index b765ebc..f091253 100644 Binary files a/docs/examples/algorithms_grid.png and b/docs/examples/algorithms_grid.png differ diff --git a/docs/examples/color_schemes_grid.png b/docs/examples/color_schemes_grid.png index 74c0478..56c2bf0 100644 Binary files a/docs/examples/color_schemes_grid.png and b/docs/examples/color_schemes_grid.png differ diff --git a/docs/examples/dbs_comparison.png b/docs/examples/dbs_comparison.png new file mode 100644 index 0000000..d18e3d1 Binary files /dev/null and b/docs/examples/dbs_comparison.png differ diff --git a/docs/examples/frankfurt_before_after.png b/docs/examples/frankfurt_before_after.png index f8511d9..b9b9a33 100644 Binary files a/docs/examples/frankfurt_before_after.png and b/docs/examples/frankfurt_before_after.png differ diff --git a/docs/examples/generate.py b/docs/examples/generate.py index 5108d49..782937e 100644 --- a/docs/examples/generate.py +++ b/docs/examples/generate.py @@ -29,6 +29,32 @@ def label(img: Image.Image, text: str, size: int = 20) -> Image.Image: return out +def hstack(images: list[Image.Image], gap: int = 4) -> Image.Image: + """Place images side by side with a dark gutter.""" + out = Image.new("RGB", (sum(i.width for i in images) + gap * (len(images) - 1), max(i.height for i in images)), (15, 15, 15)) + x = 0 + for im in images: + out.paste(im, (x, 0)) + x += im.width + gap + return out + + +def vstack(images: list[Image.Image], gap: int = 4) -> Image.Image: + """Stack images vertically with a dark gutter.""" + out = Image.new("RGB", (max(i.width for i in images), sum(i.height for i in images) + gap * (len(images) - 1)), (15, 15, 15)) + y = 0 + for im in images: + out.paste(im, (0, y)) + y += im.height + gap + return out + + +def zoom(img: Image.Image, box: tuple[int, int, int, int], factor: int = 4) -> Image.Image: + """Crop and upscale with nearest-neighbour so individual pixels stay visible.""" + crop = img.crop(box) + return crop.resize((crop.width * factor, crop.height * factor), Image.Resampling.NEAREST) + + def grid(cells: list[list[Image.Image]]) -> Image.Image: rows = len(cells) cols = max(len(r) for r in cells) @@ -48,17 +74,13 @@ def grid(cells: list[list[Image.Image]]) -> Image.Image: frankfurt_no_pre = dither_image(frankfurt_orig, SPECTRA_7_3_6COLOR_V2, mode=DitherMode.BURKES, tone=0.0, gamut=0.0).convert("RGB") frankfurt_auto = dither_image(frankfurt_orig, SPECTRA_7_3_6COLOR_V2, - mode=DitherMode.BURKES).convert("RGB") + mode=DitherMode.BURKES, tone="auto", gamut="auto").convert("RGB") p1 = label(frankfurt_orig, "Original") p2 = label(frankfurt_no_pre, "Spectra 6-color · Burkes · no preprocessing") p3 = label(frankfurt_auto, "Spectra 6-color · Burkes · auto tone + gamut") -ba = Image.new("RGB", (p1.width + p2.width + p3.width + 8, p1.height), (15, 15, 15)) -ba.paste(p1, (0, 0)) -ba.paste(p2, (p1.width + 4, 0)) -ba.paste(p3, (p1.width + p2.width + 8, 0)) -ba.save(OUT / "frankfurt_before_after.png") +hstack([p1, p2, p3]).save(OUT / "frankfurt_before_after.png") # ── 2. All algorithms grid ──────────────────────────────────────────────────── @@ -75,12 +97,14 @@ def grid(cells: list[list[Image.Image]]) -> Image.Image: (DitherMode.SIERRA, "Sierra"), (DitherMode.STUCKI, "Stucki"), (DitherMode.JARVIS_JUDICE_NINKE,"Jarvis-Judice-Ninke"), + (DitherMode.DIZZY, "Dizzy"), ] +# Two columns keep each cell large enough to judge once GitHub scales the image down. algo_cells = [] -for i in range(0, len(ALGOS), 3): +for i in range(0, len(ALGOS), 2): row = [] - for mode, name in ALGOS[i:i+3]: + for mode, name in ALGOS[i:i+2]: cell = dithered_rgb(src, SPECTRA_7_3_6COLOR_V2, mode) row.append(label(cell, name)) algo_cells.append(row) @@ -113,4 +137,21 @@ def grid(cells: list[list[Image.Image]]) -> Image.Image: grid(scheme_cells).save(OUT / "color_schemes_grid.png") +# ── 4. DBS refinement ───────────────────────────────────────────────────────── + +print("Generating DBS comparison...") +src3 = load("river.png") +dbs_kwargs = {"mode": DitherMode.BURKES, "tone": "auto", "gamut": "auto"} +burkes = dither_image(src3, SPECTRA_7_3_6COLOR_V2, **dbs_kwargs).convert("RGB") +refined = dither_image(src3, SPECTRA_7_3_6COLOR_V2, dbs=True, **dbs_kwargs).convert("RGB") + +# Sky, clouds and treeline: smooth gradients plus edges. +CROP = (230, 60, 430, 180) +dbs_rows = [ + [label(src3, "Original"), label(burkes, "Burkes"), label(refined, "Burkes + DBS")], + [label(zoom(im, CROP), f"{name} · 4× crop") for im, name in + [(src3, "Original"), (burkes, "Burkes"), (refined, "Burkes + DBS")]], +] +vstack([hstack(row) for row in dbs_rows]).save(OUT / "dbs_comparison.png") + print("Done. Output in", OUT)