Files
espresso_frame/server/app/image_pipeline.py
T
tfaour 683e3881b1 Redesign phase C: claim flow, limited manage page, device protocol
The frame-claiming pipeline, end to end. Firmware: every request now
carries ?id=<12-hex STA MAC> via build_url (mirrored in build_ota_url),
and the captive portal's success page became a redirect that hands the
user's browser to <server>/claim?device_id=... after ~7s -- enough time
for the phone to drop the provisioning AP while the device reboots.
The server pushes a per-frame device token through /frame/config during
a one-time handshake; the firmware persists it to NVS (a dedicated
single-key write that deliberately doesn't reset the connected-once
flag or WiFi cache) and prefers it over the provisioned shared token
from the next request on. Config response buffer grows 256->512. Both
board variants compile clean; new firmware also works against an old
server (which ignores ?id=) and old firmware against this server (the
phase A legacy mapping), so either deploy order survives.

Server: /claim lands the captive-portal redirect -- claim-gated signup
(a valid unclaimed/unregistered device id IS the enrollment invitation),
pending claims for the user-beats-the-frame race (auto-attached at
self-registration, 24h expiry), and a waiting page that refreshes until
the frame checks in. Unclaimed/unconfigured frames get a rendered
instruction placeholder with a QR from /frame/image (200, never an
error loop) -- new qrcode dep, placeholder shares the exact
quantize/pack path photos use.

The on-frame manage QR now resolves to a limited no-login page: scans
of / carrying device credentials (new ?id&token or the legacy shared
token) 303 to /m/<manage_token>, which allows exactly view queue,
show-next, advance, back, and scoped thumbnails -- no settings, no
removal, no other frames. Full control means logging in.

One real protocol hole found by simulating full wake cycles: after
self-registration the device could never authenticate again (the wake
cycle fetches the image BEFORE /frame/config delivers its token).
require_device now treats the id itself as the credential until the
first authenticated request flips device_token_ack -- the same trust
level as open registration, closing permanently once the handshake
completes.
2026-07-21 23:44:22 -04:00

250 lines
9.8 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
# How each orientation maps the logically-composed image onto the native
# 800x480 panel. "portrait"/"portrait_flipped" compose at 480x800 (so the
# crop ratio matches how the frame actually hangs) and rotate into native
# space afterwards -- rotation happens after dithering, which is lossless
# (a pure pixel permutation). Which of 90/270 is "portrait" vs
# "portrait_flipped" is a convention pick; whichever way the frame is
# hung, one of the two is right.
ORIENTATION_TRANSPOSE = {
"landscape": None,
"landscape_flipped": Image.Transpose.ROTATE_180,
"portrait": Image.Transpose.ROTATE_90,
"portrait_flipped": Image.Transpose.ROTATE_270,
}
def logical_render_size(orientation: str) -> tuple[int, int]:
"""(width, height) the photo is composed/cropped at for this
orientation, before rotating into native panel space."""
if orientation in ("portrait", "portrait_flipped"):
return EPD_HEIGHT, EPD_WIDTH
return EPD_WIDTH, EPD_HEIGHT
def logical_to_native(x: float, y: float, orientation: str) -> tuple[int, int]:
"""Maps a point in logical (pre-rotation) frame space to native
800x480 panel space, applying the same rotation ORIENTATION_TRANSPOSE
applies to the pixels -- anything positioned in logical coordinates
(e.g. face labels) needs this to stay attached to the rotated
content. PIL's ROTATE_90 is counterclockwise; ROTATE_270 clockwise."""
logical_w, logical_h = logical_render_size(orientation)
if orientation == "landscape_flipped":
return int(logical_w - 1 - x), int(logical_h - 1 - y)
if orientation == "portrait": # ROTATE_90 (CCW)
return int(y), int(logical_w - 1 - x)
if orientation == "portrait_flipped": # ROTATE_270 (CW)
return int(logical_h - 1 - y), int(x)
return int(x), int(y)
# 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
(0, 0, 0),
(255, 255, 255),
(255, 243, 56),
(191, 0, 0),
(100, 64, 255),
(67, 138, 28)
]
# 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 _plain_center_crop_box(
img_width: int, img_height: int, target_width: int, target_height: int
) -> tuple[float, float, int, int]:
"""The largest target_width:target_height window centered in the
source image -- the same box ImageOps.fit() computes internally when
there's no face-aware shift to apply. Returns (left, top, crop_w,
crop_h); left/top are floats (not yet rounded) since callers that go
on to face-shift this box need the unrounded center point."""
target_ratio = target_width / target_height
if img_width / img_height > target_ratio:
crop_h = img_height
crop_w = int(crop_h * target_ratio)
else:
crop_w = img_width
crop_h = int(crop_w / target_ratio)
left = (img_width - crop_w) / 2
top = (img_height - crop_h) / 2
return left, top, crop_w, crop_h
def _face_aware_crop_box(
img_width: int, img_height: int, target_width: int, target_height: int, faces: list[dict]
) -> tuple[int, int, int, int]:
"""Largest crop window matching target_width:target_height that fits
inside the source image. Starts from the plain center crop and only
shifts it the minimum amount needed to bring any faces that would
otherwise be cut off back on screen -- an already-fine composition
(faces already fully inside the center crop) is left untouched rather
than re-centered on the faces. If the faces themselves span wider than
the crop window allows, centers on their midpoint as best-effort,
since there's no shift that fits them all regardless.
Each face's box is given relative to its own imageWidth/imageHeight
(the resolution Immich ran detection on), which may differ from the
downloaded preview's resolution passed in here, so each box is scaled
into img_width/img_height space before use.
"""
min_x = min_y = float("inf")
max_x = max_y = float("-inf")
for face in faces:
face_w = face.get("imageWidth") or img_width
face_h = face.get("imageHeight") or img_height
scale_x = img_width / face_w
scale_y = img_height / face_h
min_x = min(min_x, face["boundingBoxX1"] * scale_x)
max_x = max(max_x, face["boundingBoxX2"] * scale_x)
min_y = min(min_y, face["boundingBoxY1"] * scale_y)
max_y = max(max_y, face["boundingBoxY2"] * scale_y)
left, top, crop_w, crop_h = _plain_center_crop_box(img_width, img_height, target_width, target_height)
if max_x - min_x <= crop_w:
if min_x < left:
left = min_x
elif max_x > left + crop_w:
left = max_x - crop_w
else:
left = (min_x + max_x) / 2 - crop_w / 2
if max_y - min_y <= crop_h:
if min_y < top:
top = min_y
elif max_y > top + crop_h:
top = max_y - crop_h
else:
top = (min_y + max_y) / 2 - crop_h / 2
left = max(0, min(left, img_width - crop_w))
top = max(0, min(top, img_height - crop_h))
return (int(left), int(top), int(left) + crop_w, int(top) + crop_h)
def render_frame(source: Image.Image, faces: list[dict] | None = None,
orientation: str = "landscape") -> 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.
If `faces` (from ImmichClient.get_asset_faces) is non-empty, crops
toward keeping them on screen instead of a plain center-crop.
`orientation` (see ORIENTATION_TRANSPOSE) composes the photo for how
the frame physically hangs, then rotates into native panel space --
the output byte layout is identical either way.
"""
logical_w, logical_h = logical_render_size(orientation)
fitted = ImageOps.exif_transpose(source.convert("RGB"))
if faces:
box = _face_aware_crop_box(fitted.width, fitted.height, logical_w, logical_h, faces)
fitted = fitted.crop(box).resize((logical_w, logical_h), Image.LANCZOS)
else:
fitted = ImageOps.fit(fitted, (logical_w, logical_h), method=Image.LANCZOS)
return _quantize_and_pack(fitted, orientation)
def _quantize_and_pack(logical_img: Image.Image, orientation: str) -> bytes:
"""The shared back half of rendering: 6-color Floyd-Steinberg
quantization, rotation into native panel space, and 2-pixels/byte
packing. Takes an RGB image already composed at logical_render_size()
for the orientation."""
quantized = logical_img.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
transpose = ORIENTATION_TRANSPOSE.get(orientation)
if transpose is not None:
quantized = quantized.transpose(transpose)
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)
def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape") -> bytes:
"""A readable full-panel message (plus an optional QR code) in the
same packed format as render_frame -- what /frame/image serves for a
frame that isn't claimed or configured yet, so a fresh device shows
instructions instead of an error screen and never error-loops."""
from PIL import ImageDraw, ImageFont
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
draw = ImageDraw.Draw(img)
title_font = ImageFont.load_default(size=34)
body_font = ImageFont.load_default(size=24)
qr_img = None
if qr_url:
import qrcode
qr = qrcode.QRCode(border=1, box_size=1)
qr.add_data(qr_url)
qr.make(fit=True)
raw = qr.make_image().get_image().convert("RGB")
# Integer upscale with NEAREST keeps modules crisp on the panel.
target = 220
scale = max(1, target // raw.width)
qr_img = raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
# Vertical layout: text block, then QR under it, centered as a group.
line_heights = []
for i, line in enumerate(lines):
font = title_font if i == 0 else body_font
bbox = draw.textbbox((0, 0), line, font=font)
line_heights.append((line, font, bbox[2] - bbox[0], bbox[3] - bbox[1]))
gap = 14
text_h = sum(h for _, _, _, h in line_heights) + gap * (len(line_heights) - 1 if line_heights else 0)
total_h = text_h + (qr_img.height + 28 if qr_img else 0)
y = max(20, (logical_h - total_h) // 2)
for line, font, w, h in line_heights:
draw.text(((logical_w - w) // 2, y), line, fill=(0, 0, 0), font=font)
y += h + gap
if qr_img:
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
return _quantize_and_pack(img, orientation)