Build and push server image / build-and-push (push) Successful in 40s
Advanced configuration (Configuration tab, collapsed <details> section):
a color picker per ink color (black/white/yellow/red/blue/green),
overriding image_pipeline.DEFAULT_PALETTE_RGB for that frame's actual
panel -- different units can vary enough from the documented
approximations to be worth calibrating once you can compare a rendered
photo against the real hardware. Stored as Frame.palette_rgb (NULL =
default, schema migration v4), threaded through render_frame/
render_placeholder/_quantize_and_pack (which now builds the PIL palette
image per call instead of once at import) so both photos and the
unclaimed/unconfigured placeholder screen respect it. "Reset to
defaults" clears back to NULL. Config-save validates exactly 6 #rrggbb
values, rejecting anything else with a 400.
Also: each frame's sidebar entry now shows its last-reported battery
percent (🔋NN%) next to the name, using the frame_dot's existing
recently-seen indicator conventions -- silent when never reported
(mains-only frames, or before the first report), matching how battery
is hidden everywhere else it's not applicable.
Verified against the same live-shaped database as the SMTP work: the
v3->v4 migration, save/reload/reset round trip through the real HTTP
route, an actual rendered image using a custom palette (confirmed via
its packed panel-code bytes), input validation, and the sidebar badge
against real battery data -- plus the standing legacy-device curl suite.
271 lines
11 KiB
Python
271 lines
11 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 -- reasonable
|
|
# placeholders, not measured values (Waveshare doesn't publish exact
|
|
# color primaries for this panel). This is the fallback for any frame
|
|
# that hasn't tuned its own (Frame.palette_rgb, set from a frame's
|
|
# Configuration tab -- "Advanced configuration" -- once you can compare
|
|
# a rendered test image against the real panel; different panel units
|
|
# can vary enough to be worth calibrating per frame).
|
|
DEFAULT_PALETTE_RGB = [
|
|
(0, 0, 0), # BLACK
|
|
(255, 255, 255), # WHITE
|
|
(255, 243, 56), # YELLOW
|
|
(191, 0, 0), # RED
|
|
(100, 64, 255), # BLUE
|
|
(67, 138, 28), # GREEN
|
|
]
|
|
|
|
PALETTE_LABELS = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
|
|
|
|
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
|
# in the same order as DEFAULT_PALETTE_RGB/PALETTE_LABELS -- fixed by the
|
|
# hardware protocol, never user-configurable. 0x4 is intentionally unused
|
|
# upstream.
|
|
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
|
|
|
|
|
|
def palette_to_hex(palette_rgb: list) -> list[str]:
|
|
"""[(0,0,0), ...] -> ["#000000", ...], for pre-filling the Advanced
|
|
configuration color pickers."""
|
|
return ["#%02x%02x%02x" % tuple(c) for c in palette_rgb]
|
|
|
|
|
|
def hex_to_rgb(hex_str: str) -> tuple[int, int, int] | None:
|
|
""""#1a2b3c" -> (26, 43, 60), or None for anything that isn't exactly
|
|
a 6-hex-digit color (what <input type="color"> always sends, but a
|
|
direct API call might not)."""
|
|
hex_str = hex_str.strip().lstrip("#")
|
|
if len(hex_str) != 6:
|
|
return None
|
|
try:
|
|
return (int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _build_palette_image(palette_rgb: list) -> 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
|
|
|
|
|
|
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", palette_rgb: list | None = None) -> 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.
|
|
|
|
`palette_rgb` overrides DEFAULT_PALETTE_RGB (a frame's tuned colors,
|
|
see Frame.palette_rgb) -- None uses the default.
|
|
"""
|
|
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, palette_rgb)
|
|
|
|
|
|
def _quantize_and_pack(logical_img: Image.Image, orientation: str, palette_rgb: list | None = None) -> 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."""
|
|
palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB)
|
|
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", palette_rgb: list | None = None) -> 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, palette_rgb)
|