metronome/pico/gen_font.py
Me Here ec43c694a1 PM_K-1 CircuitPython: circle pad grid, small labels, dimmer LED, faster SPI
From on-board feedback (works well; minor tweaks):
  - Pad grid uses circles now: big circle on each beat (division), small on the
    subdivisions (vectorio.Circle — native, no extra cost), coloured/lit as before.
  - Lane labels use a new small font (font_s.bin, ~12px via gen_font.py) so they're
    half-size and show more of the voice name (e.g. 'hatClos').
  - LED was blinding -> LED_BRIGHTNESS scale (default 0.15) applied on every write.
  - Residual tearing -> SPI back to 62.5 MHz (vendor speed; smaller tear window on a
    panel with no tearing-effect pin). Both are CONFIG flags.
Verified by rendering the full scene headless. font_s.bin added to gen_font.py + bundle.

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

119 lines
5.2 KiB
Python

#!/usr/bin/env python3
# Generate the anti-aliased bitmap fonts baked into main.py.
#
# Each font is one base64 blob: a small metrics table + 4-bit-alpha glyph pixels.
# main.py decodes it at boot (binascii.a2b_base64) and renders text by blending the
# foreground over the background per pixel via a 16-entry LUT (smooth, no upscaling).
#
# Re-run after changing sizes/charset: python3 pico/gen_font.py
# It writes pico/_font_m.b64 + pico/_font_l.b64 and /tmp/font_verify.png (eyeball it). Then
# inject the two base64 strings into main.py's FONT_M_B64 / FONT_L_B64 (replace the existing
# values, or re-add the @@FONT_M@@ / @@FONT_L@@ placeholders first and substitute them in).
import base64, pathlib
from PIL import Image, ImageDraw, ImageFont
FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
HERE = pathlib.Path(__file__).parent
ASCII = "".join(chr(c) for c in range(0x20, 0x7F))
SYMBOLS = "◀▶■" # ◀ ▶ ■ (prev / play / stop, anti-aliased)
M_CHARS = ASCII + SYMBOLS
L_CHARS = "0123456789 "
S_CHARS = ASCII # small font for the pad-grid lane labels (CircuitPython)
def build(size, chars):
font = ImageFont.truetype(FONT, size)
recs = [] # (cp, w, h, xoff, top, adv)
pixels = bytearray()
for ch in chars:
cp = ord(ch)
adv = round(font.getlength(ch))
bbox = font.getbbox(ch)
if not bbox or bbox[2] - bbox[0] <= 0 or bbox[3] - bbox[1] <= 0:
recs.append((cp, 0, 0, 0, 0, adv)); continue
l, t, r, b = bbox
w, h = r - l, b - t
img = Image.new("L", (w, h), 0)
ImageDraw.Draw(img).text((-l, -t), ch, fill=255, font=font)
px = img.load()
# pack 4-bit alpha, row-major, two pixels per byte (first = high nibble)
nib = []
for y in range(h):
for x in range(w):
nib.append(px[x, y] >> 4)
if len(nib) % 2: nib.append(0)
for i in range(0, len(nib), 2):
pixels.append((nib[i] << 4) | nib[i + 1])
recs.append((cp, w, h, l & 0xFF, t & 0xFF, adv & 0xFF))
header = bytearray([len(recs)])
for cp, w, h, xoff, top, adv in recs:
header += bytes([cp >> 8, cp & 0xFF, w, h, xoff, top, adv])
return bytes(header) + bytes(pixels)
# ---- reference decoder + renderer (must match main.py exactly) ----
def load_font(blob):
count = blob[0]; p = 1; pixoff = 1 + count * 7; glyphs = {}
for _ in range(count):
cp = (blob[p] << 8) | blob[p + 1]; w = blob[p + 2]; h = blob[p + 3]
xoff = blob[p + 4]; xoff = xoff - 256 if xoff > 127 else xoff
top = blob[p + 5]; adv = blob[p + 6]; p += 7
glyphs[cp] = (w, h, xoff, top, adv, pixoff)
pixoff += (w * h + 1) // 2
return glyphs, blob
def lut(fg, bg):
def unp(c): return ((c >> 11) & 0x1F, (c >> 5) & 0x3F, c & 0x1F)
fr, fgc, fb = unp(fg); br, bgc, bb = unp(bg); out = []
for a in range(16):
t = a * 17
r = (br * (255 - t) + fr * t) // 255
g = (bgc * (255 - t) + fgc * t) // 255
b = (bb * (255 - t) + fb * t) // 255
out.append((r << 11) | (g << 5) | b)
return out
def render(draw_img, font, s, x, y, fg, bg):
glyphs, blob = font; L = lut(fg, bg); pen = x
for ch in s:
g = glyphs.get(ord(ch))
if not g: continue
w, h, xoff, top, adv, off = g
for j in range(h):
for i in range(w):
k = j * w + i; byte = blob[off + (k >> 1)]
nibv = (byte >> 4) if (k & 1) == 0 else (byte & 0xF)
col = L[nibv]
r = (col >> 11) & 0x1F; gg = (col >> 5) & 0x3F; b = col & 0x1F
draw_img.putpixel((pen + xoff + i, y + top + j), (r << 3, gg << 2, b << 3))
pen += adv
return pen
blob_s = build(12, S_CHARS)
blob_m = build(22, M_CHARS)
blob_l = build(78, L_CHARS)
# base64 (baked into the MicroPython firmware, pico/main.py — FONT_M / FONT_L)
(HERE / "_font_m.b64").write_text(base64.b64encode(blob_m).decode())
(HERE / "_font_l.b64").write_text(base64.b64encode(blob_l).decode())
print("FONT_M %d bytes -> %d b64" % (len(blob_m), len(base64.b64encode(blob_m))))
print("FONT_L %d bytes -> %d b64" % (len(blob_l), len(base64.b64encode(blob_l))))
# binary blobs (read at boot by the CircuitPython firmware, pico-cp/code.py)
CP = HERE.parent / "pico-cp"
(CP / "font_s.bin").write_bytes(blob_s)
(CP / "font_m.bin").write_bytes(blob_m)
(CP / "font_l.bin").write_bytes(blob_l)
print("wrote pico-cp/font_{s,m,l}.bin (%d / %d / %d bytes)" % (len(blob_s), len(blob_m), len(blob_l)))
# verification image on a dark bg (565 colours like the firmware)
def c565(r, g, b): return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
BG = c565(6, 9, 14); CYAN = c565(10, 179, 247); TXT = c565(199, 208, 219); GREEN = c565(47, 224, 122)
img = Image.new("RGB", (320, 220), (6, 9, 14))
fm = load_font(blob_m); fl = load_font(blob_l)
render(img, fm, "PM_K-1 KIT", 12, 10, CYAN, BG)
render(img, fl, "120", 150, 40, TXT, BG)
render(img, fm, "BPM", 12, 70, TXT, BG)
render(img, fm, "▶ RUN", 12, 120, GREEN, BG)
render(img, fm, "Four-on-the-floor", 12, 150, TXT, BG)
render(img, fm, "◀◀ ▶ ▶▶ - TAP +", 12, 185, TXT, BG)
img.resize((640, 440), Image.NEAREST).save("/tmp/font_verify.png")
print("wrote /tmp/font_verify.png")