High-quality dithering algorithms for e-paper/e-ink displays, implemented in TypeScript. Works in both browser and Node.js environments.
- 9 Dithering Algorithms: From fast ordered dithering to high-quality error diffusion
- 8 Color Schemes: MONO, BWR, BWY, BWRY, BWGBRY (Spectra 6), GRAYSCALE_4/8/16
- Measured Palettes: Use real display-calibrated colors for accurate dithering (SPECTRA_7_3_6COLOR, BWRY_3_97, and more)
- LCH Color Matching: Perceptual LAB color space with hue-weighted distance — hue errors can't be recovered by error diffusion, so they're prioritized
- Serpentine Scanning: Alternates row direction to eliminate directional artifacts
- Universal: Works in browser (Canvas API) and Node.js (with sharp/jimp)
- Zero Dependencies: Pure TypeScript, no image library dependencies
- Fast: 256-entry sRGB LUT, pre-computed palette LAB arrays, typed array pixel buffers
npm install @opendisplay/epaper-dithering
# or
bun add @opendisplay/epaper-ditheringimport { ditherImage, ColorScheme, DitherMode } from '@opendisplay/epaper-dithering';
const img = new Image();
img.src = 'photo.jpg';
await img.decode();
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const dithered = ditherImage(
{ width: imageData.width, height: imageData.height, data: imageData.data },
ColorScheme.BWR,
DitherMode.FLOYD_STEINBERG,
);
// Render result
const out = ctx.createImageData(dithered.width, dithered.height);
for (let i = 0; i < dithered.indices.length; i++) {
const c = dithered.palette[dithered.indices[i]];
out.data[i * 4] = c.r; out.data[i * 4 + 1] = c.g;
out.data[i * 4 + 2] = c.b; out.data[i * 4 + 3] = 255;
}
ctx.putImageData(out, 0, 0);Standard ColorScheme values use ideal sRGB colors (e.g. white = 255,255,255). Real e-paper displays reflect significantly less light. Use a measured ColorPalette for accurate dithering:
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, DitherMode.BURKES);Available measured palettes: SPECTRA_7_3_6COLOR, BWRY_3_97, MONO_4_26, BWRY_4_2, SOLUM_BWR, HANSHOW_BWR, HANSHOW_BWY.
import sharp from 'sharp';
import { ditherImage, ColorScheme, DitherMode } from '@opendisplay/epaper-dithering';
const { data, info } = await sharp('photo.jpg')
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const dithered = ditherImage(
{ width: info.width, height: info.height, data: new Uint8ClampedArray(data) },
ColorScheme.BWR,
DitherMode.BURKES,
);
const rgbaBuffer = Buffer.alloc(dithered.width * dithered.height * 4);
for (let i = 0; i < dithered.indices.length; i++) {
const c = dithered.palette[dithered.indices[i]];
rgbaBuffer[i * 4] = c.r; rgbaBuffer[i * 4 + 1] = c.g;
rgbaBuffer[i * 4 + 2] = c.b; rgbaBuffer[i * 4 + 3] = 255;
}
await sharp(rgbaBuffer, { raw: { width: dithered.width, height: dithered.height, channels: 4 } })
.png()
.toFile('dithered.png');| Parameter | Type | Default | Description |
|---|---|---|---|
image |
ImageBuffer |
— | RGBA input image |
colorScheme |
ColorScheme | ColorPalette |
— | Target palette (enum or measured) |
mode |
DitherMode |
BURKES |
Dithering algorithm |
serpentine |
boolean |
true |
Alternate row direction to reduce artifacts |
Returns PaletteImageBuffer.
enum ColorScheme {
MONO = 0, // Black & White (2 colors)
BWR = 1, // Black, White, Red (3 colors)
BWY = 2, // Black, White, Yellow (3 colors)
BWRY = 3, // Black, White, Red, Yellow (4 colors)
BWGBRY = 4, // Black, White, Green, Blue, Red, Yellow (6 colors)
GRAYSCALE_4 = 5, // 4-level grayscale
GRAYSCALE_8 = 6, // 8-level grayscale
GRAYSCALE_16 = 7, // 16-level grayscale
}| Mode | Quality | Speed | Notes |
|---|---|---|---|
NONE |
— | Fastest | Direct palette mapping |
ORDERED |
Low | Very fast | 4×4 Bayer matrix |
SIERRA_LITE |
Medium | Fast | 3-neighbor kernel |
FLOYD_STEINBERG |
Good | Medium | Most popular |
BURKES |
Good | Medium | Default |
ATKINSON |
Good | Medium | Classic Mac aesthetic |
SIERRA |
High | Medium | — |
STUCKI |
Very high | Slow | — |
JARVIS_JUDICE_NINKE |
Highest | Slowest | — |
interface ImageBuffer {
width: number;
height: number;
data: Uint8ClampedArray; // RGBA, row-major
}
interface PaletteImageBuffer {
width: number;
height: number;
indices: Uint8Array; // palette index per pixel
palette: RGB[]; // sRGB colors
}
interface ColorPalette {
readonly colors: Record<string, RGB>;
readonly accent: string;
}An interactive browser tool for comparing dithering modes and palettes:
Hosted (always latest release): https://opendisplay.github.io/epaper-dithering/
Local (against your working branch):
cd packages/javascript
bun run dev
# → http://localhost:3456/dev.htmlFeatures: drag & drop or paste from clipboard, live re-render on every setting change, timing display, palette swatch preview.
- Python:
epaper-dithering— Python implementation (feature superset) - OpenDisplay:
py-opendisplay— Python library for OpenDisplay BLE devices