firmware/misc/q1font/render.py
2023-12-05 12:33:29 +01:00

263 lines
7.9 KiB
Python
Executable File

#!/usr/bin/env python
#
# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# render.py - Build compressed data for font rendering on the Q1.
#
import os, sys, pdb, math, zlib
from PIL import Image, ImageDraw, ImageFont, ImageColor
from collections import Counter
FONT = 'iosevka-extrabold.ttf'
FONT_SIZE = 18
# each character will be in a cell this size of pixels
# - yields final screen size, in chars of: 53x12
CELL_SIZE = (9, 22)
CELL_W, CELL_H = CELL_SIZE
DBL_CELL_SIZE = (CELL_W*2, CELL_H)
print(f'Each char: {CELL_W} x {CELL_H} pixels')
print(f'Screen: {320//CELL_W} x {240//CELL_H} chars')
# quantization for unique grey levels, system-wide
# - needs to be power of two, so divides bytes nicely
# - 4 not enough, 8 decent, 16 even better
NUM_GREYS = 16
# TODO:
# - need _ but for blank space; never found and yet may not need.
CHARSET = [chr(x) for x in range(32,127)] \
+ ['', '', '', '', '',
'', '', '',
'', '', '', '', '',
'', '', '', '', '',
'', '', '',
'', '©',
]
# these are be better as double-wide chars
DBL_WIDTH = ['', '✔︎', '','', '', '',
'', '', '',
]
NUM_CHARS = len(CHARSET)
# use a different glyph for these unicode values
# - useful for multi-codepoint sequences, which we want to encode as single char
REMAPS = {
}
# find hidden zero-width junk
assert all(len(ch) == 1 for ch in CHARSET), [ch for ch in CHARSET if len(ch) > 1]
MEM_PER_CHAR = int(round((math.log2(NUM_GREYS) * CELL_W * CELL_H) / 8, 0))
print(f'Font has {NUM_CHARS} chars')
print(f"Per char, memory: {MEM_PER_CHAR} bytes")
print(f"Total font memory: {NUM_CHARS * MEM_PER_CHAR // 1024} KiBytes")
# NOTE: compressing data per-char only saves 50% and requires 1k of ram to decompress
# plus lots of overhead, so don't do that.
def make_palette(shades, col):
# make bytes representing a NUM_GREYS palette to map back to a RGB565 colour
from struct import pack
assert len(col) == 3, 'want RGB'
assert col[0] > 20, 'expect 8-bit RGB values'
col = [i/255.0 for i in col]
assert max(col) <= 1.0
assert 0 <= min(col)
def remap(c, amt):
amt /= 255.0
r = int(c[0] * amt * 0x1f)
g = int(c[1] * amt * 0x3f)
b = int(c[2] * amt * 0x1f)
return (r<<11) | (g << 5) | b
assert len(shades) == NUM_GREYS
vals = [remap(col, s) for s in shades]
txt = ', '.join('0x%04x'% i for i in vals)
return txt, pack('>%dH' % NUM_GREYS, *vals)
def doit(out_fname='font_iosevka.py', cls_name='FontIosevka'):
font = ImageFont.truetype(FONT, FONT_SIZE)
left, top, right, bottom = font.getbbox("|")
char_h = bottom - top
left, top, right, bottom = font.getbbox("M")
char_w = right - left
assert char_h <= CELL_H
assert char_w <= CELL_W
# want this one to fit in cell -- the worst descender?
left, top, right, bottom = font.getbbox("j")
y_offset = CELL_H - bottom - 1
NUM_COL = 24
samples = Image.new('L', (((CELL_W + 1) * NUM_COL) + 1,
((CELL_H+1) * (NUM_CHARS//NUM_COL + 3))), 255)
cells = Image.new('L', (CELL_W*NUM_CHARS*2, CELL_H), 0)
data = {}
pos = {}
out_x = 0
n = 0
for ch in CHARSET:
# render each one
is_dbl = (ch in DBL_WIDTH)
img = Image.new('L', CELL_SIZE if not is_dbl else DBL_CELL_SIZE)
draw = ImageDraw.Draw(img)
x_shift = 0
left, top, right, bottom = font.getbbox(ch)
if (right-left > CELL_W) and not is_dbl:
# char is too wide: some will be lost
if ch in '←↦':
# keep left edge of these
x_shift = -left
elif ch in '':
# keep right edge of these
x_shift = (CELL_W - (right-left))
else:
# center it
x_shift = (CELL_W - (right-left)) / 2.0
# Vertical tweaks
this_y = 0
if ch == '':
# this one up a little, so arrow is more mid-line-ish
this_y = -4
draw.text((x_shift, y_offset + this_y), REMAPS.get(ch, ch), 'white', font)
# check
actual = img.getcolors()
if ch not in '':
assert len(actual) >= 2, f'blank char? {ch}'
# build sample
if is_dbl and (n % NUM_COL) == NUM_COL-1:
n += 1
samples.paste(img, box=(
((n % NUM_COL) * (CELL_W+1)) + 1,
((n // NUM_COL) * (CELL_H+1)) +1))
n += (1 if not is_dbl else 2)
# track actual pixels we'll use
cells.paste(img, box=(out_x, 0))
assert ch not in pos
pos[ch] = out_x
out_x += img.width
data[ch] = img
#if ch in 'iM_0': img.show()
#if ch in 'Aj': img.show()
x, y = 0, (samples.height-CELL_H)
for ch in 'Lazy dog jumpsX123':
samples.paste(data[ch], box=(x, y))
x += CELL_W
# quantize the same, so they all share palette
# - resulting pallette is not obvious: kinda an exponential curve between white/black
#samples.show() # before quant
q_s = samples.quantize(colors=NUM_GREYS, method=Image.Quantize.MAXCOVERAGE).convert('L')
#q_s.show() # after quant
q_s.save('sample.png')
cells = cells.quantize(colors=NUM_GREYS, method=Image.Quantize.MAXCOVERAGE)
#cells.convert('L').show()
#colours = list(col for (cnt, col) in cells.getcolors())
#print(f'Shades: {colours}')
shades = cells.getpalette('RGB')
assert set(shades[3*NUM_GREYS:]) == {0} # unused positions in 8-bit /256 value pal
shades = shades[0:3*NUM_GREYS]
assert shades[0::3] == shades[1::3] == shades[2::3], 'not all greyscale?'
# remap palette so it's in order by luma
by_luma = sorted([(n, gl) for n, gl in enumerate(shades[0::3])], key=lambda x:x[1])
mapping = list(n for n,gl in by_luma)
# apply new palette
cells = cells.remap_palette(mapping)
nsh = cells.getpalette('RGB')[0::3]
assert sorted(nsh) == nsh
print(f'Shades: {nsh}')
shades = nsh
# remainder of file only tries to handle this case.
assert NUM_GREYS == 16
# slice into chars, packed and encoded by the palette
results = []
for n, ch in enumerate(CHARSET):
is_dbl = (ch in DBL_WIDTH)
x = pos[ch]
w = CELL_W*2 if is_dbl else CELL_W
here = cells.crop( (x, 0, x+w, CELL_H) ).tobytes()
assert len(here) == w * CELL_H
# pack into nibbles
assert all(px < 16 for px in here)
here = bytes((a<<4)|b for a,b in zip(here[0::2], here[1::2]))
assert len(here) in (MEM_PER_CHAR, 2*MEM_PER_CHAR)
if ch == ' ':
# space should be blank = all zeros
assert set(here) == {0}
results.append((ch, here))
pal_vals, text_pal = make_palette(shades, (255, 255, 255))
pal_vals_inv, text_pal_inv = make_palette([255-i for i in shades], (255, 255, 255))
with open(out_fname, 'w') as fp:
tmpl = open('template.py').read()
fp.write(tmpl)
fp.write(f'''
#FONT_SHADES = {shades}
TEXT_PALETTE = {text_pal}
TEXT_PALETTE_INV = {text_pal_inv}
# same, but w/o byte swapping, packing (useful for simulator)
#TEXT_PALETTE = [{pal_vals}]
CELL_W = const({CELL_W})
CELL_H = const({CELL_H})
BYTES_PER_CHAR = const({MEM_PER_CHAR})
#SPECIAL_CHARS = {[c for c in CHARSET if ord(c) >= 128]}
#DOUBLE_WIDE = {DBL_WIDTH}
class {cls_name}:
@classmethod
def lookup(cls, cp):
# lookup glyph data for a single codepoint, or return None
px = cls._data.get(cp)
if not px: return None
return GlyphInfo(len(px) * 2 // {CELL_H}, {CELL_H}, px)
_data = ''' + '{\n')
for ch, raw in results:
fp.write(f' {ch!r}: {raw},\n')
fp.write(' }\n')
if __name__ == '__main__':
doit()
# EOF