keyboard stuff
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