Files
eink-photo-frame/server/dither.py
Thomas Hallock dd4f8e950d Add display simulator, orientation support, and per-image crop settings
- 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>
2026-03-27 16:32:38 -05:00

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