at master 17 kB view raw
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()}")