Server: Frame.panel_type (new column + migration) is auto-derived from the device's reported board (X-Frame-Board), never user-set -- the panel is a property of the hardware, not a picker in the UI. image_pipeline's packing/render pipeline is parameterized by panel geometry instead of hardcoded 800x480 globals, with the real confirmed 13.3in geometry (1600x1200) registered alongside the original 7.3in panel. Existing 7.3in frames are unaffected (column default + board mapping both resolve to the original panel). Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/ xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO module -- "xiao" alone stopped disambiguating hardware. The server keeps accepting the legacy bare names indefinitely for already-flashed devices. Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real chip-target change, not just a same-chip Kconfig variant like xiao) and a new epd13in3e driver component skeleton. The actual panel init/LUT/ refresh register sequence isn't ported from vendor demo code yet (none was available), so that component deliberately fails to compile (#error) rather than risk sending unverified register values to real hardware -- devkit/xiao are unaffected and build identically to before. CI's ee02 build step is continue-on-error for the same reason.
767 lines
36 KiB
Python
767 lines
36 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
|
|
|
|
# Registry of every supported panel's native pixel size, keyed by
|
|
# Frame.panel_type. New entries get added here as a new EPD driver
|
|
# component is supported firmware-side (see firmware/components/) --
|
|
# geometry lives in exactly one place rather than as new module-level
|
|
# globals per panel.
|
|
DEFAULT_PANEL_TYPE = "epd7in3e"
|
|
PANEL_SPECS: dict[str, tuple[int, int]] = {
|
|
"epd7in3e": (EPD_WIDTH, EPD_HEIGHT),
|
|
# Waveshare's 13.3" e-Paper (E) Spectra 6 panel (270.40x202.80mm,
|
|
# 1600x1200px, 4:3) driven by Seeed's EE02 board -- confirmed from
|
|
# Waveshare's/Seeed's public product pages, not yet from real
|
|
# hardware or vendor demo code (see firmware/components/epd13in3e's
|
|
# own docstring once it exists -- the init/LUT/refresh sequence and
|
|
# whether this panel's nibble color codes actually match PANEL_CODES
|
|
# below are still unconfirmed pending that).
|
|
"epd13in3e": (1600, 1200),
|
|
}
|
|
|
|
# Human-readable label per PANEL_SPECS key, for the frame settings page's
|
|
# read-only "Panel" line (see routers/device.py's BOARD_PANEL_MAP for how
|
|
# a frame's panel_type actually gets set -- this is display-only).
|
|
PANEL_LABELS: dict[str, str] = {
|
|
"epd7in3e": '7.3" Spectra 6',
|
|
"epd13in3e": '13.3" Spectra 6',
|
|
}
|
|
|
|
|
|
def panel_size(panel_type: str) -> tuple[int, int]:
|
|
"""(width, height) native pixel size for a Frame.panel_type key.
|
|
Unknown/blank panel_type (e.g. a frame created before this field
|
|
existed) falls back to the original 7.3" panel this project shipped
|
|
with, never raises."""
|
|
return PANEL_SPECS.get(panel_type, PANEL_SPECS[DEFAULT_PANEL_TYPE])
|
|
|
|
# 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],
|
|
radius: int = 0) -> 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. `radius` is opt-in and only
|
|
honored by "solid"/"fancy" (rounded_rectangle instead of rectangle) --
|
|
"dashed"/"dotted" trace each of the 4 edges as independent straight
|
|
segments (see _dashed_edge/_dotted_edge) and ignore it, a documented
|
|
limitation rather than a bug. Defaults to 0 (unchanged sharp-corner
|
|
behavior) and no call site passes non-zero today -- this ships the
|
|
capability for a future per-widget "rounded border" setting without
|
|
changing default behavior anywhere (see tests/test_widget_border.py's
|
|
exact-corner-pixel assertions)."""
|
|
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)
|
|
r = max(0, min(radius, (w - 1) // 2, (h - 1) // 2))
|
|
|
|
if style == "fancy":
|
|
line_t = max(1, t // 3)
|
|
gap = max(2, t - 2 * line_t)
|
|
if r:
|
|
draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=line_t)
|
|
else:
|
|
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:
|
|
inner_r = max(0, min(r - inset, (w - 1 - 2 * inset) // 2, (h - 1 - 2 * inset) // 2)) if r else 0
|
|
if inner_r:
|
|
draw.rounded_rectangle([inset, inset, w - 1 - inset, h - 1 - inset], radius=inner_r,
|
|
outline=color, width=line_t)
|
|
else:
|
|
draw.rectangle([inset, inset, w - 1 - inset, h - 1 - inset], outline=color, width=line_t)
|
|
return
|
|
|
|
if style == "solid":
|
|
if r:
|
|
draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=r, outline=color, width=t)
|
|
else:
|
|
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, panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> tuple[int, int]:
|
|
"""(width, height) the photo is composed/cropped at for this
|
|
orientation, before rotating into native panel space. Defaults to the
|
|
7.3" panel's native size; callers with a Frame in scope should pass
|
|
*panel_size(frame.panel_type) instead."""
|
|
if orientation in ("portrait", "portrait_flipped"):
|
|
return panel_h, panel_w
|
|
return panel_w, panel_h
|
|
|
|
|
|
def logical_to_native(x: float, y: float, orientation: str,
|
|
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> tuple[int, int]:
|
|
"""Maps a point in logical (pre-rotation) frame space to native
|
|
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, panel_w, panel_h)
|
|
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"]
|
|
|
|
# A community-measured alternative starting point for the same 6 slots,
|
|
# ported (data only, not code) from paperlesspaper/epdoptimize's
|
|
# src/dither/data/default-palettes.json "spectra6" entry (Apache
|
|
# License 2.0, https://github.com/paperlesspaper/epdoptimize) -- offered
|
|
# as a one-click "Load calibrated preset" in the Advanced configuration
|
|
# UI, not a new default: unlike DEFAULT_PALETTE_RGB above, these are an
|
|
# actual panel's measured appearance rather than idealized primaries
|
|
# (real Spectra 6 white/black are notably duller than pure #fff/#000),
|
|
# but measured from a different unit than any given frame's actual
|
|
# panel -- panel_style.py's own docstring already notes units vary
|
|
# enough to be worth calibrating per frame, and this hasn't been
|
|
# verified against this project's own hardware.
|
|
CALIBRATED_SPECTRA6_RGB = [
|
|
(0x1F, 0x22, 0x26), # BLACK
|
|
(0xB9, 0xC7, 0xC9), # WHITE
|
|
(0xC1, 0xBB, 0x1E), # YELLOW
|
|
(0x62, 0x20, 0x1E), # RED
|
|
(0x23, 0x3F, 0x8E), # BLUE
|
|
(0x35, 0x56, 0x3A), # GREEN
|
|
]
|
|
|
|
# The 7.3" 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. Used unconditionally
|
|
# for every panel_type today -- unverified whether the 13.3" panel's
|
|
# driver (once it exists, see PANEL_SPECS["epd13in3e"]) uses the same
|
|
# codes; if not, this needs to become a PANEL_CODE_TABLES dict keyed like
|
|
# PANEL_SPECS, with _transpose_and_pack taking the right one as a param.
|
|
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,
|
|
panel_w: int = EPD_WIDTH, panel_h: int = EPD_HEIGHT) -> Image.Image:
|
|
"""Crop/resize/letterbox `source` per display_mode -- returns an RGB
|
|
image at logical_render_size(orientation, panel_w, panel_h), before
|
|
enhancement or quantization. See render_frame for what each
|
|
display_mode does."""
|
|
return compose_into(source, faces, *logical_render_size(orientation, panel_w, panel_h), 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 the panel's EPD driver expects
|
|
(see firmware/components/epd7in3e). Returns exactly width*height/2
|
|
bytes for whatever native size `quantized` actually is post-rotation
|
|
-- the canvas was already built at the calling frame's own panel size
|
|
(see panel_size()), so this derives dimensions from the image itself
|
|
rather than a fixed global."""
|
|
transpose = ORIENTATION_TRANSPOSE.get(orientation)
|
|
if transpose is not None:
|
|
quantized = quantized.transpose(transpose)
|
|
pixels = quantized.load()
|
|
w, h = quantized.size
|
|
|
|
out = bytearray(w * h // 2)
|
|
i = 0
|
|
for y in range(h):
|
|
for x in range(0, w, 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, panel_type: str = DEFAULT_PANEL_TYPE) -> 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 the target panel_type's EPD driver expects.
|
|
Returns exactly width*height/2 bytes for that panel (see panel_size).
|
|
|
|
`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.
|
|
|
|
`panel_type` (see Frame.panel_type/panel_size) picks which panel's
|
|
native resolution to render for -- None/unrecognized falls back to
|
|
the original 7.3" panel.
|
|
"""
|
|
panel_w, panel_h = panel_size(panel_type)
|
|
fitted = _enhance(_compose(source, faces, orientation, display_mode, panel_w, panel_h),
|
|
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,
|
|
panel_type: str = DEFAULT_PANEL_TYPE) -> 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.
|
|
|
|
`panel_type` (see Frame.panel_type/panel_size) picks the target
|
|
panel's native resolution -- callers must have computed `regions`'
|
|
rects against this same panel's logical_render_size (see
|
|
routers/device.py's _render_widgets, which always derives both from
|
|
the same frame.panel_type)."""
|
|
logical_w, logical_h = logical_render_size(orientation, *panel_size(panel_type))
|
|
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, panel_type: str = DEFAULT_PANEL_TYPE) -> 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."""
|
|
panel_w, panel_h = panel_size(panel_type)
|
|
fitted = _enhance(_compose(source, faces, orientation, display_mode, panel_w, panel_h),
|
|
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,
|
|
panel_type: str = DEFAULT_PANEL_TYPE) -> 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. `panel_type`, same as render_frame's."""
|
|
margin = 24
|
|
logical_w, logical_h = logical_render_size(orientation, *panel_size(panel_type))
|
|
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
|