diff options
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 @@ +# Copyright 2021 Nick Brassel (@tzarc) +# SPDX-License-Identifier: GPL-2.0-or-later + +# Quantum Graphics File "QGF" Image File Format. +# See https://docs.qmk.fm/#/quantum_painter_qgf for more information. + +from colorsys import rgb_to_hsv +from types import FunctionType +from PIL import Image, ImageFile, ImageChops +from PIL._binary import o8, o16le as o16, o32le as o32 +import qmk.painter + + +def o24(i): +    return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16) + + +######################################################################################################################## + + +class QGFBlockHeader: +    block_size = 5 + +    def write(self, fp): +        fp.write(b''  # start off with empty bytes... +                 + o8(self.type_id)  # block type id +                 + o8((~self.type_id) & 0xFF)  # negated block type id +                 + o24(self.length)  # blob length +                 ) + + +######################################################################################################################## + + +class QGFGraphicsDescriptor: +    type_id = 0x00 +    length = 18 +    magic = 0x464751 + +    def __init__(self): +        self.header = QGFBlockHeader() +        self.header.type_id = QGFGraphicsDescriptor.type_id +        self.header.length = QGFGraphicsDescriptor.length +        self.version = 1 +        self.total_file_size = 0 +        self.image_width = 0 +        self.image_height = 0 +        self.frame_count = 0 + +    def write(self, fp): +        self.header.write(fp) +        fp.write( +            b''  # start off with empty bytes... +            + o24(QGFGraphicsDescriptor.magic)  # magic +            + o8(self.version)  # version +            + o32(self.total_file_size)  # file size +            + o32((~self.total_file_size) & 0xFFFFFFFF)  # negated file size +            + o16(self.image_width)  # width +            + o16(self.image_height)  # height +            + o16(self.frame_count)  # frame count +        ) + + +######################################################################################################################## + + +class QGFFrameOffsetDescriptorV1: +    type_id = 0x01 + +    def __init__(self, frame_count): +        self.header = QGFBlockHeader() +        self.header.type_id = QGFFrameOffsetDescriptorV1.type_id +        self.frame_offsets = [0xFFFFFFFF] * frame_count +        self.frame_count = frame_count + +    def write(self, fp): +        self.header.length = len(self.frame_offsets) * 4 +        self.header.write(fp) +        for offset in self.frame_offsets: +            fp.write(b''  # start off with empty bytes... +                     + o32(offset)  # offset +                     ) + + +######################################################################################################################## + + +class QGFFrameDescriptorV1: +    type_id = 0x02 +    length = 6 + +    def __init__(self): +        self.header = QGFBlockHeader() +        self.header.type_id = QGFFrameDescriptorV1.type_id +        self.header.length = QGFFrameDescriptorV1.length +        self.format = 0xFF +        self.flags = 0 +        self.compression = 0xFF +        self.transparency_index = 0xFF  # TODO: Work out how to retrieve the transparent palette entry from the PIL gif loader +        self.delay = 1000  # Placeholder until it gets read from the animation + +    def write(self, fp): +        self.header.write(fp) +        fp.write(b''  # start off with empty bytes... +                 + o8(self.format)  # format +                 + o8(self.flags)  # flags +                 + o8(self.compression)  # compression +                 + o8(self.transparency_index)  # transparency index +                 + o16(self.delay)  # delay +                 ) + +    @property +    def is_transparent(self): +        return (self.flags & 0x01) == 0x01 + +    @is_transparent.setter +    def is_transparent(self, val): +        if val: +            self.flags |= 0x01 +        else: +            self.flags &= ~0x01 + +    @property +    def is_delta(self): +        return (self.flags & 0x02) == 0x02 + +    @is_delta.setter +    def is_delta(self, val): +        if val: +            self.flags |= 0x02 +        else: +            self.flags &= ~0x02 + + +######################################################################################################################## + + +class QGFFramePaletteDescriptorV1: +    type_id = 0x03 + +    def __init__(self): +        self.header = QGFBlockHeader() +        self.header.type_id = QGFFramePaletteDescriptorV1.type_id +        self.header.length = 0 +        self.palette_entries = [(0xFF, 0xFF, 0xFF)] * 4 + +    def write(self, fp): +        self.header.length = len(self.palette_entries) * 3 +        self.header.write(fp) +        for entry in self.palette_entries: +            fp.write(b''  # start off with empty bytes... +                     + o8(entry[0])  # h +                     + o8(entry[1])  # s +                     + o8(entry[2])  # v +                     ) + + +######################################################################################################################## + + +class QGFFrameDeltaDescriptorV1: +    type_id = 0x04 +    length = 8 + +    def __init__(self): +        self.header = QGFBlockHeader() +        self.header.type_id = QGFFrameDeltaDescriptorV1.type_id +        self.header.length = QGFFrameDeltaDescriptorV1.length +        self.left = 0 +        self.top = 0 +        self.right = 0 +        self.bottom = 0 + +    def write(self, fp): +        self.header.write(fp) +        fp.write(b''  # start off with empty bytes... +                 + o16(self.left)  # left +                 + o16(self.top)  # top +                 + o16(self.right)  # right +                 + o16(self.bottom)  # bottom +                 ) + + +######################################################################################################################## + + +class QGFFrameDataDescriptorV1: +    type_id = 0x05 + +    def __init__(self): +        self.header = QGFBlockHeader() +        self.header.type_id = QGFFrameDataDescriptorV1.type_id +        self.data = [] + +    def write(self, fp): +        self.header.length = len(self.data) +        self.header.write(fp) +        fp.write(bytes(self.data)) + + +######################################################################################################################## + + +class QGFImageFile(ImageFile.ImageFile): + +    format = "QGF" +    format_description = "Quantum Graphics File Format" + +    def _open(self): +        raise NotImplementedError("Reading QGF files is not supported") + + +######################################################################################################################## + + +def _accept(prefix): +    """Helper method used by PIL to work out if it can parse an input file. + +    Currently unimplemented. +    """ +    return False + + +def _save(im, fp, filename): +    """Helper method used by PIL to write to an output file. +    """ +    # Work out from the parameters if we need to do anything special +    encoderinfo = im.encoderinfo.copy() +    append_images = list(encoderinfo.get("append_images", [])) +    verbose = encoderinfo.get("verbose", False) +    use_deltas = encoderinfo.get("use_deltas", True) +    use_rle = encoderinfo.get("use_rle", True) + +    # Helper for inline verbose prints +    def vprint(s): +        if verbose: +            print(s) + +    # Helper to iterate through all frames in the input image +    def _for_all_frames(x: FunctionType): +        frame_num = 0 +        last_frame = None +        for frame in [im] + append_images: +            # Get number of of frames in this image +            nfr = getattr(frame, "n_frames", 1) +            for idx in range(nfr): +                frame.seek(idx) +                frame.load() +                copy = frame.copy().convert("RGB") +                x(frame_num, copy, last_frame) +                last_frame = copy +                frame_num += 1 + +    # Collect all the frame sizes +    frame_sizes = [] +    _for_all_frames(lambda idx, frame, last_frame: frame_sizes.append(frame.size)) + +    # Make sure all frames are the same size +    if len(list(set(frame_sizes))) != 1: +        raise ValueError("Mismatching sizes on frames") + +    # Write out the initial graphics descriptor (and write a dummy value), so that we can come back and fill in the +    # correct values once we've written all the frames to the output +    graphics_descriptor_location = fp.tell() +    graphics_descriptor = QGFGraphicsDescriptor() +    graphics_descriptor.frame_count = len(frame_sizes) +    graphics_descriptor.image_width = frame_sizes[0][0] +    graphics_descriptor.image_height = frame_sizes[0][1] +    vprint(f'{"Graphics descriptor block":26s} {fp.tell():5d}d / {fp.tell():04X}h') +    graphics_descriptor.write(fp) + +    # Work out the frame offset descriptor location (and write a dummy value), so that we can come back and fill in the +    # correct offsets once we've written all the frames to the output +    frame_offset_location = fp.tell() +    frame_offsets = QGFFrameOffsetDescriptorV1(graphics_descriptor.frame_count) +    vprint(f'{"Frame offsets block":26s} {fp.tell():5d}d / {fp.tell():04X}h') +    frame_offsets.write(fp) + +    # Helper function to save each frame to the output file +    def _write_frame(idx, frame, last_frame): +        # If we replace the frame we're going to output with a delta, we can override it here +        this_frame = frame +        location = (0, 0) +        size = frame.size + +        # Work out the format we're going to use +        format = encoderinfo["qmk_format"] + +        # Convert the original frame so we can do comparisons +        converted = qmk.painter.convert_requested_format(this_frame, format) +        graphic_data = qmk.painter.convert_image_bytes(converted, format) + +        # Convert the raw data to RLE-encoded if requested +        raw_data = graphic_data[1] +        if use_rle: +            rle_data = qmk.painter.compress_bytes_qmk_rle(graphic_data[1]) +        use_raw_this_frame = not use_rle or len(raw_data) <= len(rle_data) +        image_data = raw_data if use_raw_this_frame else rle_data + +        # Work out if a delta frame is smaller than injecting it directly +        use_delta_this_frame = False +        if use_deltas and last_frame is not None: +            # If we want to use deltas, then find the difference +            diff = ImageChops.difference(frame, last_frame) + +            # Get the bounding box of those differences +            bbox = diff.getbbox() + +            # If we have a valid bounding box... +            if bbox: +                # ...create the delta frame by cropping the original. +                delta_frame = frame.crop(bbox) +                delta_location = (bbox[0], bbox[1]) +                delta_size = (bbox[2] - bbox[0], bbox[3] - bbox[1]) + +                # Convert the delta frame to the requested format +                delta_converted = qmk.painter.convert_requested_format(delta_frame, format) +                delta_graphic_data = qmk.painter.convert_image_bytes(delta_converted, format) + +                # Work out how large the delta frame is going to be with compression etc. +                delta_raw_data = delta_graphic_data[1] +                if use_rle: +                    delta_rle_data = qmk.painter.compress_bytes_qmk_rle(delta_graphic_data[1]) +                delta_use_raw_this_frame = not use_rle or len(delta_raw_data) <= len(delta_rle_data) +                delta_image_data = delta_raw_data if delta_use_raw_this_frame else delta_rle_data + +                # If the size of the delta frame (plus delta descriptor) is smaller than the original, use that instead +                # This ensures that if a non-delta is overall smaller in size, we use that in preference due to flash +                # sizing constraints. +                if (len(delta_image_data) + QGFFrameDeltaDescriptorV1.length) < len(image_data): +                    # Copy across all the delta equivalents so that the rest of the processing acts on those +                    this_frame = delta_frame +                    location = delta_location +                    size = delta_size +                    converted = delta_converted +                    graphic_data = delta_graphic_data +                    raw_data = delta_raw_data +                    rle_data = delta_rle_data +                    use_raw_this_frame = delta_use_raw_this_frame +                    image_data = delta_image_data +                    use_delta_this_frame = True + +        # Write out the frame descriptor +        frame_offsets.frame_offsets[idx] = fp.tell() +        vprint(f'{f"Frame {idx:3d} base":26s} {fp.tell():5d}d / {fp.tell():04X}h') +        frame_descriptor = QGFFrameDescriptorV1() +        frame_descriptor.is_delta = use_delta_this_frame +        frame_descriptor.is_transparent = False +        frame_descriptor.format = format['image_format_byte'] +        frame_descriptor.compression = 0x00 if use_raw_this_frame else 0x01  # See qp.h, painter_compression_t +        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 +        frame_descriptor.write(fp) + +        # Write out the palette if required +        if format['has_palette']: +            palette = graphic_data[0] +            palette_descriptor = QGFFramePaletteDescriptorV1() + +            # Helper to convert from RGB888 to the QMK "dialect" of HSV888 +            def rgb888_to_qmk_hsv888(e): +                hsv = rgb_to_hsv(e[0] / 255.0, e[1] / 255.0, e[2] / 255.0) +                return (int(hsv[0] * 255.0), int(hsv[1] * 255.0), int(hsv[2] * 255.0)) + +            # Convert all palette entries to HSV888 and write to the output +            palette_descriptor.palette_entries = list(map(rgb888_to_qmk_hsv888, palette)) +            vprint(f'{f"Frame {idx:3d} palette":26s} {fp.tell():5d}d / {fp.tell():04X}h') +            palette_descriptor.write(fp) + +        # Write out the delta info if required +        if use_delta_this_frame: +            # Set up the rendering location of where the delta frame should be situated +            delta_descriptor = QGFFrameDeltaDescriptorV1() +            delta_descriptor.left = location[0] +            delta_descriptor.top = location[1] +            delta_descriptor.right = location[0] + size[0] +            delta_descriptor.bottom = location[1] + size[1] + +            # Write the delta frame to the output +            vprint(f'{f"Frame {idx:3d} delta":26s} {fp.tell():5d}d / {fp.tell():04X}h') +            delta_descriptor.write(fp) + +        # Write out the data for this frame to the output +        data_descriptor = QGFFrameDataDescriptorV1() +        data_descriptor.data = image_data +        vprint(f'{f"Frame {idx:3d} data":26s} {fp.tell():5d}d / {fp.tell():04X}h') +        data_descriptor.write(fp) + +    # Iterate over each if the input frames, writing it to the output in the process +    _for_all_frames(_write_frame) + +    # Go back and update the graphics descriptor now that we can determine the final file size +    graphics_descriptor.total_file_size = fp.tell() +    fp.seek(graphics_descriptor_location, 0) +    graphics_descriptor.write(fp) + +    # Go back and update the frame offsets now that they're written to the file +    fp.seek(frame_offset_location, 0) +    frame_offsets.write(fp) + + +######################################################################################################################## + +# Register with PIL so that it knows about the QGF format +Image.register_open(QGFImageFile.format, QGFImageFile, _accept) +Image.register_save(QGFImageFile.format, _save) +Image.register_save_all(QGFImageFile.format, _save) +Image.register_extension(QGFImageFile.format, f".{QGFImageFile.format.lower()}") +Image.register_mime(QGFImageFile.format, f"image/{QGFImageFile.format.lower()}") | 
