summaryrefslogtreecommitdiff
path: root/lib/python/qmk/painter_qgf.py
diff options
context:
space:
mode:
authorNick Brassel <nick@tzarc.org>2022-04-13 18:00:18 +1000
committerGitHub <noreply@github.com>2022-04-13 18:00:18 +1000
commit1f2b1dedccdf21b629c45ece80b4ca32f6653296 (patch)
treea4283b928fe11c6662be10067314531f12774152 /lib/python/qmk/painter_qgf.py
parent1dbbd2b6b068b9f921ebc0341c890df16a491007 (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.py408
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
7from colorsys import rgb_to_hsv
8from types import FunctionType
9from PIL import Image, ImageFile, ImageChops
10from PIL._binary import o8, o16le as o16, o32le as o32
11import qmk.painter
12
13
14def o24(i):
15 return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16)
16
17
18########################################################################################################################
19
20
21class 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
35class 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
67class 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
88class 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
138class 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
161class 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
187class 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
204class 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
216def _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
224def _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
404Image.register_open(QGFImageFile.format, QGFImageFile, _accept)
405Image.register_save(QGFImageFile.format, _save)
406Image.register_save_all(QGFImageFile.format, _save)
407Image.register_extension(QGFImageFile.format, f".{QGFImageFile.format.lower()}")
408Image.register_mime(QGFImageFile.format, f"image/{QGFImageFile.format.lower()}")