diff options
| author | Nick Brassel <nick@tzarc.org> | 2022-04-13 18:00:18 +1000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-04-13 18:00:18 +1000 |
| commit | 1f2b1dedccdf21b629c45ece80b4ca32f6653296 (patch) | |
| tree | a4283b928fe11c6662be10067314531f12774152 /lib/python/qmk/painter_qgf.py | |
| parent | 1dbbd2b6b068b9f921ebc0341c890df16a491007 (diff) | |
Quantum Painter (#10174)
* Install dependencies before executing unit tests.
* Split out UTF-8 decoder.
* Fixup python formatting rules.
* Add documentation for QGF/QFF and the RLE format used.
* Add CLI commands for converting images and fonts.
* Add stub rules.mk for QP.
* Add stream type.
* Add base driver and comms interfaces.
* Add support for SPI, SPI+D/C comms drivers.
* Include <qp.h> when enabled.
* Add base support for SPI+D/C+RST panels, as well as concrete implementation of ST7789.
* Add support for GC9A01.
* Add support for ILI9341.
* Add support for ILI9163.
* Add support for SSD1351.
* Implement qp_setpixel, including pixdata buffer management.
* Implement qp_line.
* Implement qp_rect.
* Implement qp_circle.
* Implement qp_ellipse.
* Implement palette interpolation.
* Allow for streams to work with either flash or RAM.
* Image loading.
* Font loading.
* QGF palette loading.
* Progressive decoder of pixel data supporting Raw+RLE, 1-,2-,4-,8-bpp monochrome and palette-based images.
* Image drawing.
* Animations.
* Font rendering.
* Check against 256 colours, dump out the loaded palette if debugging enabled.
* Fix build.
* AVR is not the intended audience.
* `qmk format-c`
* Generation fix.
* First batch of docs.
* More docs and examples.
* Review comments.
* Public API documentation.
Diffstat (limited to 'lib/python/qmk/painter_qgf.py')
| -rw-r--r-- | lib/python/qmk/painter_qgf.py | 408 |
1 files changed, 408 insertions, 0 deletions
diff --git a/lib/python/qmk/painter_qgf.py b/lib/python/qmk/painter_qgf.py new file mode 100644 index 0000000000..71ce1f5a02 --- /dev/null +++ b/lib/python/qmk/painter_qgf.py | |||
| @@ -0,0 +1,408 @@ | |||
| 1 | # Copyright 2021 Nick Brassel (@tzarc) | ||
| 2 | # SPDX-License-Identifier: GPL-2.0-or-later | ||
| 3 | |||
| 4 | # Quantum Graphics File "QGF" Image File Format. | ||
| 5 | # See https://docs.qmk.fm/#/quantum_painter_qgf for more information. | ||
| 6 | |||
| 7 | from colorsys import rgb_to_hsv | ||
| 8 | from types import FunctionType | ||
| 9 | from PIL import Image, ImageFile, ImageChops | ||
| 10 | from PIL._binary import o8, o16le as o16, o32le as o32 | ||
| 11 | import qmk.painter | ||
| 12 | |||
| 13 | |||
| 14 | def o24(i): | ||
| 15 | return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16) | ||
| 16 | |||
| 17 | |||
| 18 | ######################################################################################################################## | ||
| 19 | |||
| 20 | |||
| 21 | class QGFBlockHeader: | ||
| 22 | block_size = 5 | ||
| 23 | |||
| 24 | def write(self, fp): | ||
| 25 | fp.write(b'' # start off with empty bytes... | ||
| 26 | + o8(self.type_id) # block type id | ||
| 27 | + o8((~self.type_id) & 0xFF) # negated block type id | ||
| 28 | + o24(self.length) # blob length | ||
| 29 | ) | ||
| 30 | |||
| 31 | |||
| 32 | ######################################################################################################################## | ||
| 33 | |||
| 34 | |||
| 35 | class QGFGraphicsDescriptor: | ||
| 36 | type_id = 0x00 | ||
| 37 | length = 18 | ||
| 38 | magic = 0x464751 | ||
| 39 | |||
| 40 | def __init__(self): | ||
| 41 | self.header = QGFBlockHeader() | ||
| 42 | self.header.type_id = QGFGraphicsDescriptor.type_id | ||
| 43 | self.header.length = QGFGraphicsDescriptor.length | ||
| 44 | self.version = 1 | ||
| 45 | self.total_file_size = 0 | ||
| 46 | self.image_width = 0 | ||
| 47 | self.image_height = 0 | ||
| 48 | self.frame_count = 0 | ||
| 49 | |||
| 50 | def write(self, fp): | ||
| 51 | self.header.write(fp) | ||
| 52 | fp.write( | ||
| 53 | b'' # start off with empty bytes... | ||
| 54 | + o24(QGFGraphicsDescriptor.magic) # magic | ||
| 55 | + o8(self.version) # version | ||
| 56 | + o32(self.total_file_size) # file size | ||
| 57 | + o32((~self.total_file_size) & 0xFFFFFFFF) # negated file size | ||
| 58 | + o16(self.image_width) # width | ||
| 59 | + o16(self.image_height) # height | ||
| 60 | + o16(self.frame_count) # frame count | ||
| 61 | ) | ||
| 62 | |||
| 63 | |||
| 64 | ######################################################################################################################## | ||
| 65 | |||
| 66 | |||
| 67 | class QGFFrameOffsetDescriptorV1: | ||
| 68 | type_id = 0x01 | ||
| 69 | |||
| 70 | def __init__(self, frame_count): | ||
| 71 | self.header = QGFBlockHeader() | ||
| 72 | self.header.type_id = QGFFrameOffsetDescriptorV1.type_id | ||
| 73 | self.frame_offsets = [0xFFFFFFFF] * frame_count | ||
| 74 | self.frame_count = frame_count | ||
| 75 | |||
| 76 | def write(self, fp): | ||
| 77 | self.header.length = len(self.frame_offsets) * 4 | ||
| 78 | self.header.write(fp) | ||
| 79 | for offset in self.frame_offsets: | ||
| 80 | fp.write(b'' # start off with empty bytes... | ||
| 81 | + o32(offset) # offset | ||
| 82 | ) | ||
| 83 | |||
| 84 | |||
| 85 | ######################################################################################################################## | ||
| 86 | |||
| 87 | |||
| 88 | class QGFFrameDescriptorV1: | ||
| 89 | type_id = 0x02 | ||
| 90 | length = 6 | ||
| 91 | |||
| 92 | def __init__(self): | ||
| 93 | self.header = QGFBlockHeader() | ||
| 94 | self.header.type_id = QGFFrameDescriptorV1.type_id | ||
| 95 | self.header.length = QGFFrameDescriptorV1.length | ||
| 96 | self.format = 0xFF | ||
| 97 | self.flags = 0 | ||
| 98 | self.compression = 0xFF | ||
| 99 | self.transparency_index = 0xFF # TODO: Work out how to retrieve the transparent palette entry from the PIL gif loader | ||
| 100 | self.delay = 1000 # Placeholder until it gets read from the animation | ||
| 101 | |||
| 102 | def write(self, fp): | ||
| 103 | self.header.write(fp) | ||
| 104 | fp.write(b'' # start off with empty bytes... | ||
| 105 | + o8(self.format) # format | ||
| 106 | + o8(self.flags) # flags | ||
| 107 | + o8(self.compression) # compression | ||
| 108 | + o8(self.transparency_index) # transparency index | ||
| 109 | + o16(self.delay) # delay | ||
| 110 | ) | ||
| 111 | |||
| 112 | @property | ||
| 113 | def is_transparent(self): | ||
| 114 | return (self.flags & 0x01) == 0x01 | ||
| 115 | |||
| 116 | @is_transparent.setter | ||
| 117 | def is_transparent(self, val): | ||
| 118 | if val: | ||
| 119 | self.flags |= 0x01 | ||
| 120 | else: | ||
| 121 | self.flags &= ~0x01 | ||
| 122 | |||
| 123 | @property | ||
| 124 | def is_delta(self): | ||
| 125 | return (self.flags & 0x02) == 0x02 | ||
| 126 | |||
| 127 | @is_delta.setter | ||
| 128 | def is_delta(self, val): | ||
| 129 | if val: | ||
| 130 | self.flags |= 0x02 | ||
| 131 | else: | ||
| 132 | self.flags &= ~0x02 | ||
| 133 | |||
| 134 | |||
| 135 | ######################################################################################################################## | ||
| 136 | |||
| 137 | |||
| 138 | class QGFFramePaletteDescriptorV1: | ||
| 139 | type_id = 0x03 | ||
| 140 | |||
| 141 | def __init__(self): | ||
| 142 | self.header = QGFBlockHeader() | ||
| 143 | self.header.type_id = QGFFramePaletteDescriptorV1.type_id | ||
| 144 | self.header.length = 0 | ||
| 145 | self.palette_entries = [(0xFF, 0xFF, 0xFF)] * 4 | ||
| 146 | |||
| 147 | def write(self, fp): | ||
| 148 | self.header.length = len(self.palette_entries) * 3 | ||
| 149 | self.header.write(fp) | ||
| 150 | for entry in self.palette_entries: | ||
| 151 | fp.write(b'' # start off with empty bytes... | ||
| 152 | + o8(entry[0]) # h | ||
| 153 | + o8(entry[1]) # s | ||
| 154 | + o8(entry[2]) # v | ||
| 155 | ) | ||
| 156 | |||
| 157 | |||
| 158 | ######################################################################################################################## | ||
| 159 | |||
| 160 | |||
| 161 | class QGFFrameDeltaDescriptorV1: | ||
| 162 | type_id = 0x04 | ||
| 163 | length = 8 | ||
| 164 | |||
| 165 | def __init__(self): | ||
| 166 | self.header = QGFBlockHeader() | ||
| 167 | self.header.type_id = QGFFrameDeltaDescriptorV1.type_id | ||
| 168 | self.header.length = QGFFrameDeltaDescriptorV1.length | ||
| 169 | self.left = 0 | ||
| 170 | self.top = 0 | ||
| 171 | self.right = 0 | ||
| 172 | self.bottom = 0 | ||
| 173 | |||
| 174 | def write(self, fp): | ||
| 175 | self.header.write(fp) | ||
| 176 | fp.write(b'' # start off with empty bytes... | ||
| 177 | + o16(self.left) # left | ||
| 178 | + o16(self.top) # top | ||
| 179 | + o16(self.right) # right | ||
| 180 | + o16(self.bottom) # bottom | ||
| 181 | ) | ||
| 182 | |||
| 183 | |||
| 184 | ######################################################################################################################## | ||
| 185 | |||
| 186 | |||
| 187 | class QGFFrameDataDescriptorV1: | ||
| 188 | type_id = 0x05 | ||
| 189 | |||
| 190 | def __init__(self): | ||
| 191 | self.header = QGFBlockHeader() | ||
| 192 | self.header.type_id = QGFFrameDataDescriptorV1.type_id | ||
| 193 | self.data = [] | ||
| 194 | |||
| 195 | def write(self, fp): | ||
| 196 | self.header.length = len(self.data) | ||
| 197 | self.header.write(fp) | ||
| 198 | fp.write(bytes(self.data)) | ||
| 199 | |||
| 200 | |||
| 201 | ######################################################################################################################## | ||
| 202 | |||
| 203 | |||
| 204 | class QGFImageFile(ImageFile.ImageFile): | ||
| 205 | |||
| 206 | format = "QGF" | ||
| 207 | format_description = "Quantum Graphics File Format" | ||
| 208 | |||
| 209 | def _open(self): | ||
| 210 | raise NotImplementedError("Reading QGF files is not supported") | ||
| 211 | |||
| 212 | |||
| 213 | ######################################################################################################################## | ||
| 214 | |||
| 215 | |||
| 216 | def _accept(prefix): | ||
| 217 | """Helper method used by PIL to work out if it can parse an input file. | ||
| 218 | |||
| 219 | Currently unimplemented. | ||
| 220 | """ | ||
| 221 | return False | ||
| 222 | |||
| 223 | |||
| 224 | def _save(im, fp, filename): | ||
| 225 | """Helper method used by PIL to write to an output file. | ||
| 226 | """ | ||
| 227 | # Work out from the parameters if we need to do anything special | ||
| 228 | encoderinfo = im.encoderinfo.copy() | ||
| 229 | append_images = list(encoderinfo.get("append_images", [])) | ||
| 230 | verbose = encoderinfo.get("verbose", False) | ||
| 231 | use_deltas = encoderinfo.get("use_deltas", True) | ||
| 232 | use_rle = encoderinfo.get("use_rle", True) | ||
| 233 | |||
| 234 | # Helper for inline verbose prints | ||
| 235 | def vprint(s): | ||
| 236 | if verbose: | ||
| 237 | print(s) | ||
| 238 | |||
| 239 | # Helper to iterate through all frames in the input image | ||
| 240 | def _for_all_frames(x: FunctionType): | ||
| 241 | frame_num = 0 | ||
| 242 | last_frame = None | ||
| 243 | for frame in [im] + append_images: | ||
| 244 | # Get number of of frames in this image | ||
| 245 | nfr = getattr(frame, "n_frames", 1) | ||
| 246 | for idx in range(nfr): | ||
| 247 | frame.seek(idx) | ||
| 248 | frame.load() | ||
| 249 | copy = frame.copy().convert("RGB") | ||
| 250 | x(frame_num, copy, last_frame) | ||
| 251 | last_frame = copy | ||
| 252 | frame_num += 1 | ||
| 253 | |||
| 254 | # Collect all the frame sizes | ||
| 255 | frame_sizes = [] | ||
| 256 | _for_all_frames(lambda idx, frame, last_frame: frame_sizes.append(frame.size)) | ||
| 257 | |||
| 258 | # Make sure all frames are the same size | ||
| 259 | if len(list(set(frame_sizes))) != 1: | ||
| 260 | raise ValueError("Mismatching sizes on frames") | ||
| 261 | |||
| 262 | # Write out the initial graphics descriptor (and write a dummy value), so that we can come back and fill in the | ||
| 263 | # correct values once we've written all the frames to the output | ||
| 264 | graphics_descriptor_location = fp.tell() | ||
| 265 | graphics_descriptor = QGFGraphicsDescriptor() | ||
| 266 | graphics_descriptor.frame_count = len(frame_sizes) | ||
| 267 | graphics_descriptor.image_width = frame_sizes[0][0] | ||
| 268 | graphics_descriptor.image_height = frame_sizes[0][1] | ||
| 269 | vprint(f'{"Graphics descriptor block":26s} {fp.tell():5d}d / {fp.tell():04X}h') | ||
| 270 | graphics_descriptor.write(fp) | ||
| 271 | |||
| 272 | # Work out the frame offset descriptor location (and write a dummy value), so that we can come back and fill in the | ||
| 273 | # correct offsets once we've written all the frames to the output | ||
| 274 | frame_offset_location = fp.tell() | ||
| 275 | frame_offsets = QGFFrameOffsetDescriptorV1(graphics_descriptor.frame_count) | ||
| 276 | vprint(f'{"Frame offsets block":26s} {fp.tell():5d}d / {fp.tell():04X}h') | ||
| 277 | frame_offsets.write(fp) | ||
| 278 | |||
| 279 | # Helper function to save each frame to the output file | ||
| 280 | def _write_frame(idx, frame, last_frame): | ||
| 281 | # If we replace the frame we're going to output with a delta, we can override it here | ||
| 282 | this_frame = frame | ||
| 283 | location = (0, 0) | ||
| 284 | size = frame.size | ||
| 285 | |||
| 286 | # Work out the format we're going to use | ||
| 287 | format = encoderinfo["qmk_format"] | ||
| 288 | |||
| 289 | # Convert the original frame so we can do comparisons | ||
| 290 | converted = qmk.painter.convert_requested_format(this_frame, format) | ||
| 291 | graphic_data = qmk.painter.convert_image_bytes(converted, format) | ||
| 292 | |||
| 293 | # Convert the raw data to RLE-encoded if requested | ||
| 294 | raw_data = graphic_data[1] | ||
| 295 | if use_rle: | ||
| 296 | rle_data = qmk.painter.compress_bytes_qmk_rle(graphic_data[1]) | ||
| 297 | use_raw_this_frame = not use_rle or len(raw_data) <= len(rle_data) | ||
| 298 | image_data = raw_data if use_raw_this_frame else rle_data | ||
| 299 | |||
| 300 | # Work out if a delta frame is smaller than injecting it directly | ||
| 301 | use_delta_this_frame = False | ||
| 302 | if use_deltas and last_frame is not None: | ||
| 303 | # If we want to use deltas, then find the difference | ||
| 304 | diff = ImageChops.difference(frame, last_frame) | ||
| 305 | |||
| 306 | # Get the bounding box of those differences | ||
| 307 | bbox = diff.getbbox() | ||
| 308 | |||
| 309 | # If we have a valid bounding box... | ||
| 310 | if bbox: | ||
| 311 | # ...create the delta frame by cropping the original. | ||
| 312 | delta_frame = frame.crop(bbox) | ||
| 313 | delta_location = (bbox[0], bbox[1]) | ||
| 314 | delta_size = (bbox[2] - bbox[0], bbox[3] - bbox[1]) | ||
| 315 | |||
| 316 | # Convert the delta frame to the requested format | ||
| 317 | delta_converted = qmk.painter.convert_requested_format(delta_frame, format) | ||
| 318 | delta_graphic_data = qmk.painter.convert_image_bytes(delta_converted, format) | ||
| 319 | |||
| 320 | # Work out how large the delta frame is going to be with compression etc. | ||
| 321 | delta_raw_data = delta_graphic_data[1] | ||
| 322 | if use_rle: | ||
| 323 | delta_rle_data = qmk.painter.compress_bytes_qmk_rle(delta_graphic_data[1]) | ||
| 324 | delta_use_raw_this_frame = not use_rle or len(delta_raw_data) <= len(delta_rle_data) | ||
| 325 | delta_image_data = delta_raw_data if delta_use_raw_this_frame else delta_rle_data | ||
| 326 | |||
| 327 | # If the size of the delta frame (plus delta descriptor) is smaller than the original, use that instead | ||
| 328 | # This ensures that if a non-delta is overall smaller in size, we use that in preference due to flash | ||
| 329 | # sizing constraints. | ||
| 330 | if (len(delta_image_data) + QGFFrameDeltaDescriptorV1.length) < len(image_data): | ||
| 331 | # Copy across all the delta equivalents so that the rest of the processing acts on those | ||
| 332 | this_frame = delta_frame | ||
| 333 | location = delta_location | ||
| 334 | size = delta_size | ||
| 335 | converted = delta_converted | ||
| 336 | graphic_data = delta_graphic_data | ||
| 337 | raw_data = delta_raw_data | ||
| 338 | rle_data = delta_rle_data | ||
| 339 | use_raw_this_frame = delta_use_raw_this_frame | ||
| 340 | image_data = delta_image_data | ||
| 341 | use_delta_this_frame = True | ||
| 342 | |||
| 343 | # Write out the frame descriptor | ||
| 344 | frame_offsets.frame_offsets[idx] = fp.tell() | ||
| 345 | vprint(f'{f"Frame {idx:3d} base":26s} {fp.tell():5d}d / {fp.tell():04X}h') | ||
| 346 | frame_descriptor = QGFFrameDescriptorV1() | ||
| 347 | frame_descriptor.is_delta = use_delta_this_frame | ||
| 348 | frame_descriptor.is_transparent = False | ||
| 349 | frame_descriptor.format = format['image_format_byte'] | ||
| 350 | frame_descriptor.compression = 0x00 if use_raw_this_frame else 0x01 # See qp.h, painter_compression_t | ||
| 351 | frame_descriptor.delay = frame.info['duration'] if 'duration' in frame.info else 1000 # If we're not an animation, just pretend we're delaying for 1000ms | ||
| 352 | frame_descriptor.write(fp) | ||
| 353 | |||
| 354 | # Write out the palette if required | ||
| 355 | if format['has_palette']: | ||
| 356 | palette = graphic_data[0] | ||
| 357 | palette_descriptor = QGFFramePaletteDescriptorV1() | ||
| 358 | |||
| 359 | # Helper to convert from RGB888 to the QMK "dialect" of HSV888 | ||
| 360 | def rgb888_to_qmk_hsv888(e): | ||
| 361 | hsv = rgb_to_hsv(e[0] / 255.0, e[1] / 255.0, e[2] / 255.0) | ||
| 362 | return (int(hsv[0] * 255.0), int(hsv[1] * 255.0), int(hsv[2] * 255.0)) | ||
| 363 | |||
| 364 | # Convert all palette entries to HSV888 and write to the output | ||
| 365 | palette_descriptor.palette_entries = list(map(rgb888_to_qmk_hsv888, palette)) | ||
| 366 | vprint(f'{f"Frame {idx:3d} palette":26s} {fp.tell():5d}d / {fp.tell():04X}h') | ||
| 367 | palette_descriptor.write(fp) | ||
| 368 | |||
| 369 | # Write out the delta info if required | ||
| 370 | if use_delta_this_frame: | ||
| 371 | # Set up the rendering location of where the delta frame should be situated | ||
| 372 | delta_descriptor = QGFFrameDeltaDescriptorV1() | ||
| 373 | delta_descriptor.left = location[0] | ||
| 374 | delta_descriptor.top = location[1] | ||
| 375 | delta_descriptor.right = location[0] + size[0] | ||
| 376 | delta_descriptor.bottom = location[1] + size[1] | ||
| 377 | |||
| 378 | # Write the delta frame to the output | ||
| 379 | vprint(f'{f"Frame {idx:3d} delta":26s} {fp.tell():5d}d / {fp.tell():04X}h') | ||
| 380 | delta_descriptor.write(fp) | ||
| 381 | |||
| 382 | # Write out the data for this frame to the output | ||
| 383 | data_descriptor = QGFFrameDataDescriptorV1() | ||
| 384 | data_descriptor.data = image_data | ||
| 385 | vprint(f'{f"Frame {idx:3d} data":26s} {fp.tell():5d}d / {fp.tell():04X}h') | ||
| 386 | data_descriptor.write(fp) | ||
| 387 | |||
| 388 | # Iterate over each if the input frames, writing it to the output in the process | ||
| 389 | _for_all_frames(_write_frame) | ||
| 390 | |||
| 391 | # Go back and update the graphics descriptor now that we can determine the final file size | ||
| 392 | graphics_descriptor.total_file_size = fp.tell() | ||
| 393 | fp.seek(graphics_descriptor_location, 0) | ||
| 394 | graphics_descriptor.write(fp) | ||
| 395 | |||
| 396 | # Go back and update the frame offsets now that they're written to the file | ||
| 397 | fp.seek(frame_offset_location, 0) | ||
| 398 | frame_offsets.write(fp) | ||
| 399 | |||
| 400 | |||
| 401 | ######################################################################################################################## | ||
| 402 | |||
| 403 | # Register with PIL so that it knows about the QGF format | ||
| 404 | Image.register_open(QGFImageFile.format, QGFImageFile, _accept) | ||
| 405 | Image.register_save(QGFImageFile.format, _save) | ||
| 406 | Image.register_save_all(QGFImageFile.format, _save) | ||
| 407 | Image.register_extension(QGFImageFile.format, f".{QGFImageFile.format.lower()}") | ||
| 408 | Image.register_mime(QGFImageFile.format, f"image/{QGFImageFile.format.lower()}") | ||
