at master 14 kB view raw
1"""Functions that help us work with Quantum Painter's file formats. 2""" 3import datetime 4import math 5import re 6from pathlib import Path 7from string import Template 8from PIL import Image, ImageOps 9 10# The list of valid formats Quantum Painter supports 11valid_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 85def _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 90def _render_numeration(metadata): 91 return _render_text(range(len(metadata))) 92 93 94def _render_values(metadata, key): 95 return _render_text([i[key] for i in metadata]) 96 97 98def _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 141def 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 160def 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 205license_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 214def render_license(subs): 215 license_txt = Template(license_template) 216 return license_txt.substitute(subs) 217 218 219header_file_template = """\ 220${license} 221#pragma once 222 223#include <qp.h> 224 225extern const uint32_t ${var_prefix}_${sane_name}_length; 226extern const uint8_t ${var_prefix}_${sane_name}[${byte_count}]; 227""" 228 229 230def render_header(subs): 231 header_txt = Template(header_file_template) 232 return header_txt.substitute(subs) 233 234 235source_file_template = """\ 236${license} 237${metadata} 238 239#include <qp.h> 240 241const uint32_t ${var_prefix}_${sane_name}_length = ${byte_count}; 242 243// clang-format off 244const uint8_t ${var_prefix}_${sane_name}[${byte_count}] = { 245${bytes_lines} 246}; 247// clang-format on 248""" 249 250 251def render_source(subs): 252 source_txt = Template(source_file_template) 253 return source_txt.substitute(subs) 254 255 256def 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 267def 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 273def 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 279def 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 320def 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 326def 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 409def 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