qmk_firmware/lib/python/qmk/painter.py
Pablo Martínez 88afd53b1f
[CLI] Refactor painter arguments to table instead of commandline (#24456)
Co-authored-by: Nick Brassel <nick@tzarc.org>
2024-11-21 17:16:46 +11:00

457 lines
14 KiB
Python

"""Functions that help us work with Quantum Painter's file formats.
"""
import datetime
import math
import re
from pathlib import Path
from string import Template
from PIL import Image, ImageOps
# The list of valid formats Quantum Painter supports
valid_formats = {
'rgb888': {
'image_format': 'IMAGE_FORMAT_RGB888',
'bpp': 24,
'has_palette': False,
'num_colors': 16777216,
'image_format_byte': 0x09, # see qp_internal_formats.h
},
'rgb565': {
'image_format': 'IMAGE_FORMAT_RGB565',
'bpp': 16,
'has_palette': False,
'num_colors': 65536,
'image_format_byte': 0x08, # see qp_internal_formats.h
},
'pal256': {
'image_format': 'IMAGE_FORMAT_PALETTE',
'bpp': 8,
'has_palette': True,
'num_colors': 256,
'image_format_byte': 0x07, # see qp_internal_formats.h
},
'pal16': {
'image_format': 'IMAGE_FORMAT_PALETTE',
'bpp': 4,
'has_palette': True,
'num_colors': 16,
'image_format_byte': 0x06, # see qp_internal_formats.h
},
'pal4': {
'image_format': 'IMAGE_FORMAT_PALETTE',
'bpp': 2,
'has_palette': True,
'num_colors': 4,
'image_format_byte': 0x05, # see qp_internal_formats.h
},
'pal2': {
'image_format': 'IMAGE_FORMAT_PALETTE',
'bpp': 1,
'has_palette': True,
'num_colors': 2,
'image_format_byte': 0x04, # see qp_internal_formats.h
},
'mono256': {
'image_format': 'IMAGE_FORMAT_GRAYSCALE',
'bpp': 8,
'has_palette': False,
'num_colors': 256,
'image_format_byte': 0x03, # see qp_internal_formats.h
},
'mono16': {
'image_format': 'IMAGE_FORMAT_GRAYSCALE',
'bpp': 4,
'has_palette': False,
'num_colors': 16,
'image_format_byte': 0x02, # see qp_internal_formats.h
},
'mono4': {
'image_format': 'IMAGE_FORMAT_GRAYSCALE',
'bpp': 2,
'has_palette': False,
'num_colors': 4,
'image_format_byte': 0x01, # see qp_internal_formats.h
},
'mono2': {
'image_format': 'IMAGE_FORMAT_GRAYSCALE',
'bpp': 1,
'has_palette': False,
'num_colors': 2,
'image_format_byte': 0x00, # see qp_internal_formats.h
}
}
def _render_text(values):
# FIXME: May need more chars with GIFs containing lots of frames (or longer durations)
return "|".join([f"{i:4d}" for i in values])
def _render_numeration(metadata):
return _render_text(range(len(metadata)))
def _render_values(metadata, key):
return _render_text([i[key] for i in metadata])
def _render_image_metadata(metadata):
size = metadata.pop(0)
lines = [
"// Image's metadata",
"// ----------------",
f"// Width: {size['width']}",
f"// Height: {size['height']}",
]
if len(metadata) == 1:
lines.append("// Single frame")
else:
lines.extend([
f"// Frame: {_render_numeration(metadata)}",
f"// Duration(ms): {_render_values(metadata, 'delay')}",
f"// Compression: {_render_values(metadata, 'compression')} >> See qp.h, painter_compression_t",
f"// Delta: {_render_values(metadata, 'delta')}",
])
deltas = []
for i, v in enumerate(metadata):
# Not a delta frame, go to next one
if not v["delta"]:
continue
# Unpack rect's coords
l, t, r, b = v["delta_rect"]
delta_px = (r - l) * (b - t)
px = size["width"] * size["height"]
# FIXME: May need need more chars here too
deltas.append(f"// Frame {i:3d}: ({l:3d}, {t:3d}) - ({r:3d}, {b:3d}) >> {delta_px:4d}/{px:4d} pixels ({100*delta_px/px:.2f}%)")
if deltas:
lines.append("// Areas on delta frames")
lines.extend(deltas)
return "\n".join(lines)
def command_args_str(cli, command_name):
"""Given a command name, introspect milc to get the arguments passed in."""
args = {}
max_length = 0
for arg_name, was_passed in cli.args_passed[command_name].items():
max_length = max(max_length, len(arg_name))
val = getattr(cli.args, arg_name.replace("-", "_"))
# do not leak full paths, keep just file name
if isinstance(val, Path):
val = val.name
args[arg_name] = val
return "\n".join(f"// {arg_name.ljust(max_length)} | {val}" for arg_name, val in args.items())
def generate_subs(cli, out_bytes, *, font_metadata=None, image_metadata=None, command_name):
if font_metadata is not None and image_metadata is not None:
raise ValueError("Cant generate subs for font and image at the same time")
args = command_args_str(cli, command_name)
subs = {
"year": datetime.date.today().strftime("%Y"),
"input_file": cli.args.input.name,
"sane_name": re.sub(r"[^a-zA-Z0-9]", "_", cli.args.input.stem),
"byte_count": len(out_bytes),
"bytes_lines": render_bytes(out_bytes),
"format": cli.args.format,
"generator_command": command_name.replace("_", "-"),
"command_args": args,
}
if font_metadata is not None:
subs.update({
"generated_type": "font",
"var_prefix": "font",
# not using triple quotes to avoid extra indentation/weird formatted code
"metadata": "\n".join([
"// Font's metadata",
"// ---------------",
f"// Glyphs: {', '.join([i for i in font_metadata['glyphs']])}",
]),
})
elif image_metadata is not None:
subs.update({
"generated_type": "image",
"var_prefix": "gfx",
"generator_command": command_name,
"metadata": _render_image_metadata(image_metadata),
})
else:
raise ValueError("Pass metadata for either an image or a font")
subs.update({"license": render_license(subs)})
return subs
license_template = """\
// Copyright ${year} QMK -- generated source code only, ${generated_type} retains original copyright
// SPDX-License-Identifier: GPL-2.0-or-later
// This file was auto-generated by `${generator_command}` with arguments:
${command_args}
"""
def render_license(subs):
license_txt = Template(license_template)
return license_txt.substitute(subs)
header_file_template = """\
${license}
#pragma once
#include <qp.h>
extern const uint32_t ${var_prefix}_${sane_name}_length;
extern const uint8_t ${var_prefix}_${sane_name}[${byte_count}];
"""
def render_header(subs):
header_txt = Template(header_file_template)
return header_txt.substitute(subs)
source_file_template = """\
${license}
${metadata}
#include <qp.h>
const uint32_t ${var_prefix}_${sane_name}_length = ${byte_count};
// clang-format off
const uint8_t ${var_prefix}_${sane_name}[${byte_count}] = {
${bytes_lines}
};
// clang-format on
"""
def render_source(subs):
source_txt = Template(source_file_template)
return source_txt.substitute(subs)
def render_bytes(bytes, newline_after=16):
lines = ''
for n in range(len(bytes)):
if n % newline_after == 0 and n > 0 and n != len(bytes):
lines = lines + "\n "
elif n == 0:
lines = lines + " "
lines = lines + " 0x{0:02X},".format(bytes[n])
return lines.rstrip()
def clean_output(str):
str = re.sub(r'\r', '', str)
str = re.sub(r'[\n]{3,}', r'\n\n', str)
return str
def rescale_byte(val, maxval):
"""Rescales a byte value to the supplied range, i.e. [0,255] -> [0,maxval].
"""
return int(round(val * maxval / 255.0))
def convert_requested_format(im, format):
"""Convert an image to the requested format.
"""
# Work out the requested format
ncolors = format["num_colors"]
image_format = format["image_format"]
# -- Check if ncolors is valid
# Formats accepting several options
if image_format in ['IMAGE_FORMAT_GRAYSCALE', 'IMAGE_FORMAT_PALETTE']:
valid = [2, 4, 8, 16, 256]
# Formats expecting a particular number
else:
# Read number from specs dict, instead of hardcoding
for _, fmt in valid_formats.items():
if fmt["image_format"] == image_format:
# has to be an iterable, to use `in`
valid = [fmt["num_colors"]]
break
if ncolors not in valid:
raise ValueError(f"Number of colors must be: {', '.join(valid)}.")
# Work out where we're getting the bytes from
if image_format == 'IMAGE_FORMAT_GRAYSCALE':
# If mono, convert input to grayscale, then to RGB, then grab the raw bytes corresponding to the intensity of the red channel
im = ImageOps.grayscale(im)
im = im.convert("RGB")
elif image_format == 'IMAGE_FORMAT_PALETTE':
# If color, convert input to RGB, palettize based on the supplied number of colors, then get the raw palette bytes
im = im.convert("RGB")
im = im.convert("P", palette=Image.ADAPTIVE, colors=ncolors)
elif image_format in ['IMAGE_FORMAT_RGB565', 'IMAGE_FORMAT_RGB888']:
# Convert input to RGB
im = im.convert("RGB")
return im
def rgb_to565(r, g, b):
msb = ((r >> 3 & 0x1F) << 3) + (g >> 5 & 0x07)
lsb = ((g >> 2 & 0x07) << 5) + (b >> 3 & 0x1F)
return [msb, lsb]
def convert_image_bytes(im, format):
"""Convert the supplied image to the equivalent bytes required by the QMK firmware.
"""
# Work out the requested format
ncolors = format["num_colors"]
image_format = format["image_format"]
shifter = int(math.log2(ncolors))
pixels_per_byte = int(8 / math.log2(ncolors))
bytes_per_pixel = math.ceil(math.log2(ncolors) / 8)
(width, height) = im.size
if (pixels_per_byte != 0):
expected_byte_count = ((width * height) + (pixels_per_byte - 1)) // pixels_per_byte
else:
expected_byte_count = width * height * bytes_per_pixel
if image_format == 'IMAGE_FORMAT_GRAYSCALE':
# Take the red channel
image_bytes = im.tobytes("raw", "R")
image_bytes_len = len(image_bytes)
# No palette
palette = None
bytearray = []
for x in range(expected_byte_count):
byte = 0
for n in range(pixels_per_byte):
byte_offset = x * pixels_per_byte + n
if byte_offset < image_bytes_len:
# If mono, each input byte is a grayscale [0,255] pixel -- rescale to the range we want then pack together
byte = byte | (rescale_byte(image_bytes[byte_offset], ncolors - 1) << int(n * shifter))
bytearray.append(byte)
elif image_format == 'IMAGE_FORMAT_PALETTE':
# Convert each pixel to the palette bytes
image_bytes = im.tobytes("raw", "P")
image_bytes_len = len(image_bytes)
# Export the palette
palette = []
pal = im.getpalette()
for n in range(0, ncolors * 3, 3):
palette.append((pal[n + 0], pal[n + 1], pal[n + 2]))
bytearray = []
for x in range(expected_byte_count):
byte = 0
for n in range(pixels_per_byte):
byte_offset = x * pixels_per_byte + n
if byte_offset < image_bytes_len:
# If color, each input byte is the index into the color palette -- pack them together
byte = byte | ((image_bytes[byte_offset] & (ncolors - 1)) << int(n * shifter))
bytearray.append(byte)
if image_format == 'IMAGE_FORMAT_RGB565':
# Take the red, green, and blue channels
red = im.tobytes("raw", "R")
green = im.tobytes("raw", "G")
blue = im.tobytes("raw", "B")
# No palette
palette = None
bytearray = [byte for r, g, b in zip(red, green, blue) for byte in rgb_to565(r, g, b)]
if image_format == 'IMAGE_FORMAT_RGB888':
# Take the red, green, and blue channels
red = im.tobytes("raw", "R")
green = im.tobytes("raw", "G")
blue = im.tobytes("raw", "B")
# No palette
palette = None
bytearray = [byte for r, g, b in zip(red, green, blue) for byte in (r, g, b)]
if len(bytearray) != expected_byte_count:
raise Exception(f"Wrong byte count, was {len(bytearray)}, expected {expected_byte_count}")
return (palette, bytearray)
def compress_bytes_qmk_rle(bytearray):
debug_dump = False
output = []
temp = []
repeat = False
def append_byte(c):
if debug_dump:
print('Appending byte:', '0x{0:02X}'.format(int(c)), '=', c)
output.append(c)
def append_range(r):
append_byte(127 + len(r))
if debug_dump:
print('Appending {0} byte(s):'.format(len(r)), '[', ', '.join(['{0:02X}'.format(e) for e in r]), ']')
output.extend(r)
for n in range(0, len(bytearray) + 1):
end = True if n == len(bytearray) else False
if not end:
c = bytearray[n]
temp.append(c)
if len(temp) <= 1:
continue
if debug_dump:
print('Temp buffer state {0:3d} bytes:'.format(len(temp)), '[', ', '.join(['{0:02X}'.format(e) for e in temp]), ']')
if repeat:
if temp[-1] != temp[-2]:
repeat = False
if not repeat or len(temp) == 128 or end:
append_byte(len(temp) if end else len(temp) - 1)
append_byte(temp[0])
temp = [temp[-1]]
repeat = False
else:
if len(temp) >= 2 and temp[-1] == temp[-2]:
repeat = True
if len(temp) > 2:
append_range(temp[0:(len(temp) - 2)])
temp = [temp[-1], temp[-1]]
continue
if len(temp) == 128 or end:
append_range(temp)
temp = []
repeat = False
return output