Build and push server image / build-and-push (push) Successful in 42s
calendar_feed.py/calendar_render.py: fetch/merge per-user ICS feeds, render agenda/week/month views. manage_overlay.py: composites the manage-button overlay server-side (QR, battery, location/date, share-QR, face labels), reused by every render mode. device.py/common.py wire both together: mode dispatch for /frame/image+advance+back, and the &manage=1 flag. Plus UI (frame_config.html Calendar card, settings calendar URL field) and the icalendar/recurring-ical-events deps.
213 lines
8.6 KiB
Python
213 lines
8.6 KiB
Python
"""Composites the manage-button overlay -- "scan to manage" QR, battery,
|
|
location/date-taken, share-QR, named face labels -- server-side, onto an
|
|
already-composed image (any mode: a photo, or a calendar view), before
|
|
quantization. Replaces what used to be firmware/main/manage_qr_overlay.c
|
|
generating and positioning all of this on-device.
|
|
|
|
Corner/spacing constants below are plain Python now, not a protocol
|
|
contract with firmware -- adjustable here without touching anything else.
|
|
Uses the same toolkit image_pipeline.render_placeholder already does
|
|
(PIL ImageDraw/ImageFont, the qrcode library), just doing more with it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
PADDING = 16
|
|
QR_TEXT_GAP = 8
|
|
LINE_GAP = 4
|
|
PANEL_MARGIN = 20
|
|
QR_TARGET_PX = 180
|
|
|
|
TITLE_FONT_SIZE = 22
|
|
BODY_FONT_SIZE = 20
|
|
|
|
BATTERY_ICON_W = 40
|
|
BATTERY_ICON_H = 22
|
|
BATTERY_ICON_STROKE = 2
|
|
BATTERY_NUB_W = 5
|
|
BATTERY_NUB_H = 10
|
|
BATTERY_ICON_TEXT_GAP = 8
|
|
BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box
|
|
|
|
FACE_LABEL_PADDING = 8
|
|
FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box
|
|
|
|
|
|
def _font(size: int) -> ImageFont.ImageFont:
|
|
return ImageFont.load_default(size=size)
|
|
|
|
|
|
def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image:
|
|
import qrcode
|
|
|
|
qr = qrcode.QRCode(border=1, box_size=1)
|
|
qr.add_data(url)
|
|
qr.make(fit=True)
|
|
raw = qr.make_image().get_image().convert("RGB")
|
|
scale = max(1, target_px // raw.width)
|
|
return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST)
|
|
|
|
|
|
def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont) -> tuple[int, int]:
|
|
"""(width, height) of `lines` stacked with LINE_GAP between them, at
|
|
`font` -- the box _draw_text_box below will need."""
|
|
w = 0
|
|
h = 0
|
|
for i, line in enumerate(lines):
|
|
bbox = draw.textbbox((0, 0), line, font=font)
|
|
w = max(w, bbox[2] - bbox[0])
|
|
h += (bbox[3] - bbox[1]) + (LINE_GAP if i else 0)
|
|
return w, h
|
|
|
|
|
|
def _draw_centered_lines(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.ImageFont,
|
|
center_x: int, top: int) -> None:
|
|
y = top
|
|
for line in lines:
|
|
bbox = draw.textbbox((0, 0), line, font=font)
|
|
w = bbox[2] - bbox[0]
|
|
draw.text((center_x - w // 2, y), line, fill=(0, 0, 0), font=font)
|
|
y += (bbox[3] - bbox[1]) + LINE_GAP
|
|
|
|
|
|
def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption: list[str],
|
|
corner: str) -> tuple[int, int, int, int]:
|
|
"""White-padded box with a QR code and centered caption lines below
|
|
it, placed in one of the panel's four corners. Returns (x0, y0, w, h)
|
|
-- callers that need to anchor something else relative to this box
|
|
(the battery, below the manage QR) use it instead of recomputing the
|
|
same geometry a second time."""
|
|
qr_img = _qr_image(url)
|
|
text_w, text_h = _text_box(draw, caption, _font(TITLE_FONT_SIZE)) if caption else (0, 0)
|
|
content_w = max(qr_img.width, text_w)
|
|
content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0)
|
|
|
|
w = content_w + PADDING * 2
|
|
h = content_h + PADDING * 2
|
|
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
|
|
|
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
|
center_x = x0 + w // 2
|
|
img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING))
|
|
if caption:
|
|
_draw_centered_lines(draw, caption, _font(TITLE_FONT_SIZE), center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP)
|
|
return x0, y0, w, h
|
|
|
|
|
|
def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None:
|
|
"""White-padded box with centered text lines, placed in one of the
|
|
panel's four corners."""
|
|
font = _font(BODY_FONT_SIZE)
|
|
text_w, text_h = _text_box(draw, lines, font)
|
|
w = text_w + PADDING * 2
|
|
h = text_h + PADDING * 2
|
|
x0, y0 = _corner_origin(img.size, (w, h), corner)
|
|
|
|
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
|
_draw_centered_lines(draw, lines, font, x0 + w // 2, y0 + PADDING)
|
|
|
|
|
|
def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: str) -> tuple[int, int]:
|
|
img_w, img_h = img_size
|
|
box_w, box_h = box_size
|
|
if corner == "top-left":
|
|
return PANEL_MARGIN, PANEL_MARGIN
|
|
if corner == "top-right":
|
|
return img_w - PANEL_MARGIN - box_w, PANEL_MARGIN
|
|
if corner == "bottom-left":
|
|
return PANEL_MARGIN, img_h - PANEL_MARGIN - box_h
|
|
return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right
|
|
|
|
|
|
def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int,
|
|
anchor_w: int, anchor_h: int) -> None:
|
|
"""Battery glyph + "NN%" text, right-aligned under the given anchor
|
|
box (the manage QR box) -- a sensible default position, not a
|
|
constraint anything else has to route around; move this call site's
|
|
arguments to place it anywhere else instead."""
|
|
font = _font(BODY_FONT_SIZE)
|
|
text = f"{percent}%"
|
|
icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W
|
|
text_w = draw.textlength(text, font=font)
|
|
content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w
|
|
content_h = max(font.size, BATTERY_ICON_H)
|
|
|
|
w = int(content_w + PADDING * 2)
|
|
h = int(content_h + PADDING * 2)
|
|
x0 = anchor_x0 + anchor_w - w
|
|
y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP
|
|
|
|
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
|
|
|
icon_x = x0 + PADDING
|
|
icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2
|
|
draw.rectangle([icon_x, icon_y, icon_x + BATTERY_ICON_W, icon_y + BATTERY_ICON_H], outline=(0, 0, 0),
|
|
width=BATTERY_ICON_STROKE)
|
|
nub_y = icon_y + (BATTERY_ICON_H - BATTERY_NUB_H) // 2
|
|
draw.rectangle([icon_x + BATTERY_ICON_W, nub_y, icon_x + BATTERY_ICON_W + BATTERY_NUB_W, nub_y + BATTERY_NUB_H],
|
|
fill=(0, 0, 0))
|
|
draw.text((icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2),
|
|
text, fill=(0, 0, 0), font=font)
|
|
|
|
|
|
def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None:
|
|
"""White-padded name label centered under an arbitrary (anchor_x,
|
|
anchor_y) point, flipped above if there's no room below, clamped to
|
|
stay fully on-panel -- unlike the four corner boxes (always in-bounds
|
|
by construction), a face can be anywhere, including near an edge."""
|
|
font = _font(BODY_FONT_SIZE)
|
|
text_w = draw.textlength(name, font=font)
|
|
bbox = draw.textbbox((0, 0), name, font=font)
|
|
text_h = bbox[3] - bbox[1]
|
|
|
|
w = int(text_w + FACE_LABEL_PADDING * 2)
|
|
h = int(text_h + FACE_LABEL_PADDING * 2)
|
|
img_w, img_h = img.size
|
|
|
|
x0 = anchor_x - w // 2
|
|
y0 = anchor_y + FACE_LABEL_GAP
|
|
if y0 + h > img_h:
|
|
y0 = anchor_y - FACE_LABEL_GAP - h # no room below -- place above instead
|
|
x0 = max(0, min(x0, img_w - w))
|
|
y0 = max(0, min(y0, img_h - h))
|
|
|
|
draw.rectangle([x0, y0, x0 + w, y0 + h], fill=(255, 255, 255), outline=(0, 0, 0))
|
|
draw.text((x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, fill=(0, 0, 0), font=font)
|
|
|
|
|
|
def compose(image: Image.Image, management_url: str, battery_percent: int | None = None,
|
|
location_lines: tuple[str, str] | None = None, taken_at: str | None = None,
|
|
share_url: str | None = None, face_labels: list[dict] | None = None) -> Image.Image:
|
|
"""Draws the manage overlay onto a copy of `image` (RGB, any mode's
|
|
already-composed/enhanced logical-space canvas) and returns it.
|
|
management_url's "scan to manage" box always shows; everything else
|
|
is optional and simply omitted when not given -- battery_percent
|
|
None or out of 0-100 skips the battery box, location_lines/taken_at/
|
|
share_url empty/None skip their own box, face_labels empty skips
|
|
those."""
|
|
img = image.copy()
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
qr_x0, qr_y0, qr_w, qr_h = _draw_qr_box(img, draw, management_url, ["SCAN TO", "MANAGE"], "top-right")
|
|
|
|
if battery_percent is not None and 0 <= battery_percent <= 100:
|
|
_draw_battery(img, draw, battery_percent, qr_x0, qr_y0, qr_w, qr_h)
|
|
|
|
if location_lines and location_lines[0]:
|
|
lines = [line for line in location_lines if line]
|
|
_draw_text_box(img, draw, lines, "top-left")
|
|
|
|
if taken_at:
|
|
_draw_text_box(img, draw, [taken_at], "bottom-right")
|
|
|
|
if share_url:
|
|
_draw_qr_box(img, draw, share_url, ["SCAN TO", "DOWNLOAD"], "bottom-left")
|
|
|
|
for label in face_labels or []:
|
|
if label.get("name"):
|
|
_draw_face_label(img, draw, label["name"], label["x"], label["y"])
|
|
|
|
return img
|