Files
pictureFrame-webApp/convert_photo.py
T
football2801 a536baabd6 feat: initial commit — BMAD tooling, Claude memories, firmware scaffold
Adds the complete project foundation:
- BMAD BMM workflow tooling (_bmad/)
- Claude slash commands, skills, and project memories (.claude/)
- ESP32 firmware scaffold (PlatformIO + Waveshare e-ink driver)
- .gitignore excluding _bmad-output/ and .pio/ build artifacts

Planning artifacts (PRD, architecture, epics) are intentionally not
tracked — they live in _bmad-output/ per project convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:38:46 -04:00

120 lines
4.3 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
"""Download a photo, letterbox to 800×480, Floyd-Steinberg dither to the
Waveshare 7.3" 6-colour palette, and write src/image.h as a PROGMEM array."""
import io
import math
import urllib.request
from pathlib import Path
import numpy as np
from PIL import Image
W, H = 480, 800 # portrait canvas; firmware rotates 90° CW onto landscape display
OUT = Path(__file__).parent / "src" / "image.h"
URL = (
"https://d3eguztg5751m.cloudfront.net/as/assets-mem-com/cmi/3/0/0/7/11557003"
"/20231128_115457370_0_orig.jpg/-/kenneth-edholm-fort-wayne-in-obituary.jpg"
"?maxheight=650"
)
# Waveshare 6-colour palette: (display code, match RGB)
# RGB values tuned for photographic content — skin tones sit between
# white and yellow/red, so those two anchors matter most.
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) # (6,3)
def floyd_steinberg(img_rgb: np.ndarray) -> np.ndarray:
"""In-place F-S dither; returns (H,W) array of display colour codes."""
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)
diffs = PAL_RGB - px
idx = int(np.argmin((diffs ** 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 main() -> None:
print(f"Downloading {URL} ...")
req = urllib.request.Request(URL, headers={"User-Agent": "pictureFrame/1.0"})
with urllib.request.urlopen(req, timeout=30) as r:
img_bytes = r.read()
img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
print(f"Downloaded: {img.size[0]}×{img.size[1]}")
# Letterbox: fit inside 800×480, centre on white background
scale = min(W / img.size[0], H / img.size[1])
new_w = int(img.size[0] * scale)
new_h = int(img.size[1] * scale)
resized = img.resize((new_w, new_h), Image.LANCZOS)
canvas = Image.new("RGB", (W, H), (255, 255, 255))
canvas.paste(resized, ((W - new_w) // 2, (H - new_h) // 2))
canvas.save("/tmp/picture_frame_preview.png")
print("Preview → /tmp/picture_frame_preview.png")
print(f"Dithering {W}×{H} to 6-colour palette (this takes ~60s) ...")
arr = np.array(canvas, dtype=np.float32)
codes = floyd_steinberg(arr)
# Colour distribution
names = ["Black", "White", "Yellow", "Red", "Blue", "Green"]
for i, (code, name) in enumerate(zip(CODES, names)):
cnt = int((codes == code).sum())
print(f" {name:7s} ({code:#04x}): {cnt:7d} px ({100*cnt/(W*H):.1f}%)")
# Pack 4bpp: high nibble = even column, low nibble = odd column
high = codes[:, 0::2].astype(np.uint8)
low = codes[:, 1::2].astype(np.uint8)
packed = ((high << 4) | low)
packed_bytes = packed.tobytes()
assert len(packed_bytes) == H * (W // 2)
OUT.parent.mkdir(parents=True, exist_ok=True)
print(f"Writing {OUT} ...")
ROW = W // 2
with OUT.open("w") as f:
f.write("#pragma once\n")
f.write("#include <pgmspace.h>\n\n")
f.write(f"// {W}×{H} photo, 4bpp Waveshare 7.3\" 6-colour\n")
f.write("// Packing: byte[x/2] = (code[x]<<4)|code[x+1], even col = high nibble\n")
f.write(f"#define IMAGE_ROW {ROW} // bytes per display row\n\n")
f.write("const uint8_t IMAGE_DATA[] PROGMEM = {\n")
for y in range(H):
row = packed_bytes[y * ROW:(y + 1) * ROW]
for off in range(0, ROW, 16):
chunk = row[off:off + 16]
f.write(" " + ",".join(f"0x{b:02X}" for b in chunk) + ",\n")
f.write("};\n")
kb = len(packed_bytes) / 1024
print(f"Done — {len(packed_bytes):,} bytes ({kb:.1f} KB) → {OUT}")
if __name__ == "__main__":
main()