Dithering algorithms optimized for e-ink/e-paper displays with limited color palettes.
# With uv
uv add epaper-dithering
# With pip
pip install epaper-dithering- Rust Core: All dithering runs in a compiled Rust extension — fast enough for 800×480 images in ~30ms
- Perceptually Correct: Weighted Cartesian OKLab color matching — preserves hue without the achromatic-attractor bug that plagues LCH-weighted approaches
- 9 Dithering Algorithms: From simple ordered dithering to high-quality Jarvis-Judice-Ninke
- 8 Color Schemes: Support for mono, 3-color, 4-color, 6-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)
- RGBA Support: Automatic compositing on white background for transparent images
from PIL import Image
from epaper_dithering import dither_image, ColorScheme, DitherMode
# Load your image
image = Image.open("photo.jpg")
# Apply dithering for a black/white/red display
dithered = dither_image(image, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG)
# Save result
dithered.save("output.png")All arguments after color_scheme are keyword-only:
dither_image(
image, palette,
*,
mode=DitherMode.BURKES, # algorithm
serpentine=True, # alternate row scan direction
exposure=1.0, # linear-RGB multiplier (1.0 = no change)
saturation=1.0, # OKLab saturation (1.0 = no change, 0.0 = grayscale)
shadows=0.0, # shadow lift, S-curve lower half
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
)- MONO - Black and white (1-bit)
- BWR - Black, white, red (3-color)
- BWY - Black, white, yellow (3-color)
- BWRY - Black, white, red, yellow (4-color)
- BWGBRY - Black, white, green, blue, red, yellow (6-color Spectra)
- GRAYSCALE_4 - 4-level grayscale (2-bit)
- GRAYSCALE_8 - 8-level grayscale (3-bit, e.g. Inkplate 10)
- GRAYSCALE_16 - 16-level grayscale (4-bit, e.g. Waveshare 6" HD)
| Algorithm | Quality | Speed | Best For |
|---|---|---|---|
| NONE | Lowest | Fastest | Testing, simple graphics |
| ORDERED | Low | Very Fast | Patterns, textures |
| SIERRA_LITE | Medium | Fast | Quick results |
| BURKES | Good | Medium | General purpose (default) |
| FLOYD_STEINBERG | Good | Medium | Popular standard |
| SIERRA | High | Medium | Balanced quality |
| ATKINSON | Good | Medium | High contrast, artistic |
| STUCKI | Very High | Slow | Maximum quality |
| JARVIS_JUDICE_NINKE | Highest | Slowest | Smooth gradients |
from PIL import Image
from epaper_dithering import dither_image, ColorScheme, DitherMode
img = Image.open("photo.jpg")
result = dither_image(img, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG)
result.save("dithered.png")# Black and white only
dithered = dither_image(img, ColorScheme.MONO)
# Black, white, and red (common for e-paper tags)
dithered = dither_image(img, ColorScheme.BWR)
# Grayscale (4 levels)
dithered = dither_image(img, ColorScheme.GRAYSCALE_4)
# 6-color display (Spectra)
dithered = dither_image(img, ColorScheme.BWGBRY)By default, error diffusion algorithms use serpentine scanning (alternating scan direction per row) to reduce directional artifacts and "worm" patterns. You can disable this for raster scanning:
# Default: serpentine scanning (recommended for best quality)
result = dither_image(img, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG, serpentine=True)
# Disable serpentine for raster scanning (left-to-right only)
result = dither_image(img, ColorScheme.BWR, mode=DitherMode.FLOYD_STEINBERG, serpentine=False)Note: The serpentine parameter only affects error diffusion algorithms (Floyd-Steinberg, Burkes, Atkinson, Sierra, Sierra Lite, Stucki, Jarvis-Judice-Ninke). It has no effect on NONE and ORDERED modes.
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() from esp32-photoframe by aitjcize. It is enabled by default (tone="auto") 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.0maps the full [0,1] range to the display range.0.0disables compression.
from epaper_dithering import dither_image, SPECTRA_7_3_6COLOR, DitherMode
# Default: auto tone compression (recommended)
result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG)
# 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)Note: tone has no effect when using theoretical ColorScheme palettes (e.g., ColorScheme.BWR), since their black/white values already span the full range.
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.
# Default: auto gamut compression (activates only when image exceeds palette gamut)
result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.BURKES)
# 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)Note: gamut also has no effect for theoretical ColorScheme palettes.
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).
# Brighten and boost saturation for vivid output
result = dither_image(img, SPECTRA_7_3_6COLOR, exposure=1.3, saturation=1.4)
# Lift shadows on a dark image
result = dither_image(img, SPECTRA_7_3_6COLOR, shadows=0.5)
# Compress highlights on an overexposed image
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)Pipeline order: exposure → saturation → shadows/highlights → tone → gamut → dither.
Images with transparency (RGBA mode) are automatically composited on a white background, matching the typical appearance of e-paper displays:
# RGBA images are handled automatically
rgba_img = Image.open("transparent.png") # Has alpha channel
result = dither_image(rgba_img, ColorScheme.BWR)
# Transparent areas become whiteFor the most accurate dithering, use measured RGB values from your specific e-paper display instead of theoretical pure RGB colors.
E-paper displays use reflective technology, making colors 30-87% darker than pure RGB:
- Pure RGB White: (255, 255, 255) → Real display: ~(180-200, 180-200, 180-200)
- Pure RGB Red: (255, 0, 0) → Real display: ~(115-125, 10-20, 0-10)
Using measured values ensures dithered images match your display's actual appearance.
The library includes measured palettes for common displays:
from epaper_dithering import dither_image, SPECTRA_7_3_6COLOR, DitherMode
# Use measured palette for Spectra 7.3" 6-color display
result = dither_image(img, SPECTRA_7_3_6COLOR, mode=DitherMode.FLOYD_STEINBERG)Available measured palettes:
SPECTRA_7_3_6COLOR- 7.3" Spectra™ 6-color (BWGBRY), v1 measurementSPECTRA_7_3_6COLOR_V2- 7.3" Spectra™ 6-color (BWGBRY), v2 measurement (recommended)MONO_4_26- 4.26" MonochromeBWRY_4_2- 4.2" BWRYBWRY_3_97- 3.97" BWRYSOLUM_BWR- Solum BWRHANSHOW_BWR- Hanshow BWRHANSHOW_BWY- Hanshow BWY
See CALIBRATION.md for measuring your specific display.
Measure your display and create a custom palette:
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)
},
accent='red'
)
# Use it directly
result = dither_image(img, my_display, mode=DitherMode.FLOYD_STEINBERG)- Display full-screen color patches on your e-paper
- Photograph in consistent lighting (avoid shadows/reflections)
- Sample RGB values from center using photo editor
- Average 5+ samples per color
- Create ColorPalette with measured values
See docs/CALIBRATION.md for detailed measurement procedures, including camera calibration, colorimeter usage, and validation techniques.
# Install dependencies (requires Rust toolchain: https://rustup.rs)
uv sync --all-extras
# Build and install the Rust extension (required before running tests)
uv run maturin develop --release
# Run tests
uv run pytest tests/ -v
# Run tests with coverage
uv run pytest tests/ --cov=src/epaper_dithering
# Lint
uv run ruff check src/ tests/
# Type check
uv run mypy src/epaper_ditheringMeasured color calibration techniques and reference measurements inspired by:
- esp32-photoframe by aitjcize - Measured palette methodology, dynamic range compression algorithm, and reference values for Waveshare 7.3" displays