keyboard stuff
1# Copyright 2021 Nick Brassel (@tzarc)
2# Copyright 2023 Pablo Martinez (@elpekenin) <elpekenin@elpekenin.dev>
3# SPDX-License-Identifier: GPL-2.0-or-later
4
5# Quantum Graphics File "QGF" Image File Format.
6# See https://docs.qmk.fm/#/quantum_painter_qgf for more information.
7
8import functools
9from colorsys import rgb_to_hsv
10from types import FunctionType
11from PIL import Image, ImageFile, ImageChops
12from PIL._binary import o8, o16le as o16, o32le as o32
13import qmk.painter
14
15
16def o24(i):
17 return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16)
18
19
20# Helper to convert from RGB888 to the QMK "dialect" of HSV888
21def rgb888_to_qmk_hsv888(e):
22 hsv = rgb_to_hsv(e[0] / 255.0, e[1] / 255.0, e[2] / 255.0)
23 return (int(hsv[0] * 255.0), int(hsv[1] * 255.0), int(hsv[2] * 255.0))
24
25
26########################################################################################################################
27
28
29class QGFBlockHeader:
30 block_size = 5
31
32 def write(self, fp):
33 fp.write(b'' # start off with empty bytes...
34 + o8(self.type_id) # block type id
35 + o8((~self.type_id) & 0xFF) # negated block type id
36 + o24(self.length) # blob length
37 )
38
39
40########################################################################################################################
41
42
43class QGFGraphicsDescriptor:
44 type_id = 0x00
45 length = 18
46 magic = 0x464751
47
48 def __init__(self):
49 self.header = QGFBlockHeader()
50 self.header.type_id = QGFGraphicsDescriptor.type_id
51 self.header.length = QGFGraphicsDescriptor.length
52 self.version = 1
53 self.total_file_size = 0
54 self.image_width = 0
55 self.image_height = 0
56 self.frame_count = 0
57
58 def write(self, fp):
59 self.header.write(fp)
60 fp.write(
61 b'' # start off with empty bytes...
62 + o24(QGFGraphicsDescriptor.magic) # magic
63 + o8(self.version) # version
64 + o32(self.total_file_size) # file size
65 + o32((~self.total_file_size) & 0xFFFFFFFF) # negated file size
66 + o16(self.image_width) # width
67 + o16(self.image_height) # height
68 + o16(self.frame_count) # frame count
69 )
70
71 @property
72 def image_size(self):
73 return self.image_width, self.image_height
74
75 @image_size.setter
76 def image_size(self, size):
77 self.image_width, self.image_height = size
78
79
80########################################################################################################################
81
82
83class QGFFrameOffsetDescriptorV1:
84 type_id = 0x01
85
86 def __init__(self, frame_count):
87 self.header = QGFBlockHeader()
88 self.header.type_id = QGFFrameOffsetDescriptorV1.type_id
89 self.frame_offsets = [0xFFFFFFFF] * frame_count
90 self.frame_count = frame_count
91
92 def write(self, fp):
93 self.header.length = len(self.frame_offsets) * 4
94 self.header.write(fp)
95 for offset in self.frame_offsets:
96 fp.write(b'' # start off with empty bytes...
97 + o32(offset) # offset
98 )
99
100
101########################################################################################################################
102
103
104class QGFFrameDescriptorV1:
105 type_id = 0x02
106 length = 6
107
108 def __init__(self):
109 self.header = QGFBlockHeader()
110 self.header.type_id = QGFFrameDescriptorV1.type_id
111 self.header.length = QGFFrameDescriptorV1.length
112 self.format = 0xFF
113 self.flags = 0
114 self.compression = 0xFF
115 self.transparency_index = 0xFF # TODO: Work out how to retrieve the transparent palette entry from the PIL gif loader
116 self.delay = 1000 # Placeholder until it gets read from the animation
117
118 def write(self, fp):
119 self.header.write(fp)
120 fp.write(b'' # start off with empty bytes...
121 + o8(self.format) # format
122 + o8(self.flags) # flags
123 + o8(self.compression) # compression
124 + o8(self.transparency_index) # transparency index
125 + o16(self.delay) # delay
126 )
127
128 @property
129 def is_transparent(self):
130 return (self.flags & 0x01) == 0x01
131
132 @is_transparent.setter
133 def is_transparent(self, val):
134 if val:
135 self.flags |= 0x01
136 else:
137 self.flags &= ~0x01
138
139 @property
140 def is_delta(self):
141 return (self.flags & 0x02) == 0x02
142
143 @is_delta.setter
144 def is_delta(self, val):
145 if val:
146 self.flags |= 0x02
147 else:
148 self.flags &= ~0x02
149
150
151########################################################################################################################
152
153
154class QGFFramePaletteDescriptorV1:
155 type_id = 0x03
156
157 def __init__(self):
158 self.header = QGFBlockHeader()
159 self.header.type_id = QGFFramePaletteDescriptorV1.type_id
160 self.header.length = 0
161 self.palette_entries = [(0xFF, 0xFF, 0xFF)] * 4
162
163 def write(self, fp):
164 self.header.length = len(self.palette_entries) * 3
165 self.header.write(fp)
166 for entry in self.palette_entries:
167 fp.write(b'' # start off with empty bytes...
168 + o8(entry[0]) # h
169 + o8(entry[1]) # s
170 + o8(entry[2]) # v
171 )
172
173
174########################################################################################################################
175
176
177class QGFFrameDeltaDescriptorV1:
178 type_id = 0x04
179 length = 8
180
181 def __init__(self):
182 self.header = QGFBlockHeader()
183 self.header.type_id = QGFFrameDeltaDescriptorV1.type_id
184 self.header.length = QGFFrameDeltaDescriptorV1.length
185 self.left = 0
186 self.top = 0
187 self.right = 0
188 self.bottom = 0
189
190 def write(self, fp):
191 self.header.write(fp)
192 fp.write(b'' # start off with empty bytes...
193 + o16(self.left) # left
194 + o16(self.top) # top
195 + o16(self.right) # right
196 + o16(self.bottom) # bottom
197 )
198
199 @property
200 def bbox(self):
201 return self.left, self.top, self.right, self.bottom
202
203 @bbox.setter
204 def bbox(self, bbox):
205 self.left, self.top, self.right, self.bottom = bbox
206
207
208########################################################################################################################
209
210
211class QGFFrameDataDescriptorV1:
212 type_id = 0x05
213
214 def __init__(self):
215 self.header = QGFBlockHeader()
216 self.header.type_id = QGFFrameDataDescriptorV1.type_id
217 self.data = []
218
219 def write(self, fp):
220 self.header.length = len(self.data)
221 self.header.write(fp)
222 fp.write(bytes(self.data))
223
224
225########################################################################################################################
226
227
228class QGFImageFile(ImageFile.ImageFile):
229
230 format = "QGF"
231 format_description = "Quantum Graphics File Format"
232
233 def _open(self):
234 raise NotImplementedError("Reading QGF files is not supported")
235
236
237########################################################################################################################
238
239
240def _accept(prefix):
241 """Helper method used by PIL to work out if it can parse an input file.
242
243 Currently unimplemented.
244 """
245 return False
246
247
248def _for_all_frames(x: FunctionType, /, images):
249 frame_num = 0
250 last_frame = None
251 for frame in images:
252 # Get number of of frames in this image
253 nfr = getattr(frame, "n_frames", 1)
254 for idx in range(nfr):
255 frame.seek(idx)
256 frame.load()
257 copy = frame.copy().convert("RGB")
258 x(frame_num, copy, last_frame)
259 last_frame = copy
260 frame_num += 1
261
262
263def _compress_image(frame, last_frame, *, use_rle, use_deltas, format_, **_kwargs):
264 # Convert the original frame so we can do comparisons
265 converted = qmk.painter.convert_requested_format(frame, format_)
266 graphic_data = qmk.painter.convert_image_bytes(converted, format_)
267
268 # Convert the raw data to RLE-encoded if requested
269 raw_data = graphic_data[1]
270 if use_rle:
271 rle_data = qmk.painter.compress_bytes_qmk_rle(graphic_data[1])
272 use_raw_this_frame = not use_rle or len(raw_data) <= len(rle_data)
273 image_data = raw_data if use_raw_this_frame else rle_data
274
275 # Work out if a delta frame is smaller than injecting it directly
276 use_delta_this_frame = False
277 bbox = None
278 if use_deltas and last_frame is not None:
279 # If we want to use deltas, then find the difference
280 diff = ImageChops.difference(frame, last_frame)
281
282 # Get the bounding box of those differences
283 bbox = diff.getbbox()
284
285 # If we have a valid bounding box...
286 if bbox:
287 # ...create the delta frame by cropping the original.
288 delta_frame = frame.crop(bbox)
289
290 # Convert the delta frame to the requested format
291 delta_converted = qmk.painter.convert_requested_format(delta_frame, format_)
292 delta_graphic_data = qmk.painter.convert_image_bytes(delta_converted, format_)
293
294 # Work out how large the delta frame is going to be with compression etc.
295 delta_raw_data = delta_graphic_data[1]
296 if use_rle:
297 delta_rle_data = qmk.painter.compress_bytes_qmk_rle(delta_graphic_data[1])
298 delta_use_raw_this_frame = not use_rle or len(delta_raw_data) <= len(delta_rle_data)
299 delta_image_data = delta_raw_data if delta_use_raw_this_frame else delta_rle_data
300
301 # If the size of the delta frame (plus delta descriptor) is smaller than the original, use that instead
302 # This ensures that if a non-delta is overall smaller in size, we use that in preference due to flash
303 # sizing constraints.
304 if (len(delta_image_data) + QGFFrameDeltaDescriptorV1.length) < len(image_data):
305 # Copy across all the delta equivalents so that the rest of the processing acts on those
306 graphic_data = delta_graphic_data
307 raw_data = delta_raw_data
308 rle_data = delta_rle_data
309 use_raw_this_frame = delta_use_raw_this_frame
310 image_data = delta_image_data
311 use_delta_this_frame = True
312
313 # Default to whole image
314 bbox = bbox or [0, 0, *frame.size]
315 # Fix sze (as per #20296), we need to cast first as tuples are inmutable
316 bbox = list(bbox)
317 bbox[2] -= 1
318 bbox[3] -= 1
319
320 return {
321 "bbox": bbox,
322 "graphic_data": graphic_data,
323 "image_data": image_data,
324 "use_delta_this_frame": use_delta_this_frame,
325 "use_raw_this_frame": use_raw_this_frame,
326 }
327
328
329# Helper function to save each frame to the output file
330def _write_frame(idx, frame, last_frame, *, fp, frame_offsets, metadata, **kwargs):
331 # Not an argument of the function as it would then not be part of kwargs
332 # This would cause an issue with `_compress_image(**kwargs)` missing an argument
333 format_ = kwargs["format_"]
334
335 # (potentially) Apply RLE and/or delta, and work out output image's information
336 outputs = _compress_image(frame, last_frame, **kwargs)
337 bbox = outputs["bbox"]
338 graphic_data = outputs["graphic_data"]
339 image_data = outputs["image_data"]
340 use_delta_this_frame = outputs["use_delta_this_frame"]
341 use_raw_this_frame = outputs["use_raw_this_frame"]
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.get('duration', 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 # Convert all palette entries to HSV888 and write to the output
360 palette_descriptor.palette_entries = list(map(rgb888_to_qmk_hsv888, palette))
361 vprint(f'{f"Frame {idx:3d} palette":26s} {fp.tell():5d}d / {fp.tell():04X}h')
362 palette_descriptor.write(fp)
363
364 # Write out the delta info if required
365 if use_delta_this_frame:
366 # Set up the rendering location of where the delta frame should be situated
367 delta_descriptor = QGFFrameDeltaDescriptorV1()
368 delta_descriptor.bbox = bbox
369
370 # Write the delta frame to the output
371 vprint(f'{f"Frame {idx:3d} delta":26s} {fp.tell():5d}d / {fp.tell():04X}h')
372 delta_descriptor.write(fp)
373
374 # Store metadata, showed later in a comment in the generated file
375 frame_metadata = {
376 "compression": frame_descriptor.compression,
377 "delta": frame_descriptor.is_delta,
378 "delay": frame_descriptor.delay,
379 }
380 if frame_metadata["delta"]:
381 frame_metadata.update({"delta_rect": [
382 delta_descriptor.left,
383 delta_descriptor.top,
384 delta_descriptor.right,
385 delta_descriptor.bottom,
386 ]})
387 metadata.append(frame_metadata)
388
389 # Write out the data for this frame to the output
390 data_descriptor = QGFFrameDataDescriptorV1()
391 data_descriptor.data = image_data
392 vprint(f'{f"Frame {idx:3d} data":26s} {fp.tell():5d}d / {fp.tell():04X}h')
393 data_descriptor.write(fp)
394
395
396def _save(im, fp, _filename):
397 """Helper method used by PIL to write to an output file.
398 """
399 # Work out from the parameters if we need to do anything special
400 encoderinfo = im.encoderinfo.copy()
401
402 # Store image file in metadata structure
403 metadata = encoderinfo.get("metadata", [])
404 metadata.append({"width": im.width, "height": im.height})
405
406 # Helper for prints, noop taking any args if not verbose
407 global vprint
408 verbose = encoderinfo.get("verbose", False)
409 vprint = print if verbose else lambda *_args, **_kwargs: None
410
411 # Helper to iterate through all frames in the input image
412 append_images = list(encoderinfo.get("append_images", []))
413 for_all_frames = functools.partial(_for_all_frames, images=[im, *append_images])
414
415 # Collect all the frame sizes
416 frame_sizes = []
417 for_all_frames(lambda _idx, frame, _last_frame: frame_sizes.append(frame.size))
418
419 # Make sure all frames are the same size
420 if len(set(frame_sizes)) != 1:
421 raise ValueError("Mismatching sizes on frames")
422
423 # Write out the initial graphics descriptor (and write a dummy value), so that we can come back and fill in the
424 # correct values once we've written all the frames to the output
425 graphics_descriptor_location = fp.tell()
426 graphics_descriptor = QGFGraphicsDescriptor()
427 graphics_descriptor.frame_count = len(frame_sizes)
428 graphics_descriptor.image_size = frame_sizes[0]
429 vprint(f'{"Graphics descriptor block":26s} {fp.tell():5d}d / {fp.tell():04X}h')
430 graphics_descriptor.write(fp)
431
432 # Work out the frame offset descriptor location (and write a dummy value), so that we can come back and fill in the
433 # correct offsets once we've written all the frames to the output
434 frame_offset_location = fp.tell()
435 frame_offsets = QGFFrameOffsetDescriptorV1(graphics_descriptor.frame_count)
436 vprint(f'{"Frame offsets block":26s} {fp.tell():5d}d / {fp.tell():04X}h')
437 frame_offsets.write(fp)
438
439 # Iterate over each if the input frames, writing it to the output in the process
440 write_frame = functools.partial(_write_frame, format_=encoderinfo["qmk_format"], fp=fp, use_deltas=encoderinfo.get("use_deltas", True), use_rle=encoderinfo.get("use_rle", True), frame_offsets=frame_offsets, metadata=metadata)
441 for_all_frames(write_frame)
442
443 # Go back and update the graphics descriptor now that we can determine the final file size
444 graphics_descriptor.total_file_size = fp.tell()
445 fp.seek(graphics_descriptor_location, 0)
446 graphics_descriptor.write(fp)
447
448 # Go back and update the frame offsets now that they're written to the file
449 fp.seek(frame_offset_location, 0)
450 frame_offsets.write(fp)
451
452
453########################################################################################################################
454
455# Register with PIL so that it knows about the QGF format
456Image.register_open(QGFImageFile.format, QGFImageFile, _accept)
457Image.register_save(QGFImageFile.format, _save)
458Image.register_save_all(QGFImageFile.format, _save)
459Image.register_extension(QGFImageFile.format, f".{QGFImageFile.format.lower()}")
460Image.register_mime(QGFImageFile.format, f"image/{QGFImageFile.format.lower()}")