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 .github/workflows/javascript-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ jobs:

- name: Build WASM
working-directory: .
run: wasm-pack build packages/rust/wasm --target bundler --out-dir ../../javascript/src/wasm-core
run: wasm-pack build packages/rust/wasm --target web --out-dir ../../javascript/src/wasm-core

- uses: oven-sh/setup-bun@v2

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ jobs:

- name: Build WASM
working-directory: ${{ github.workspace }}
run: wasm-pack build packages/rust/wasm --target bundler --out-dir ../../javascript/src/wasm-core
run: wasm-pack build packages/rust/wasm --target web --out-dir ../../javascript/src/wasm-core

- uses: actions/setup-node@v4
with:
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ uv run prek run --all-files --config .pre-commit-config.yaml # lint (ruff, myp

### JavaScript (`packages/javascript/`)

The WASM bundle must be built into `src/wasm-core/` before anything else works:
The WASM bundle must be built into `src/wasm-core/` before anything else works. Use `--target web`: `core.ts` inlines the `.wasm` bytes (tsup binary loader) and initialises with the generated `initSync`, so it never touches wasm-bindgen's internal glue.

```bash
# from repo root; requires wasm-pack and the wasm32-unknown-unknown target
wasm-pack build packages/rust/wasm --target bundler --out-dir ../../javascript/src/wasm-core
wasm-pack build packages/rust/wasm --target web --out-dir ../../javascript/src/wasm-core

cd packages/javascript
bun install
Expand Down
26 changes: 24 additions & 2 deletions packages/javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ High-quality dithering algorithms for e-paper/e-ink displays, powered by a Rust/
- **OKLab Color Matching**: Weighted Cartesian OKLab — preserves hue without the achromatic-attractor bug that plagues LCH-weighted approaches
- **Pre-dither Adjustments**: Per-image exposure, saturation, shadows, highlights, dynamic-range compression, and gamut compression — all orthogonal knobs
- **Serpentine Scanning**: Alternates row direction to eliminate directional artifacts
- **DBS Refinement**: Optional Direct Binary Search pass that optimizes the dithered pixels against a model of the eye
- **Universal**: Works in browser (Canvas API) and Node.js (≥18)
- **Zero Dependencies**: WASM binary bundled inline, no image library required

Expand Down Expand Up @@ -124,8 +125,29 @@ ditherImage(image: ImageBuffer, palette: ColorScheme | ColorPalette, options?: D
| `highlights` | `number` | `0.0` | Highlight compression strength (S-curve upper half). `0.0` = off, `1.0` = strong |
| `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` |
| `dbs` | `boolean \| DbsParams` | `false` | Direct Binary Search refinement after dithering. `true` = default parameters. See below |

Pre-processing pipeline: `exposure → saturation → shadows/highlights → tone → gamut → dither`. Each step is a no-op at its identity value.
Pipeline: `exposure → saturation → shadows/highlights → tone → gamut → dither → dbs`. Each step is a no-op at its identity value.

#### DBS refinement

Direct Binary Search (DBS) improves the dithered result directly: it repeatedly changes pixels to other inks, or swaps them with neighbours, whenever that lowers the error *as perceived* through a model of the eye — sharp for brightness, blurrier for colour, scaled to viewing distance and panel resolution. It is much slower than plain dithering (seconds on a full panel in a single-threaded browser) and blocks the calling thread, so run it in a Web Worker.

```typescript
// Default geometry: 40 cm from a ~127 ppi panel (7.3" 800×480)
ditherImage(imageBuffer, SPECTRA_7_3_6COLOR, { dbs: true });

// Match your setup
ditherImage(imageBuffer, SPECTRA_7_3_6COLOR, { dbs: { viewingDistanceCm: 150, ppi: 150, maxPasses: 10 } });
```

| `DbsParams` field | Default | Description |
|---|---|---|
| `viewingDistanceCm` | `40` | Distance between viewer and panel. Larger values blur more |
| `ppi` | `127` | Panel pixel density |
| `maxPasses` | `10` | Upper bound on refinement passes; stops early once nothing improves |

Pixels pinned to exact display colors (see below) are never changed by DBS.

`DitherMode.NONE` performs direct nearest-color mapping without error diffusion or ordered dithering. It is intended for already-quantized graphics, not continuous-tone photos: because there is no error diffusion, on limited palettes (especially BWR) a continuous-tone image or a large flat mid-tone area can map to an unexpected ink — for example, a solid mid-gray region can render as solid red. Use an error-diffusion mode (e.g. `FLOYD_STEINBERG`, `BURKES`) for photographic input. 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.

Expand Down Expand Up @@ -210,7 +232,7 @@ Features: drag & drop or paste from clipboard, live re-render on every setting c
bun install

# When Rust source changes, rebuild the WASM (from repo root):
wasm-pack build packages/rust/wasm --target bundler --out-dir ../../javascript/src/wasm-core
wasm-pack build packages/rust/wasm --target web --out-dir ../../javascript/src/wasm-core

bun run test # vitest
bun run build # tsup → dist/
Expand Down
65 changes: 49 additions & 16 deletions packages/javascript/src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,35 @@ import type { ImageBuffer, PaletteImageBuffer, ColorPalette } from './types';
import { DitherMode } from './enums';
import { ColorScheme, getPalette } from './palettes';
import {
initSync,
dither_image as wasmDitherImage,
composite_rgba as wasmCompositeRgba,
__wbg_set_wasm,
__wbindgen_init_externref_table,
__wbindgen_cast_0000000000000001,
} from './wasm-core/epaper_dithering_wasm_bg.js';
} from './wasm-core/epaper_dithering_wasm.js';
import wasmBytes from './wasm-core/epaper_dithering_wasm_bg.wasm';

// Synchronous WASM initialization — runs once at module load time.
const wasmModule = new WebAssembly.Module(wasmBytes as unknown as ArrayBuffer);
const wasmInstance = new WebAssembly.Instance(wasmModule, {
'./epaper_dithering_wasm_bg.js': {
__wbindgen_init_externref_table,
__wbindgen_cast_0000000000000001,
},
});
__wbg_set_wasm(wasmInstance.exports);
(wasmInstance.exports as Record<string, () => void>).__wbindgen_start?.();
// Synchronous WASM initialization from the inlined binary, once at module load time, via
// wasm-bindgen's own `initSync` (built with `wasm-pack --target web`).
initSync({ module: wasmBytes });

/**
* Direct Binary Search refinement settings. DBS improves a dithered image by repeatedly
* changing pixels to other inks or swapping them with neighbours whenever that lowers the
* error as perceived through a model of the eye at the given viewing geometry.
*/
export interface DbsParams {
/** Distance between viewer and panel in cm. Larger values blur more. Default: `40`. */
viewingDistanceCm?: number;
/** Panel pixel density. A 7.3" 800×480 panel is about 127 ppi. Default: `127`. */
ppi?: number;
/** Upper bound on full-image passes; stops early once nothing improves. Default: `10`. */
maxPasses?: number;
}

/**
* Options for `ditherImage`. All fields are optional — defaults are sensible.
*
* Pre-processing pipeline (each step is a no-op at its identity value):
* `exposure → saturation → shadows/highlights → tone → gamut → dither`.
* Pipeline (each step is a no-op at its identity value):
* `exposure → saturation → shadows/highlights → tone → gamut → dither → dbs`.
*/
export interface DitherOptions {
/** Dithering algorithm. Default: `DitherMode.BURKES`. */
Expand All @@ -44,6 +49,12 @@ export interface DitherOptions {
tone?: number | 'auto' | 'off';
/** Gamut compression: `0.0`/`'off'` disables, `'auto'` opts in. Default: `0.0`. */
gamut?: number | 'auto' | 'off';
/**
* Direct Binary Search refinement after dithering: `false` disables, `true` uses default
* {@link DbsParams}. Slow — seconds on a full panel, and it blocks the calling thread, so
* run it in a Web Worker in the browser. Default: `false`.
*/
dbs?: boolean | DbsParams;
}

/**
Expand All @@ -68,7 +79,9 @@ export function ditherImage(
highlights = 0.0,
tone = 0.0,
gamut = 0.0,
dbs = false,
} = options;
const dbsArgs = parseDbs(dbs);

const expectedLength = image.width * image.height * 4;
if (image.data.length !== expectedLength) {
Expand Down Expand Up @@ -114,11 +127,31 @@ export function ditherImage(
mode as number, serpentine,
exposure, saturation, shadows, highlights,
toneArg, gamutArg,
...dbsArgs,
);

return { width: image.width, height: image.height, indices, palette: outputColors };
}

/**
* Map the `dbs` option onto the binding's `(dbs_passes, viewing_distance_cm, ppi)` arguments;
* `dbs_passes = undefined` disables DBS. Validated here because wasm-bindgen silently coerces
* numbers into `u32` (e.g. `-1` → 4294967295).
*/
function parseDbs(dbs: boolean | DbsParams): [number | undefined, number, number] {
if (dbs === false) return [undefined, 40, 127];
const { viewingDistanceCm = 40, ppi = 127, maxPasses = 10 } = dbs === true ? {} : dbs;
for (const [name, value] of [['viewingDistanceCm', viewingDistanceCm], ['ppi', ppi]] as const) {
if (!(Number.isFinite(value) && value > 0)) {
throw new Error(`dbs.${name} must be finite and greater than 0, got ${value}`);
}
}
if (!(Number.isInteger(maxPasses) && maxPasses >= 0 && maxPasses <= 0xffffffff)) {
throw new Error(`dbs.maxPasses must be a non-negative integer, got ${maxPasses}`);
}
return [maxPasses, viewingDistanceCm, ppi];
}

/** Map `'auto'` → `undefined` (Rust None = auto), `'off'` → 0.0, number → pass through. */
function parseCompression(v: number | 'auto' | 'off'): number | undefined {
if (v === 'auto') return undefined;
Expand Down
2 changes: 1 addition & 1 deletion packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { ditherImage } from './core';
export type { DitherOptions } from './core';
export type { DbsParams, DitherOptions } from './core';
export { DitherMode } from './enums';
export {
ColorScheme,
Expand Down
21 changes: 0 additions & 21 deletions packages/javascript/src/wasm.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,3 @@ declare module '*.wasm' {
const bytes: Uint8Array;
export default bytes;
}

/**
* Hand-written types for the wasm-bindgen-generated `_bg.js` shim. wasm-pack only
* emits `.d.ts` for the main module; the `_bg.js` exports we need for synchronous
* initialization are declared here.
*/
declare module '*epaper_dithering_wasm_bg.js' {
/** Throws if `rgba.length` is not a multiple of 4. */
export function composite_rgba(rgba: Uint8Array): Uint8Array;
export function dither_image(
pixels: Uint8Array, width: number,
scheme_id: number | null | undefined, palette_bytes: Uint8Array, accent_idx: number,
mode_id: number, serpentine: boolean,
exposure: number, saturation: number, shadows: number, highlights: number,
tone?: number | null, gamut?: number | null,
): Uint8Array;
export function measured_palettes(): string;
export function __wbg_set_wasm(wasm: unknown): void;
export function __wbindgen_init_externref_table(): void;
export function __wbindgen_cast_0000000000000001(arg0: number, arg1: number): unknown;
}
48 changes: 46 additions & 2 deletions packages/javascript/tests/dithering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import {
fromValue,
SPECTRA_7_3_6COLOR,
} from '../src';
import type { DbsParams, DitherOptions } from '../src';
import { createTestImage, createGradient, createTransparentTestImage } from './fixtures';
// Imported after '../src' so that core.ts has already run __wbg_set_wasm.
import { composite_rgba as compositeRgba } from '../src/wasm-core/epaper_dithering_wasm_bg.js';
// Imported after '../src' so that core.ts has already run initSync.
import { composite_rgba as compositeRgba } from '../src/wasm-core/epaper_dithering_wasm.js';

describe('Dithering Algorithms', () => {
it.each(Object.values(DitherMode).filter((v) => typeof v === 'number'))(
Expand Down Expand Up @@ -544,3 +545,46 @@ describe('Dizzy dithering', () => {
expect(Array.from(a.indices)).toEqual(Array.from(b.indices));
});
});

describe('DBS refinement', () => {
// Grayscale gradient on BWR: enough structure that DBS changes the halftone.
const image = createGradient(40, 30);
const indices = (options: DitherOptions = {}) => Array.from(ditherImage(image, ColorScheme.BWR, options).indices);

it('changes the output for a color scheme', () => {
expect(indices({ dbs: true })).not.toEqual(indices());
});

it('changes the output for a measured palette', () => {
const plain = ditherImage(image, SPECTRA_7_3_6COLOR).indices;
const refined = ditherImage(image, SPECTRA_7_3_6COLOR, { dbs: true }).indices;
expect(Array.from(refined)).not.toEqual(Array.from(plain));
});

it('true means default parameters', () => {
expect(indices({ dbs: true })).toEqual(indices({ dbs: {} }));
expect(indices({ dbs: true })).toEqual(indices({ dbs: { viewingDistanceCm: 40, ppi: 127, maxPasses: 10 } }));
});

it('false and omitted match plain dithering', () => {
expect(indices({ dbs: false })).toEqual(indices());
});

it('passes viewing geometry through', () => {
expect(indices({ dbs: { viewingDistanceCm: 15 } })).not.toEqual(indices({ dbs: { viewingDistanceCm: 150 } }));
});

it('zero passes matches plain dithering', () => {
expect(indices({ dbs: { maxPasses: 0 } })).toEqual(indices());
});

it.each([
[{ ppi: 0 }, /ppi/],
[{ ppi: Number.NaN }, /ppi/],
[{ viewingDistanceCm: -1 }, /viewingDistanceCm/],
[{ maxPasses: -1 }, /maxPasses/],
[{ maxPasses: 1.5 }, /maxPasses/],
] as [DbsParams, RegExp][])('rejects invalid parameters %o', (dbs, message) => {
expect(() => ditherImage(image, ColorScheme.BWR, { dbs })).toThrow(message);
});
});
29 changes: 23 additions & 6 deletions packages/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pip install epaper-dithering
- **10 Color Schemes**: Support for mono, 3-color, 4-color, 6-color, 7-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)
- **DBS Refinement**: Optional Direct Binary Search pass that optimizes the dithered pixels against a model of the eye (slower: ~0.3 s per 800×480 panel on a multi-core machine)
- **RGBA Support**: Automatic compositing on white background for transparent images

## Quick Start
Expand Down Expand Up @@ -60,6 +61,7 @@ dither_image(
highlights=0.0, # highlight compression, S-curve upper half
tone="auto", # dynamic-range compression: "auto" | 0.0–1.0
gamut="auto", # gamut compression: "auto" | 0.0–1.0
dbs=None, # DBS refinement: None | True | DbsParams(...)
)
```

Expand Down Expand Up @@ -185,6 +187,22 @@ result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES, gamut="of

Note: `gamut` also has no effect for theoretical `ColorScheme` palettes.

#### DBS Refinement

Direct Binary Search (DBS) runs after dithering and improves the result directly: it repeatedly changes pixels to other inks, or swaps them with neighbours, whenever that lowers the error *as perceived* through a model of the eye — sharp for brightness, blurrier for colour, scaled to your viewing distance and panel resolution. It typically cuts visible tone and colour error noticeably, at the cost of runtime (about 0.3 s per 800×480 panel on a multi-core machine, versus ~30 ms for plain dithering). The dither call releases the GIL, so other threads keep running meanwhile.

```python
from epaper_dithering import DbsParams

# Default viewing geometry: 40 cm from a ~127 ppi panel (7.3" 800×480)
result = dither_image(img, SPECTRA_7_3_6COLOR, dbs=True)

# Match your setup: a wall-mounted 13.3" panel viewed from 1.5 m
result = dither_image(img, SPECTRA_7_3_6COLOR, dbs=DbsParams(viewing_distance_cm=150, ppi=150))
```

`max_passes` (default 10) caps the number of refinement passes; most of the improvement happens in the first few. Pixels pinned to exact display colors (see below) are never changed by DBS.

`DitherMode.NONE` performs direct nearest-color mapping without error diffusion or ordered dithering. It is intended for already-quantized graphics, not continuous-tone photos: because there is no error diffusion, on limited palettes (especially BWR) a continuous-tone image or a large flat mid-tone area can map to an unexpected ink — for example, a solid mid-gray region can render as solid red. Use an error-diffusion mode (e.g. `FLOYD_STEINBERG`, `BURKES`) for photographic input. 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.
Expand All @@ -204,8 +222,7 @@ result = dither_image(img, SPECTRA_7_3_6COLOR, shadows=0.5)
result = dither_image(img, SPECTRA_7_3_6COLOR, highlights=0.7)

# Combine for a "vivid photo" look
result = dither_image(img, SPECTRA_7_3_6COLOR,
exposure=1.1, saturation=1.3, shadows=0.3, highlights=0.5)
result = dither_image(img, SPECTRA_7_3_6COLOR, exposure=1.1, saturation=1.3, shadows=0.3, highlights=0.5)
```

Pipeline order: `exposure → saturation → shadows/highlights → tone → gamut → dither`.
Expand Down Expand Up @@ -266,11 +283,11 @@ from epaper_dithering import dither_image, ColorPalette, DitherMode
# Your measured RGB values
my_display = ColorPalette(
colors={
'black': (5, 5, 5), # Measured from your display
'white': (185, 190, 180), # Much darker than (255,255,255)
'red': (120, 15, 5), # Much darker than (255,0,0)
"black": (5, 5, 5), # Measured from your display
"white": (185, 190, 180), # Much darker than (255,255,255)
"red": (120, 15, 5), # Much darker than (255,0,0)
},
accent='red'
accent="red",
)

# Use it directly
Expand Down
22 changes: 11 additions & 11 deletions packages/python/docs/CALIBRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,14 +141,14 @@ from epaper_dithering import ColorPalette

my_display = ColorPalette(
colors={
'black': (26, 13, 35),
'white': (185, 202, 205),
'yellow': (202, 184, 0),
'red': (121, 9, 0),
'blue': (0, 69, 139),
'green': (40, 82, 57),
"black": (26, 13, 35),
"white": (185, 202, 205),
"yellow": (202, 184, 0),
"red": (121, 9, 0),
"blue": (0, 69, 139),
"green": (40, 82, 57),
},
accent='red'
accent="red",
)
```

Expand Down Expand Up @@ -186,11 +186,11 @@ To contribute your measurements:
```python
MY_DISPLAY_BWR = ColorPalette(
colors={
'black': (5, 5, 5),
'white': (185, 190, 180),
'red': (120, 15, 5),
"black": (5, 5, 5),
"white": (185, 190, 180),
"red": (120, 15, 5),
},
accent='red'
accent="red",
)
```

Expand Down
3 changes: 2 additions & 1 deletion packages/python/src/epaper_dithering/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
for limited-color e-paper/e-ink displays.
"""

from .core import dither_image
from .core import DbsParams, dither_image
from .enums import DitherMode
from .palettes import (
BWRY_3_97,
Expand All @@ -23,6 +23,7 @@

__all__ = [
"dither_image",
"DbsParams",
"DitherMode",
"ColorPalette",
"ColorScheme",
Expand Down
Loading
Loading