661 lines
30 KiB
Python
661 lines
30 KiB
Python
"""Resize, quantize, and pack a photo into the panel's raw 4bpp format."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import math
|
|
|
|
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
|
|
|
|
EPD_WIDTH = 800
|
|
EPD_HEIGHT = 480
|
|
|
|
# PIL's TrueType rendering antialiases by default (graduated gray edge
|
|
# pixels). Those survive straight into _quantize's Floyd-Steinberg
|
|
# dithering, which -- confirmed visually -- turns them into scattered
|
|
# colored speckles along every glyph edge once forced onto the panel's 6
|
|
# colors, since a mid-gray input has no close palette match and the
|
|
# diffused error bounces between whichever colors are nearest. Drawing
|
|
# through a thresholded bilevel mask instead keeps every edge pure
|
|
# black/white, which _quantize then reproduces exactly (both are already
|
|
# palette colors, nothing to dither). Shared by every module that draws
|
|
# text before quantization (this file's render_placeholder,
|
|
# calendar_render.py, manage_overlay.py).
|
|
_TEXT_MASK_THRESHOLD = 110
|
|
|
|
|
|
def draw_text(img: Image.Image, xy: tuple[int, int], text: str, font: ImageFont.ImageFont,
|
|
fill: tuple[int, int, int] = (0, 0, 0)) -> None:
|
|
bbox = font.getbbox(text)
|
|
w, h = max(1, bbox[2] - bbox[0]), max(1, bbox[3] - bbox[1])
|
|
mask = Image.new("L", (w, h), 0)
|
|
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), text, fill=255, font=font)
|
|
mask = mask.point(lambda p: 255 if p > _TEXT_MASK_THRESHOLD else 0)
|
|
img.paste(fill, (xy[0] + bbox[0], xy[1] + bbox[1]), mask)
|
|
|
|
|
|
def _dashed_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: float,
|
|
width: int, color: tuple[int, int, int], dash: float, gap: float) -> None:
|
|
length = math.hypot(x1 - x0, y1 - y0)
|
|
if length <= 0:
|
|
return
|
|
ux, uy = (x1 - x0) / length, (y1 - y0) / length
|
|
pos = 0.0
|
|
while pos < length:
|
|
end = min(pos + dash, length)
|
|
draw.line([(x0 + ux * pos, y0 + uy * pos), (x0 + ux * end, y0 + uy * end)], fill=color, width=width)
|
|
pos += dash + gap
|
|
|
|
|
|
def _dotted_edge(draw: ImageDraw.ImageDraw, x0: float, y0: float, x1: float, y1: float,
|
|
width: int, color: tuple[int, int, int], spacing: float) -> None:
|
|
length = math.hypot(x1 - x0, y1 - y0)
|
|
if length <= 0:
|
|
return
|
|
ux, uy = (x1 - x0) / length, (y1 - y0) / length
|
|
r = max(1, width / 2)
|
|
pos = 0.0
|
|
while pos <= length:
|
|
cx, cy = x0 + ux * pos, y0 + uy * pos
|
|
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color)
|
|
pos += spacing
|
|
|
|
|
|
def draw_widget_border(img: Image.Image, style: str, thickness: int, color: tuple[int, int, int]) -> None:
|
|
"""Draws a border inset within img's own bounds, mutating it in
|
|
place -- called once per widget's own region (routers/device.py's
|
|
_render_widgets, and each widget type's own dialog preview) before
|
|
that region's image is pasted onto the shared canvas, so a border
|
|
never straddles the boundary between two adjacent widgets. `color`
|
|
should already be an exact palette RGB (see resolve_border_color) so
|
|
the stroke quantizes with zero dithering error, same reasoning as
|
|
the weather/battery icons' exact-panel-ink-RGB fills.
|
|
|
|
"solid"/"dashed"/"dotted" are a single thickness-px stroke traced
|
|
just inside the image's edge; "fancy" is two thinner concentric
|
|
strokes with a gap between them, picture-frame-mat style. "none" (or
|
|
a non-positive thickness) draws nothing."""
|
|
if style == "none" or thickness <= 0:
|
|
return
|
|
w, h = img.size
|
|
t = max(1, min(int(thickness), min(w, h) // 2))
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
if style == "fancy":
|
|
line_t = max(1, t // 3)
|
|
gap = max(2, t - 2 * line_t)
|
|
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=line_t)
|
|
inset = line_t + gap
|
|
if w - 2 * inset > 1 and h - 2 * inset > 1:
|
|
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
|
|
return
|
|
|
|
if style == "solid":
|
|
draw.rectangle([0, 0, w - 1, h - 1], outline=color, width=t)
|
|
return
|
|
|
|
# dashed/dotted trace the same centered-on-the-edge path solid/
|
|
# fancy's rectangle outline draws, so all four styles sit at the
|
|
# same inset regardless of which is chosen.
|
|
half = t / 2
|
|
x0, y0, x1, y1 = half, half, w - 1 - half, h - 1 - half
|
|
edges = [(x0, y0, x1, y0), (x1, y0, x1, y1), (x1, y1, x0, y1), (x0, y1, x0, y0)]
|
|
if style == "dashed":
|
|
dash, gap = t * 3, t * 2
|
|
for ex0, ey0, ex1, ey1 in edges:
|
|
_dashed_edge(draw, ex0, ey0, ex1, ey1, t, color, dash, gap)
|
|
elif style == "dotted":
|
|
spacing = max(t * 2, t + 4)
|
|
for ex0, ey0, ex1, ey1 in edges:
|
|
_dotted_edge(draw, ex0, ey0, ex1, ey1, t, color, spacing)
|
|
|
|
|
|
# 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, 219, 0), # YELLOW
|
|
(207, 0, 15), # RED
|
|
(0, 39, 133), # BLUE
|
|
(0, 133, 55), # 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]
|
|
|
|
# Per-widget optional border (models.Widget.border_style, see
|
|
# draw_widget_border below). "none" is the default/no-op; the rest are
|
|
# thickness-px strokes inset within the widget's own region.
|
|
BORDER_STYLES = ["none", "solid", "dashed", "dotted", "fancy"]
|
|
BORDER_STYLE_LABELS = {
|
|
"none": "None",
|
|
"solid": "Solid",
|
|
"dashed": "Dashed",
|
|
"dotted": "Dotted",
|
|
"fancy": "Fancy (double line)",
|
|
}
|
|
MIN_BORDER_THICKNESS = 1
|
|
MAX_BORDER_THICKNESS = 8
|
|
DEFAULT_BORDER_THICKNESS = 3
|
|
|
|
|
|
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 resolve_border_color(color_index: int, palette_rgb: list | None) -> tuple[int, int, int]:
|
|
"""Widget.border_color_index -> an actual RGB tuple, against this
|
|
frame's tuned palette if it has one (falls back to
|
|
DEFAULT_PALETTE_RGB) -- so a border always renders as one of the
|
|
panel's real 6 ink colors and never needs to be dithered, same
|
|
reasoning as the weather/battery icons' exact-panel-ink-RGB fills
|
|
(see docs/widgets.md). Out-of-range indexes (a stale value from a
|
|
frame that used to have more colors, though that never happens
|
|
today) fall back to Black rather than raising."""
|
|
palette = palette_rgb or DEFAULT_PALETTE_RGB
|
|
if 0 <= color_index < len(palette):
|
|
return tuple(palette[color_index])
|
|
return tuple(palette[0])
|
|
|
|
|
|
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 _has_bounding_box(face: dict) -> bool:
|
|
"""Immich has occasionally been observed to return a face entry with
|
|
a still-pending or otherwise incomplete bounding box (a null field)
|
|
-- treat it as undetected rather than crash on arithmetic with None."""
|
|
return all(
|
|
face.get(k) is not None
|
|
for k in ("boundingBoxX1", "boundingBoxX2", "boundingBoxY1", "boundingBoxY2")
|
|
)
|
|
|
|
|
|
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:
|
|
if not _has_bounding_box(face):
|
|
continue
|
|
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)
|
|
|
|
|
|
# Display modes: how a photo's aspect ratio gets reconciled with the
|
|
# panel's. "crop_faces" falls back to "crop_fill" behavior when no faces
|
|
# were detected/passed. DEFAULT_DISPLAY_MODE matches this project's old
|
|
# always-on smart_crop_faces=True default.
|
|
DISPLAY_MODES = ["crop_fill", "crop_faces", "stretch_fill", "letterbox"]
|
|
DISPLAY_MODE_LABELS = {
|
|
"crop_fill": "Crop to fill",
|
|
"crop_faces": "Crop to faces",
|
|
"stretch_fill": "Stretch to fill",
|
|
"letterbox": "Shrink to fit",
|
|
}
|
|
DEFAULT_DISPLAY_MODE = "crop_faces"
|
|
LETTERBOX_BG = (255, 255, 255)
|
|
|
|
# Static-image widget only offers a subset of DISPLAY_MODES -- no face
|
|
# detection for an uploaded image, so "crop_faces" (which silently falls
|
|
# back to crop_fill anyway, see compose_into) would just be a confusing
|
|
# duplicate entry in that dialog's dropdown.
|
|
STATIC_DISPLAY_MODES = ["crop_fill", "stretch_fill", "letterbox"]
|
|
DEFAULT_STATIC_DISPLAY_MODE = "crop_fill"
|
|
|
|
|
|
def _placement_transform(
|
|
img_width: int, img_height: int, target_w: int, target_h: int,
|
|
display_mode: str, faces: list[dict] | None = None,
|
|
) -> tuple[float, float, float, float]:
|
|
"""Returns (scale_x, scale_y, offset_x, offset_y) mapping a point in
|
|
source-image pixel space to a point in target logical space, for the
|
|
given display_mode. Shared by render_frame (which also does the
|
|
actual pixel crop/resize/pad) and face_labels.py (label position
|
|
math) -- they must stay in exact agreement or overlay labels drift
|
|
off the people they're meant to point at."""
|
|
if display_mode == "stretch_fill":
|
|
return target_w / img_width, target_h / img_height, 0.0, 0.0
|
|
if display_mode == "letterbox":
|
|
scale = min(target_w / img_width, target_h / img_height)
|
|
return scale, scale, (target_w - img_width * scale) / 2, (target_h - img_height * scale) / 2
|
|
if display_mode == "crop_faces" and faces:
|
|
left, top, right, bottom = _face_aware_crop_box(img_width, img_height, target_w, target_h, faces)
|
|
crop_w, crop_h = right - left, bottom - top
|
|
else:
|
|
left, top, crop_w, crop_h = _plain_center_crop_box(img_width, img_height, target_w, target_h)
|
|
scale_x, scale_y = target_w / crop_w, target_h / crop_h
|
|
return scale_x, scale_y, -left * scale_x, -top * scale_y
|
|
|
|
|
|
def compose_into(source: Image.Image, faces: list[dict] | None, target_w: int, target_h: int,
|
|
display_mode: str) -> Image.Image:
|
|
"""Crop/resize/letterbox `source` per display_mode into an arbitrary
|
|
target_w x target_h box -- returns an RGB image, before enhancement or
|
|
quantization. See render_frame for what each display_mode does.
|
|
_compose() is the common case of this (target = the full panel, at
|
|
logical_render_size(orientation)); this more general form also backs
|
|
calendar_render.py's agenda photo-inlay, which composes into just a
|
|
sub-region of the panel instead of the whole thing."""
|
|
fitted = ImageOps.exif_transpose(source.convert("RGB"))
|
|
|
|
if display_mode == "stretch_fill":
|
|
return fitted.resize((target_w, target_h), Image.LANCZOS)
|
|
if display_mode == "letterbox":
|
|
scale = min(target_w / fitted.width, target_h / fitted.height)
|
|
new_w, new_h = max(1, round(fitted.width * scale)), max(1, round(fitted.height * scale))
|
|
resized = fitted.resize((new_w, new_h), Image.LANCZOS)
|
|
canvas = Image.new("RGB", (target_w, target_h), LETTERBOX_BG)
|
|
canvas.paste(resized, ((target_w - new_w) // 2, (target_h - new_h) // 2))
|
|
return canvas
|
|
if display_mode == "crop_faces" and faces:
|
|
box = _face_aware_crop_box(fitted.width, fitted.height, target_w, target_h, faces)
|
|
return fitted.crop(box).resize((target_w, target_h), Image.LANCZOS)
|
|
return ImageOps.fit(fitted, (target_w, target_h), method=Image.LANCZOS) # crop_fill, or crop_faces w/ no faces
|
|
|
|
|
|
def _compose(source: Image.Image, faces: list[dict] | None, orientation: str, display_mode: str) -> Image.Image:
|
|
"""Crop/resize/letterbox `source` per display_mode -- returns an RGB
|
|
image at logical_render_size(orientation), before enhancement or
|
|
quantization. See render_frame for what each display_mode does."""
|
|
return compose_into(source, faces, *logical_render_size(orientation), display_mode)
|
|
|
|
|
|
def _enhance(img: Image.Image, color_boost: float, contrast_boost: float) -> Image.Image:
|
|
if color_boost != 1.0:
|
|
img = ImageEnhance.Color(img).enhance(color_boost)
|
|
if contrast_boost != 1.0:
|
|
img = ImageEnhance.Contrast(img).enhance(contrast_boost)
|
|
return img
|
|
|
|
|
|
def _quantize(img: Image.Image, palette_rgb: list | None, dither_strength: float) -> Image.Image:
|
|
"""RGB -> palette-quantized P-mode image, same size/orientation as
|
|
`img` (no rotation here). dither_strength blends `img` toward its own
|
|
flat (undithered) quantization before running Floyd-Steinberg on the
|
|
blend: at 0 there's zero quantization error left to diffuse (so the
|
|
result IS the flat quantization, no dithering texture at all); at 1
|
|
it's `img` unchanged (full-strength dithering, this project's
|
|
original always-on behavior); values between give a smooth continuum
|
|
of dithering intensity rather than an on/off toggle."""
|
|
palette_image = _build_palette_image(palette_rgb or DEFAULT_PALETTE_RGB)
|
|
if dither_strength >= 1.0:
|
|
return img.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
|
if dither_strength <= 0.0:
|
|
return img.quantize(palette=palette_image, dither=Image.Dither.NONE)
|
|
flat = img.quantize(palette=palette_image, dither=Image.Dither.NONE).convert("RGB")
|
|
blended = Image.blend(flat, img, dither_strength)
|
|
return blended.quantize(palette=palette_image, dither=Image.Dither.FLOYDSTEINBERG)
|
|
|
|
|
|
def _transpose_and_pack(quantized: Image.Image, orientation: str) -> bytes:
|
|
"""Rotates a logical-space quantized image into native panel space
|
|
and packs it 2 pixels/byte the way epd7in3e.c expects. Always
|
|
returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes."""
|
|
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 _apply_manage_overlay(img: Image.Image, manage: dict | None) -> Image.Image:
|
|
"""Composites the manage-button overlay (scan-to-manage QR, battery,
|
|
location/date/share-QR, named face labels) onto an already-composed,
|
|
already-enhanced image, if requested -- see manage_overlay.compose().
|
|
Local import: manage_overlay is an optional, occasionally-used
|
|
concern (only /frame/*?manage=1 requests need it), same reasoning
|
|
render_placeholder already applies to its own `import qrcode`."""
|
|
if manage is None:
|
|
return img
|
|
from . import manage_overlay
|
|
|
|
return manage_overlay.compose(img, **manage)
|
|
|
|
|
|
def render_frame(source: Image.Image, faces: list[dict] | None = None,
|
|
orientation: str = "landscape", palette_rgb: list | None = None,
|
|
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
|
|
contrast_boost: float = 1.0, dither_strength: float = 1.0,
|
|
manage: dict | None = None) -> bytes:
|
|
"""Fits `source` to the panel's resolution, applies color/contrast
|
|
enhancement, quantizes it to the 6-color palette, and packs 2
|
|
pixels/byte the way epd7in3e.c expects. Always returns exactly
|
|
EPD_WIDTH*EPD_HEIGHT/2 bytes.
|
|
|
|
`display_mode` (see DISPLAY_MODES) picks how the photo's aspect ratio
|
|
is reconciled with the panel's: crop_fill (center-crop to fill,
|
|
excess trimmed), crop_faces (as crop_fill, but shifts the crop to
|
|
keep `faces` on screen -- falls back to crop_fill if none), stretch_fill
|
|
(fills exactly, aspect ratio not preserved), letterbox (whole photo
|
|
visible, letterboxed with LETTERBOX_BG where it doesn't fill).
|
|
|
|
`color_boost`/`contrast_boost` are PIL ImageEnhance factors (1.0 =
|
|
unchanged, matching PIL's own convention); `dither_strength` is
|
|
0.0-1.0 (see _quantize).
|
|
|
|
`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.
|
|
|
|
`manage` is a dict of manage_overlay.compose()'s kwargs (management_url,
|
|
battery_percent, location_lines, taken_at, share_url, face_labels), or
|
|
None to skip it -- see routers/device.py's build_manage_content(),
|
|
which callers pass this straight through from. Applied after
|
|
enhancement, before quantization, so the overlay's pure black/white
|
|
graphics aren't affected by color/contrast boost.
|
|
"""
|
|
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
|
fitted = _apply_manage_overlay(fitted, manage)
|
|
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
|
return _transpose_and_pack(quantized, orientation)
|
|
|
|
|
|
def _png_bytes(img: Image.Image) -> bytes:
|
|
buf = io.BytesIO()
|
|
img.convert("RGB").save(buf, format="PNG")
|
|
return buf.getvalue()
|
|
|
|
|
|
def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape",
|
|
palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0,
|
|
dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False,
|
|
capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
|
|
"""The widget system's compositor -- generalizes render_frame's tail
|
|
(paste, enhance once, overlay once, quantize once, pack once) from
|
|
"compose one photo" to "paste N already-rendered regions, then run
|
|
the same single shared pipeline over the result." Not a
|
|
restructuring: the calendar mode's old photo-inlay feature already
|
|
pasted a second, independently-composed image onto the canvas before
|
|
`_enhance`/`_quantize` ran exactly once over the whole thing -- this
|
|
just generalizes that from a fixed 1-2 region split to an arbitrary
|
|
list.
|
|
|
|
Each region is (rect, image): rect is (x, y, w, h) in *logical*
|
|
(pre-rotation) canvas space -- the same space logical_render_size(
|
|
orientation) describes, and what app/grid.py's cell_to_pixels()
|
|
produces -- and image is an already-composed RGB image exactly w x h
|
|
in size (e.g. from compose_into() for a photo/whiteboard widget, or
|
|
calendar_render's own builder for a calendar widget). Regions are
|
|
expected not to overlap (see models.Widget's docstring on why) --
|
|
this function doesn't enforce that itself, callers/the placement API
|
|
do, since by the time rendering happens it's too late to do anything
|
|
but paste in whatever order they're given (later entries would just
|
|
paint over earlier ones).
|
|
|
|
Quantizing/dithering the *whole* composited canvas once, rather than
|
|
each region separately before pasting, is what keeps a 6-color
|
|
e-ink panel's dithering pattern consistent across a widget boundary
|
|
instead of showing a visible seam where two independently-dithered
|
|
regions meet.
|
|
|
|
as_png=True returns a normal browser-viewable PNG in logical (upright)
|
|
orientation instead of packed native-panel bytes, same convention as
|
|
render_preview_png -- used for the web UI's live "how it's displaying"
|
|
thumbnail.
|
|
|
|
capture_snapshot=True (only meaningful alongside as_png=False) returns
|
|
(packed_bytes, png_bytes) instead of just packed_bytes -- both derived
|
|
from the same already-quantized canvas, so a device-facing render can
|
|
also persist a browser-viewable copy (see routers/device.py's
|
|
_record_last_displayed) without re-running composition/quantization a
|
|
second time."""
|
|
logical_w, logical_h = logical_render_size(orientation)
|
|
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
|
|
for (x, y, w, h), region_img in regions:
|
|
canvas.paste(region_img.convert("RGB"), (x, y))
|
|
|
|
fitted = _enhance(canvas, color_boost, contrast_boost)
|
|
fitted = _apply_manage_overlay(fitted, manage)
|
|
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
|
if as_png:
|
|
return _png_bytes(quantized)
|
|
packed = _transpose_and_pack(quantized, orientation)
|
|
if capture_snapshot:
|
|
return packed, _png_bytes(quantized)
|
|
return packed
|
|
|
|
|
|
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
|
|
orientation: str = "landscape", palette_rgb: list | None = None,
|
|
display_mode: str = DEFAULT_DISPLAY_MODE, color_boost: float = 1.0,
|
|
contrast_boost: float = 1.0, dither_strength: float = 1.0,
|
|
manage: dict | None = None) -> bytes:
|
|
"""Identical composition/enhancement/quantization pipeline as
|
|
render_frame, but returned as a normal browser-viewable PNG in
|
|
logical (upright, as-the-frame-actually-hangs) orientation rather
|
|
than packed native-panel bytes and rotation -- what the web UI's
|
|
"how it will look on the frame" preview shows."""
|
|
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
|
|
fitted = _apply_manage_overlay(fitted, manage)
|
|
quantized = _quantize(fitted, palette_rgb, dither_strength)
|
|
return _png_bytes(quantized)
|
|
|
|
|
|
def render_placeholder(lines: list[str], qr_url: str | None = None,
|
|
orientation: str = "landscape", palette_rgb: list | None = None,
|
|
manage: dict | None = None, as_png: bool = False,
|
|
capture_snapshot: bool = False) -> bytes | tuple[bytes, 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.
|
|
|
|
`manage`, same as render_frame's -- lets the manage button still work
|
|
(at minimum, the scan-to-manage QR) on a frame that isn't configured
|
|
yet. `capture_snapshot`, same as render_panel's -- (packed, png)
|
|
instead of just packed."""
|
|
margin = 24
|
|
logical_w, logical_h = logical_render_size(orientation)
|
|
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
|
|
draw = ImageDraw.Draw(img) # measurement only (textbbox/textlength) -- painting goes through draw_text
|
|
|
|
title_font = ImageFont.load_default(size=34)
|
|
body_font = ImageFont.load_default(size=24)
|
|
max_text_w = logical_w - margin * 2
|
|
|
|
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)
|
|
|
|
# Word-wrap each input line to the panel's actual width (portrait is
|
|
# much narrower than landscape -- a line written assuming ~800px
|
|
# would otherwise run straight off the edge) before laying anything
|
|
# out, so wrapped sub-lines count toward the vertical centering below.
|
|
def wrap(text: str, font) -> list[str]:
|
|
words = text.split()
|
|
if not words:
|
|
return [text]
|
|
out, current = [], words[0]
|
|
for word in words[1:]:
|
|
candidate = f"{current} {word}"
|
|
if draw.textlength(candidate, font=font) <= max_text_w:
|
|
current = candidate
|
|
else:
|
|
out.append(current)
|
|
current = word
|
|
out.append(current)
|
|
return out
|
|
|
|
# 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
|
|
for sub_line in wrap(line, font):
|
|
bbox = draw.textbbox((0, 0), sub_line, font=font)
|
|
line_heights.append((sub_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(img, ((logical_w - w) // 2, y), line, font)
|
|
y += h + gap
|
|
|
|
if qr_img:
|
|
img.paste(qr_img, ((logical_w - qr_img.width) // 2, y + 14))
|
|
|
|
img = _apply_manage_overlay(img, manage)
|
|
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
|
if as_png:
|
|
return _png_bytes(quantized)
|
|
packed = _transpose_and_pack(quantized, orientation)
|
|
if capture_snapshot:
|
|
return packed, _png_bytes(quantized)
|
|
return packed
|