- E-paper display simulator: Python port of the C++ Floyd-Steinberg dithering (same palette, same coefficients) with side-by-side preview in the web UI. Interactive pan/zoom controls with live re-rendering. - Frame orientation: landscape / portrait_cw / portrait_ccw setting controls logical display dimensions (800x480 vs 480x800). Images are rotated to match the physical buffer after processing. - Display modes: zoom (cover+crop) and letterbox (fit with padding), configurable globally and per-image. Zoom mode supports pan_x/pan_y (0.0-1.0) to control crop position. - Settings persistence: frame settings, per-image settings, and frame state stored as JSON, surviving restarts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
143 lines
5.1 KiB
Python
143 lines
5.1 KiB
Python
"""
|
|
Floyd-Steinberg dithering for E Ink Spectra 6 (6-color) display.
|
|
|
|
This is a 1:1 Python port of the C++ dithering in firmware/src/dither.h.
|
|
Same palette RGB values, same error diffusion coefficients, same color
|
|
distance metric. The output should be pixel-identical to the firmware.
|
|
|
|
Native panel color codes (what the controller expects over SPI):
|
|
0x00 = Black
|
|
0x01 = White
|
|
0x02 = Yellow
|
|
0x03 = Red
|
|
0x04 = Orange (not used in Spectra 6, but accepted by controller)
|
|
0x05 = Blue
|
|
0x06 = Green
|
|
|
|
Pixel packing: 4bpp, 2 pixels per byte.
|
|
High nibble (bits 7-4) = LEFT pixel
|
|
Low nibble (bits 3-0) = RIGHT pixel
|
|
Scan order: left-to-right, top-to-bottom
|
|
|
|
Data path on the ESP32:
|
|
JPEG → decode to RGB888 → Floyd-Steinberg dither → 4bpp native buffer
|
|
→ writeNative(buf, invert=true) → SPI command 0x10 → panel refresh
|
|
"""
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
# Palette: (R, G, B, native_panel_index)
|
|
# These RGB values must match firmware/src/dither.h PALETTE[] exactly.
|
|
PALETTE = [
|
|
( 0, 0, 0, 0), # Black
|
|
(255, 255, 255, 1), # White
|
|
(200, 30, 30, 3), # Red
|
|
( 0, 145, 0, 6), # Green
|
|
( 0, 0, 160, 5), # Blue
|
|
(230, 210, 0, 2), # Yellow
|
|
]
|
|
|
|
# Extract just the RGB values as a numpy array for vectorized distance calc
|
|
PALETTE_RGB = np.array([(r, g, b) for r, g, b, _ in PALETTE], dtype=np.int32)
|
|
PALETTE_INDICES = [idx for _, _, _, idx in PALETTE]
|
|
|
|
# Display RGB values — what the e-paper pigments actually look like.
|
|
# Used for rendering the simulator output. These may differ from the palette
|
|
# values above (which are what we compare against during dithering).
|
|
# For now they're the same, but can be tuned independently.
|
|
DISPLAY_RGB = {
|
|
0: ( 0, 0, 0), # Black
|
|
1: (255, 255, 255), # White
|
|
2: (230, 210, 0), # Yellow
|
|
3: (200, 30, 30), # Red
|
|
5: ( 0, 0, 160), # Blue
|
|
6: ( 0, 145, 0), # Green
|
|
}
|
|
|
|
|
|
def find_closest_color(r: int, g: int, b: int) -> int:
|
|
"""Find the palette index with minimum squared Euclidean distance."""
|
|
best_idx = 0
|
|
best_dist = float('inf')
|
|
for i, (pr, pg, pb, _) in enumerate(PALETTE):
|
|
dr = r - pr
|
|
dg = g - pg
|
|
db = b - pb
|
|
dist = dr * dr + dg * dg + db * db
|
|
if dist < best_dist:
|
|
best_dist = dist
|
|
best_idx = i
|
|
return best_idx
|
|
|
|
|
|
def dither_floyd_steinberg(img: Image.Image) -> tuple[np.ndarray, Image.Image]:
|
|
"""
|
|
Apply Floyd-Steinberg dithering to an 800x480 RGB image.
|
|
|
|
Returns:
|
|
native_buffer: numpy array of shape (480, 400) dtype uint8
|
|
Each byte = 2 pixels packed as 4bpp native panel indices.
|
|
High nibble = left pixel, low nibble = right pixel.
|
|
preview_img: PIL Image showing what the e-paper display would look like.
|
|
"""
|
|
assert img.size == (800, 480), f"Image must be 800x480, got {img.size}"
|
|
|
|
width, height = 800, 480
|
|
# Work with int16 to allow negative error values
|
|
pixels = np.array(img, dtype=np.int16)
|
|
|
|
# Output: what native panel index each pixel gets
|
|
result = np.zeros((height, width), dtype=np.uint8)
|
|
|
|
for y in range(height):
|
|
for x in range(width):
|
|
r = int(np.clip(pixels[y, x, 0], 0, 255))
|
|
g = int(np.clip(pixels[y, x, 1], 0, 255))
|
|
b = int(np.clip(pixels[y, x, 2], 0, 255))
|
|
|
|
ci = find_closest_color(r, g, b)
|
|
pr, pg, pb, native_idx = PALETTE[ci]
|
|
result[y, x] = native_idx
|
|
|
|
# Quantization error
|
|
er = r - pr
|
|
eg = g - pg
|
|
eb = b - pb
|
|
|
|
# Floyd-Steinberg error diffusion (same coefficients as C++)
|
|
# Right: 7/16, Bottom-left: 3/16, Bottom: 5/16, Bottom-right: 1/16
|
|
if x + 1 < width:
|
|
pixels[y, x + 1, 0] += er * 7 // 16
|
|
pixels[y, x + 1, 1] += eg * 7 // 16
|
|
pixels[y, x + 1, 2] += eb * 7 // 16
|
|
if y + 1 < height:
|
|
if x - 1 >= 0:
|
|
pixels[y + 1, x - 1, 0] += er * 3 // 16
|
|
pixels[y + 1, x - 1, 1] += eg * 3 // 16
|
|
pixels[y + 1, x - 1, 2] += eb * 3 // 16
|
|
pixels[y + 1, x, 0] += er * 5 // 16
|
|
pixels[y + 1, x, 1] += eg * 5 // 16
|
|
pixels[y + 1, x, 2] += eb * 5 // 16
|
|
if x + 1 < width:
|
|
pixels[y + 1, x + 1, 0] += er * 1 // 16
|
|
pixels[y + 1, x + 1, 1] += eg * 1 // 16
|
|
pixels[y + 1, x + 1, 2] += eb * 1 // 16
|
|
|
|
# Pack into 4bpp buffer (2 pixels per byte, high nibble = left)
|
|
native_buffer = np.zeros((height, width // 2), dtype=np.uint8)
|
|
for y in range(height):
|
|
for x in range(0, width, 2):
|
|
left = result[y, x]
|
|
right = result[y, x + 1]
|
|
native_buffer[y, x // 2] = (left << 4) | right
|
|
|
|
# Render preview image using display RGB values
|
|
preview = np.zeros((height, width, 3), dtype=np.uint8)
|
|
for y in range(height):
|
|
for x in range(width):
|
|
preview[y, x] = DISPLAY_RGB[result[y, x]]
|
|
preview_img = Image.fromarray(preview)
|
|
|
|
return native_buffer, preview_img
|