diff --git a/README.md b/README.md index a0a391a..a4b2dfe 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,11 @@ img = Image.open("photo.jpg") # Idealized palette dithered = dither_image(img, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG) -# Measured palette — auto tone + gamut compression +# Measured palette — calibrated color matching 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") ``` See [`packages/python/README.md`](packages/python/README.md) for full documentation. @@ -59,8 +62,11 @@ import { ditherImage, ColorScheme, DitherMode, SPECTRA_7_3_6COLOR_V2 } from '@op // ImageBuffer from Canvas API or Node.js (sharp, etc.) const dithered = ditherImage(imageBuffer, ColorScheme.BWR, { mode: DitherMode.BURKES }); -// Measured palette — auto tone + gamut compression +// Measured palette — calibrated color matching 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' }); ``` See [`packages/javascript/README.md`](packages/javascript/README.md) for full documentation. @@ -70,7 +76,7 @@ See [`packages/javascript/README.md`](packages/javascript/README.md) for full do - **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 - **8 Color Schemes**: MONO, BWR, BWY, BWRY, BWGBRY (Spectra 6), GRAYSCALE_4, GRAYSCALE_8, GRAYSCALE_16 -- **Measured Palettes**: Calibrated RGB values for real displays with tone + gamut compression +- **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 diff --git a/packages/javascript/README.md b/packages/javascript/README.md index 28c6900..745c43a 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -65,8 +65,14 @@ Standard `ColorScheme` values use ideal sRGB colors (e.g. white = 255,255,255). ```typescript import { ditherImage, SPECTRA_7_3_6COLOR, BWRY_3_97 } from '@opendisplay/epaper-dithering'; -// Automatically applies tone compression to fit the display's actual dynamic range const dithered = ditherImage(imageBuffer, SPECTRA_7_3_6COLOR, { mode: DitherMode.BURKES }); + +// Opt in when you want automatic tone/gamut compression for photos +const compressed = ditherImage(imageBuffer, SPECTRA_7_3_6COLOR, { + mode: DitherMode.BURKES, + tone: 'auto', + gamut: 'auto', +}); ``` Available measured palettes: `SPECTRA_7_3_6COLOR_V2`, `SPECTRA_7_3_6COLOR`, `BWRY_3_97`, `MONO_4_26`, `BWRY_4_2`, `SOLUM_BWR`, `HANSHOW_BWR`, `HANSHOW_BWY`. @@ -116,11 +122,15 @@ ditherImage(image: ImageBuffer, palette: ColorScheme | ColorPalette, options?: D | `saturation` | `number` | `1.0` | OKLab saturation multiplier. `0.0` = grayscale, `>1` = boost. Hue-preserving | | `shadows` | `number` | `0.0` | Shadow lift strength (S-curve lower half). `0.0` = off, `1.0` = strong | | `highlights` | `number` | `0.0` | Highlight compression strength (S-curve upper half). `0.0` = off, `1.0` = strong | -| `tone` | `number \| 'auto' \| 'off'` | `'auto'` | Dynamic range compression. `'auto'` = histogram-based; numeric = fixed strength. Ignored for `ColorScheme` | -| `gamut` | `number \| 'auto' \| 'off'` | `'auto'` | Pre-dither gamut compression. `'auto'` = activate when image exceeds palette gamut; numeric = fixed. Ignored for `ColorScheme` | +| `tone` | `number \| 'auto' \| 'off'` | `0.0` | Dynamic range compression. `0.0`/`'off'` = disabled; `'auto'` = histogram-based; numeric = fixed strength. Ignored for `ColorScheme` | +| `gamut` | `number \| 'auto' \| 'off'` | `0.0` | Pre-dither gamut compression. `0.0`/`'off'` = disabled; `'auto'` = activate when image exceeds palette gamut; numeric = fixed. Ignored for `ColorScheme` | Pre-processing pipeline: `exposure → saturation → shadows/highlights → tone → gamut → dither`. Each step is a no-op at its identity value. +`DitherMode.NONE` performs direct nearest-color mapping without error diffusion or ordered dithering. Built-in measured palettes carry their canonical firmware `scheme`, so pure display colors map to the corresponding firmware palette index even when measured RGB values are used for matching. + +For built-in measured palettes, exact canonical display colors are also protected in ordered and error-diffusion modes when pre-processing is off: an image made entirely of display colors is returned as a direct palette-index map, and exact display-color pixels inside a mixed image keep their canonical index instead of being rematched to the measured RGB palette. Pre-processing runs before that exact-pixel check, so explicit `tone: 'auto'`, `gamut: 'auto'`, or other adjustments may intentionally alter those pixels first. + Returns `PaletteImageBuffer`. ### Color Schemes @@ -171,6 +181,7 @@ interface PaletteImageBuffer { interface ColorPalette { readonly colors: Record; readonly accent: string; + readonly scheme?: number; } ``` diff --git a/packages/javascript/demo.html b/packages/javascript/demo.html index e9e00ef..dbf72fc 100644 --- a/packages/javascript/demo.html +++ b/packages/javascript/demo.html @@ -1,269 +1,1042 @@ - - - epaper-dithering Demo - + + + Dither Lab + + + -

📄 E-Paper Dithering Demo

-

Upload an image and see it dithered for e-paper displays using different algorithms and color schemes.

- -
- - - - - +
+ +
+ + +
+ + +
+
Color Scheme
+ +
- - + + + + + + + + + +
- -
-
-

Original

- -
-
+ +
+
Options
+
+ Serpentine scan + +
+
+ + +
+
Pre-dither
+
Exposure
+
+ + 1.00 +
+
Saturation
+
+ + 1.00 +
+
Shadows
+
+ + 0.00 +
+
Highlights
+
+ + 0.00 +
+
+ + +
+
Tone Compression
+
+ Auto levels + +
+
+ + 0% +
+
Gamut Compression
+
+ Auto gamut + +
+
+ + 0% +
+
+ +
-
-

Dithered for E-Paper

- -
+ +
+
Palette
+
+
+ +
+ + +
+
size
+
colors
+
time
+
+ + + + +
+ +
+
+
no image loaded
+
+ + - +
+ +
+ + +
+
Drop Image
+
png · jpg · webp · gif
+
+ + +document.addEventListener('paste', (e) => { + const item = [...e.clipboardData.items].find(i => i.type.startsWith('image/')); + if (item) loadFile(item.getAsFile()); +}); + - \ No newline at end of file + diff --git a/packages/javascript/dev.html b/packages/javascript/dev.html index ccf669a..23bccc0 100644 --- a/packages/javascript/dev.html +++ b/packages/javascript/dev.html @@ -637,25 +637,25 @@
Auto levels
- - auto + + 0%
Gamut Compression
Auto gamut
- - auto + + 0%
@@ -830,12 +830,12 @@ } function getToneCompression() { - if (!isMeasuredPalette()) return 'auto'; + if (!isMeasuredPalette()) return 0.0; return toneAutoToggle.checked ? 'auto' : toneSlider.value / 100; } function getGamutCompression() { - if (!isMeasuredPalette()) return 'auto'; + if (!isMeasuredPalette()) return 0.0; return gamutAutoToggle.checked ? 'auto' : gamutSlider.value / 100; } diff --git a/packages/javascript/src/core.ts b/packages/javascript/src/core.ts index 75c3f4a..36ea2a8 100644 --- a/packages/javascript/src/core.ts +++ b/packages/javascript/src/core.ts @@ -40,9 +40,9 @@ export interface DitherOptions { shadows?: number; /** Highlight compression strength (S-curve upper half). 0.0 = off, 1.0 = strong. Default: `0.0`. */ highlights?: number; - /** Dynamic-range compression: `'auto'` | `'off'` | 0.0–1.0. Default: `'auto'`. */ + /** Dynamic-range compression: `0.0`/`'off'` disables, `'auto'` opts in. Default: `0.0`. */ tone?: number | 'auto' | 'off'; - /** Gamut compression: `'auto'` | `'off'` | 0.0–1.0. Default: `'auto'`. */ + /** Gamut compression: `0.0`/`'off'` disables, `'auto'` opts in. Default: `0.0`. */ gamut?: number | 'auto' | 'off'; } @@ -66,8 +66,8 @@ export function ditherImage( saturation = 1.0, shadows = 0.0, highlights = 0.0, - tone = 'auto', - gamut = 'auto', + tone = 0.0, + gamut = 0.0, } = options; const rgba = new Uint8Array(image.data.buffer, image.data.byteOffset, image.data.byteLength); @@ -88,6 +88,7 @@ export function ditherImage( paletteBytes = new Uint8Array(0); outputColors = Object.values(getPalette(palette).colors); } else { + schemeId = palette.scheme ?? 255; const colors = Object.values(palette.colors); paletteBytes = new Uint8Array(colors.flatMap(c => [c.r, c.g, c.b])); accentIdx = Object.keys(palette.colors).indexOf(palette.accent); diff --git a/packages/javascript/src/palettes.ts b/packages/javascript/src/palettes.ts index 1d20f21..f07f47d 100644 --- a/packages/javascript/src/palettes.ts +++ b/packages/javascript/src/palettes.ts @@ -146,6 +146,7 @@ export function fromValue(value: number): ColorScheme { // Measured: 2026-02-03, iPhone 15 Pro Max RAW + Hue Play bars @ 6500K // Paper reference RGB(215,217,218); normalization: value × (255/paper_channel) export const SPECTRA_7_3_6COLOR: ColorPalette = { + scheme: ColorScheme.BWGBRY, colors: { black: { r: 26, g: 13, b: 35 }, white: { r: 185, g: 202, b: 205 }, @@ -159,6 +160,7 @@ export const SPECTRA_7_3_6COLOR: ColorPalette = { // 4.26" Monochrome (MONO scheme) export const MONO_4_26: ColorPalette = { + scheme: ColorScheme.MONO, colors: { black: { r: 5, g: 5, b: 5 }, white: { r: 220, g: 220, b: 220 }, @@ -168,6 +170,7 @@ export const MONO_4_26: ColorPalette = { // 4.2" BWRY (BWRY scheme) export const BWRY_4_2: ColorPalette = { + scheme: ColorScheme.BWRY, colors: { black: { r: 5, g: 5, b: 5 }, white: { r: 200, g: 200, b: 200 }, @@ -179,6 +182,7 @@ export const BWRY_4_2: ColorPalette = { // Solum BWR (harvested display, BWR scheme) export const SOLUM_BWR: ColorPalette = { + scheme: ColorScheme.BWR, colors: { black: { r: 5, g: 5, b: 5 }, white: { r: 200, g: 200, b: 200 }, @@ -189,6 +193,7 @@ export const SOLUM_BWR: ColorPalette = { // Hanshow BWR (harvested display, BWR scheme) export const HANSHOW_BWR: ColorPalette = { + scheme: ColorScheme.BWR, colors: { black: { r: 5, g: 5, b: 5 }, white: { r: 200, g: 200, b: 200 }, @@ -199,6 +204,7 @@ export const HANSHOW_BWR: ColorPalette = { // Hanshow BWY (harvested display, BWY scheme) export const HANSHOW_BWY: ColorPalette = { + scheme: ColorScheme.BWY, colors: { black: { r: 5, g: 5, b: 5 }, white: { r: 200, g: 200, b: 200 }, @@ -212,6 +218,7 @@ export const HANSHOW_BWY: ColorPalette = { // Measured: 2026-03-15, iPhone 15 Pro Max RAW + Affinity (v3), A4 paper white reference // Method: DNG with linear tone curve, WB from A4 paper, uniform ×2.4 scale export const SPECTRA_7_3_6COLOR_V2: ColorPalette = { + scheme: ColorScheme.BWGBRY, colors: { black: { r: 31, g: 24, b: 41 }, white: { r: 168, g: 180, b: 182 }, @@ -226,6 +233,7 @@ export const SPECTRA_7_3_6COLOR_V2: ColorPalette = { // Measured: 2026-03-06, iPhone RAW // Paper reference RGB(205,205,205); normalization: value × (255/205) export const BWRY_3_97: ColorPalette = { + scheme: ColorScheme.BWRY, colors: { black: { r: 10, g: 7, b: 14 }, white: { r: 173, g: 178, b: 174 }, diff --git a/packages/javascript/src/types.ts b/packages/javascript/src/types.ts index 034c15c..9d98e5b 100644 --- a/packages/javascript/src/types.ts +++ b/packages/javascript/src/types.ts @@ -33,4 +33,6 @@ export interface PaletteImageBuffer { export interface ColorPalette { readonly colors: Record; readonly accent: string; -} \ No newline at end of file + /** Canonical firmware color scheme value, if this is a measured palette. */ + readonly scheme?: number; +} diff --git a/packages/javascript/tests/dithering.test.ts b/packages/javascript/tests/dithering.test.ts index 7f0381f..b570a29 100644 --- a/packages/javascript/tests/dithering.test.ts +++ b/packages/javascript/tests/dithering.test.ts @@ -120,6 +120,71 @@ describe('Dithering Algorithms', () => { expect(result.indices[i]).toBeLessThan(6); } }); + + it('defaults tone/gamut to off and accepts the off alias', () => { + const image = createTestImage(16, 16, { r: 128, g: 128, b: 128 }); + const resultDefault = ditherImage(image, SPECTRA_7_3_6COLOR, { mode: DitherMode.BURKES }); + const resultZero = ditherImage(image, SPECTRA_7_3_6COLOR, { + mode: DitherMode.BURKES, + tone: 0.0, + gamut: 0.0, + }); + const resultOff = ditherImage(image, SPECTRA_7_3_6COLOR, { + mode: DitherMode.BURKES, + tone: 'off', + gamut: 'off', + }); + + expect(resultDefault.indices).toEqual(resultZero.indices); + expect(resultDefault.indices).toEqual(resultOff.indices); + }); + + it('measured palettes record their canonical firmware scheme', () => { + expect(SPECTRA_7_3_6COLOR.scheme).toBe(ColorScheme.BWGBRY); + }); + + it('DitherMode.NONE maps pure display colors directly for measured palettes', () => { + const red = getPalette(ColorScheme.BWGBRY).colors.red; + const image = createTestImage(4, 4, red); + const result = ditherImage(image, SPECTRA_7_3_6COLOR, { mode: DitherMode.NONE }); + + expect(new Set(result.indices)).toEqual(new Set([3])); + }); + + it.each([DitherMode.ORDERED, DitherMode.BURKES, DitherMode.FLOYD_STEINBERG])( + 'pins exact display colors inside mixed measured images for mode %s', + (mode) => { + const green = getPalette(ColorScheme.BWGBRY).colors.green; + const image = createTestImage(8, 4, { r: 128, g: 128, b: 128 }); + for (let y = 0; y < 2; y++) { + for (let x = 0; x < 4; x++) { + const idx = (y * image.width + x) * 4; + image.data[idx] = green.r; + image.data[idx + 1] = green.g; + image.data[idx + 2] = green.b; + image.data[idx + 3] = 255; + } + } + + const result = ditherImage(image, SPECTRA_7_3_6COLOR, { mode }); + const pinned = []; + for (let y = 0; y < 2; y++) { + for (let x = 0; x < 4; x++) { + pinned.push(result.indices[y * image.width + x]); + } + } + + expect(new Set(pinned)).toEqual(new Set([5])); + } + ); + + it('predefined measured palettes return measured preview colors', () => { + const red = getPalette(ColorScheme.BWGBRY).colors.red; + const image = createTestImage(1, 1, red); + const result = ditherImage(image, SPECTRA_7_3_6COLOR, { mode: DitherMode.NONE }); + + expect(result.palette[3]).toEqual(SPECTRA_7_3_6COLOR.colors.red); + }); }); describe('ColorScheme', () => { diff --git a/packages/python/README.md b/packages/python/README.md index beb5db7..b06e350 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -138,22 +138,26 @@ Note: The `serpentine` parameter only affects error diffusion algorithms (Floyd- E-paper displays can't reproduce the full luminance range of digital images. Pure white on a display is much darker than (255, 255, 255), and pure black is lighter than (0, 0, 0). Without tone compression, dithering tries to represent unreachable brightness levels, causing large accumulated errors and noisy output. -Tone compression remaps image luminance to the display's actual range before dithering. Based on [`fast_compress_dynamic_range()`](https://github.com/aitjcize/esp32-photoframe) from esp32-photoframe by aitjcize. It is enabled by default (`tone="auto"`) and only applies when using measured `ColorPalette` instances: +Tone compression remaps image luminance to the display's actual range before dithering. Based on [`fast_compress_dynamic_range()`](https://github.com/aitjcize/esp32-photoframe) from esp32-photoframe by aitjcize. It is off by default (`tone=0.0`) and only applies when using measured `ColorPalette` instances: -- **`"auto"`** (default): Analyzes the image histogram and remaps its actual luminance range to the display range. Maximizes contrast by stretching only the used range. -- **`0.0-1.0`**: Fixed linear compression strength. `1.0` maps the full [0,1] range to the display range. `0.0` disables compression. +- **`0.0` / `"off"`** (default): Disable tone compression. +- **`"auto"`**: Analyze the image histogram and remap its actual luminance range to the display range. Maximizes contrast by stretching only the used range. +- **`0.0-1.0`**: Fixed linear compression strength. `1.0` maps the full [0,1] range to the display range. ```python from epaper_dithering import dither_image, SPECTRA_7_3_6COLOR, DitherMode -# Default: auto tone compression (recommended) +# Default: tone compression off result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG) +# Auto tone compression +result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG, tone="auto") + # Fixed linear compression result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG, tone=1.0) -# Disable tone compression -result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG, tone=0.0) +# Disable tone compression explicitly +result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG, tone="off") ``` Note: `tone` has no effect when using theoretical `ColorScheme` palettes (e.g., `ColorScheme.BWR`), since their black/white values already span the full range. @@ -163,18 +167,25 @@ Note: `tone` has no effect when using theoretical `ColorScheme` palettes (e.g., Some images contain highly saturated colors that a limited palette simply cannot reproduce (e.g. vivid purple on a BWGBRY display). Without gamut compression, the ditherer tries to mix palette colors to approximate the hue — often producing muddy results. Gamut compression pre-blends out-of-gamut pixels toward the nearest palette color before dithering, giving error diffusion a better starting point. ```python -# Default: auto gamut compression (activates only when image exceeds palette gamut) +# Default: gamut compression off result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES) +# Auto gamut compression +result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES, gamut="auto") + # Fixed strength (0.7–0.9 recommended for very saturated images) result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES, gamut=0.8) # Disable -result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES, gamut=0.0) +result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES, gamut="off") ``` Note: `gamut` also has no effect for theoretical `ColorScheme` palettes. +`DitherMode.NONE` performs direct nearest-color mapping without error diffusion or ordered dithering. For built-in measured palettes, pure canonical display colors such as `(255, 0, 0)` map directly to the corresponding firmware palette index even though matching uses measured display RGB values. + +For built-in measured palettes, exact canonical display colors are also protected in ordered and error-diffusion modes when pre-processing is off: an image made entirely of display colors is returned as a direct palette-index map, and exact display-color pixels inside a mixed image keep their canonical index instead of being rematched to the measured RGB palette. Pre-processing runs before that exact-pixel check, so explicit `tone="auto"`/`gamut="auto"` or other adjustments may intentionally alter those pixels first. + #### Per-Image Tonal Adjustments `exposure`, `saturation`, `shadows`, and `highlights` let you tweak the image *before* tone/gamut compression. Each is independent — set just the ones you want. All default to identity (no effect). @@ -263,6 +274,8 @@ my_display = ColorPalette( result = dither_image(img, my_display, mode=DitherMode.FLOYD_STEINBERG) ``` +Built-in measured palettes store the canonical firmware `ColorScheme` they are based on. Custom measured palettes may omit it; in that case direct mapping and exact-color bypass use the custom measured RGB values. + ### Measurement Quick Start 1. **Display full-screen color patches** on your e-paper @@ -298,4 +311,4 @@ uv run mypy src/epaper_dithering ## Credits Measured color calibration techniques and reference measurements inspired by: -- [esp32-photoframe](https://github.com/aitjcize/esp32-photoframe) by aitjcize - Measured palette methodology, dynamic range compression algorithm, and reference values for Waveshare 7.3" displays \ No newline at end of file +- [esp32-photoframe](https://github.com/aitjcize/esp32-photoframe) by aitjcize - Measured palette methodology, dynamic range compression algorithm, and reference values for Waveshare 7.3" displays diff --git a/packages/python/scripts/compare.py b/packages/python/scripts/compare.py index f6571f0..d7abc34 100644 --- a/packages/python/scripts/compare.py +++ b/packages/python/scripts/compare.py @@ -82,7 +82,7 @@ def render( src: Image.Image, scheme: object, mode: DitherMode, tc: float | str, gc: float | str = 0.0 ) -> tuple[Image.Image, float]: t0 = time.perf_counter() - dithered = dither_image(src, scheme, mode, tone_compression=tc, gamut_compression=gc) + dithered = dither_image(src, scheme, mode=mode, tone=tc, gamut=gc) return dithered.convert("RGB"), time.perf_counter() - t0 diff --git a/packages/python/scripts/pipeline.py b/packages/python/scripts/pipeline.py index edb4b97..ee29646 100644 --- a/packages/python/scripts/pipeline.py +++ b/packages/python/scripts/pipeline.py @@ -5,8 +5,8 @@ uv run scripts/pipeline.py [options] [image1.jpg image2.jpg ...] Options: - --tc auto|0|0.5|1.0 Tone compression (default: auto) - --gc auto|0|0.5|1.0 Gamut compression (default: auto) + --tc auto|0|0.5|1.0 Tone compression (default: 0) + --gc auto|0|0.5|1.0 Gamut compression (default: 0) --palette SPECTRA_V2|SPECTRA_V1|BWRY_3_97|... (default: SPECTRA_V2) --mode BURKES|FLOYD_STEINBERG|ATKINSON|... (default: BURKES) @@ -239,13 +239,13 @@ def run( ) # ── Step 5: Direct palette map ──────────────────────────────────────────── - direct = _lib.dither_image(src, palette, DitherMode.NONE, tone_compression=tc, gamut_compression=gc) + direct = _lib.dither_image(src, palette, mode=DitherMode.NONE, tone=tc, gamut=gc) panel5 = add_label( direct.convert("RGB"), f"5. Direct palette map (no error diffusion) tc={fmt(tc)} gc={fmt(gc)}", font ) # ── Step 6: Final output ────────────────────────────────────────────────── - final = _lib.dither_image(src, palette, mode, tone_compression=tc, gamut_compression=gc) + final = _lib.dither_image(src, palette, mode=mode, tone=tc, gamut=gc) panel6 = add_label(final.convert("RGB"), f"6. Final: {mode.name} tc={fmt(tc)} gc={fmt(gc)}", font) # ── Assemble vertical strip ─────────────────────────────────────────────── @@ -264,8 +264,8 @@ def run( def main() -> None: parser = argparse.ArgumentParser(description="Pipeline visualization for epaper dithering.") parser.add_argument("images", nargs="*", help="Image paths (default: marienplatz.jpg)") - parser.add_argument("--tc", default="auto", help="Tone compression: auto|0|0.5|1.0 (default: auto)") - parser.add_argument("--gc", default="auto", help="Gamut compression: auto|0|0.5|1.0 (default: auto)") + parser.add_argument("--tc", default="0", help="Tone compression: auto|0|0.5|1.0 (default: 0)") + parser.add_argument("--gc", default="0", help="Gamut compression: auto|0|0.5|1.0 (default: 0)") parser.add_argument("--palette", default="SPECTRA_V2", choices=list(PALETTES), help="Palette (default: SPECTRA_V2)") parser.add_argument("--mode", default="BURKES", choices=list(MODES), help="Dither mode (default: BURKES)") args = parser.parse_args() diff --git a/packages/python/src/epaper_dithering/_rs.pyi b/packages/python/src/epaper_dithering/_rs.pyi index c1ef419..1e1cfd4 100644 --- a/packages/python/src/epaper_dithering/_rs.pyi +++ b/packages/python/src/epaper_dithering/_rs.pyi @@ -15,4 +15,4 @@ def dither_image( tone: float | None = ..., gamut: float | None = ..., ) -> bytes: ... -def measured_palettes() -> list[tuple[str, list[int], list[str], int]]: ... +def measured_palettes() -> list[tuple[str, list[int], list[str], int, int]]: ... diff --git a/packages/python/src/epaper_dithering/core.py b/packages/python/src/epaper_dithering/core.py index bcbb2aa..5b666fc 100644 --- a/packages/python/src/epaper_dithering/core.py +++ b/packages/python/src/epaper_dithering/core.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import cast from PIL import Image @@ -27,8 +28,12 @@ def _to_rgb_bytes(image: Image.Image) -> tuple[bytes, int, int]: def _compression(v: float | str) -> float | None: - """Map Python compression param to Rust Option: 'auto' → None, float → Some.""" - return None if v == "auto" else float(v) + """Map compression params: 'auto' -> Rust None, 'off' -> 0.0, float -> Some.""" + if v == "auto": + return None + if v == "off": + return 0.0 + return float(v) def dither_image( # pylint: disable=too-many-arguments @@ -41,8 +46,8 @@ def dither_image( # pylint: disable=too-many-arguments saturation: float = 1.0, shadows: float = 0.0, highlights: float = 0.0, - tone: float | str = "auto", - gamut: float | str = "auto", + tone: float | str = 0.0, + gamut: float | str = 0.0, ) -> Image.Image: """Apply dithering to an image for e-paper display. @@ -60,18 +65,18 @@ def dither_image( # pylint: disable=too-many-arguments Hue-preserving. shadows: Shadow lift strength (S-curve lower half). 0.0 = off, 1.0 = strong. highlights: Highlight compression strength (S-curve upper half). 0.0 = off, 1.0 = strong. - tone: Dynamic-range compression. "auto" = histogram-based fit to display range, - 0.0 = off, 0.0–1.0 = fixed strength. Only meaningful for measured palettes. - gamut: Gamut compression for out-of-gamut pixels. "auto" = full strength on - out-of-gamut pixels (smoothstep), 0.0 = off, 0.0–1.0 = fixed strength. + tone: Dynamic-range compression. 0.0 = off, "auto" = histogram-based fit + to display range, 0.0–1.0 = fixed strength. Only meaningful for measured palettes. + gamut: Gamut compression for out-of-gamut pixels. 0.0 = off, "auto" = full + strength on out-of-gamut pixels (smoothstep), 0.0–1.0 = fixed strength. Returns: Dithered palette-mode (`"P"`) PIL Image matching the color scheme. """ if not isinstance(tone, (float, int, str)): - raise TypeError(f"tone must be float or 'auto', got {type(tone).__name__}") + raise TypeError(f"tone must be float, 'auto', or 'off', got {type(tone).__name__}") if not isinstance(gamut, (float, int, str)): - raise TypeError(f"gamut must be float or 'auto', got {type(gamut).__name__}") + raise TypeError(f"gamut must be float, 'auto', or 'off', got {type(gamut).__name__}") scheme_name = color_scheme.name if isinstance(color_scheme, ColorScheme) else "custom" _LOGGER.debug("Applying %s dithering for %s palette", mode.name, scheme_name) @@ -105,10 +110,12 @@ def dither_image( # pylint: disable=too-many-arguments palette_colors = list(color_scheme.colors.values()) palette_bytes = bytes(c for rgb in palette_colors for c in rgb) accent_idx = list(color_scheme.colors.keys()).index(color_scheme.accent) + scheme_id = cast(int, color_scheme.scheme.value) if color_scheme.scheme is not None else None indices = _rs.dither_image( pixels, width, height, + scheme_id=scheme_id, palette_bytes=palette_bytes, accent_idx=accent_idx, **common_kwargs, # type: ignore[arg-type] diff --git a/packages/python/src/epaper_dithering/palettes.py b/packages/python/src/epaper_dithering/palettes.py index f5187cc..e289746 100644 --- a/packages/python/src/epaper_dithering/palettes.py +++ b/packages/python/src/epaper_dithering/palettes.py @@ -12,6 +12,7 @@ class ColorPalette: colors: dict[str, tuple[int, int, int]] # name -> RGB tuple accent: str # Primary accent color name + scheme: ColorScheme | None = None # Canonical firmware color scheme, if known class ColorScheme(Enum): @@ -199,12 +200,16 @@ def _load_measured_palettes() -> dict[str, "ColorPalette"]: from . import _rs # noqa: PLC0415 (local import avoids circular dependency at module level) result: dict[str, ColorPalette] = {} - for name, rgb_bytes, color_names, accent_idx in _rs.measured_palettes(): + for name, rgb_bytes, color_names, accent_idx, scheme_id in _rs.measured_palettes(): colors = { color_names[i]: (rgb_bytes[i * 3], rgb_bytes[i * 3 + 1], rgb_bytes[i * 3 + 2]) for i in range(len(color_names)) } - result[name] = ColorPalette(colors=colors, accent=color_names[accent_idx]) + result[name] = ColorPalette( + colors=colors, + accent=color_names[accent_idx], + scheme=ColorScheme.from_value(scheme_id), + ) return result diff --git a/packages/python/src/lib.rs b/packages/python/src/lib.rs index b32cb58..9a4a0bb 100644 --- a/packages/python/src/lib.rs +++ b/packages/python/src/lib.rs @@ -1,5 +1,5 @@ use epaper_dithering_core::{ - dither, DitherConfig, + dither, dither_with_canonical, DitherConfig, enums::{DitherMode, GamutCompression, ToneCompression}, measured_palettes::CATALOG, palettes::{ColorScheme, Palette}, @@ -37,7 +37,7 @@ fn parse_gamut(v: Option) -> GamutCompression { scheme_id=None, palette_bytes=None, accent_idx=0, mode_id=1, serpentine=true, exposure=1.0, saturation=1.0, shadows=0.0, highlights=0.0, - tone=None, gamut=None, + tone=0.0, gamut=0.0, ))] #[allow(clippy::too_many_arguments)] fn dither_image( @@ -70,13 +70,19 @@ fn dither_image( }; match (palette_bytes, scheme_id) { - (Some(bytes), _) => { + (Some(bytes), scheme_id) => { if !bytes.len().is_multiple_of(3) { return Err(PyValueError::new_err("palette_bytes length must be a multiple of 3")); } let colors: Vec<[u8; 3]> = bytes.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(); let palette = Palette::new(colors, accent_idx); - Ok(dither(&img, palette, config)) + if let Some(id) = scheme_id { + let scheme = ColorScheme::try_from(id) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + Ok(dither_with_canonical(&img, &palette, scheme.palette(), config)) + } else { + Ok(dither(&img, palette, config)) + } } (None, Some(id)) => { let scheme = ColorScheme::try_from(id) @@ -89,9 +95,9 @@ fn dither_image( /// Returns all measured palettes from the Rust catalog. /// -/// Each entry is `(id, rgb_bytes, color_names, accent_idx)`. +/// Each entry is `(id, rgb_bytes, color_names, accent_idx, scheme_id)`. #[pyfunction] -fn measured_palettes() -> Vec<(String, Vec, Vec, usize)> { +fn measured_palettes() -> Vec<(String, Vec, Vec, usize, u8)> { CATALOG .iter() .map(|e| { @@ -100,6 +106,7 @@ fn measured_palettes() -> Vec<(String, Vec, Vec, usize)> { e.palette.colors.iter().flatten().copied().collect(), e.color_names.iter().map(|&s| s.to_string()).collect(), e.palette.accent_idx, + u8::from(e.scheme), ) }) .collect() diff --git a/packages/python/tests/test_dithering.py b/packages/python/tests/test_dithering.py index fe7a3c4..1a17ba7 100644 --- a/packages/python/tests/test_dithering.py +++ b/packages/python/tests/test_dithering.py @@ -223,15 +223,27 @@ def test_tone_compression_skipped_for_color_scheme(self): @pytest.mark.parametrize("mode", list(DitherMode)) def test_auto_tone_compression_all_modes(self, mode): - """Auto tone compression (default) should produce valid output for all modes.""" + """Auto tone compression should produce valid output for all modes.""" from epaper_dithering import SPECTRA_7_3_6COLOR img = Image.new("RGB", (10, 10), (128, 128, 128)) - result = dither_image(img, SPECTRA_7_3_6COLOR, mode=mode) + result = dither_image(img, SPECTRA_7_3_6COLOR, mode=mode, tone="auto", gamut="auto") assert result.mode == "P" assert result.size == (10, 10) + def test_default_tone_gamut_match_off_aliases(self): + """tone/gamut default to off, and 'off' is the string alias for 0.0.""" + from epaper_dithering import SPECTRA_7_3_6COLOR + + img = Image.new("RGB", (16, 16), (128, 128, 128)) + result_default = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES) + result_zero = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES, tone=0.0, gamut=0.0) + result_off = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES, tone="off", gamut="off") + + assert np.array_equal(np.array(result_default), np.array(result_zero)) + assert np.array_equal(np.array(result_default), np.array(result_off)) + def test_tone_compression_changes_measured_output(self): """Tone compression should change the output for measured palettes.""" from epaper_dithering import SPECTRA_7_3_6COLOR diff --git a/packages/python/tests/test_palettes.py b/packages/python/tests/test_palettes.py index 0be512d..363d3c2 100644 --- a/packages/python/tests/test_palettes.py +++ b/packages/python/tests/test_palettes.py @@ -120,6 +120,51 @@ def test_predefined_measured_palettes_work(self, small_test_image): result = dither_image(small_test_image, HANSHOW_BWR, mode=DitherMode.SIERRA) assert result.mode == "P" + def test_predefined_measured_palettes_record_canonical_scheme(self): + """Measured palettes identify the pure display palette they produce.""" + from epaper_dithering import BWRY_3_97, HANSHOW_BWR, SPECTRA_7_3_6COLOR + + assert SPECTRA_7_3_6COLOR.scheme == ColorScheme.BWGBRY + assert BWRY_3_97.scheme == ColorScheme.BWRY + assert HANSHOW_BWR.scheme == ColorScheme.BWR + + def test_none_uses_canonical_indices_for_measured_palette(self): + """DitherMode.NONE maps pure display colors directly for measured palettes.""" + from epaper_dithering import SPECTRA_7_3_6COLOR + + img = Image.new("RGB", (4, 4), ColorScheme.BWGBRY.palette.colors["red"]) + result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.NONE) + pixels = list(result.get_flattened_data()) + + assert set(pixels) == {3} + + @pytest.mark.parametrize("mode", [DitherMode.ORDERED, DitherMode.BURKES, DitherMode.FLOYD_STEINBERG]) + def test_exact_display_colors_are_pinned_inside_mixed_measured_image(self, mode): + """Pure display-color pixels stay exact even when neighboring pixels need dithering.""" + from epaper_dithering import SPECTRA_7_3_6COLOR + + img = Image.new("RGB", (8, 4), (128, 128, 128)) + for x in range(4): + for y in range(2): + img.putpixel((x, y), ColorScheme.BWGBRY.palette.colors["green"]) + + result = dither_image(img, SPECTRA_7_3_6COLOR, mode=mode) + pixels = list(result.get_flattened_data()) + green_pixels = [pixels[y * 8 + x] for y in range(2) for x in range(4)] + + assert set(green_pixels) == {5} + + def test_predefined_measured_palette_outputs_measured_preview_palette(self): + """Indices stay canonical-compatible; the PIL preview palette stays measured.""" + from epaper_dithering import SPECTRA_7_3_6COLOR + + img = Image.new("RGB", (1, 1), ColorScheme.BWGBRY.palette.colors["red"]) + result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.NONE) + palette = result.getpalette() + + assert palette is not None + assert tuple(palette[3 * 3 : 3 * 3 + 3]) == SPECTRA_7_3_6COLOR.colors["red"] + class TestPureColorMapping: """Test that pure palette colors map to themselves.""" diff --git a/packages/rust/core/README.md b/packages/rust/core/README.md index 954efc1..c5ee40e 100644 --- a/packages/rust/core/README.md +++ b/packages/rust/core/README.md @@ -46,17 +46,21 @@ let indices = dither(&img, &SPECTRA_7_3_6COLOR, DitherConfig { mode: DitherMode::Stucki, saturation: 1.3, // boost saturation shadows: 0.4, // lift shadows - tone: ToneCompression::Auto, + tone: ToneCompression::Auto, // opt in for photos gamut: GamutCompression::Auto, ..Default::default() }); ``` `DitherConfig` defaults: `Burkes`, `serpentine: true`, `exposure: 1.0`, `saturation: 1.0`, -`shadows: 0.0`, `highlights: 0.0`, `tone: Auto`, `gamut: Auto`. +`shadows: 0.0`, `highlights: 0.0`, `tone: Fixed(0.0)`, `gamut: None`. Pipeline order: `exposure → saturation → shadows/highlights → tone → gamut → dither`. +`DitherMode::None` performs direct nearest-color mapping without error diffusion or ordered dithering. `dither_with_canonical` lets measured palettes use calibrated RGB values for matching while preserving the canonical display palette for exact-color bypass and firmware indices. + +With `dither_with_canonical`, exact canonical display colors are also protected in ordered and error-diffusion modes when pre-processing is off: an image made entirely of display colors is returned as a direct palette-index map, and exact display-color pixels inside a mixed image keep their canonical index instead of being rematched to the measured RGB palette. Pre-processing runs before that exact-pixel check, so explicit tone/gamut compression or other adjustments may intentionally alter those pixels first. + ## Related packages - Python: [`epaper-dithering`](https://pypi.org/project/epaper-dithering/) diff --git a/packages/rust/core/benches/dithering.rs b/packages/rust/core/benches/dithering.rs index 55517a6..e3f75e9 100644 --- a/packages/rust/core/benches/dithering.rs +++ b/packages/rust/core/benches/dithering.rs @@ -114,7 +114,7 @@ fn bench_direct_map(c: &mut Criterion) { let pixels = synthetic_image(w, h); group.throughput(Throughput::Elements((w * h) as u64)); group.bench_with_input(BenchmarkId::from_parameter(format!("{w}x{h}")), &(), |b, _| { - b.iter(|| direct_map(&pixels, palette)) + b.iter(|| direct_map(&pixels, palette, palette)) }); } @@ -244,7 +244,7 @@ fn bench_real_images(c: &mut Criterion) { group.throughput(Throughput::Elements((800 * 480) as u64)); for (filename, label) in FIXTURES { - let (pixels, w, h) = load_fixture(filename); + let (pixels, w, _h) = load_fixture(filename); let img = ImageBuffer::new(&pixels, w); group.bench_with_input( @@ -255,10 +255,12 @@ fn bench_real_images(c: &mut Criterion) { dither( &img, &SPECTRA_7_3_6COLOR, - DitherMode::Burkes, - true, - ToneCompression::Auto, - GamutCompression::Auto, + DitherConfig { + mode: DitherMode::Burkes, + tone: ToneCompression::Auto, + gamut: GamutCompression::Auto, + ..Default::default() + }, ) }) }, @@ -305,4 +307,4 @@ criterion_group!( bench_real_images, bench_full_res, ); -criterion_main!(benches); \ No newline at end of file +criterion_main!(benches); diff --git a/packages/rust/core/examples/dither.rs b/packages/rust/core/examples/dither.rs index 8923b46..f55bc8f 100644 --- a/packages/rust/core/examples/dither.rs +++ b/packages/rust/core/examples/dither.rs @@ -1,18 +1,18 @@ -/// Quick visual test: dither a real image and save the result. -/// -/// Usage: -/// cargo run --example dither [output] [scheme] [mode] [tone] [gamut] -/// -/// Examples: -/// cargo run --example dither photo.jpg -/// cargo run --example dither photo.jpg out.png spectra stucki auto none -/// cargo run --example dither photo.jpg out.png spectra stucki 0.8 0.5 -/// -/// Schemes: mono, bwr, bwy, bwry, bwgbry, grayscale4, grayscale8, grayscale16 -/// 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 -/// Tone: auto, none, 0.0–1.0 (default: auto) -/// Gamut: none, auto, 0.0–1.0 (default: none) +// Quick visual test: dither a real image and save the result. +// +// Usage: +// cargo run --example dither [output] [scheme] [mode] [tone] [gamut] +// +// Examples: +// cargo run --example dither photo.jpg +// cargo run --example dither photo.jpg out.png spectra stucki auto none +// cargo run --example dither photo.jpg out.png spectra stucki 0.8 0.5 +// +// Schemes: mono, bwr, bwy, bwry, bwgbry, grayscale4, grayscale8, grayscale16 +// 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 +// Tone: auto, none, 0.0-1.0 (default: none) +// Gamut: none, auto, 0.0-1.0 (default: none) use epaper_dithering_core::enums::{DitherMode, GamutCompression, ToneCompression}; use epaper_dithering_core::measured_palettes::{ @@ -36,7 +36,7 @@ fn main() { let output_path = args.get(2).map(String::as_str).unwrap_or("dithered.png"); let scheme_name = args.get(3).map(String::as_str).unwrap_or("bwr"); let mode_name = args.get(4).map(String::as_str).unwrap_or("burkes"); - let tone_name = args.get(5).map(String::as_str).unwrap_or("auto"); + let tone_name = args.get(5).map(String::as_str).unwrap_or("none"); let gamut_name = args.get(6).map(String::as_str).unwrap_or("none"); let palette: &Palette = match scheme_name { diff --git a/packages/rust/core/src/algorithms.rs b/packages/rust/core/src/algorithms.rs index ddd097c..291215a 100644 --- a/packages/rust/core/src/algorithms.rs +++ b/packages/rust/core/src/algorithms.rs @@ -137,6 +137,43 @@ pub fn error_diffusion_dither( palette: &Palette, kernel: &Kernel, serpentine: bool, +) -> Vec { + error_diffusion_dither_impl(pixels, width, height, palette, None, kernel, serpentine) +} + +/// Error diffusion with exact canonical display-color pixels pinned. +/// +/// Exact canonical pixels are already displayable, so they emit their firmware +/// palette index directly and absorb any accumulated error instead of diffusing +/// it into neighboring pixels. +pub fn error_diffusion_dither_with_canonical( + pixels: &[u8], + width: usize, + height: usize, + palette: &Palette, + canonical_palette: &Palette, + kernel: &Kernel, + serpentine: bool, +) -> Vec { + error_diffusion_dither_impl( + pixels, + width, + height, + palette, + Some(canonical_palette), + kernel, + serpentine, + ) +} + +fn error_diffusion_dither_impl( + pixels: &[u8], + width: usize, + height: usize, + palette: &Palette, + canonical_palette: Option<&Palette>, + kernel: &Kernel, + serpentine: bool, ) -> Vec { let (_palette_linear, palette_lab) = build_palette_lab(palette); @@ -163,6 +200,14 @@ pub fn error_diffusion_dither( let x = if reverse { width - 1 - xi } else { xi }; let idx = (y * width + x) * 3; + if let Some(canonical_palette) = canonical_palette + && let Some(exact_idx) = + exact_palette_index(&pixels[idx..idx + 3], canonical_palette) + { + output[y * width + x] = exact_idx; + continue; + } + 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); @@ -227,11 +272,30 @@ pub fn jarvis_judice_ninke(pixels: &[u8], w: usize, h: usize, palette: &Palette, // ── Direct palette map (no dithering) ──────────────────────────────────────── /// Nearest-color mapping with no dithering. Each pixel maps independently. -pub fn direct_map(pixels: &[u8], palette: &Palette) -> Vec { +fn exact_palette_index(rgb: &[u8], palette: &Palette) -> Option { + palette + .colors + .iter() + .position(|&color| rgb == color) + .and_then(|idx| u8::try_from(idx).ok()) +} + +pub fn try_exact_palette_map(pixels: &[u8], canonical_palette: &Palette) -> Option> { + pixels + .par_chunks(3) + .map(|rgb| exact_palette_index(rgb, canonical_palette)) + .collect() +} + +pub fn direct_map(pixels: &[u8], palette: &Palette, canonical_palette: &Palette) -> Vec { let (_, palette_lab) = build_palette_lab(palette); pixels .par_chunks(3) .map(|rgb| { + if let Some(idx) = exact_palette_index(rgb, canonical_palette) { + return idx; + } + let r = srgb_channel_to_linear(rgb[0]); let g = srgb_channel_to_linear(rgb[1]); let b = srgb_channel_to_linear(rgb[2]); @@ -261,12 +325,36 @@ const BAYER_4X4: [[f64; 4]; 4] = [ /// sRGB-space thresholding gives uniform perceptual dot density across the tonal range, /// matching how error diffusion already accumulates error. See GitHub issue #27. pub fn ordered_dither(pixels: &[u8], width: usize, palette: &Palette) -> Vec { + ordered_dither_impl(pixels, width, palette, None) +} + +pub fn ordered_dither_with_canonical( + pixels: &[u8], + width: usize, + palette: &Palette, + canonical_palette: &Palette, +) -> Vec { + ordered_dither_impl(pixels, width, palette, Some(canonical_palette)) +} + +fn ordered_dither_impl( + pixels: &[u8], + width: usize, + palette: &Palette, + canonical_palette: Option<&Palette>, +) -> Vec { let (_palette_linear, palette_lab) = build_palette_lab(palette); pixels .par_chunks(3) .enumerate() .map(|(i, rgb)| { + if let Some(canonical_palette) = canonical_palette + && let Some(idx) = exact_palette_index(rgb, canonical_palette) + { + return idx; + } + let x = i % width; let y = i / width; @@ -331,14 +419,14 @@ mod tests { assert_eq!(sierra_lite(&pixels, 10, 7, pal, false).len(), 70); assert_eq!(jarvis_judice_ninke(&pixels, 10, 7, pal, false).len(), 70); assert_eq!(ordered_dither(&pixels, 10, pal).len(), 70); - assert_eq!(direct_map(&pixels, pal).len(), 70); + assert_eq!(direct_map(&pixels, pal, pal).len(), 70); } #[test] fn direct_map_pure_red_maps_to_red_in_bwr() { // BWR palette: index 0=black, 1=white, 2=red — pure red should map to red ink let pixels = vec![255u8, 0, 0]; - let out = direct_map(&pixels, ColorScheme::Bwr.palette()); + let out = direct_map(&pixels, ColorScheme::Bwr.palette(), ColorScheme::Bwr.palette()); assert_eq!(out[0], 2, "pure sRGB red should map to red ink (index 2) in BWR"); } @@ -346,7 +434,7 @@ mod tests { fn direct_map_pure_blue_maps_to_blue_in_bwgbry() { // BWGBRY palette order: 0=black, 1=white, 2=yellow, 3=red, 4=blue, 5=green let pixels = vec![0u8, 0, 255]; - let out = direct_map(&pixels, ColorScheme::Bwgbry.palette()); + let out = direct_map(&pixels, ColorScheme::Bwgbry.palette(), ColorScheme::Bwgbry.palette()); assert_eq!(out[0], 4, "pure sRGB blue should map to blue ink (index 4) in BWGBRY"); } diff --git a/packages/rust/core/src/lib.rs b/packages/rust/core/src/lib.rs index 421f976..25eab58 100644 --- a/packages/rust/core/src/lib.rs +++ b/packages/rust/core/src/lib.rs @@ -53,23 +53,147 @@ impl Default for DitherConfig { saturation: 1.0, shadows: 0.0, highlights: 0.0, - tone: ToneCompression::Auto, - gamut: GamutCompression::Auto, + tone: ToneCompression::Fixed(0.0), + gamut: GamutCompression::None, } } } -fn dispatch(img: &ImageBuffer, p: &Palette, mode: DitherMode, serpentine: bool) -> Vec { +fn dispatch( + img: &ImageBuffer, + p: &Palette, + canonical: &Palette, + mode: DitherMode, + serpentine: bool, + pin_exact_pixels: bool, +) -> Vec { match mode { - DitherMode::None => algorithms::direct_map(img.data, p), + DitherMode::None => algorithms::direct_map(img.data, p, canonical), + DitherMode::Ordered if pin_exact_pixels => { + algorithms::ordered_dither_with_canonical(img.data, img.width, p, canonical) + } DitherMode::Ordered => algorithms::ordered_dither(img.data, img.width, p), - DitherMode::FloydSteinberg => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::FLOYD_STEINBERG, serpentine), - DitherMode::Burkes => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::BURKES, serpentine), - DitherMode::Atkinson => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::ATKINSON, serpentine), - DitherMode::Stucki => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::STUCKI, serpentine), - DitherMode::Sierra => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::SIERRA, serpentine), - DitherMode::SierraLite => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::SIERRA_LITE, serpentine), - DitherMode::JarvisJudiceNinke => algorithms::error_diffusion_dither(img.data, img.width, img.height, p, &algorithms::JARVIS_JUDICE_NINKE, serpentine), + DitherMode::FloydSteinberg if pin_exact_pixels => algorithms::error_diffusion_dither_with_canonical( + img.data, + img.width, + img.height, + p, + canonical, + &algorithms::FLOYD_STEINBERG, + serpentine, + ), + DitherMode::Burkes if pin_exact_pixels => algorithms::error_diffusion_dither_with_canonical( + img.data, + img.width, + img.height, + p, + canonical, + &algorithms::BURKES, + serpentine, + ), + DitherMode::Atkinson if pin_exact_pixels => algorithms::error_diffusion_dither_with_canonical( + img.data, + img.width, + img.height, + p, + canonical, + &algorithms::ATKINSON, + serpentine, + ), + DitherMode::Stucki if pin_exact_pixels => algorithms::error_diffusion_dither_with_canonical( + img.data, + img.width, + img.height, + p, + canonical, + &algorithms::STUCKI, + serpentine, + ), + DitherMode::Sierra if pin_exact_pixels => algorithms::error_diffusion_dither_with_canonical( + img.data, + img.width, + img.height, + p, + canonical, + &algorithms::SIERRA, + serpentine, + ), + DitherMode::SierraLite if pin_exact_pixels => algorithms::error_diffusion_dither_with_canonical( + img.data, + img.width, + img.height, + p, + canonical, + &algorithms::SIERRA_LITE, + serpentine, + ), + DitherMode::JarvisJudiceNinke if pin_exact_pixels => { + algorithms::error_diffusion_dither_with_canonical( + img.data, + img.width, + img.height, + p, + canonical, + &algorithms::JARVIS_JUDICE_NINKE, + serpentine, + ) + } + DitherMode::FloydSteinberg => algorithms::error_diffusion_dither( + img.data, + img.width, + img.height, + p, + &algorithms::FLOYD_STEINBERG, + serpentine, + ), + DitherMode::Burkes => algorithms::error_diffusion_dither( + img.data, + img.width, + img.height, + p, + &algorithms::BURKES, + serpentine, + ), + DitherMode::Atkinson => algorithms::error_diffusion_dither( + img.data, + img.width, + img.height, + p, + &algorithms::ATKINSON, + serpentine, + ), + DitherMode::Stucki => algorithms::error_diffusion_dither( + img.data, + img.width, + img.height, + p, + &algorithms::STUCKI, + serpentine, + ), + DitherMode::Sierra => algorithms::error_diffusion_dither( + img.data, + img.width, + img.height, + p, + &algorithms::SIERRA, + serpentine, + ), + DitherMode::SierraLite => algorithms::error_diffusion_dither( + img.data, + img.width, + img.height, + p, + &algorithms::SIERRA_LITE, + serpentine, + ), + DitherMode::JarvisJudiceNinke => algorithms::error_diffusion_dither( + img.data, + img.width, + img.height, + p, + &algorithms::JARVIS_JUDICE_NINKE, + serpentine, + ), } } @@ -87,9 +211,39 @@ fn needs_preprocess(c: &DitherConfig) -> bool { /// Returns palette indices (one `u8` per pixel, length = `width × height`). pub fn dither(img: &ImageBuffer, palette: impl AsRef, config: DitherConfig) -> Vec { let p = palette.as_ref(); + dither_impl(img, p, p, config, false) +} +/// Dither an image using one palette for color matching and another for exact +/// already-displayable RGB passthrough. +/// +/// Measured palettes should be supplied as `matching_palette`; the ideal display +/// color scheme should be supplied as `canonical_palette`. +pub fn dither_with_canonical( + img: &ImageBuffer, + matching_palette: impl AsRef, + canonical_palette: impl AsRef, + config: DitherConfig, +) -> Vec { + let p = matching_palette.as_ref(); + let canonical = canonical_palette.as_ref(); + dither_impl(img, p, canonical, config, true) +} + +fn dither_impl( + img: &ImageBuffer, + p: &Palette, + canonical: &Palette, + config: DitherConfig, + pin_exact_pixels: bool, +) -> Vec { if !needs_preprocess(&config) { - return dispatch(img, p, config.mode, config.serpentine); + if config.mode != DitherMode::None + && let Some(indices) = algorithms::try_exact_palette_map(img.data, canonical) + { + return indices; + } + return dispatch(img, p, canonical, config.mode, config.serpentine, pin_exact_pixels); } // Convert sRGB bytes → linear, apply pre-processing pipeline, convert back. @@ -119,5 +273,119 @@ pub fn dither(img: &ImageBuffer, palette: impl AsRef, config: DitherCon .collect(); let processed_img = ImageBuffer::new(&processed, img.width); - dispatch(&processed_img, p, config.mode, config.serpentine) + dispatch(&processed_img, p, canonical, config.mode, config.serpentine, pin_exact_pixels) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::measured_palettes::SPECTRA_7_3_6COLOR; + use crate::palettes::ColorScheme; + + fn pixels(rgb: [u8; 3], count: usize) -> Vec { + std::iter::repeat_n(rgb, count).flatten().collect() + } + + #[test] + fn default_config_has_preprocessing_off() { + let config = DitherConfig::default(); + assert!(matches!(config.tone, ToneCompression::Fixed(s) if s == 0.0)); + assert_eq!(config.gamut, GamutCompression::None); + assert!(!needs_preprocess(&config)); + } + + #[test] + fn none_uses_canonical_exact_colors_with_measured_palette() { + let image = pixels([255, 0, 0], 4); + let img = ImageBuffer::new(&image, 2); + let output = dither_with_canonical( + &img, + &SPECTRA_7_3_6COLOR, + ColorScheme::Bwgbry.palette(), + DitherConfig { mode: DitherMode::None, ..Default::default() }, + ); + assert_eq!(output, vec![3, 3, 3, 3]); + } + + #[test] + fn exact_canonical_colors_bypass_error_diffusion() { + let image = [ + [0, 0, 0], + [255, 255, 255], + [255, 255, 0], + [255, 0, 0], + ] + .into_iter() + .flatten() + .collect::>(); + let img = ImageBuffer::new(&image, 2); + let output = dither_with_canonical( + &img, + &SPECTRA_7_3_6COLOR, + ColorScheme::Bwry.palette(), + DitherConfig { mode: DitherMode::Burkes, ..Default::default() }, + ); + assert_eq!(output, vec![0, 1, 2, 3]); + } + + #[test] + fn exact_canonical_pixels_are_pinned_inside_mixed_error_diffusion_image() { + let mut image = pixels([128, 128, 128], 8); + image[0..3].copy_from_slice(&[0, 255, 0]); + image[9..12].copy_from_slice(&[0, 255, 0]); + let img = ImageBuffer::new(&image, 4); + + for mode in [ + DitherMode::Burkes, + DitherMode::FloydSteinberg, + DitherMode::Atkinson, + DitherMode::Stucki, + DitherMode::Sierra, + DitherMode::SierraLite, + DitherMode::JarvisJudiceNinke, + ] { + let output = dither_with_canonical( + &img, + &SPECTRA_7_3_6COLOR, + ColorScheme::Bwgbry.palette(), + DitherConfig { mode, ..Default::default() }, + ); + assert_eq!(output[0], 5, "{mode:?} should pin exact green at pixel 0"); + assert_eq!(output[3], 5, "{mode:?} should pin exact green at pixel 3"); + } + } + + #[test] + fn exact_canonical_pixels_are_pinned_inside_mixed_ordered_image() { + let mut image = pixels([128, 128, 128], 8); + image[0..3].copy_from_slice(&[0, 255, 0]); + image[9..12].copy_from_slice(&[0, 255, 0]); + let img = ImageBuffer::new(&image, 4); + + let output = dither_with_canonical( + &img, + &SPECTRA_7_3_6COLOR, + ColorScheme::Bwgbry.palette(), + DitherConfig { mode: DitherMode::Ordered, ..Default::default() }, + ); + assert_eq!(output[0], 5); + assert_eq!(output[3], 5); + } + + #[test] + fn exact_bypass_is_skipped_when_preprocessing_is_enabled() { + let image = pixels([255, 0, 0], 4); + let img = ImageBuffer::new(&image, 2); + let output = dither_with_canonical( + &img, + &SPECTRA_7_3_6COLOR, + ColorScheme::Bwgbry.palette(), + DitherConfig { + mode: DitherMode::Burkes, + tone: ToneCompression::Fixed(1.0), + ..Default::default() + }, + ); + assert_eq!(output.len(), 4); + } } diff --git a/packages/rust/core/src/measured_palettes.rs b/packages/rust/core/src/measured_palettes.rs index e8f5985..93530b0 100644 --- a/packages/rust/core/src/measured_palettes.rs +++ b/packages/rust/core/src/measured_palettes.rs @@ -8,7 +8,7 @@ use std::borrow::Cow; -use crate::palettes::Palette; +use crate::palettes::{ColorScheme, Palette}; // ── Catalog (used by language bindings to expose named palettes) ────────────── @@ -17,6 +17,7 @@ use crate::palettes::Palette; pub struct MeasuredPaletteEntry { pub id: &'static str, pub palette: &'static Palette, + pub scheme: ColorScheme, pub color_names: &'static [&'static str], } @@ -25,41 +26,49 @@ pub static CATALOG: &[MeasuredPaletteEntry] = &[ MeasuredPaletteEntry { id: "SPECTRA_7_3_6COLOR", palette: &SPECTRA_7_3_6COLOR, + scheme: ColorScheme::Bwgbry, color_names: &["black", "white", "yellow", "red", "blue", "green"], }, MeasuredPaletteEntry { id: "SPECTRA_7_3_6COLOR_V2", palette: &SPECTRA_7_3_6COLOR_V2, + scheme: ColorScheme::Bwgbry, color_names: &["black", "white", "yellow", "red", "blue", "green"], }, MeasuredPaletteEntry { id: "MONO_4_26", palette: &MONO_4_26, + scheme: ColorScheme::Mono, color_names: &["black", "white"], }, MeasuredPaletteEntry { id: "BWRY_4_2", palette: &BWRY_4_2, + scheme: ColorScheme::Bwry, color_names: &["black", "white", "yellow", "red"], }, MeasuredPaletteEntry { id: "BWRY_3_97", palette: &BWRY_3_97, + scheme: ColorScheme::Bwry, color_names: &["black", "white", "yellow", "red"], }, MeasuredPaletteEntry { id: "SOLUM_BWR", palette: &SOLUM_BWR, + scheme: ColorScheme::Bwr, color_names: &["black", "white", "red"], }, MeasuredPaletteEntry { id: "HANSHOW_BWR", palette: &HANSHOW_BWR, + scheme: ColorScheme::Bwr, color_names: &["black", "white", "red"], }, MeasuredPaletteEntry { id: "HANSHOW_BWY", palette: &HANSHOW_BWY, + scheme: ColorScheme::Bwy, color_names: &["black", "white", "yellow"], }, ]; diff --git a/packages/rust/wasm/src/lib.rs b/packages/rust/wasm/src/lib.rs index 593ecd7..51d644d 100644 --- a/packages/rust/wasm/src/lib.rs +++ b/packages/rust/wasm/src/lib.rs @@ -1,5 +1,5 @@ use epaper_dithering_core::{ - dither, DitherConfig, + dither, dither_with_canonical, DitherConfig, enums::{DitherMode, GamutCompression, ToneCompression}, measured_palettes::CATALOG, palettes::{ColorScheme, Palette}, @@ -70,7 +70,11 @@ pub fn dither_image( } let colors: Vec<[u8; 3]> = palette_bytes.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(); let palette = Palette::new(colors, accent_idx); - Ok(dither(&img, palette, config)) + if let Ok(scheme) = ColorScheme::try_from(scheme_id) { + Ok(dither_with_canonical(&img, &palette, scheme.palette(), config)) + } else { + Ok(dither(&img, palette, config)) + } } } @@ -92,7 +96,7 @@ pub fn composite_rgba(rgba: &[u8]) -> Vec { /// Returns all measured palettes as a JSON string. /// -/// Format: `[{"id": "SPECTRA_7_3_6COLOR", "colors": [[r,g,b], ...], "color_names": [...], "accent_idx": 3}, ...]` +/// Format: `[{"id": "SPECTRA_7_3_6COLOR", "colors": [[r,g,b], ...], "color_names": [...], "accent_idx": 3, "scheme_id": 4}, ...]` #[wasm_bindgen] pub fn measured_palettes() -> String { let entries: Vec = CATALOG @@ -105,11 +109,12 @@ pub fn measured_palettes() -> String { .map(|s| format!("\"{}\"", s)) .collect(); format!( - "{{\"id\":\"{}\",\"colors\":[{}],\"color_names\":[{}],\"accent_idx\":{}}}", + "{{\"id\":\"{}\",\"colors\":[{}],\"color_names\":[{}],\"accent_idx\":{},\"scheme_id\":{}}}", e.id, colors.join(","), names.join(","), e.palette.accent_idx, + u8::from(e.scheme), ) }) .collect();