#!/usr/bin/env python3 """ Generate 1200×1600 portrait setup-screen backgrounds for the Waveshare 13.3" Spectra-6 panel. Layout mirrors the 7.3" gen_screens.py (yellow header + instructions + QR codes) but adapted for portrait: a single yellow header band on top, then a two-column body — instructions + manual QR on the left, the two runtime QR codes on the right. No orientation chooser (13.3" setup is portrait-only — see project memory). Run from the firmware/ directory: python3 scripts/gen_screens_13e6.py Exports the QR overlay constants the firmware uses to overlay the runtime QR images on the static .bin backgrounds; the driver must hold the same numbers. """ from PIL import Image, ImageDraw, ImageFont import qrcode import os, sys MANUAL_URL = "https://pictureframe.edholm.me/help" W, H = 1200, 1600 # ── Spectra-6 palette ──────────────────────────────────────────────────────── BLACK = 0x0; BK = (26, 26, 26 ) WHITE = 0x1; WH = (245, 245, 240) YELLOW = 0x2; YL = (240, 208, 0 ) RED = 0x3; RD = (192, 48, 32 ) BLUE = 0x5; BL = (24, 64, 192) GREEN = 0x6; GR = (16, 160, 64 ) PALETTE_RGB = {BLACK: BK, WHITE: WH, YELLOW: YL, RED: RD, BLUE: BL, GREEN: GR} def nearest(r, g, b): best, best_d = WHITE, float("inf") for n, (pr, pg, pb) in PALETTE_RGB.items(): d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2 if d < best_d: best, best_d = n, d return best def pack(img): """RGB PIL → 4bpp packed bytearray, row-major panel-native order.""" px = img.load() out = bytearray() for y in range(H): for x in range(0, W, 2): hi = nearest(*px[x, y]) lo = nearest(*px[x + 1, y]) out.append((hi << 4) | lo) return out # ── Fonts ──────────────────────────────────────────────────────────────────── FONT_DIR = "/usr/share/fonts/truetype/dejavu" def ttf(name, size): try: return ImageFont.truetype(os.path.join(FONT_DIR, name), size) except Exception: return ImageFont.load_default() F_BAR = ttf("DejaVuSans-Bold.ttf", 34) # header band text F_CHIP = ttf("DejaVuSans-Bold.ttf", 30) # SSID chip F_HEAD = ttf("DejaVuSans-Bold.ttf", 78) # "Connect to WiFi" heading F_TITLE = ttf("DejaVuSans-Bold.ttf", 60) # "Almost ready" on setup screen F_LABEL = ttf("DejaVuSans-Bold.ttf", 36) # column section labels F_STEPN = ttf("DejaVuSans-Bold.ttf", 32) # step number inside black box F_STEP_B = ttf("DejaVuSans-Bold.ttf", 30) # bold step body F_STEP = ttf("DejaVuSans.ttf", 30) # step body F_FOOT = ttf("DejaVuSans.ttf", 26) # captions under QR F_TINY = ttf("DejaVuSans-Bold.ttf", 22) # progress-bar labels F_URL = ttf("DejaVuSans.ttf", 22) # URL bar mono # ── Layout constants ───────────────────────────────────────────────────────── HEADER_H = 130 # header band height BODY_Y = HEADER_H DIV_X = 600 # vertical divider between left + right columns LEFT_X = 0; LEFT_W = 600 RIGHT_X = 602; RIGHT_W = W - RIGHT_X # 598 LEFT_PAD = 36 RIGHT_PAD = 36 # ── QR overlay regions — MUST match the panel driver ───────────────────────── # Dynamic QRs (left as WHITE rectangles in the .bin so firmware can overlay). AP_QR_MODS = 37 # version 5, ECC_LOW AP_QR_CELL = 14 # 37 × 14 = 518 px AP_QR_PX = AP_QR_MODS * AP_QR_CELL AP_QR_X = RIGHT_X + (RIGHT_W - AP_QR_PX) // 2 # 642 AP_QR_Y = BODY_Y + 100 # 230 SETUP_QR_MODS = 41 # version 6, ECC_LOW SETUP_QR_CELL = 16 # 41 × 16 = 656 px SETUP_QR_PX = SETUP_QR_MODS * SETUP_QR_CELL SETUP_QR_X = (W - SETUP_QR_PX) // 2 # 272 SETUP_QR_Y = BODY_Y + 360 # 490 def text_center(draw, cx, y, text, font, fill): bb = draw.textbbox((0, 0), text, font=font) tw = bb[2] - bb[0] draw.text((cx - tw // 2, y), text, font=font, fill=fill) def leave_qr_white(draw, qr_x, qr_y, qr_px): draw.rectangle([qr_x, qr_y, qr_x + qr_px - 1, qr_y + qr_px - 1], fill=WH) def draw_qr_frame(draw, qx, qy, qp, accent): """Two-layer decorative border around a QR — accent outer + black inner.""" draw.rectangle([qx - 12, qy - 12, qx + qp + 11, qy + qp + 11], outline=accent, width=6) draw.rectangle([qx - 4, qy - 4, qx + qp + 3, qy + qp + 3], outline=BK, width=4) def draw_header(draw, accent, header_text, ssid_text): """Yellow/red band along the top with a section title + SSID chip.""" draw.rectangle([0, 0, W - 1, HEADER_H - 1], fill=accent) draw.text((LEFT_PAD, 40), header_text, font=F_BAR, fill=BK) if ssid_text: bb = draw.textbbox((0, 0), ssid_text, font=F_CHIP) chip_w = bb[2] - bb[0] + 36 chip_x = W - chip_w - LEFT_PAD draw.rectangle([chip_x, 25, chip_x + chip_w, HEADER_H - 25], fill=BK) draw.text((chip_x + 18, 38), ssid_text, font=F_CHIP, fill=accent) def draw_divider(draw): """Vertical divider between the two body columns.""" draw.rectangle([DIV_X, BODY_Y + 20, DIV_X + 1, H - 30], fill=BK) def up_arrow(draw, cx, cy, half_w=18, h=34, color=BK): """Solid filled triangle pointing up, centered on (cx, cy).""" draw.polygon([ (cx, cy - h // 2), (cx - half_w, cy + h // 2), (cx + half_w, cy + h // 2), ], fill=color) def left_arrow(draw, cx, cy, half_h=18, w=34, color=BK): """Solid filled triangle pointing left, centered on (cx, cy).""" draw.polygon([ (cx - w // 2, cy), (cx + w // 2, cy - half_h), (cx + w // 2, cy + half_h), ], fill=color) def paste_rotated_text(img, text, font, fill, anchor_xy, ccw_degrees=90): """ Render `text` horizontally onto a transparent overlay, rotate ccw, and paste it onto `img` at `anchor_xy` (top-left of the rotated bounding box). Used for vertical labels on orientation diagrams — PIL's ImageDraw.text() can't rotate, so we render-then-rotate. """ bb = font.getbbox(text) tw, th = bb[2] - bb[0], bb[3] - bb[1] pad = 4 layer = Image.new("RGBA", (tw + pad * 2, th + pad * 2), (0, 0, 0, 0)) ImageDraw.Draw(layer).text((pad - bb[0], pad - bb[1]), text, font=font, fill=fill) rotated = layer.rotate(ccw_degrees, expand=True, resample=Image.BILINEAR) img.paste(rotated, anchor_xy, rotated) def orientation_diagrams(img, cx, top_y, label_color=None): """ Side-by-side PORTRAIT / LANDSCAPE diagrams illustrating the two ways the user can hang the frame. Drawn in current portrait-view coords: PORTRAIT = upright tall rect, ribbon along the bottom short edge, up-arrow inside. LANDSCAPE = pre-rotated 90° CCW from upright landscape. The frame rotation portrait→landscape is 90° CW (ribbon moves bottom→left as viewed by the user); the CCW pre-rotation cancels that, so when the user picks the frame up and rotates it 90° CW into landscape the diagram lands upright (wide rect, ribbon-left, up-arrow). In the portrait rendering that means: tall rect, ribbon along bottom edge (was the LEFT edge upright), LEFT- pointing arrow (was UP upright), and the "LANDSCAPE" label rotated 90° CCW so it runs up the long edge — reads horizontally once the frame is mounted landscape. """ if label_color is None: label_color = BK draw = ImageDraw.Draw(img) # Section heading text_center(draw, cx, top_y, "FRAME", F_TINY, label_color) text_center(draw, cx, top_y + 28, "ORIENTATION", F_TINY, label_color) # Same external dimensions for both diagrams (the LANDSCAPE is a 90°-CCW # rotation of an upright wide rect — its bounding box is square'd to # match portrait so the pair sits in a clean two-up grid). diag_w, diag_h = 130, 200 ribbon_thick = 14 pair_gap = 100 # extra room so the rotated label doesn't crowd the divider pair_w = diag_w * 2 + pair_gap base_y = top_y + 100 pt_x = cx - pair_w // 2 ls_x = pt_x + diag_w + pair_gap pt_y = base_y ls_y = base_y diag_bottom = base_y + diag_h # PORTRAIT — upright tall rect, ribbon along bottom short edge. draw.rectangle([pt_x, pt_y, pt_x + diag_w - 1, pt_y + diag_h - 1], outline=BK, width=3) draw.rectangle([pt_x, pt_y + diag_h - ribbon_thick, pt_x + diag_w - 1, pt_y + diag_h - 1], fill=BK) up_arrow(draw, pt_x + diag_w // 2, pt_y + (diag_h - ribbon_thick) // 2) text_center(draw, pt_x + diag_w // 2, diag_bottom + 14, "PORTRAIT", F_TINY, BK) # LANDSCAPE — pre-rotated 90° CCW from upright. # Upright landscape: wide rect, ribbon along LEFT long edge, UP arrow. # After 90° CCW (in portrait rendering): tall rect, ribbon along BOTTOM # short edge, LEFT-pointing arrow. Label runs up the LEFT long edge, # rotated 90° CCW so it reads L→R once the frame is rotated to landscape. draw.rectangle([ls_x, ls_y, ls_x + diag_w - 1, ls_y + diag_h - 1], outline=BK, width=3) draw.rectangle([ls_x, ls_y + diag_h - ribbon_thick, ls_x + diag_w - 1, ls_y + diag_h - 1], fill=BK) left_arrow(draw, ls_x + diag_w // 2, ls_y + (diag_h - ribbon_thick) // 2) # Rotated label, anchored just left of the diagram's left long edge. label_text = "LANDSCAPE" bb = F_TINY.getbbox(label_text) label_w = bb[2] - bb[0] label_h = bb[3] - bb[1] # Rotated label is `label_w` tall, `label_h` wide. Centred vertically # against the rect, sitting just to its left. rotated_x = ls_x - label_h - 16 rotated_y = ls_y + (diag_h - label_w) // 2 paste_rotated_text(img, label_text, F_TINY, BK, (rotated_x, rotated_y), ccw_degrees=90) # ═══════════════════════════════════════════════════════════════════════════════ # AP SCREEN — yellow (or red retry) accent, Step 1 of 2 # ═══════════════════════════════════════════════════════════════════════════════ def gen_ap(accent=YL, header_text="SETUP MODE — STEP 1 OF 2", qr_label="SCAN TO CONNECT"): img = Image.new("RGB", (W, H), WH) draw = ImageDraw.Draw(img) draw_header(draw, accent, header_text, "PictureFrame-91F8") draw_divider(draw) # ── LEFT COLUMN ────────────────────────────────────────────────────────── # Big "Connect to WiFi" heading with accent underline. head_y = BODY_Y + 30 draw.text((LEFT_PAD, head_y), "Connect to", font=F_HEAD, fill=BK) draw.text((LEFT_PAD, head_y + 90), "WiFi", font=F_HEAD, fill=BK) bb = draw.textbbox((0, 0), "WiFi", font=F_HEAD) underline_y = head_y + 90 + bb[3] + 6 draw.rectangle([LEFT_PAD, underline_y, LEFT_PAD + bb[2] + 4, underline_y + 6], fill=accent) # 5 numbered steps — same instructions as the 7.3" but with the larger # type that fits comfortably on a 13.3" portrait body. steps = [ ("Plug in the frame", ""), ("Unlock your phone", ""), ("Scan QR 1", "joins your phone to PictureFrame"), ("Scan QR 2", "opens the setup page"), ("Type your home WiFi", "password and tap Connect"), ] step_y0 = head_y + 240 step_pitch = 92 box = 50 # numbered black box size for i, (l1, l2) in enumerate(steps): by = step_y0 + i * step_pitch draw.rectangle([LEFT_PAD, by, LEFT_PAD + box, by + box], fill=BK) text_center(draw, LEFT_PAD + box // 2, by + 8, str(i + 1), F_STEPN, accent) draw.text((LEFT_PAD + box + 22, by - 4), l1, font=F_STEP_B, fill=BK) if l2: draw.text((LEFT_PAD + box + 22, by + 32), l2, font=F_STEP, fill=BK) # Orientation diagrams — tucked between the steps and the manual QR so # the user sees both possible hanging positions before they commit. orientation_diagrams(img, LEFT_X + LEFT_W // 2, step_y0 + len(steps) * step_pitch + 60) # Manual QR bottom-left — covers "captive portal didn't open" + general # troubleshooting. Same intent as the 7.3" but bigger box, more room. manual_qr = qrcode.QRCode( version=None, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=7, border=2, ) manual_qr.add_data(MANUAL_URL) manual_qr.make(fit=True) manual_img = manual_qr.make_image(fill_color=BK, back_color=WH).convert("RGB") mw, mh = manual_img.size manual_x = LEFT_PAD manual_y = H - mh - 60 img.paste(manual_img, (manual_x, manual_y)) # Side label aligned with manual QR. label_x = manual_x + mw + 28 label_y = manual_y + (mh - 80) // 2 draw.text((label_x, label_y), "Need help?", font=F_STEP_B, fill=BK) draw.text((label_x, label_y + 38), "Scan for setup", font=F_STEP, fill=BK) draw.text((label_x, label_y + 70), "& troubleshoot", font=F_STEP, fill=BK) # ── RIGHT COLUMN ───────────────────────────────────────────────────────── rcx = RIGHT_X + RIGHT_W // 2 # Step 1 — WiFi join QR (dynamic, firmware overlay) text_center(draw, rcx, AP_QR_Y - 56, "STEP 1 — JOIN WIFI", F_LABEL, BK) draw_qr_frame(draw, AP_QR_X, AP_QR_Y, AP_QR_PX, accent) leave_qr_white(draw, AP_QR_X, AP_QR_Y, AP_QR_PX) text_center(draw, rcx, AP_QR_Y + AP_QR_PX + 16, qr_label, F_FOOT, BK) # Step 2 — URL QR (static "http://192.168.4.1/", baked into bg). Scanning # this in iOS Safari forces the captive portal to render — works around # iOS's reluctance to auto-launch CNA from a QR-scan WiFi join. url_qr = qrcode.QRCode( version=None, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=2, ) url_qr.add_data("http://192.168.4.1/") url_qr.make(fit=True) url_img = url_qr.make_image(fill_color=BK, back_color=WH).convert("RGB") uw, uh = url_img.size url_x = rcx - uw // 2 url_y = H - uh - 100 text_center(draw, rcx, url_y - 56, "STEP 2 — OPEN PAGE", F_LABEL, BK) draw.rectangle([url_x - 12, url_y - 12, url_x + uw + 11, url_y + uh + 11], outline=accent, width=6) draw.rectangle([url_x - 4, url_y - 4, url_x + uw + 3, url_y + uh + 3], outline=BK, width=4) img.paste(url_img, (url_x, url_y)) text_center(draw, rcx, url_y + uh + 12, "http://192.168.4.1/", F_FOOT, BK) return img def gen_ap_retry(): """Red-accented retry screen, served after a failed WiFi-join attempt.""" return gen_ap( accent=RD, header_text="CONNECTION FAILED — TRY AGAIN", qr_label="Connection failed — try again", ) # ═══════════════════════════════════════════════════════════════════════════════ # SETUP SCREEN — green accent, post-WiFi setup-claim QR # Single-column centred layout: heading + numbered steps + big setup QR + MAC. # No two-column split here because the QR is much bigger (16-px cells × 41 # modules = 656 px) and a side column would crowd it. # ═══════════════════════════════════════════════════════════════════════════════ def gen_setup(): img = Image.new("RGB", (W, H), WH) draw = ImageDraw.Draw(img) draw_header(draw, GR, "WIFI CONNECTED — STEP 2 OF 2", "192.168.x.x") # Centered heading text_center(draw, W // 2, BODY_Y + 30, "Almost ready", F_HEAD, BK) bb = draw.textbbox((0, 0), "Almost ready", font=F_HEAD) underline_w = bb[2] + 4 text_w = bb[2] - bb[0] underline_x = (W - text_w) // 2 underline_y = BODY_Y + 30 + bb[3] + 6 draw.rectangle([underline_x, underline_y, underline_x + text_w, underline_y + 6], fill=GR) text_center(draw, W // 2, BODY_Y + 170, "Scan the QR to link this frame", F_STEP_B, BK) text_center(draw, W // 2, BODY_Y + 210, "to your pictureframe.edholm.me account.", F_STEP, BK) # Setup QR with decorative border + green/black double frame draw_qr_frame(draw, SETUP_QR_X, SETUP_QR_Y, SETUP_QR_PX, GR) leave_qr_white(draw, SETUP_QR_X, SETUP_QR_Y, SETUP_QR_PX) text_center(draw, W // 2, SETUP_QR_Y - 56, "SCAN TO FINISH", F_LABEL, BK) # MAC chip below QR — frame's identifier so the user knows which device # they're claiming. Static placeholder until firmware writes text here. mac = "1C:C3:AB:D1:91:F8" bb = draw.textbbox((0, 0), mac, font=F_CHIP) chip_w = bb[2] - bb[0] + 32 chip_x = (W - chip_w) // 2 chip_y = SETUP_QR_Y + SETUP_QR_PX + 30 draw.rectangle([chip_x, chip_y, chip_x + chip_w, chip_y + 50], fill=BK) text_center(draw, W // 2, chip_y + 14, mac, F_CHIP, WH) text_center(draw, W // 2, chip_y + 80, "Your frame's MAC — handy for support", F_FOOT, BK) # Progress track at the bottom track_y = H - 90 track_h = 14 seg_pad = 20 segs = [("WiFi", GR), ("Account", BK), ("Frame ready", (200, 200, 195))] seg_w = (W - LEFT_PAD * 2 - seg_pad * 2) // 3 text_center(draw, W // 2, track_y - 36, "SETUP PROGRESS", F_TINY, BK) for i, (label, color) in enumerate(segs): sx = LEFT_PAD + i * (seg_w + seg_pad) draw.rectangle([sx, track_y, sx + seg_w, track_y + track_h], fill=color) text_center(draw, sx + seg_w // 2, track_y + track_h + 10, label, F_FOOT, BK) return img # ── Save ───────────────────────────────────────────────────────────────────── def save_bin(img, path, preview_path): data = pack(img) with open(path, "wb") as f: f.write(data) print(f"Written {len(data):,} bytes → {os.path.abspath(path)}") prev = Image.new("RGB", (W, H)) px = prev.load() for y in range(H): for x in range(0, W, 2): byte = data[y * (W // 2) + x // 2] px[x, y] = PALETTE_RGB.get(byte >> 4, (128, 128, 128)) px[x + 1, y] = PALETTE_RGB.get(byte & 0xF, (128, 128, 128)) prev.save(preview_path) print(f"Preview → {os.path.abspath(preview_path)}") if __name__ == "__main__": out_dir = os.path.join(os.path.dirname(__file__), "../data/waveshare13e6-v1") os.makedirs(out_dir, exist_ok=True) print("Generating AP screen…") save_bin(gen_ap(), f"{out_dir}/ap_bg.bin", f"{out_dir}/ap_bg_preview.png") print() print("Generating AP retry screen…") save_bin(gen_ap_retry(), f"{out_dir}/ap_bg_retry.bin", f"{out_dir}/ap_bg_retry_preview.png") print() print("Generating setup screen…") save_bin(gen_setup(), f"{out_dir}/setup_bg.bin", f"{out_dir}/setup_bg_preview.png") print() print("QR overlay constants — keep these in sync with epd_driver.cpp:") print(f" AP_QR_X={AP_QR_X}, AP_QR_Y={AP_QR_Y}, AP_QR_CELL={AP_QR_CELL}, AP_QR_PX={AP_QR_PX}") print(f" SETUP_QR_X={SETUP_QR_X}, SETUP_QR_Y={SETUP_QR_Y}, " f"SETUP_QR_CELL={SETUP_QR_CELL}, SETUP_QR_PX={SETUP_QR_PX}")