Files
pictureFrame-firmware/scripts/gen_screens_13e6.py
T
football2801 eff34717c9 feat(brand): real logo composited onto setup screens
Replaces the bordered text placeholder with the composed WeVisto logo
(harbour photo + dark gradient + 'WeVisto' wordmark with the yellow V)
in the top-right of every setup screen. Pure-PIL composition mirroring
webApp/frontend/public/logo.svg — no cairosvg/rsvg dependency needed.

Source asset: webApp/brand/IMG_2524-square900.jpg. Logo box went from a
300×92 wide placeholder to an 110×110 square on 13.3 and a 44×44 square
on 7.3 — matches Matt's request for a 'nice square, readable rendition'
and keeps a comfortable margin within each panel's header band.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:38:13 -04:00

533 lines
23 KiB
Python
Raw 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 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://wevisto.com/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 wrap_to_width(draw, text, font, max_w):
"""Greedy word-wrap so each rendered line stays within max_w pixels.
Doesn't split individual words. Returns a list of lines."""
lines = []
current = ""
for word in text.split():
candidate = (current + " " + word) if current else word
bb = draw.textbbox((0, 0), candidate, font=font)
if bb[2] - bb[0] <= max_w:
current = candidate
else:
if current:
lines.append(current)
current = word
if current:
lines.append(current)
return lines
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):
"""Coloured band along the top with section title on the left. Logo
placeholder is rendered separately by draw_logo_placeholder() so the
brand mark sits in the top right of every setup screen."""
draw.rectangle([0, 0, W - 1, HEADER_H - 1], fill=accent)
draw.text((LEFT_PAD, 40), header_text, font=F_BAR, fill=BK)
# ── Logo ─────────────────────────────────────────────────────────────────────
# Composed mirror of webApp/frontend/public/logo.svg: harbour photo +
# black-fade gradient + "WeVisto" wordmark (V in yellow). Rendered in
# pure PIL because cairosvg / rsvg / imagemagick aren't installed on the
# build host. Source photo lives in webApp/brand/.
LOGO_SIDE = 110 # square; fits within HEADER_H=130 with padding
LOGO_X = W - LOGO_SIDE - LEFT_PAD # 1054
LOGO_Y = (HEADER_H - LOGO_SIDE) // 2 # 10
LOGO_SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "..", "webApp", "brand",
"IMG_2524-square900.jpg")
def compose_logo(size):
"""Returns a size×size RGB PIL image of the WeVisto wordmark over the
Camogli harbour photo. Matches the SVG layout in
webApp/frontend/public/logo.svg (320×320 viewBox)."""
bg = Image.open(LOGO_SRC).convert("RGB").resize((size, size), Image.LANCZOS)
# Black overlay band: 45% opacity peak at 42-58% height, fading at edges.
# Matches the linearGradient stops in the SVG.
overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0))
od = ImageDraw.Draw(overlay)
for y in range(size):
t = y / max(1, size - 1)
if t < 0.42:
a = 0.45 * (t / 0.42)
elif t < 0.58:
a = 0.45
else:
a = 0.45 * ((1 - t) / 0.42)
od.line([(0, y), (size, y)], fill=(0, 0, 0, int(a * 255)))
bg = Image.alpha_composite(bg.convert("RGBA"), overlay).convert("RGB")
# Wordmark: "We" white + "V" yellow + "isto" white, centred at y≈0.547.
draw = ImageDraw.Draw(bg)
font_px = max(10, int(62 * size / 320))
font = ttf("DejaVuSans-Bold.ttf", font_px)
parts = [("We", WH), ("V", YL), ("isto", WH)]
widths = [draw.textbbox((0, 0), t, font=font)[2] for t, _ in parts]
total_w = sum(widths)
x = (size - total_w) // 2
y = int(0.547 * size) - font_px // 2 - 4 # nudged up to match SVG
for (t, fill), w in zip(parts, widths):
draw.text((x, y), t, font=font, fill=fill)
x += w
return bg
def draw_logo_placeholder(img):
"""Paste the composed WeVisto logo in the top-right of the header."""
img.paste(compose_logo(LOGO_SIDE), (LOGO_X, LOGO_Y))
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)
draw_logo_placeholder(img)
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 = [
("Turn on your WeVisto", ""),
("Unlock your phone", ""),
("Scan QR 1", "This will connect your phone to the WeVisto"),
("Scan QR 2", "This will open the WeVisto setup page"),
]
step_y0 = head_y + 240
step_pitch = 112
box = 50 # numbered black box size
text_x = LEFT_PAD + box + 22
text_max_w = DIV_X - text_x - 18 # don't cross the column divider
line_h = 34 # ~F_STEP line height
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((text_x, by - 4), l1, font=F_STEP_B, fill=BK)
if l2:
for j, line in enumerate(wrap_to_width(draw, l2, F_STEP, text_max_w)):
draw.text((text_x, by + 32 + j * line_h), line, 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")
draw_logo_placeholder(img)
# 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 wevisto.com 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}")