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