Files
pictureFrame/scripts/generate_default_image.py
football2801 6bce4822e7 feat: orientation model, password confirm, frontend build
- Collapse orientation to landscape/portrait (ribbon left = portrait standard)
- Add OrientationPicker component and wire settings sheet in HomeView
- Add password confirmation field to registration form (RepeatedType)
- Build frontend SPA to public/build/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 16:59:03 -04:00

226 lines
9.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Generate the default pictureFrame registration screen.
Full-field baby blue background, "pictureFrame" title, centered QR code, and
registration instructions. Output is a 4bpp binary packed for the Waveshare
7.3" display (always 800×480 on the wire; portrait content is rotated 90° CW
into that format so the frame can be mounted either way).
Nibble map (verified on hardware):
BLACK=0x0 WHITE=0x1 YELLOW=0x2 RED=0x3 BLUE=0x5 GREEN=0x6
Usage:
python3 scripts/generate_default_image.py --orientation landscape
python3 scripts/generate_default_image.py --orientation portrait
python3 scripts/generate_default_image.py --mac AA:BB:CC:DD:EE:FF --orientation portrait
"""
import argparse
from pathlib import Path
import numpy as np
import qrcode
from PIL import Image, ImageDraw, ImageFont
EPD_W, EPD_H = 800, 480 # physical display dimensions, always
BABY_BLUE = (135, 206, 235)
BLACK = (0, 0, 0)
BASE_URL = "https://pictureframe.edholm.me"
FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
FONT_PLAIN = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
# Verified on Waveshare 7.3" hardware. Same map applies to 13.3" Spectra 6.
_PALETTE = [
(0x0, ( 0, 0, 0)), # Black
(0x1, (255, 255, 255)), # White
(0x2, (255, 220, 0)), # Yellow
(0x3, (220, 40, 40)), # Red
(0x5, ( 20, 80, 200)), # Blue
(0x6, ( 50, 160, 50)), # Green
]
_CODES = np.array([c for c, _ in _PALETTE], dtype=np.uint8)
_PAL_RGB = np.array([rgb for _, rgb in _PALETTE], dtype=np.float32)
def _floyd_steinberg(img_rgb: np.ndarray) -> np.ndarray:
h, w = img_rgb.shape[:2]
arr = img_rgb.astype(np.float32)
out = np.zeros((h, w), dtype=np.uint8)
for y in range(h):
for x in range(w):
px = np.clip(arr[y, x], 0, 255)
idx = int(np.argmin(((_PAL_RGB - px) ** 2).sum(axis=1)))
out[y, x] = _CODES[idx]
err = px - _PAL_RGB[idx]
if x + 1 < w:
arr[y, x + 1] += err * (7 / 16)
if y + 1 < h:
if x > 0:
arr[y + 1, x - 1] += err * (3 / 16)
arr[y + 1, x] += err * (5 / 16)
if x + 1 < w:
arr[y + 1, x + 1] += err * (1 / 16)
return out
def _make_canvas_landscape(url: str) -> Image.Image:
"""800×480 — frame mounted horizontally."""
W, H = 800, 480
canvas = Image.new("RGB", (W, H), BABY_BLUE)
draw = ImageDraw.Draw(canvas)
font_title = ImageFont.truetype(FONT_BOLD, 52)
font_body = ImageFont.truetype(FONT_PLAIN, 26)
title = "pictureFrame"
tw = draw.textlength(title, font=font_title)
draw.text(((W - tw) / 2, 26), title, fill=BLACK, font=font_title)
qr_size = 268
qr_x, qr_y = (W - qr_size) // 2, 108
_paste_qr(canvas, url, qr_x, qr_y, qr_size)
for i, line in enumerate(["Scan to register your frame", url]):
lw = draw.textlength(line, font=font_body)
draw.text(((W - lw) / 2, qr_y + qr_size + 16 + i * 34), line, fill=BLACK, font=font_body)
return canvas
def _make_canvas_portrait(url: str) -> Image.Image:
"""Design at 480×800 (portrait), rotate 90° CCW → 800×480 for the display.
Hold the frame with the RIGHT edge up to read portrait content correctly."""
W, H = 480, 800
canvas = Image.new("RGB", (W, H), BABY_BLUE)
draw = ImageDraw.Draw(canvas)
font_title = ImageFont.truetype(FONT_BOLD, 52)
font_body = ImageFont.truetype(FONT_PLAIN, 26)
title = "pictureFrame"
tw = draw.textlength(title, font=font_title)
draw.text(((W - tw) / 2, 36), title, fill=BLACK, font=font_title)
qr_size = 320
qr_x, qr_y = (W - qr_size) // 2, 130
_paste_qr(canvas, url, qr_x, qr_y, qr_size)
for i, line in enumerate(["Scan to register your frame", url]):
lw = draw.textlength(line, font=font_body)
draw.text(((W - lw) / 2, qr_y + qr_size + 20 + i * 36), line, fill=BLACK, font=font_body)
# ROTATE_90 (90° CCW): hold frame with RIGHT edge up to see content upright.
# Combined framing guide uses the same direction — keep them in sync.
return canvas.transpose(Image.ROTATE_90)
def _half_canvas(url: str, w: int, h: int, title_size: int, body_size: int, qr_size: int, qr_y: int) -> Image.Image:
"""Build a registration layout at arbitrary (w, h) — used for each half of the combined image."""
canvas = Image.new("RGB", (w, h), BABY_BLUE)
draw = ImageDraw.Draw(canvas)
font_title = ImageFont.truetype(FONT_BOLD, title_size)
font_body = ImageFont.truetype(FONT_PLAIN, body_size)
title = "pictureFrame"
tw = draw.textlength(title, font=font_title)
draw.text(((w - tw) / 2, 20), title, fill=BLACK, font=font_title)
_paste_qr(canvas, url, (w - qr_size) // 2, qr_y, qr_size)
for i, line in enumerate(["Scan to register your frame", url]):
lw = draw.textlength(line, font=font_body)
draw.text(((w - lw) / 2, qr_y + qr_size + 10 + i * (body_size + 6)), line, fill=BLACK, font=font_body)
return canvas
def _make_canvas_combined(url: str) -> Image.Image:
"""800×480 — left half shows landscape content upright; right half shows portrait
content physically rotated 90° CCW, so it appears sideways when the frame is flat.
The user rotates the frame to see each half correctly — making orientation obvious."""
DIVIDER = 2
LEFT_W = (EPD_W - DIVIDER) // 2 # 399
RIGHT_W = EPD_W - LEFT_W - DIVIDER # 399
combined = Image.new("RGB", (EPD_W, EPD_H), BABY_BLUE)
draw = ImageDraw.Draw(combined)
font_lbl = ImageFont.truetype(FONT_BOLD, 18)
# ── Left half: upright landscape layout ──────────────────────────────────
left = _half_canvas(url, w=LEFT_W, h=EPD_H, title_size=36, body_size=18, qr_size=210, qr_y=76)
combined.paste(left, (0, 0))
draw.text((6, EPD_H - 26), "LANDSCAPE", fill=BLACK, font=font_lbl)
# ── Divider ───────────────────────────────────────────────────────────────
draw.rectangle([LEFT_W, 0, LEFT_W + DIVIDER - 1, EPD_H - 1], fill=BLACK)
# ── Right half: portrait content designed at 480×399, rotated 90° CCW ────
# Designing at 480(h)×399(w) then rotating 90° CCW gives 399(w)×480(h) to
# fill the right half. The content appears sideways on the flat display —
# rotate the frame 90° CCW (left edge up) to read this half correctly.
portrait_src = _half_canvas(url, w=480, h=RIGHT_W,
title_size=36, body_size=18, qr_size=210, qr_y=76)
# ROTATE_90 = 90° CCW: 480×399 → 399×480
portrait_half = portrait_src.transpose(Image.ROTATE_90)
combined.paste(portrait_half, (LEFT_W + DIVIDER, 0))
# Label drawn rotated — PIL doesn't rotate text natively, so draw on a
# temp canvas then rotate and composite into bottom-right corner.
lbl_text = "PORTRAIT"
lbl_font = ImageFont.truetype(FONT_BOLD, 18)
lbl_w = int(draw.textlength(lbl_text, font=lbl_font))
lbl_h = 24
lbl_img = Image.new("RGB", (lbl_w + 4, lbl_h), BABY_BLUE)
ImageDraw.Draw(lbl_img).text((2, 2), lbl_text, fill=BLACK, font=lbl_font)
lbl_rot = lbl_img.transpose(Image.ROTATE_90) # rotate label to match content
lx = LEFT_W + DIVIDER + 4
ly = EPD_H - lbl_rot.height - 4
combined.paste(lbl_rot, (lx, ly))
return combined
def _paste_qr(canvas: Image.Image, url: str, x: int, y: int, size: int) -> None:
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=10, border=2)
qr.add_data(url)
qr.make(fit=True)
qr_img = qr.make_image(fill_color=BLACK, back_color=BABY_BLUE).convert("RGB")
canvas.paste(qr_img.resize((size, size), Image.NEAREST), (x, y))
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--orientation", choices=["landscape", "portrait", "combined"], default="landscape")
ap.add_argument("--mac", default=None, metavar="AA:BB:CC:DD:EE:FF")
ap.add_argument("--out", default=None, help="Output .bin path (default: firmware/data/img_<orientation>.bin)")
ap.add_argument("--preview", default=None, help="Preview PNG path (default: /tmp/pictureframe_<orientation>_preview.png)")
args = ap.parse_args()
url = f"{BASE_URL}/setup/{args.mac}" if args.mac else BASE_URL
out = Path(args.out) if args.out else Path(f"firmware/data/img_{args.orientation}.bin")
preview = Path(args.preview) if args.preview else Path(f"/tmp/pictureframe_{args.orientation}_preview.png")
print(f"Orientation : {args.orientation}")
print(f"QR URL : {url}")
makers = {"landscape": _make_canvas_landscape, "portrait": _make_canvas_portrait, "combined": _make_canvas_combined}
canvas = makers[args.orientation](url)
assert canvas.size == (EPD_W, EPD_H), f"canvas is {canvas.size}, expected ({EPD_W}, {EPD_H})"
canvas.save(str(preview))
print(f"Preview : {preview}")
print(f"Dithering {EPD_W}×{EPD_H} to 6-color palette…")
codes = _floyd_steinberg(np.array(canvas, dtype=np.float32))
packed = ((codes[:, 0::2] << 4) | codes[:, 1::2]).astype(np.uint8).tobytes()
assert len(packed) == EPD_H * (EPD_W // 2)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_bytes(packed)
print(f"Written : {len(packed):,} bytes → {out}")
if __name__ == "__main__":
main()