#!/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_.bin)") ap.add_argument("--preview", default=None, help="Preview PNG path (default: /tmp/pictureframe__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()