summaryrefslogtreecommitdiff
path: root/lib/python/qmk
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
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')
-rw-r--r--lib/python/qmk/cli/__init__.py4
-rw-r--r--lib/python/qmk/cli/painter/__init__.py2
-rw-r--r--lib/python/qmk/cli/painter/convert_graphics.py86
-rw-r--r--lib/python/qmk/cli/painter/make_font.py87
-rw-r--r--lib/python/qmk/painter.py268
-rw-r--r--lib/python/qmk/painter_qff.py401
-rw-r--r--lib/python/qmk/painter_qgf.py408
7 files changed, 1255 insertions, 1 deletions
diff --git a/lib/python/qmk/cli/__init__.py b/lib/python/qmk/cli/__init__.py
index 5f65e677e5..85baa238a8 100644
--- a/lib/python/qmk/cli/__init__.py
+++ b/lib/python/qmk/cli/__init__.py
@@ -16,7 +16,8 @@ import_names = {
16 # A mapping of package name to importable name 16 # A mapping of package name to importable name
17 'pep8-naming': 'pep8ext_naming', 17 'pep8-naming': 'pep8ext_naming',
18 'pyusb': 'usb.core', 18 'pyusb': 'usb.core',
19 'qmk-dotty-dict': 'dotty_dict' 19 'qmk-dotty-dict': 'dotty_dict',
20 'pillow': 'PIL'
20} 21}
21 22
22safe_commands = [ 23safe_commands = [
@@ -67,6 +68,7 @@ subcommands = [
67 'qmk.cli.multibuild', 68 'qmk.cli.multibuild',
68 'qmk.cli.new.keyboard', 69 'qmk.cli.new.keyboard',
69 'qmk.cli.new.keymap', 70 'qmk.cli.new.keymap',
71 'qmk.cli.painter',
70 'qmk.cli.pyformat', 72 'qmk.cli.pyformat',
71 'qmk.cli.pytest', 73 'qmk.cli.pytest',
72 'qmk.cli.via2json', 74 'qmk.cli.via2json',
diff --git a/lib/python/qmk/cli/painter/__init__.py b/lib/python/qmk/cli/painter/__init__.py
new file mode 100644
index 0000000000..d1a225346c
--- /dev/null
+++ b/lib/python/qmk/cli/painter/__init__.py
@@ -0,0 +1,2 @@
1from . import convert_graphics
2from . import make_font
diff --git a/lib/python/qmk/cli/painter/convert_graphics.py b/lib/python/qmk/cli/painter/convert_graphics.py
new file mode 100644
index 0000000000..bbc30d26ff
--- /dev/null
+++ b/lib/python/qmk/cli/painter/convert_graphics.py
@@ -0,0 +1,86 @@
1"""This script tests QGF functionality.
2"""
3import re
4import datetime
5from io import BytesIO
6from qmk.path import normpath
7from qmk.painter import render_header, render_source, render_license, render_bytes, valid_formats
8from milc import cli
9from PIL import Image
10
11
12@cli.argument('-v', '--verbose', arg_only=True, action='store_true', help='Turns on verbose output.')
13@cli.argument('-i', '--input', required=True, help='Specify input graphic file.')
14@cli.argument('-o', '--output', default='', help='Specify output directory. Defaults to same directory as input.')
15@cli.argument('-f', '--format', required=True, help='Output format, valid types: %s' % (', '.join(valid_formats.keys())))
16@cli.argument('-r', '--no-rle', arg_only=True, action='store_true', help='Disables the use of RLE when encoding images.')
17@cli.argument('-d', '--no-deltas', arg_only=True, action='store_true', help='Disables the use of delta frames when encoding animations.')
18@cli.subcommand('Converts an input image to something QMK understands')
19def painter_convert_graphics(cli):
20 """Converts an image file to a format that Quantum Painter understands.
21
22 This command uses the `qmk.painter` module to generate a Quantum Painter image defintion from an image. The generated definitions are written to a files next to the input -- `INPUT.c` and `INPUT.h`.
23 """
24 # Work out the input file
25 if cli.args.input != '-':
26 cli.args.input = normpath(cli.args.input)
27
28 # Error checking
29 if not cli.args.input.exists():
30 cli.log.error('Input image file does not exist!')
31 cli.print_usage()
32 return False
33
34 # Work out the output directory
35 if len(cli.args.output) == 0:
36 cli.args.output = cli.args.input.parent
37 cli.args.output = normpath(cli.args.output)
38
39 # Ensure we have a valid format
40 if cli.args.format not in valid_formats.keys():
41 cli.log.error('Output format %s is invalid. Allowed values: %s' % (cli.args.format, ', '.join(valid_formats.keys())))
42 cli.print_usage()
43 return False
44
45 # Work out the encoding parameters
46 format = valid_formats[cli.args.format]
47
48 # Load the input image
49 input_img = Image.open(cli.args.input)
50
51 # Convert the image to QGF using PIL
52 out_data = BytesIO()
53 input_img.save(out_data, "QGF", use_deltas=(not cli.args.no_deltas), use_rle=(not cli.args.no_rle), qmk_format=format, verbose=cli.args.verbose)
54 out_bytes = out_data.getvalue()
55
56 # Work out the text substitutions for rendering the output data
57 subs = {
58 'generated_type': 'image',
59 'var_prefix': 'gfx',
60 'generator_command': f'qmk painter-convert-graphics -i {cli.args.input.name} -f {cli.args.format}',
61 'year': datetime.date.today().strftime("%Y"),
62 'input_file': cli.args.input.name,
63 'sane_name': re.sub(r"[^a-zA-Z0-9]", "_", cli.args.input.stem),
64 'byte_count': len(out_bytes),
65 'bytes_lines': render_bytes(out_bytes),
66 'format': cli.args.format,
67 }
68
69 # Render the license
70 subs.update({'license': render_license(subs)})
71
72 # Render and write the header file
73 header_text = render_header(subs)
74 header_file = cli.args.output / (cli.args.input.stem + ".qgf.h")
75 with open(header_file, 'w') as header:
76 print(f"Writing {header_file}...")
77 header.write(header_text)
78 header.close()
79
80 # Render and write the source file
81 source_text = render_source(subs)
82 source_file = cli.args.output / (cli.args.input.stem + ".qgf.c")
83 with open(source_file, 'w') as source:
84 print(f"Writing {source_file}...")
85 source.write(source_text)
86 source.close()
diff --git a/lib/python/qmk/cli/painter/make_font.py b/lib/python/qmk/cli/painter/make_font.py
new file mode 100644
index 0000000000..0762843fd3
--- /dev/null
+++ b/lib/python/qmk/cli/painter/make_font.py
@@ -0,0 +1,87 @@
1"""This script automates the conversion of font files into a format QMK firmware understands.
2"""
3
4import re
5import datetime
6from io import BytesIO
7from qmk.path import normpath
8from qmk.painter_qff import QFFFont
9from qmk.painter import render_header, render_source, render_license, render_bytes, valid_formats
10from milc import cli
11
12
13@cli.argument('-f', '--font', required=True, help='Specify input font file.')
14@cli.argument('-o', '--output', required=True, help='Specify output image path.')
15@cli.argument('-s', '--size', default=12, help='Specify font size. Default 12.')
16@cli.argument('-n', '--no-ascii', arg_only=True, action='store_true', help='Disables output of the full ASCII character set (0x20..0x7E), exporting only the glyphs specified.')
17@cli.argument('-u', '--unicode-glyphs', default='', help='Also generate the specified unicode glyphs.')
18@cli.argument('-a', '--no-aa', arg_only=True, action='store_true', help='Disable anti-aliasing on fonts.')
19@cli.subcommand('Converts an input font to something QMK understands')
20def painter_make_font_image(cli):
21 # Create the font object
22 font = QFFFont(cli)
23 # Read from the input file
24 cli.args.font = normpath(cli.args.font)
25 font.generate_image(cli.args.font, cli.args.size, include_ascii_glyphs=(not cli.args.no_ascii), unicode_glyphs=cli.args.unicode_glyphs, use_aa=(False if cli.args.no_aa else True))
26 # Render out the data
27 font.save_to_image(normpath(cli.args.output))
28
29
30@cli.argument('-i', '--input', help='Specify input graphic file.')
31@cli.argument('-o', '--output', default='', help='Specify output directory. Defaults to same directory as input.')
32@cli.argument('-n', '--no-ascii', arg_only=True, action='store_true', help='Disables output of the full ASCII character set (0x20..0x7E), exporting only the glyphs specified.')
33@cli.argument('-u', '--unicode-glyphs', default='', help='Also generate the specified unicode glyphs.')
34@cli.argument('-f', '--format', required=True, help='Output format, valid types: %s' % (', '.join(valid_formats.keys())))
35@cli.argument('-r', '--no-rle', arg_only=True, action='store_true', help='Disable the use of RLE to minimise converted image size.')
36@cli.subcommand('Converts an input font image to something QMK firmware understands')
37def painter_convert_font_image(cli):
38 # Work out the format
39 format = valid_formats[cli.args.format]
40
41 # Create the font object
42 font = QFFFont(cli.log)
43
44 # Read from the input file
45 cli.args.input = normpath(cli.args.input)
46 font.read_from_image(cli.args.input, include_ascii_glyphs=(not cli.args.no_ascii), unicode_glyphs=cli.args.unicode_glyphs)
47
48 # Work out the output directory
49 if len(cli.args.output) == 0:
50 cli.args.output = cli.args.input.parent
51 cli.args.output = normpath(cli.args.output)
52
53 # Render out the data
54 out_data = BytesIO()
55 font.save_to_qff(format, (False if cli.args.no_rle else True), out_data)
56
57 # Work out the text substitutions for rendering the output data
58 subs = {
59 'generated_type': 'font',
60 'var_prefix': 'font',
61 'generator_command': f'qmk painter-convert-font-image -i {cli.args.input.name} -f {cli.args.format}',
62 'year': datetime.date.today().strftime("%Y"),
63 'input_file': cli.args.input.name,
64 'sane_name': re.sub(r"[^a-zA-Z0-9]", "_", cli.args.input.stem),
65 'byte_count': out_data.getbuffer().nbytes,
66 'bytes_lines': render_bytes(out_data.getbuffer().tobytes()),
67 'format': cli.args.format,
68 }
69
70 # Render the license
71 subs.update({'license': render_license(subs)})
72
73 # Render and write the header file
74 header_text = render_header(subs)
75 header_file = cli.args.output / (cli.args.input.stem + ".qff.h")
76 with open(header_file, 'w') as header:
77 print(f"Writing {header_file}...")
78 header.write(header_text)
79 header.close()
80
81 # Render and write the source file
82 source_text = render_source(subs)
83 source_file = cli.args.output / (cli.args.input.stem + ".qff.c")
84 with open(source_file, 'w') as source:
85 print(f"Writing {source_file}...")
86 source.write(source_text)
87 source.close()
diff --git a/lib/python/qmk/painter.py b/lib/python/qmk/painter.py
new file mode 100644
index 0000000000..d0cc1dddec
--- /dev/null
+++ b/lib/python/qmk/painter.py
@@ -0,0 +1,268 @@
1"""Functions that help us work with Quantum Painter's file formats.
2"""
3import math
4import re
5from string import Template
6from PIL import Image, ImageOps
7
8# The list of valid formats Quantum Painter supports
9valid_formats = {
10 'pal256': {
11 'image_format': 'IMAGE_FORMAT_PALETTE',
12 'bpp': 8,
13 'has_palette': True,
14 'num_colors': 256,
15 'image_format_byte': 0x07, # see qp_internal_formats.h
16 },
17 'pal16': {
18 'image_format': 'IMAGE_FORMAT_PALETTE',
19 'bpp': 4,
20 'has_palette': True,
21 'num_colors': 16,
22 'image_format_byte': 0x06, # see qp_internal_formats.h
23 },
24 'pal4': {
25 'image_format': 'IMAGE_FORMAT_PALETTE',
26 'bpp': 2,
27 'has_palette': True,
28 'num_colors': 4,
29 'image_format_byte': 0x05, # see qp_internal_formats.h
30 },
31 'pal2': {
32 'image_format': 'IMAGE_FORMAT_PALETTE',
33 'bpp': 1,
34 'has_palette': True,
35 'num_colors': 2,
36 'image_format_byte': 0x04, # see qp_internal_formats.h
37 },
38 'mono256': {
39 'image_format': 'IMAGE_FORMAT_GRAYSCALE',
40 'bpp': 8,
41 'has_palette': False,
42 'num_colors': 256,
43 'image_format_byte': 0x03, # see qp_internal_formats.h
44 },
45 'mono16': {
46 'image_format': 'IMAGE_FORMAT_GRAYSCALE',
47 'bpp': 4,
48 'has_palette': False,
49 'num_colors': 16,
50 'image_format_byte': 0x02, # see qp_internal_formats.h
51 },
52 'mono4': {
53 'image_format': 'IMAGE_FORMAT_GRAYSCALE',
54 'bpp': 2,
55 'has_palette': False,
56 'num_colors': 4,
57 'image_format_byte': 0x01, # see qp_internal_formats.h
58 },
59 'mono2': {
60 'image_format': 'IMAGE_FORMAT_GRAYSCALE',
61 'bpp': 1,
62 'has_palette': False,
63 'num_colors': 2,
64 'image_format_byte': 0x00, # see qp_internal_formats.h
65 }
66}
67
68license_template = """\
69// Copyright ${year} QMK -- generated source code only, ${generated_type} retains original copyright
70// SPDX-License-Identifier: GPL-2.0-or-later
71
72// This file was auto-generated by `${generator_command}`
73"""
74
75
76def render_license(subs):
77 license_txt = Template(license_template)
78 return license_txt.substitute(subs)
79
80
81header_file_template = """\
82${license}
83#pragma once
84
85#include <qp.h>
86
87extern const uint32_t ${var_prefix}_${sane_name}_length;
88extern const uint8_t ${var_prefix}_${sane_name}[${byte_count}];
89"""
90
91
92def render_header(subs):
93 header_txt = Template(header_file_template)
94 return header_txt.substitute(subs)
95
96
97source_file_template = """\
98${license}
99#include <qp.h>
100
101const uint32_t ${var_prefix}_${sane_name}_length = ${byte_count};
102
103// clang-format off
104const uint8_t ${var_prefix}_${sane_name}[${byte_count}] = {
105${bytes_lines}
106};
107// clang-format on
108"""
109
110
111def render_source(subs):
112 source_txt = Template(source_file_template)
113 return source_txt.substitute(subs)
114
115
116def render_bytes(bytes, newline_after=16):
117 lines = ''
118 for n in range(len(bytes)):
119 if n % newline_after == 0 and n > 0 and n != len(bytes):
120 lines = lines + "\n "
121 elif n == 0:
122 lines = lines + " "
123 lines = lines + " 0x{0:02X},".format(bytes[n])
124 return lines.rstrip()
125
126
127def clean_output(str):
128 str = re.sub(r'\r', '', str)
129 str = re.sub(r'[\n]{3,}', r'\n\n', str)
130 return str
131
132
133def rescale_byte(val, maxval):
134 """Rescales a byte value to the supplied range, i.e. [0,255] -> [0,maxval].
135 """
136 return int(round(val * maxval / 255.0))
137
138
139def convert_requested_format(im, format):
140 """Convert an image to the requested format.
141 """
142
143 # Work out the requested format
144 ncolors = format["num_colors"]
145 image_format = format["image_format"]
146
147 # Ensure we have a valid number of colors for the palette
148 if ncolors <= 0 or ncolors > 256 or (ncolors & (ncolors - 1) != 0):
149 raise ValueError("Number of colors must be 2, 4, 16, or 256.")
150
151 # Work out where we're getting the bytes from
152 if image_format == 'IMAGE_FORMAT_GRAYSCALE':
153 # If mono, convert input to grayscale, then to RGB, then grab the raw bytes corresponding to the intensity of the red channel
154 im = ImageOps.grayscale(im)
155 im = im.convert("RGB")
156 elif image_format == 'IMAGE_FORMAT_PALETTE':
157 # If color, convert input to RGB, palettize based on the supplied number of colors, then get the raw palette bytes
158 im = im.convert("RGB")
159 im = im.convert("P", palette=Image.ADAPTIVE, colors=ncolors)
160
161 return im
162
163
164def convert_image_bytes(im, format):
165 """Convert the supplied image to the equivalent bytes required by the QMK firmware.
166 """
167
168 # Work out the requested format
169 ncolors = format["num_colors"]
170 image_format = format["image_format"]
171 shifter = int(math.log2(ncolors))
172 pixels_per_byte = int(8 / math.log2(ncolors))
173 (width, height) = im.size
174 expected_byte_count = ((width * height) + (pixels_per_byte - 1)) // pixels_per_byte
175
176 if image_format == 'IMAGE_FORMAT_GRAYSCALE':
177 # Take the red channel
178 image_bytes = im.tobytes("raw", "R")
179 image_bytes_len = len(image_bytes)
180
181 # No palette
182 palette = None
183
184 bytearray = []
185 for x in range(expected_byte_count):
186 byte = 0
187 for n in range(pixels_per_byte):
188 byte_offset = x * pixels_per_byte + n
189 if byte_offset < image_bytes_len:
190 # If mono, each input byte is a grayscale [0,255] pixel -- rescale to the range we want then pack together
191 byte = byte | (rescale_byte(image_bytes[byte_offset], ncolors - 1) << int(n * shifter))
192 bytearray.append(byte)
193
194 elif image_format == 'IMAGE_FORMAT_PALETTE':
195 # Convert each pixel to the palette bytes
196 image_bytes = im.tobytes("raw", "P")
197 image_bytes_len = len(image_bytes)
198
199 # Export the palette
200 palette = []
201 pal = im.getpalette()
202 for n in range(0, ncolors * 3, 3):
203 palette.append((pal[n + 0], pal[n + 1], pal[n + 2]))
204
205 bytearray = []
206 for x in range(expected_byte_count):
207 byte = 0
208 for n in range(pixels_per_byte):
209 byte_offset = x * pixels_per_byte + n
210 if byte_offset < image_bytes_len:
211 # If color, each input byte is the index into the color palette -- pack them together
212 byte = byte | ((image_bytes[byte_offset] & (ncolors - 1)) << int(n * shifter))
213 bytearray.append(byte)
214
215 if len(bytearray) != expected_byte_count:
216 raise Exception(f"Wrong byte count, was {len(bytearray)}, expected {expected_byte_count}")
217
218 return (palette, bytearray)
219
220
221def compress_bytes_qmk_rle(bytearray):
222 debug_dump = False
223 output = []
224 temp = []
225 repeat = False
226
227 def append_byte(c):
228 if debug_dump:
229 print('Appending byte:', '0x{0:02X}'.format(int(c)), '=', c)
230 output.append(c)
231
232 def append_range(r):
233 append_byte(127 + len(r))
234 if debug_dump:
235 print('Appending {0} byte(s):'.format(len(r)), '[', ', '.join(['{0:02X}'.format(e) for e in r]), ']')
236 output.extend(r)
237
238 for n in range(0, len(bytearray) + 1):
239 end = True if n == len(bytearray) else False
240 if not end:
241 c = bytearray[n]
242 temp.append(c)
243 if len(temp) <= 1:
244 continue
245
246 if debug_dump:
247 print('Temp buffer state {0:3d} bytes:'.format(len(temp)), '[', ', '.join(['{0:02X}'.format(e) for e in temp]), ']')
248
249 if repeat:
250 if temp[-1] != temp[-2]:
251 repeat = False
252 if not repeat or len(temp) == 128 or end:
253 append_byte(len(temp) if end else len(temp) - 1)
254 append_byte(temp[0])
255 temp = [temp[-1]]
256 repeat = False
257 else:
258 if len(temp) >= 2 and temp[-1] == temp[-2]:
259 repeat = True
260 if len(temp) > 2:
261 append_range(temp[0:(len(temp) - 2)])
262 temp = [temp[-1], temp[-1]]
263 continue
264 if len(temp) == 128 or end:
265 append_range(temp)
266 temp = []
267 repeat = False
268 return output
diff --git a/lib/python/qmk/painter_qff.py b/lib/python/qmk/painter_qff.py
new file mode 100644
index 0000000000..746bb166e5
--- /dev/null
+++ b/lib/python/qmk/painter_qff.py
@@ -0,0 +1,401 @@
1# Copyright 2021 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later
3
4# Quantum Font File "QFF" Font File Format.
5# See https://docs.qmk.fm/#/quantum_painter_qff for more information.
6
7from pathlib import Path
8from typing import Dict, Any
9from colorsys import rgb_to_hsv
10from PIL import Image, ImageDraw, ImageFont, ImageChops
11from PIL._binary import o8, o16le as o16, o32le as o32
12from qmk.painter_qgf import QGFBlockHeader, QGFFramePaletteDescriptorV1
13from milc.attrdict import AttrDict
14import qmk.painter
15
16
17def o24(i):
18 return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16)
19
20
21########################################################################################################################
22
23
24class QFFGlyphInfo(AttrDict):
25 def __init__(self, *args, **kwargs):
26 super().__init__()
27
28 for n, value in enumerate(args):
29 self[f'arg:{n}'] = value
30
31 for key, value in kwargs.items():
32 self[key] = value
33
34 def write(self, fp, include_code_point):
35 if include_code_point is True:
36 fp.write(o24(ord(self.code_point)))
37
38 value = ((self.data_offset << 6) & 0xFFFFC0) | (self.w & 0x3F)
39 fp.write(o24(value))
40
41
42########################################################################################################################
43
44
45class QFFFontDescriptor:
46 type_id = 0x00
47 length = 20
48 magic = 0x464651
49
50 def __init__(self):
51 self.header = QGFBlockHeader()
52 self.header.type_id = QFFFontDescriptor.type_id
53 self.header.length = QFFFontDescriptor.length
54 self.version = 1
55 self.total_file_size = 0
56 self.line_height = 0
57 self.has_ascii_table = False
58 self.unicode_glyph_count = 0
59 self.format = 0xFF
60 self.flags = 0
61 self.compression = 0xFF
62 self.transparency_index = 0xFF # TODO: Work out how to retrieve the transparent palette entry from the PIL gif loader
63
64 def write(self, fp):
65 self.header.write(fp)
66 fp.write(
67 b'' # start off with empty bytes...
68 + o24(QFFFontDescriptor.magic) # magic
69 + o8(self.version) # version
70 + o32(self.total_file_size) # file size
71 + o32((~self.total_file_size) & 0xFFFFFFFF) # negated file size
72 + o8(self.line_height) # line height
73 + o8(1 if self.has_ascii_table is True else 0) # whether or not we have an ascii table present
74 + o16(self.unicode_glyph_count & 0xFFFF) # number of unicode glyphs present
75 + o8(self.format) # format
76 + o8(self.flags) # flags
77 + o8(self.compression) # compression
78 + o8(self.transparency_index) # transparency index
79 )
80
81 @property
82 def is_transparent(self):
83 return (self.flags & 0x01) == 0x01
84
85 @is_transparent.setter
86 def is_transparent(self, val):
87 if val:
88 self.flags |= 0x01
89 else:
90 self.flags &= ~0x01
91
92
93########################################################################################################################
94
95
96class QFFAsciiGlyphTableV1:
97 type_id = 0x01
98 length = 95 * 3 # We have 95 glyphs: [0x20...0x7E]
99
100 def __init__(self):
101 self.header = QGFBlockHeader()
102 self.header.type_id = QFFAsciiGlyphTableV1.type_id
103 self.header.length = QFFAsciiGlyphTableV1.length
104
105 # Each glyph is key=code_point, value=QFFGlyphInfo
106 self.glyphs = {}
107
108 def add_glyph(self, glyph: QFFGlyphInfo):
109 self.glyphs[ord(glyph.code_point)] = glyph
110
111 def write(self, fp):
112 self.header.write(fp)
113
114 for n in range(0x20, 0x7F):
115 self.glyphs[n].write(fp, False)
116
117
118########################################################################################################################
119
120
121class QFFUnicodeGlyphTableV1:
122 type_id = 0x02
123
124 def __init__(self):
125 self.header = QGFBlockHeader()
126 self.header.type_id = QFFUnicodeGlyphTableV1.type_id
127 self.header.length = 0
128
129 # Each glyph is key=code_point, value=QFFGlyphInfo
130 self.glyphs = {}
131
132 def add_glyph(self, glyph: QFFGlyphInfo):
133 self.glyphs[ord(glyph.code_point)] = glyph
134
135 def write(self, fp):
136 self.header.length = len(self.glyphs.keys()) * 6
137 self.header.write(fp)
138
139 for n in sorted(self.glyphs.keys()):
140 self.glyphs[n].write(fp, True)
141
142
143########################################################################################################################
144
145
146class QFFFontDataDescriptorV1:
147 type_id = 0x04
148
149 def __init__(self):
150 self.header = QGFBlockHeader()
151 self.header.type_id = QFFFontDataDescriptorV1.type_id
152 self.data = []
153
154 def write(self, fp):
155 self.header.length = len(self.data)
156 self.header.write(fp)
157 fp.write(bytes(self.data))
158
159
160########################################################################################################################
161
162
163def _generate_font_glyphs_list(use_ascii, unicode_glyphs):
164 # The set of glyphs that we want to generate images for
165 glyphs = {}
166
167 # Add ascii charset if requested
168 if use_ascii is True:
169 for c in range(0x20, 0x7F): # does not include 0x7F!
170 glyphs[chr(c)] = True
171
172 # Append any extra unicode glyphs
173 unicode_glyphs = list(unicode_glyphs)
174 for c in unicode_glyphs:
175 glyphs[c] = True
176
177 return sorted(glyphs.keys())
178
179
180class QFFFont:
181 def __init__(self, logger):
182 self.logger = logger
183 self.image = None
184 self.glyph_data = {}
185 self.glyph_height = 0
186 return
187
188 def _extract_glyphs(self, format):
189 total_data_size = 0
190 total_rle_data_size = 0
191
192 converted_img = qmk.painter.convert_requested_format(self.image, format)
193 (self.palette, _) = qmk.painter.convert_image_bytes(converted_img, format)
194
195 # Work out how many bytes used for RLE vs. non-RLE
196 for _, glyph_entry in self.glyph_data.items():
197 glyph_img = converted_img.crop((glyph_entry.x, 1, glyph_entry.x + glyph_entry.w, 1 + self.glyph_height))
198 (_, this_glyph_image_bytes) = qmk.painter.convert_image_bytes(glyph_img, format)
199 this_glyph_rle_bytes = qmk.painter.compress_bytes_qmk_rle(this_glyph_image_bytes)
200 total_data_size += len(this_glyph_image_bytes)
201 total_rle_data_size += len(this_glyph_rle_bytes)
202 glyph_entry['image_uncompressed_bytes'] = this_glyph_image_bytes
203 glyph_entry['image_compressed_bytes'] = this_glyph_rle_bytes
204
205 return (total_data_size, total_rle_data_size)
206
207 def _parse_image(self, img, include_ascii_glyphs: bool = True, unicode_glyphs: str = ''):
208 # Clear out any existing font metadata
209 self.image = None
210 # Each glyph is key=code_point, value={ x: ?, w: ? }
211 self.glyph_data = {}
212 self.glyph_height = 0
213
214 # Work out the list of glyphs required
215 glyphs = _generate_font_glyphs_list(include_ascii_glyphs, unicode_glyphs)
216
217 # Work out the geometry
218 (width, height) = img.size
219
220 # Work out the glyph offsets/widths
221 glyph_pixel_offsets = []
222 glyph_pixel_widths = []
223 pixels = img.load()
224
225 # Run through the markers and work out where each glyph starts/stops
226 glyph_split_color = pixels[0, 0] # top left pixel is the marker color we're going to use to split each glyph
227 glyph_pixel_offsets.append(0)
228 last_offset = 0
229 for x in range(1, width):
230 if pixels[x, 0] == glyph_split_color:
231 glyph_pixel_offsets.append(x)
232 glyph_pixel_widths.append(x - last_offset)
233 last_offset = x
234 glyph_pixel_widths.append(width - last_offset)
235
236 # Make sure the number of glyphs we're attempting to generate matches the input image
237 if len(glyph_pixel_offsets) != len(glyphs):
238 self.logger.error('The number of glyphs to generate doesn\'t match the number of detected glyphs in the input image.')
239 return
240
241 # Set up the required metadata for each glyph
242 for n in range(0, len(glyph_pixel_offsets)):
243 self.glyph_data[glyphs[n]] = QFFGlyphInfo(code_point=glyphs[n], x=glyph_pixel_offsets[n], w=glyph_pixel_widths[n])
244
245 # Parsing was successful, keep the image in this instance
246 self.image = img
247 self.glyph_height = height - 1 # subtract the line with the markers
248
249 def generate_image(self, ttf_file: Path, font_size: int, include_ascii_glyphs: bool = True, unicode_glyphs: str = '', include_before_left: bool = False, use_aa: bool = True):
250 # Load the font
251 font = ImageFont.truetype(str(ttf_file), int(font_size))
252 # Work out the max font size
253 max_font_size = font.font.ascent + abs(font.font.descent)
254 # Work out the list of glyphs required
255 glyphs = _generate_font_glyphs_list(include_ascii_glyphs, unicode_glyphs)
256
257 baseline_offset = 9999999
258 total_glyph_width = 0
259 max_glyph_height = -1
260
261 # Measure each glyph to determine the overall baseline offset required
262 for glyph in glyphs:
263 (ls_l, ls_t, ls_r, ls_b) = font.getbbox(glyph, anchor='ls')
264 glyph_width = (ls_r - ls_l) if include_before_left else (ls_r)
265 glyph_height = font.getbbox(glyph, anchor='la')[3]
266 if max_glyph_height < glyph_height:
267 max_glyph_height = glyph_height
268 total_glyph_width += glyph_width
269 if baseline_offset > ls_t:
270 baseline_offset = ls_t
271
272 # Create the output image
273 img = Image.new("RGB", (total_glyph_width + 1, max_font_size * 2 + 1), (0, 0, 0, 255))
274 cur_x_pos = 0
275
276 # Loop through each glyph...
277 for glyph in glyphs:
278 # Work out this glyph's bounding box
279 (ls_l, ls_t, ls_r, ls_b) = font.getbbox(glyph, anchor='ls')
280 glyph_width = (ls_r - ls_l) if include_before_left else (ls_r)
281 glyph_height = ls_b - ls_t
282 x_offset = -ls_l
283 y_offset = ls_t - baseline_offset
284
285 # Draw each glyph to its own image so we don't get anti-aliasing applied to the final image when straddling edges
286 glyph_img = Image.new("RGB", (glyph_width, max_font_size), (0, 0, 0, 255))
287 glyph_draw = ImageDraw.Draw(glyph_img)
288 if not use_aa:
289 glyph_draw.fontmode = "1"
290 glyph_draw.text((x_offset, y_offset), glyph, font=font, anchor='lt')
291
292 # Place the glyph-specific image in the correct location overall
293 img.paste(glyph_img, (cur_x_pos, 1))
294
295 # Set up the marker for start of each glyph
296 pixels = img.load()
297 pixels[cur_x_pos, 0] = (255, 0, 255)
298
299 # Increment for the next glyph's position
300 cur_x_pos += glyph_width
301
302 # Add the ending marker so that the difference/crop works
303 pixels = img.load()
304 pixels[cur_x_pos, 0] = (255, 0, 255)
305
306 # Determine the usable font area
307 dummy_img = Image.new("RGB", (total_glyph_width + 1, max_font_size + 1), (0, 0, 0, 255))
308 bbox = ImageChops.difference(img, dummy_img).getbbox()
309 bbox = (bbox[0], bbox[1], bbox[2] - 1, bbox[3]) # remove the unused end-marker
310
311 # Crop and re-parse the resulting image to ensure we're generating the correct format
312 self._parse_image(img.crop(bbox), include_ascii_glyphs, unicode_glyphs)
313
314 def save_to_image(self, img_file: Path):
315 # Drop out if there's no image loaded
316 if self.image is None:
317 self.logger.error('No image is loaded.')
318 return
319
320 # Save the image to the supplied file
321 self.image.save(str(img_file))
322
323 def read_from_image(self, img_file: Path, include_ascii_glyphs: bool = True, unicode_glyphs: str = ''):
324 # Load and parse the supplied image file
325 self._parse_image(Image.open(str(img_file)), include_ascii_glyphs, unicode_glyphs)
326 return
327
328 def save_to_qff(self, format: Dict[str, Any], use_rle: bool, fp):
329 # Drop out if there's no image loaded
330 if self.image is None:
331 self.logger.error('No image is loaded.')
332 return
333
334 # Work out if we want to use RLE at all, skipping it if it's not any smaller (it's applied per-glyph)
335 (total_data_size, total_rle_data_size) = self._extract_glyphs(format)
336 if use_rle:
337 use_rle = (total_rle_data_size < total_data_size)
338
339 # For each glyph, work out which image data we want to use and append it to the image buffer, recording the byte-wise offset
340 img_buffer = bytes()
341 for _, glyph_entry in self.glyph_data.items():
342 glyph_entry['data_offset'] = len(img_buffer)
343 glyph_img_bytes = glyph_entry.image_compressed_bytes if use_rle else glyph_entry.image_uncompressed_bytes
344 img_buffer += bytes(glyph_img_bytes)
345
346 font_descriptor = QFFFontDescriptor()
347 ascii_table = QFFAsciiGlyphTableV1()
348 unicode_table = QFFUnicodeGlyphTableV1()
349 data_descriptor = QFFFontDataDescriptorV1()
350 data_descriptor.data = img_buffer
351
352 # Check if we have all the ASCII glyphs present
353 include_ascii_glyphs = all([chr(n) in self.glyph_data for n in range(0x20, 0x7F)])
354
355 # Helper for populating the blocks
356 for code_point, glyph_entry in self.glyph_data.items():
357 if ord(code_point) >= 0x20 and ord(code_point) <= 0x7E and include_ascii_glyphs:
358 ascii_table.add_glyph(glyph_entry)
359 else:
360 unicode_table.add_glyph(glyph_entry)
361
362 # Configure the font descriptor
363 font_descriptor.line_height = self.glyph_height
364 font_descriptor.has_ascii_table = include_ascii_glyphs
365 font_descriptor.unicode_glyph_count = len(unicode_table.glyphs.keys())
366 font_descriptor.is_transparent = False
367 font_descriptor.format = format['image_format_byte']
368 font_descriptor.compression = 0x01 if use_rle else 0x00
369
370 # Write a dummy font descriptor -- we'll have to come back and write it properly once we've rendered out everything else
371 font_descriptor_location = fp.tell()
372 font_descriptor.write(fp)
373
374 # Write out the ASCII table if required
375 if font_descriptor.has_ascii_table:
376 ascii_table.write(fp)
377
378 # Write out the unicode table if required
379 if font_descriptor.unicode_glyph_count > 0:
380 unicode_table.write(fp)
381
382 # Write out the palette if required
383 if format['has_palette']:
384 palette_descriptor = QGFFramePaletteDescriptorV1()
385
386 # Helper to convert from RGB888 to the QMK "dialect" of HSV888
387 def rgb888_to_qmk_hsv888(e):
388 hsv = rgb_to_hsv(e[0] / 255.0, e[1] / 255.0, e[2] / 255.0)
389 return (int(hsv[0] * 255.0), int(hsv[1] * 255.0), int(hsv[2] * 255.0))
390
391 # Convert all palette entries to HSV888 and write to the output
392 palette_descriptor.palette_entries = list(map(rgb888_to_qmk_hsv888, self.palette))
393 palette_descriptor.write(fp)
394
395 # Write out the image data
396 data_descriptor.write(fp)
397
398 # Now fix up the overall font descriptor, then write it in the correct location
399 font_descriptor.total_file_size = fp.tell()
400 fp.seek(font_descriptor_location, 0)
401 font_descriptor.write(fp)
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()}")