@opendisplay/epaper-dithering 2.1.4 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -123
- package/dist/index.cjs +262 -206
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +45 -22
- package/dist/index.d.ts +45 -22
- package/dist/index.js +255 -207
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# @opendisplay/epaper-dithering
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@opendisplay/epaper-dithering)
|
|
4
|
+
|
|
5
|
+
High-quality dithering algorithms for e-paper/e-ink displays, powered by a Rust/WASM core. Works in both browser and Node.js environments.
|
|
4
6
|
|
|
5
7
|
## Features
|
|
6
8
|
|
|
9
|
+
- **Rust/WASM Core**: Compiled Rust logic bundled inline — no async init, no external files, works everywhere
|
|
7
10
|
- **9 Dithering Algorithms**: From fast ordered dithering to high-quality error diffusion
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
11
|
+
- **8 Color Schemes**: MONO, BWR, BWY, BWRY, BWGBRY (Spectra 6), GRAYSCALE\_4/8/16
|
|
12
|
+
- **Measured Palettes**: Use real display-calibrated colors for accurate dithering (SPECTRA\_7\_3\_6COLOR\_V2, BWRY\_3\_97, and more)
|
|
13
|
+
- **OKLab Color Matching**: Weighted Cartesian OKLab — preserves hue without the achromatic-attractor bug that plagues LCH-weighted approaches
|
|
14
|
+
- **Pre-dither Adjustments**: Per-image exposure, saturation, shadows, highlights, dynamic-range compression, and gamut compression — all orthogonal knobs
|
|
15
|
+
- **Serpentine Scanning**: Alternates row direction to eliminate directional artifacts
|
|
16
|
+
- **Universal**: Works in browser (Canvas API) and Node.js (≥18)
|
|
17
|
+
- **Zero Dependencies**: WASM binary bundled inline, no image library required
|
|
13
18
|
|
|
14
19
|
## Installation
|
|
15
20
|
|
|
@@ -17,8 +22,6 @@ High-quality dithering algorithms for e-paper/e-ink displays, implemented in Typ
|
|
|
17
22
|
npm install @opendisplay/epaper-dithering
|
|
18
23
|
# or
|
|
19
24
|
bun add @opendisplay/epaper-dithering
|
|
20
|
-
# or
|
|
21
|
-
yarn add @opendisplay/epaper-dithering
|
|
22
25
|
```
|
|
23
26
|
|
|
24
27
|
## Quick Start
|
|
@@ -28,12 +31,10 @@ yarn add @opendisplay/epaper-dithering
|
|
|
28
31
|
```typescript
|
|
29
32
|
import { ditherImage, ColorScheme, DitherMode } from '@opendisplay/epaper-dithering';
|
|
30
33
|
|
|
31
|
-
// Load image
|
|
32
34
|
const img = new Image();
|
|
33
35
|
img.src = 'photo.jpg';
|
|
34
36
|
await img.decode();
|
|
35
37
|
|
|
36
|
-
// Convert to ImageBuffer
|
|
37
38
|
const canvas = document.createElement('canvas');
|
|
38
39
|
canvas.width = img.width;
|
|
39
40
|
canvas.height = img.height;
|
|
@@ -41,157 +42,130 @@ const ctx = canvas.getContext('2d')!;
|
|
|
41
42
|
ctx.drawImage(img, 0, 0);
|
|
42
43
|
const imageData = ctx.getImageData(0, 0, img.width, img.height);
|
|
43
44
|
|
|
44
|
-
const imageBuffer = {
|
|
45
|
-
width: imageData.width,
|
|
46
|
-
height: imageData.height,
|
|
47
|
-
data: imageData.data,
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
// Dither
|
|
51
45
|
const dithered = ditherImage(
|
|
52
|
-
|
|
46
|
+
{ width: imageData.width, height: imageData.height, data: imageData.data },
|
|
53
47
|
ColorScheme.BWR,
|
|
54
|
-
DitherMode.FLOYD_STEINBERG
|
|
48
|
+
{ mode: DitherMode.FLOYD_STEINBERG },
|
|
55
49
|
);
|
|
56
50
|
|
|
57
51
|
// Render result
|
|
58
|
-
const
|
|
59
|
-
resultCanvas.width = dithered.width;
|
|
60
|
-
resultCanvas.height = dithered.height;
|
|
61
|
-
const resultCtx = resultCanvas.getContext('2d')!;
|
|
62
|
-
const resultData = resultCtx.createImageData(dithered.width, dithered.height);
|
|
63
|
-
|
|
52
|
+
const out = ctx.createImageData(dithered.width, dithered.height);
|
|
64
53
|
for (let i = 0; i < dithered.indices.length; i++) {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
resultData.data[i * 4 + 2] = color.b;
|
|
69
|
-
resultData.data[i * 4 + 3] = 255;
|
|
54
|
+
const c = dithered.palette[dithered.indices[i]];
|
|
55
|
+
out.data[i * 4] = c.r; out.data[i * 4 + 1] = c.g;
|
|
56
|
+
out.data[i * 4 + 2] = c.b; out.data[i * 4 + 3] = 255;
|
|
70
57
|
}
|
|
58
|
+
ctx.putImageData(out, 0, 0);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Measured Palettes
|
|
71
62
|
|
|
72
|
-
|
|
73
|
-
|
|
63
|
+
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:
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { ditherImage, SPECTRA_7_3_6COLOR, BWRY_3_97 } from '@opendisplay/epaper-dithering';
|
|
67
|
+
|
|
68
|
+
// Automatically applies tone compression to fit the display's actual dynamic range
|
|
69
|
+
const dithered = ditherImage(imageBuffer, SPECTRA_7_3_6COLOR, { mode: DitherMode.BURKES });
|
|
74
70
|
```
|
|
75
71
|
|
|
72
|
+
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`.
|
|
73
|
+
|
|
76
74
|
### Node.js (with sharp)
|
|
77
75
|
|
|
78
76
|
```typescript
|
|
79
77
|
import sharp from 'sharp';
|
|
80
78
|
import { ditherImage, ColorScheme, DitherMode } from '@opendisplay/epaper-dithering';
|
|
81
79
|
|
|
82
|
-
// Load image
|
|
83
80
|
const { data, info } = await sharp('photo.jpg')
|
|
84
81
|
.ensureAlpha()
|
|
85
82
|
.raw()
|
|
86
83
|
.toBuffer({ resolveWithObject: true });
|
|
87
84
|
|
|
88
|
-
const
|
|
89
|
-
width: info.width,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
// Dither
|
|
95
|
-
const dithered = ditherImage(imageBuffer, ColorScheme.BWR, DitherMode.BURKES);
|
|
85
|
+
const dithered = ditherImage(
|
|
86
|
+
{ width: info.width, height: info.height, data: new Uint8ClampedArray(data) },
|
|
87
|
+
ColorScheme.BWR,
|
|
88
|
+
{ mode: DitherMode.BURKES },
|
|
89
|
+
);
|
|
96
90
|
|
|
97
|
-
// Convert back to RGBA
|
|
98
91
|
const rgbaBuffer = Buffer.alloc(dithered.width * dithered.height * 4);
|
|
99
92
|
for (let i = 0; i < dithered.indices.length; i++) {
|
|
100
|
-
const
|
|
101
|
-
rgbaBuffer[i * 4] =
|
|
102
|
-
rgbaBuffer[i * 4 +
|
|
103
|
-
rgbaBuffer[i * 4 + 2] = color.b;
|
|
104
|
-
rgbaBuffer[i * 4 + 3] = 255;
|
|
93
|
+
const c = dithered.palette[dithered.indices[i]];
|
|
94
|
+
rgbaBuffer[i * 4] = c.r; rgbaBuffer[i * 4 + 1] = c.g;
|
|
95
|
+
rgbaBuffer[i * 4 + 2] = c.b; rgbaBuffer[i * 4 + 3] = 255;
|
|
105
96
|
}
|
|
106
97
|
|
|
107
|
-
|
|
108
|
-
await sharp(rgbaBuffer, {
|
|
109
|
-
raw: {
|
|
110
|
-
width: dithered.width,
|
|
111
|
-
height: dithered.height,
|
|
112
|
-
channels: 4,
|
|
113
|
-
},
|
|
114
|
-
})
|
|
98
|
+
await sharp(rgbaBuffer, { raw: { width: dithered.width, height: dithered.height, channels: 4 } })
|
|
115
99
|
.png()
|
|
116
100
|
.toFile('dithered.png');
|
|
117
101
|
```
|
|
118
102
|
|
|
119
103
|
## API Reference
|
|
120
104
|
|
|
121
|
-
### `ditherImage(image, colorScheme,
|
|
105
|
+
### `ditherImage(image, colorScheme, options?)`
|
|
122
106
|
|
|
123
|
-
|
|
107
|
+
```typescript
|
|
108
|
+
ditherImage(image: ImageBuffer, palette: ColorScheme | ColorPalette, options?: DitherOptions): PaletteImageBuffer
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
| `options` field | Type | Default | Description |
|
|
112
|
+
|---|---|---|---|
|
|
113
|
+
| `mode` | `DitherMode` | `BURKES` | Dithering algorithm |
|
|
114
|
+
| `serpentine` | `boolean` | `true` | Alternate row direction to reduce artifacts |
|
|
115
|
+
| `exposure` | `number` | `1.0` | Linear-RGB exposure multiplier. `2.0` = +1 stop, `0.5` = −1 stop |
|
|
116
|
+
| `saturation` | `number` | `1.0` | OKLab saturation multiplier. `0.0` = grayscale, `>1` = boost. Hue-preserving |
|
|
117
|
+
| `shadows` | `number` | `0.0` | Shadow lift strength (S-curve lower half). `0.0` = off, `1.0` = strong |
|
|
118
|
+
| `highlights` | `number` | `0.0` | Highlight compression strength (S-curve upper half). `0.0` = off, `1.0` = strong |
|
|
119
|
+
| `tone` | `number \| 'auto' \| 'off'` | `'auto'` | Dynamic range compression. `'auto'` = histogram-based; numeric = fixed strength. Ignored for `ColorScheme` |
|
|
120
|
+
| `gamut` | `number \| 'auto' \| 'off'` | `'auto'` | Pre-dither gamut compression. `'auto'` = activate when image exceeds palette gamut; numeric = fixed. Ignored for `ColorScheme` |
|
|
124
121
|
|
|
125
|
-
|
|
126
|
-
- `image: ImageBuffer` - Input image in RGBA format
|
|
127
|
-
- `colorScheme: ColorScheme` - Target e-paper color scheme
|
|
128
|
-
- `mode?: DitherMode` - Dithering algorithm (default: `DitherMode.BURKES`)
|
|
122
|
+
Pre-processing pipeline: `exposure → saturation → shadows/highlights → tone → gamut → dither`. Each step is a no-op at its identity value.
|
|
129
123
|
|
|
130
|
-
|
|
124
|
+
Returns `PaletteImageBuffer`.
|
|
131
125
|
|
|
132
126
|
### Color Schemes
|
|
133
127
|
|
|
134
128
|
```typescript
|
|
135
129
|
enum ColorScheme {
|
|
136
|
-
MONO
|
|
137
|
-
BWR
|
|
138
|
-
BWY
|
|
139
|
-
BWRY
|
|
140
|
-
BWGBRY
|
|
141
|
-
GRAYSCALE_4
|
|
130
|
+
MONO = 0, // Black & White (2 colors)
|
|
131
|
+
BWR = 1, // Black, White, Red (3 colors)
|
|
132
|
+
BWY = 2, // Black, White, Yellow (3 colors)
|
|
133
|
+
BWRY = 3, // Black, White, Red, Yellow (4 colors)
|
|
134
|
+
BWGBRY = 4, // Black, White, Green, Blue, Red, Yellow (6 colors)
|
|
135
|
+
GRAYSCALE_4 = 5, // 4-level grayscale
|
|
136
|
+
GRAYSCALE_8 = 6, // 8-level grayscale
|
|
137
|
+
GRAYSCALE_16 = 7, // 16-level grayscale
|
|
142
138
|
}
|
|
143
139
|
```
|
|
144
140
|
|
|
145
141
|
### Dither Modes
|
|
146
142
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
## Algorithm Comparison
|
|
162
|
-
|
|
163
|
-
| Algorithm | Quality | Speed | Use Case |
|
|
164
|
-
|-----------|---------|-------|----------|
|
|
165
|
-
| NONE | Lowest | Fastest | Testing, solid colors |
|
|
166
|
-
| ORDERED | Low | Very Fast | Simple images, patterns |
|
|
167
|
-
| SIERRA_LITE | Medium | Fast | Quick previews |
|
|
168
|
-
| BURKES | Good | Medium | **Default - best balance** |
|
|
169
|
-
| FLOYD_STEINBERG | Good | Medium | Popular choice, smooth gradients |
|
|
170
|
-
| ATKINSON | Good | Medium | Classic retro aesthetic |
|
|
171
|
-
| SIERRA | High | Medium | Detailed images |
|
|
172
|
-
| STUCKI | Very High | Slow | Photos, high detail |
|
|
173
|
-
| JARVIS_JUDICE_NINKE | Highest | Slowest | Maximum quality |
|
|
143
|
+
| Mode | Quality | Speed | Notes |
|
|
144
|
+
|---|---|---|---|
|
|
145
|
+
| `NONE` | — | Fastest | Direct palette mapping |
|
|
146
|
+
| `ORDERED` | Low | Very fast | 4×4 Bayer matrix |
|
|
147
|
+
| `SIERRA_LITE` | Medium | Fast | 3-neighbor kernel |
|
|
148
|
+
| `FLOYD_STEINBERG` | Good | Medium | Most popular |
|
|
149
|
+
| `BURKES` | Good | Medium | **Default** |
|
|
150
|
+
| `ATKINSON` | Good | Medium | Classic Mac aesthetic |
|
|
151
|
+
| `SIERRA` | High | Medium | — |
|
|
152
|
+
| `STUCKI` | Very high | Slow | — |
|
|
153
|
+
| `JARVIS_JUDICE_NINKE` | Highest | Slowest | — |
|
|
174
154
|
|
|
175
|
-
|
|
155
|
+
### Types
|
|
176
156
|
|
|
177
157
|
```typescript
|
|
178
|
-
interface RGB {
|
|
179
|
-
r: number; // 0-255
|
|
180
|
-
g: number; // 0-255
|
|
181
|
-
b: number; // 0-255
|
|
182
|
-
}
|
|
183
|
-
|
|
184
158
|
interface ImageBuffer {
|
|
185
159
|
width: number;
|
|
186
160
|
height: number;
|
|
187
|
-
data: Uint8ClampedArray; // RGBA
|
|
161
|
+
data: Uint8ClampedArray; // RGBA, row-major
|
|
188
162
|
}
|
|
189
163
|
|
|
190
164
|
interface PaletteImageBuffer {
|
|
191
165
|
width: number;
|
|
192
166
|
height: number;
|
|
193
|
-
indices: Uint8Array;
|
|
194
|
-
palette: RGB[];
|
|
167
|
+
indices: Uint8Array; // palette index per pixel
|
|
168
|
+
palette: RGB[]; // sRGB colors
|
|
195
169
|
}
|
|
196
170
|
|
|
197
171
|
interface ColorPalette {
|
|
@@ -200,32 +174,35 @@ interface ColorPalette {
|
|
|
200
174
|
}
|
|
201
175
|
```
|
|
202
176
|
|
|
203
|
-
##
|
|
177
|
+
## Preview Tool
|
|
204
178
|
|
|
205
|
-
|
|
179
|
+
An interactive browser tool for comparing dithering modes and palettes:
|
|
206
180
|
|
|
207
|
-
|
|
181
|
+
**Hosted** (always latest release): https://opendisplay.github.io/epaper-dithering/
|
|
208
182
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
183
|
+
**Local** (against your working branch):
|
|
184
|
+
```bash
|
|
185
|
+
cd packages/javascript
|
|
186
|
+
bun run dev
|
|
187
|
+
# → http://localhost:3456/dev.html
|
|
188
|
+
```
|
|
212
189
|
|
|
213
|
-
|
|
190
|
+
Features: drag & drop or paste from clipboard, live re-render on every setting change, timing display, palette swatch preview.
|
|
214
191
|
|
|
215
|
-
|
|
192
|
+
## Development
|
|
216
193
|
|
|
217
|
-
|
|
194
|
+
```bash
|
|
195
|
+
bun install
|
|
218
196
|
|
|
219
|
-
|
|
220
|
-
-
|
|
221
|
-
- **SIERRA_LITE**: ~100ms
|
|
222
|
-
- **BURKES/FLOYD_STEINBERG**: ~150ms
|
|
223
|
-
- **SIERRA/ATKINSON**: ~200ms
|
|
224
|
-
- **STUCKI/JARVIS**: ~300ms
|
|
197
|
+
# When Rust source changes, rebuild the WASM (from repo root):
|
|
198
|
+
wasm-pack build packages/rust/wasm --target bundler --out-dir ../../javascript/src/wasm-core
|
|
225
199
|
|
|
226
|
-
|
|
200
|
+
bun run test # vitest
|
|
201
|
+
bun run build # tsup → dist/
|
|
202
|
+
bun run type-check
|
|
203
|
+
```
|
|
227
204
|
|
|
228
205
|
## Related Projects
|
|
229
206
|
|
|
230
|
-
- **Python**: [`epaper-dithering`](https://pypi.org/project/epaper-dithering/)
|
|
231
|
-
- **OpenDisplay**: [`py-opendisplay`](https://github.com/OpenDisplay-org/py-opendisplay)
|
|
207
|
+
- **Python**: [`epaper-dithering`](https://pypi.org/project/epaper-dithering/) — Python package, shares the same Rust core
|
|
208
|
+
- **OpenDisplay**: [`py-opendisplay`](https://github.com/OpenDisplay-org/py-opendisplay) — Python library for OpenDisplay BLE devices
|