Implements the server side of the architecture decided on: the ESP32-C6 has no PSRAM and a tight RAM budget, so all the heavy lifting (JPEG decode, resize, Floyd-Steinberg dithering, 6-color quantization, 4bpp packing) happens here instead of on-device. The frame just does a single GET and streams the response straight to SPI. - GET /frame/image: looks up the current cursor's asset in the configured Immich album, downloads its preview thumbnail, and returns it packed into the panel's exact 800x480/4bpp/2px-per-byte format (application/octet-stream, always exactly 192,000 bytes). - GET / + POST /api/config + GET /api/albums: a small web UI for entering the Immich URL/API key and picking an album, rather than cramming that into the ESP32's captive portal form. - Config (Immich creds, selected album, cursor) persists to a JSON file via a docker-compose volume mount. Verified locally with a venv (Docker isn't available in this environment): unit-tested image_pipeline against a synthetic image (exact byte count, valid panel color codes only), and ran a full end-to-end pass against a mock Immich HTTP server exercising the real /frame/image path. Pinned dependency versions in requirements.txt after hitting a real bug with unpinned floors: the latest starlette (1.3.1) resolved by `pip install fastapi` breaks Jinja2Templates outright. Not yet wired to the ESP32 side (task 6) or authenticated -- /frame/image is unauthenticated for now, fine on a trusted LAN but worth revisiting once the firmware sends a shared device token.
58 lines
1.9 KiB
Python
58 lines
1.9 KiB
Python
"""Resize, quantize, and pack a photo into the panel's raw 4bpp format."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from PIL import Image, ImageOps
|
|
|
|
EPD_WIDTH = 800
|
|
EPD_HEIGHT = 480
|
|
|
|
# Approximate sRGB for each of the panel's 6 ink colors. These are
|
|
# reasonable placeholders, not measured values -- Waveshare doesn't publish
|
|
# exact color primaries for this panel. Tune them once you can compare a
|
|
# rendered test image against the real panel.
|
|
PALETTE_RGB = [
|
|
(0, 0, 0), # BLACK
|
|
(255, 255, 255), # WHITE
|
|
(255, 219, 0), # YELLOW
|
|
(207, 0, 15), # RED
|
|
(0, 39, 133), # BLUE
|
|
(0, 133, 55), # GREEN
|
|
]
|
|
|
|
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
|
# in the same order as PALETTE_RGB. 0x4 is intentionally unused upstream.
|
|
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
|
|
|
|
|
|
def _build_palette_image() -> Image.Image:
|
|
pal_img = Image.new("P", (1, 1))
|
|
pal_img.putpalette([channel for rgb in PALETTE_RGB for channel in rgb])
|
|
return pal_img
|
|
|
|
|
|
_PALETTE_IMAGE = _build_palette_image()
|
|
|
|
|
|
def render_frame(source: Image.Image) -> bytes:
|
|
"""Fits `source` to the panel's resolution, quantizes it to the 6-color
|
|
palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way
|
|
epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.
|
|
"""
|
|
fitted = ImageOps.exif_transpose(source.convert("RGB"))
|
|
fitted = ImageOps.fit(fitted, (EPD_WIDTH, EPD_HEIGHT), method=Image.LANCZOS)
|
|
|
|
quantized = fitted.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
|
|
pixels = quantized.load()
|
|
|
|
out = bytearray(EPD_WIDTH * EPD_HEIGHT // 2)
|
|
i = 0
|
|
for y in range(EPD_HEIGHT):
|
|
for x in range(0, EPD_WIDTH, 2):
|
|
left = PANEL_CODES[pixels[x, y]]
|
|
right = PANEL_CODES[pixels[x + 1, y]]
|
|
out[i] = (left << 4) | right
|
|
i += 1
|
|
|
|
return bytes(out)
|