firmware/graphics/compress.py
2023-12-05 11:22:17 +01:00

196 lines
5.3 KiB
Python
Executable File

#!/usr/bin/env python3
#
# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# Read in PNG (or even JPG) and output heavily compressed RGB565 data suited to Q1's LCD panel.
#
# - also renders status bar icons/indicators
#
import os, sys, pdb
from PIL import Image, ImageOps, ImageFont, ImageDraw
import zlib
from struct import pack
WBITS = -10
FONT_PATH = './fonts/'
def read_img(fn):
img = Image.open(fn)
w,h = img.size
assert 1 <= w <= 320, f'too wide; {w}'
assert 1 <= h <= 240, f'too tall: {h}'
img = img.convert('RGB')
# maybe: quantitize to a reasonable num colours, so compression
# can work better?
return img
def compress(n, wbits=WBITS):
# NOTE: neg wbits implies no zlib header, and receiver may need to know it?
z = zlib.compressobj(wbits=wbits, level=zlib.Z_BEST_COMPRESSION)
rv = z.compress(n)
rv += z.flush(zlib.Z_FINISH)
return rv
def crunch(n):
# try them all... not finding any difference tho.
a = [(wb,compress(n, wb)) for wb in range(-9, -15, -1)]
a.sort(key=lambda i: (-len(i[1]), -i[0]))
print("Wbit values:")
print('\n'.join("%3d => %d" % (wb,len(d)) for wb,d in a))
return a[0]
# LCD Display wants RGB565 values, but wrong endian from us, so green gets split weird.
def swizzle(r,g,b):
# from 0-255 per component => two bytes
b = (b >> 3)
g = (g >> 3) # should be >> 2 for 6 bits; but looks trash?
r = (r >> 3)
return pack('<H', ((r<<11) | (g<<6) | b))
# these values tested on real hardware
assert swizzle(255, 0, 0) == b'\x00\xf8' # red
#assert swizzle(0, 255, 0) == b'\xc0\x0f' # green (6 bits)
assert swizzle(0, 255, 0) == b'\xc0\x07' # green (5 bits)
assert swizzle(0, 0, 255) == b'\x1f\x00' # blue
def into_bgr565(img):
# get the raw bytes needed for this specific display
rv = bytearray()
for y in range(img.height):
for x in range(img.width):
px = img.getpixel((x, y))
assert len(px) == 3
r,g,b = px
rv.extend(swizzle(r,g,b))
return rv
def make_icons():
# return list of (varname, img) for each image
# - see shared/lcd_display.py TOP_MARGIN for this
ICON_SIZE = 14
MAX_HEIGHT = 14
# PROBLEM: this file costs money... altho free version looks okay too
try:
awesome = ImageFont.truetype(FONT_PATH + 'Font Awesome 6 Sharp-Regular-400.otf', ICON_SIZE)
except:
raise
sm_font = ImageFont.truetype(FONT_PATH + 'iosevka-heavy.ttf')
targets = [
( 'shift', True, 'SHIFT', {} ),
( 'symbol', True, 'SYMB', {} ),
( 'caps', True, 'CAPS', {} ),
( 'bip39', True, 'PASSPHRASE', {} ),
( 'tmp', True, 'EPHEMERAL', dict(col_0='black') ),
( 'bat_0', False, '\uf244', dict(col='red', y=0)),
( 'bat_1', False, '\uf243', dict(col='yellow', y=0)),
( 'bat_2', False, '\uf242', dict(col='#0f0', y=0)),
( 'bat_3', False, '\uf240', dict(col='green', y=0)),
( 'plugged', False, '\uf1e6', dict(col='green')),
#( 'locked', False, '\uf023', dict(col='green')),
#( 'unlocked', False, '\uf3c1', dict(col='green')), # why tho?
]
samples = Image.new('RGB', (320*2, ICON_SIZE+1))
s_x = 5
for basename, is_text, body, opts in targets:
for state in [0, 1]:
col = opts.get('col', '#fff' if state else '#444')
vn = f'{basename}_{state}'
if 'col' in opts:
if state == 0: continue
vn = basename
if state == 0 and 'col_0' in opts:
col = opts['col_0']
img = Image.new('RGB', (100,100))
d = ImageDraw.Draw(img)
f = sm_font if is_text else awesome
x, y = (0, 1 if is_text else 0)
y += opts.get('y', 0)
x += opts.get('x', 0)
tl = (x, y)
_,_, w,h = d.textbbox(tl, body, font=f)
if h > MAX_HEIGHT:
h = MAX_HEIGHT
print(f'{vn} too tall, cropped')
d.text(tl, body, font=f, fill=col)
rv = img.crop( (0, 0, w,h) )
samples.paste(rv, (s_x, 0))
s_x += w + 10
yield (vn, rv)
samples = samples.crop( (0,0, s_x, samples.height ))
samples.save('icon-samples.png')
def doit(outfname, fnames):
assert outfname.endswith('.py')
assert outfname != 'compress.py'
assert fnames, "need some files"
fp = open(outfname, 'wt')
fp.write("""\
# autogenerated; don't edit
#
# BGR565 pixel data
#
class Graphics:
# (w,h, data)
""")
fnames += make_icons()
for fn in fnames:
if isinstance(fn, str):
img = read_img(fn)
varname = fn.split('/')[-1].split('.')[0].replace('-', '_')
else:
varname, img = fn
assert img.mode == 'RGB'
w,h = img.size
raw = into_bgr565(img)
comp = compress(raw)
#crunch(raw)
print(" %s = (%d, %d,\n %r\n )\n" % (varname, w, h, comp), file=fp)
print("done: '%s' (%d x %d) => %d raw => %d compressed bytes" % (
varname, w, h, len(raw), len(comp)))
fp.write("\n# EOF\n")
if 1:
doit(sys.argv[1], sys.argv[2:])
# EOF