diff options
Diffstat (limited to 'lib/python/qmk')
| -rw-r--r-- | lib/python/qmk/cli/__init__.py | 4 | ||||
| -rw-r--r-- | lib/python/qmk/cli/painter/__init__.py | 2 | ||||
| -rw-r--r-- | lib/python/qmk/cli/painter/convert_graphics.py | 86 | ||||
| -rw-r--r-- | lib/python/qmk/cli/painter/make_font.py | 87 | ||||
| -rw-r--r-- | lib/python/qmk/painter.py | 268 | ||||
| -rw-r--r-- | lib/python/qmk/painter_qff.py | 401 | ||||
| -rw-r--r-- | lib/python/qmk/painter_qgf.py | 408 |
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 | ||
| 22 | safe_commands = [ | 23 | safe_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 @@ | |||
| 1 | from . import convert_graphics | ||
| 2 | from . 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 | """ | ||
| 3 | import re | ||
| 4 | import datetime | ||
| 5 | from io import BytesIO | ||
| 6 | from qmk.path import normpath | ||
| 7 | from qmk.painter import render_header, render_source, render_license, render_bytes, valid_formats | ||
| 8 | from milc import cli | ||
| 9 | from 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') | ||
| 19 | def 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 | |||
| 4 | import re | ||
| 5 | import datetime | ||
| 6 | from io import BytesIO | ||
| 7 | from qmk.path import normpath | ||
| 8 | from qmk.painter_qff import QFFFont | ||
| 9 | from qmk.painter import render_header, render_source, render_license, render_bytes, valid_formats | ||
| 10 | from 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') | ||
| 20 | def 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') | ||
| 37 | def 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 | """ | ||
| 3 | import math | ||
| 4 | import re | ||
| 5 | from string import Template | ||
| 6 | from PIL import Image, ImageOps | ||
| 7 | |||
| 8 | # The list of valid formats Quantum Painter supports | ||
| 9 | valid_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 | |||
| 68 | license_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 | |||
| 76 | def render_license(subs): | ||
| 77 | license_txt = Template(license_template) | ||
| 78 | return license_txt.substitute(subs) | ||
| 79 | |||
| 80 | |||
| 81 | header_file_template = """\ | ||
| 82 | ${license} | ||
| 83 | #pragma once | ||
| 84 | |||
| 85 | #include <qp.h> | ||
| 86 | |||
| 87 | extern const uint32_t ${var_prefix}_${sane_name}_length; | ||
| 88 | extern const uint8_t ${var_prefix}_${sane_name}[${byte_count}]; | ||
| 89 | """ | ||
| 90 | |||
| 91 | |||
| 92 | def render_header(subs): | ||
| 93 | header_txt = Template(header_file_template) | ||
| 94 | return header_txt.substitute(subs) | ||
| 95 | |||
| 96 | |||
| 97 | source_file_template = """\ | ||
| 98 | ${license} | ||
| 99 | #include <qp.h> | ||
| 100 | |||
| 101 | const uint32_t ${var_prefix}_${sane_name}_length = ${byte_count}; | ||
| 102 | |||
| 103 | // clang-format off | ||
| 104 | const uint8_t ${var_prefix}_${sane_name}[${byte_count}] = { | ||
| 105 | ${bytes_lines} | ||
| 106 | }; | ||
| 107 | // clang-format on | ||
| 108 | """ | ||
| 109 | |||
| 110 | |||
| 111 | def render_source(subs): | ||
| 112 | source_txt = Template(source_file_template) | ||
| 113 | return source_txt.substitute(subs) | ||
| 114 | |||
| 115 | |||
| 116 | def 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 | |||
| 127 | def 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 | |||
| 133 | def 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 | |||
| 139 | def 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 | |||
| 164 | def 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 | |||
| 221 | def 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 | |||
| 7 | from pathlib import Path | ||
| 8 | from typing import Dict, Any | ||
| 9 | from colorsys import rgb_to_hsv | ||
| 10 | from PIL import Image, ImageDraw, ImageFont, ImageChops | ||
| 11 | from PIL._binary import o8, o16le as o16, o32le as o32 | ||
| 12 | from qmk.painter_qgf import QGFBlockHeader, QGFFramePaletteDescriptorV1 | ||
| 13 | from milc.attrdict import AttrDict | ||
| 14 | import qmk.painter | ||
| 15 | |||
| 16 | |||
| 17 | def o24(i): | ||
| 18 | return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16) | ||
| 19 | |||
| 20 | |||
| 21 | ######################################################################################################################## | ||
| 22 | |||
| 23 | |||
| 24 | class 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 | |||
| 45 | class 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 | |||
| 96 | class 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 | |||
| 121 | class 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 | |||
| 146 | class 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 | |||
| 163 | def _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 | |||
| 180 | class 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 | |||
| 7 | from colorsys import rgb_to_hsv | ||
| 8 | from types import FunctionType | ||
| 9 | from PIL import Image, ImageFile, ImageChops | ||
| 10 | from PIL._binary import o8, o16le as o16, o32le as o32 | ||
| 11 | import qmk.painter | ||
| 12 | |||
| 13 | |||
| 14 | def o24(i): | ||
| 15 | return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16) | ||
| 16 | |||
| 17 | |||
| 18 | ######################################################################################################################## | ||
| 19 | |||
| 20 | |||
| 21 | class 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 | |||
| 35 | class 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 | |||
| 67 | class 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 | |||
| 88 | class 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 | |||
| 138 | class 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 | |||
| 161 | class 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 | |||
| 187 | class 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 | |||
| 204 | class 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 | |||
| 216 | def _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 | |||
| 224 | def _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 | ||
| 404 | Image.register_open(QGFImageFile.format, QGFImageFile, _accept) | ||
| 405 | Image.register_save(QGFImageFile.format, _save) | ||
| 406 | Image.register_save_all(QGFImageFile.format, _save) | ||
| 407 | Image.register_extension(QGFImageFile.format, f".{QGFImageFile.format.lower()}") | ||
| 408 | Image.register_mime(QGFImageFile.format, f"image/{QGFImageFile.format.lower()}") | ||
