qmk_firmware

QMK firmware for my keyboards (Corne, Sweep Ferris) and trackball (Ploopy Adept)
Log | Files | Refs | Submodules | LICENSE

painter.py (14190B)


      1 """Functions that help us work with Quantum Painter's file formats.
      2 """
      3 import datetime
      4 import math
      5 import re
      6 from pathlib import Path
      7 from string import Template
      8 from PIL import Image, ImageOps
      9 
     10 # The list of valid formats Quantum Painter supports
     11 valid_formats = {
     12     'rgb888': {
     13         'image_format': 'IMAGE_FORMAT_RGB888',
     14         'bpp': 24,
     15         'has_palette': False,
     16         'num_colors': 16777216,
     17         'image_format_byte': 0x09,  # see qp_internal_formats.h
     18     },
     19     'rgb565': {
     20         'image_format': 'IMAGE_FORMAT_RGB565',
     21         'bpp': 16,
     22         'has_palette': False,
     23         'num_colors': 65536,
     24         'image_format_byte': 0x08,  # see qp_internal_formats.h
     25     },
     26     'pal256': {
     27         'image_format': 'IMAGE_FORMAT_PALETTE',
     28         'bpp': 8,
     29         'has_palette': True,
     30         'num_colors': 256,
     31         'image_format_byte': 0x07,  # see qp_internal_formats.h
     32     },
     33     'pal16': {
     34         'image_format': 'IMAGE_FORMAT_PALETTE',
     35         'bpp': 4,
     36         'has_palette': True,
     37         'num_colors': 16,
     38         'image_format_byte': 0x06,  # see qp_internal_formats.h
     39     },
     40     'pal4': {
     41         'image_format': 'IMAGE_FORMAT_PALETTE',
     42         'bpp': 2,
     43         'has_palette': True,
     44         'num_colors': 4,
     45         'image_format_byte': 0x05,  # see qp_internal_formats.h
     46     },
     47     'pal2': {
     48         'image_format': 'IMAGE_FORMAT_PALETTE',
     49         'bpp': 1,
     50         'has_palette': True,
     51         'num_colors': 2,
     52         'image_format_byte': 0x04,  # see qp_internal_formats.h
     53     },
     54     'mono256': {
     55         'image_format': 'IMAGE_FORMAT_GRAYSCALE',
     56         'bpp': 8,
     57         'has_palette': False,
     58         'num_colors': 256,
     59         'image_format_byte': 0x03,  # see qp_internal_formats.h
     60     },
     61     'mono16': {
     62         'image_format': 'IMAGE_FORMAT_GRAYSCALE',
     63         'bpp': 4,
     64         'has_palette': False,
     65         'num_colors': 16,
     66         'image_format_byte': 0x02,  # see qp_internal_formats.h
     67     },
     68     'mono4': {
     69         'image_format': 'IMAGE_FORMAT_GRAYSCALE',
     70         'bpp': 2,
     71         'has_palette': False,
     72         'num_colors': 4,
     73         'image_format_byte': 0x01,  # see qp_internal_formats.h
     74     },
     75     'mono2': {
     76         'image_format': 'IMAGE_FORMAT_GRAYSCALE',
     77         'bpp': 1,
     78         'has_palette': False,
     79         'num_colors': 2,
     80         'image_format_byte': 0x00,  # see qp_internal_formats.h
     81     }
     82 }
     83 
     84 
     85 def _render_text(values):
     86     # FIXME: May need more chars with GIFs containing lots of frames (or longer durations)
     87     return "|".join([f"{i:4d}" for i in values])
     88 
     89 
     90 def _render_numeration(metadata):
     91     return _render_text(range(len(metadata)))
     92 
     93 
     94 def _render_values(metadata, key):
     95     return _render_text([i[key] for i in metadata])
     96 
     97 
     98 def _render_image_metadata(metadata):
     99     size = metadata.pop(0)
    100 
    101     lines = [
    102         "// Image's metadata",
    103         "// ----------------",
    104         f"// Width: {size['width']}",
    105         f"// Height: {size['height']}",
    106     ]
    107 
    108     if len(metadata) == 1:
    109         lines.append("// Single frame")
    110 
    111     else:
    112         lines.extend([
    113             f"//        Frame: {_render_numeration(metadata)}",
    114             f"// Duration(ms): {_render_values(metadata, 'delay')}",
    115             f"//  Compression: {_render_values(metadata, 'compression')} >> See qp.h, painter_compression_t",
    116             f"//        Delta: {_render_values(metadata, 'delta')}",
    117         ])
    118 
    119         deltas = []
    120         for i, v in enumerate(metadata):
    121             # Not a delta frame, go to next one
    122             if not v["delta"]:
    123                 continue
    124 
    125             # Unpack rect's coords
    126             l, t, r, b = v["delta_rect"]
    127 
    128             delta_px = (r - l) * (b - t)
    129             px = size["width"] * size["height"]
    130 
    131             # FIXME: May need need more chars here too
    132             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}%)")
    133 
    134         if deltas:
    135             lines.append("// Areas on delta frames")
    136             lines.extend(deltas)
    137 
    138     return "\n".join(lines)
    139 
    140 
    141 def command_args_str(cli, command_name):
    142     """Given a command name, introspect milc to get the arguments passed in."""
    143 
    144     args = {}
    145     max_length = 0
    146     for arg_name, was_passed in cli.args_passed[command_name].items():
    147         max_length = max(max_length, len(arg_name))
    148 
    149         val = getattr(cli.args, arg_name.replace("-", "_"))
    150 
    151         # do not leak full paths, keep just file name
    152         if isinstance(val, Path):
    153             val = val.name
    154 
    155         args[arg_name] = val
    156 
    157     return "\n".join(f"//    {arg_name.ljust(max_length)} | {val}" for arg_name, val in args.items())
    158 
    159 
    160 def generate_subs(cli, out_bytes, *, font_metadata=None, image_metadata=None, command_name):
    161     if font_metadata is not None and image_metadata is not None:
    162         raise ValueError("Cant generate subs for font and image at the same time")
    163 
    164     args = command_args_str(cli, command_name)
    165 
    166     subs = {
    167         "year": datetime.date.today().strftime("%Y"),
    168         "input_file": cli.args.input.name,
    169         "sane_name": re.sub(r"[^a-zA-Z0-9]", "_", cli.args.input.stem),
    170         "byte_count": len(out_bytes),
    171         "bytes_lines": render_bytes(out_bytes),
    172         "format": cli.args.format,
    173         "generator_command": command_name.replace("_", "-"),
    174         "command_args": args,
    175     }
    176 
    177     if font_metadata is not None:
    178         subs.update({
    179             "generated_type": "font",
    180             "var_prefix": "font",
    181             # not using triple quotes to avoid extra indentation/weird formatted code
    182             "metadata": "\n".join([
    183                 "// Font's metadata",
    184                 "// ---------------",
    185                 f"// Glyphs: {', '.join([i for i in font_metadata['glyphs']])}",
    186             ]),
    187         })
    188 
    189     elif image_metadata is not None:
    190         subs.update({
    191             "generated_type": "image",
    192             "var_prefix": "gfx",
    193             "generator_command": command_name,
    194             "metadata": _render_image_metadata(image_metadata),
    195         })
    196 
    197     else:
    198         raise ValueError("Pass metadata for either an image or a font")
    199 
    200     subs.update({"license": render_license(subs)})
    201 
    202     return subs
    203 
    204 
    205 license_template = """\
    206 // Copyright ${year} QMK -- generated source code only, ${generated_type} retains original copyright
    207 // SPDX-License-Identifier: GPL-2.0-or-later
    208 
    209 // This file was auto-generated by `${generator_command}` with arguments:
    210 ${command_args}
    211 """
    212 
    213 
    214 def render_license(subs):
    215     license_txt = Template(license_template)
    216     return license_txt.substitute(subs)
    217 
    218 
    219 header_file_template = """\
    220 ${license}
    221 #pragma once
    222 
    223 #include <qp.h>
    224 
    225 extern const uint32_t ${var_prefix}_${sane_name}_length;
    226 extern const uint8_t  ${var_prefix}_${sane_name}[${byte_count}];
    227 """
    228 
    229 
    230 def render_header(subs):
    231     header_txt = Template(header_file_template)
    232     return header_txt.substitute(subs)
    233 
    234 
    235 source_file_template = """\
    236 ${license}
    237 ${metadata}
    238 
    239 #include <qp.h>
    240 
    241 const uint32_t ${var_prefix}_${sane_name}_length = ${byte_count};
    242 
    243 // clang-format off
    244 const uint8_t ${var_prefix}_${sane_name}[${byte_count}] = {
    245 ${bytes_lines}
    246 };
    247 // clang-format on
    248 """
    249 
    250 
    251 def render_source(subs):
    252     source_txt = Template(source_file_template)
    253     return source_txt.substitute(subs)
    254 
    255 
    256 def render_bytes(bytes, newline_after=16):
    257     lines = ''
    258     for n in range(len(bytes)):
    259         if n % newline_after == 0 and n > 0 and n != len(bytes):
    260             lines = lines + "\n   "
    261         elif n == 0:
    262             lines = lines + "   "
    263         lines = lines + " 0x{0:02X},".format(bytes[n])
    264     return lines.rstrip()
    265 
    266 
    267 def clean_output(str):
    268     str = re.sub(r'\r', '', str)
    269     str = re.sub(r'[\n]{3,}', r'\n\n', str)
    270     return str
    271 
    272 
    273 def rescale_byte(val, maxval):
    274     """Rescales a byte value to the supplied range, i.e. [0,255] -> [0,maxval].
    275     """
    276     return int(round(val * maxval / 255.0))
    277 
    278 
    279 def convert_requested_format(im, format):
    280     """Convert an image to the requested format.
    281     """
    282 
    283     # Work out the requested format
    284     ncolors = format["num_colors"]
    285     image_format = format["image_format"]
    286 
    287     # -- Check if ncolors is valid
    288     # Formats accepting several options
    289     if image_format in ['IMAGE_FORMAT_GRAYSCALE', 'IMAGE_FORMAT_PALETTE']:
    290         valid = [2, 4, 8, 16, 256]
    291 
    292     # Formats expecting a particular number
    293     else:
    294         # Read number from specs dict, instead of hardcoding
    295         for _, fmt in valid_formats.items():
    296             if fmt["image_format"] == image_format:
    297                 # has to be an iterable, to use `in`
    298                 valid = [fmt["num_colors"]]
    299                 break
    300 
    301     if ncolors not in valid:
    302         raise ValueError(f"Number of colors must be: {', '.join(valid)}.")
    303 
    304     # Work out where we're getting the bytes from
    305     if image_format == 'IMAGE_FORMAT_GRAYSCALE':
    306         # If mono, convert input to grayscale, then to RGB, then grab the raw bytes corresponding to the intensity of the red channel
    307         im = ImageOps.grayscale(im)
    308         im = im.convert("RGB")
    309     elif image_format == 'IMAGE_FORMAT_PALETTE':
    310         # If color, convert input to RGB, palettize based on the supplied number of colors, then get the raw palette bytes
    311         im = im.convert("RGB")
    312         im = im.convert("P", palette=Image.ADAPTIVE, colors=ncolors)
    313     elif image_format in ['IMAGE_FORMAT_RGB565', 'IMAGE_FORMAT_RGB888']:
    314         # Convert input to RGB
    315         im = im.convert("RGB")
    316 
    317     return im
    318 
    319 
    320 def rgb_to565(r, g, b):
    321     msb = ((r >> 3 & 0x1F) << 3) + (g >> 5 & 0x07)
    322     lsb = ((g >> 2 & 0x07) << 5) + (b >> 3 & 0x1F)
    323     return [msb, lsb]
    324 
    325 
    326 def convert_image_bytes(im, format):
    327     """Convert the supplied image to the equivalent bytes required by the QMK firmware.
    328     """
    329 
    330     # Work out the requested format
    331     ncolors = format["num_colors"]
    332     image_format = format["image_format"]
    333     shifter = int(math.log2(ncolors))
    334     pixels_per_byte = int(8 / math.log2(ncolors))
    335     bytes_per_pixel = math.ceil(math.log2(ncolors) / 8)
    336     (width, height) = im.size
    337     if (pixels_per_byte != 0):
    338         expected_byte_count = ((width * height) + (pixels_per_byte - 1)) // pixels_per_byte
    339     else:
    340         expected_byte_count = width * height * bytes_per_pixel
    341 
    342     if image_format == 'IMAGE_FORMAT_GRAYSCALE':
    343         # Take the red channel
    344         image_bytes = im.tobytes("raw", "R")
    345         image_bytes_len = len(image_bytes)
    346 
    347         # No palette
    348         palette = None
    349 
    350         bytearray = []
    351         for x in range(expected_byte_count):
    352             byte = 0
    353             for n in range(pixels_per_byte):
    354                 byte_offset = x * pixels_per_byte + n
    355                 if byte_offset < image_bytes_len:
    356                     # If mono, each input byte is a grayscale [0,255] pixel -- rescale to the range we want then pack together
    357                     byte = byte | (rescale_byte(image_bytes[byte_offset], ncolors - 1) << int(n * shifter))
    358             bytearray.append(byte)
    359 
    360     elif image_format == 'IMAGE_FORMAT_PALETTE':
    361         # Convert each pixel to the palette bytes
    362         image_bytes = im.tobytes("raw", "P")
    363         image_bytes_len = len(image_bytes)
    364 
    365         # Export the palette
    366         palette = []
    367         pal = im.getpalette()
    368         for n in range(0, ncolors * 3, 3):
    369             palette.append((pal[n + 0], pal[n + 1], pal[n + 2]))
    370 
    371         bytearray = []
    372         for x in range(expected_byte_count):
    373             byte = 0
    374             for n in range(pixels_per_byte):
    375                 byte_offset = x * pixels_per_byte + n
    376                 if byte_offset < image_bytes_len:
    377                     # If color, each input byte is the index into the color palette -- pack them together
    378                     byte = byte | ((image_bytes[byte_offset] & (ncolors - 1)) << int(n * shifter))
    379             bytearray.append(byte)
    380 
    381     if image_format == 'IMAGE_FORMAT_RGB565':
    382         # Take the red, green, and blue channels
    383         red = im.tobytes("raw", "R")
    384         green = im.tobytes("raw", "G")
    385         blue = im.tobytes("raw", "B")
    386 
    387         # No palette
    388         palette = None
    389 
    390         bytearray = [byte for r, g, b in zip(red, green, blue) for byte in rgb_to565(r, g, b)]
    391 
    392     if image_format == 'IMAGE_FORMAT_RGB888':
    393         # Take the red, green, and blue channels
    394         red = im.tobytes("raw", "R")
    395         green = im.tobytes("raw", "G")
    396         blue = im.tobytes("raw", "B")
    397 
    398         # No palette
    399         palette = None
    400 
    401         bytearray = [byte for r, g, b in zip(red, green, blue) for byte in (r, g, b)]
    402 
    403     if len(bytearray) != expected_byte_count:
    404         raise Exception(f"Wrong byte count, was {len(bytearray)}, expected {expected_byte_count}")
    405 
    406     return (palette, bytearray)
    407 
    408 
    409 def compress_bytes_qmk_rle(bytearray):
    410     debug_dump = False
    411     output = []
    412     temp = []
    413     repeat = False
    414 
    415     def append_byte(c):
    416         if debug_dump:
    417             print('Appending byte:', '0x{0:02X}'.format(int(c)), '=', c)
    418         output.append(c)
    419 
    420     def append_range(r):
    421         append_byte(127 + len(r))
    422         if debug_dump:
    423             print('Appending {0} byte(s):'.format(len(r)), '[', ', '.join(['{0:02X}'.format(e) for e in r]), ']')
    424         output.extend(r)
    425 
    426     for n in range(0, len(bytearray) + 1):
    427         end = True if n == len(bytearray) else False
    428         if not end:
    429             c = bytearray[n]
    430             temp.append(c)
    431             if len(temp) <= 1:
    432                 continue
    433 
    434         if debug_dump:
    435             print('Temp buffer state {0:3d} bytes:'.format(len(temp)), '[', ', '.join(['{0:02X}'.format(e) for e in temp]), ']')
    436 
    437         if repeat:
    438             if temp[-1] != temp[-2]:
    439                 repeat = False
    440             if not repeat or len(temp) == 128 or end:
    441                 append_byte(len(temp) if end else len(temp) - 1)
    442                 append_byte(temp[0])
    443                 temp = [temp[-1]]
    444                 repeat = False
    445         else:
    446             if len(temp) >= 2 and temp[-1] == temp[-2]:
    447                 repeat = True
    448                 if len(temp) > 2:
    449                     append_range(temp[0:(len(temp) - 2)])
    450                     temp = [temp[-1], temp[-1]]
    451                 continue
    452             if len(temp) == 128 or end:
    453                 append_range(temp)
    454                 temp = []
    455                 repeat = False
    456     return output