|
|
@ -1,9 +1,11 @@ |
|
|
|
# Copyright 2021 Nick Brassel (@tzarc) |
|
|
|
# Copyright 2023 Pablo Martinez (@elpekenin) <elpekenin@elpekenin.dev> |
|
|
|
# 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. |
|
|
|
|
|
|
|
import functools |
|
|
|
from colorsys import rgb_to_hsv |
|
|
|
from types import FunctionType |
|
|
|
from PIL import Image, ImageFile, ImageChops |
|
|
@ -15,6 +17,12 @@ def o24(i): |
|
|
|
return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16) |
|
|
|
|
|
|
|
|
|
|
|
# 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)) |
|
|
|
|
|
|
|
|
|
|
|
######################################################################################################################## |
|
|
|
|
|
|
|
|
|
|
@ -60,6 +68,14 @@ class QGFGraphicsDescriptor: |
|
|
|
+ o16(self.frame_count) # frame count |
|
|
|
) |
|
|
|
|
|
|
|
@property |
|
|
|
def image_size(self): |
|
|
|
return self.image_width, self.image_height |
|
|
|
|
|
|
|
@image_size.setter |
|
|
|
def image_size(self, size): |
|
|
|
self.image_width, self.image_height = size |
|
|
|
|
|
|
|
|
|
|
|
######################################################################################################################## |
|
|
|
|
|
|
@ -180,6 +196,14 @@ class QGFFrameDeltaDescriptorV1: |
|
|
|
+ o16(self.bottom) # bottom |
|
|
|
) |
|
|
|
|
|
|
|
@property |
|
|
|
def bbox(self): |
|
|
|
return self.left, self.top, self.right, self.bottom |
|
|
|
|
|
|
|
@bbox.setter |
|
|
|
def bbox(self, bbox): |
|
|
|
self.left, self.top, self.right, self.bottom = bbox |
|
|
|
|
|
|
|
|
|
|
|
######################################################################################################################## |
|
|
|
|
|
|
@ -221,42 +245,159 @@ def _accept(prefix): |
|
|
|
return False |
|
|
|
|
|
|
|
|
|
|
|
def _save(im, fp, filename): |
|
|
|
def _for_all_frames(x: FunctionType, /, images): |
|
|
|
frame_num = 0 |
|
|
|
last_frame = None |
|
|
|
for frame in 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 |
|
|
|
|
|
|
|
|
|
|
|
def _compress_image(frame, last_frame, *, use_rle, use_deltas, format_, **_kwargs): |
|
|
|
# Convert the original frame so we can do comparisons |
|
|
|
converted = qmk.painter.convert_requested_format(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 |
|
|
|
bbox = None |
|
|
|
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) |
|
|
|
|
|
|
|
# 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 |
|
|
|
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 |
|
|
|
|
|
|
|
# Default to whole image |
|
|
|
bbox = bbox or [0, 0, *frame.size] |
|
|
|
# Fix sze (as per #20296), we need to cast first as tuples are inmutable |
|
|
|
bbox = list(bbox) |
|
|
|
bbox[2] -= 1 |
|
|
|
bbox[3] -= 1 |
|
|
|
|
|
|
|
return { |
|
|
|
"bbox": bbox, |
|
|
|
"graphic_data": graphic_data, |
|
|
|
"image_data": image_data, |
|
|
|
"use_delta_this_frame": use_delta_this_frame, |
|
|
|
"use_raw_this_frame": use_raw_this_frame, |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
# Helper function to save each frame to the output file |
|
|
|
def _write_frame(idx, frame, last_frame, *, fp, frame_offsets, **kwargs): |
|
|
|
# Not an argument of the function as it would consume from **kwargs |
|
|
|
format_ = kwargs["format_"] |
|
|
|
|
|
|
|
# (potentially) Apply RLE and/or delta, and work out output image's information |
|
|
|
outputs = _compress_image(frame, last_frame, **kwargs) |
|
|
|
bbox = outputs["bbox"] |
|
|
|
graphic_data = outputs["graphic_data"] |
|
|
|
image_data = outputs["image_data"] |
|
|
|
use_delta_this_frame = outputs["use_delta_this_frame"] |
|
|
|
use_raw_this_frame = outputs["use_raw_this_frame"] |
|
|
|
|
|
|
|
# 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.get('duration', 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() |
|
|
|
|
|
|
|
# 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.bbox = bbox |
|
|
|
|
|
|
|
# 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) |
|
|
|
|
|
|
|
|
|
|
|
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 for prints, noop taking any args if not verbose |
|
|
|
global vprint |
|
|
|
verbose = encoderinfo.get("verbose", False) |
|
|
|
vprint = print if verbose else lambda *_args, **_kwargs: None |
|
|
|
|
|
|
|
# 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 |
|
|
|
append_images = list(encoderinfo.get("append_images", [])) |
|
|
|
for_all_frames = functools.partial(_for_all_frames, images=[im, *append_images]) |
|
|
|
|
|
|
|
# Collect all the frame sizes |
|
|
|
frame_sizes = [] |
|
|
|
_for_all_frames(lambda idx, frame, last_frame: frame_sizes.append(frame.size)) |
|
|
|
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: |
|
|
|
if len(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 |
|
|
@ -264,8 +405,7 @@ def _save(im, fp, filename): |
|
|
|
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] |
|
|
|
graphics_descriptor.image_size = frame_sizes[0] |
|
|
|
vprint(f'{"Graphics descriptor block":26s} {fp.tell():5d}d / {fp.tell():04X}h') |
|
|
|
graphics_descriptor.write(fp) |
|
|
|
|
|
|
@ -276,117 +416,9 @@ def _save(im, fp, filename): |
|
|
|
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] - 1 |
|
|
|
delta_descriptor.bottom = location[1] + size[1] - 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) |
|
|
|
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) |
|
|
|
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() |
|
|
|