| #!/usr/bin/env python3 |
| |
| """ |
| 32blit sprite file format |
| |
| # 12 byte common header |
| |
| - 8: Type of file identifier ("SPRITEPK", "SPRITERW") |
| - 2: Total file size |
| |
| # SPRITEPK header (Packed paletted sprite) |
| |
| A packed sprite can have a variable number of palette colours up to 255. |
| |
| Data is packed at the number of bits-per-pixel required to index each palette entry. |
| |
| IE: a two colour image will be packed with 1bpp |
| |
| - 2: Sprite width |
| - 2: Sprite height |
| - 2: Sprite columns |
| - 2: Sprite rows |
| - 1: Format (always paletted) |
| - 1: Palette size |
| - X * 4: Pallete entries (where X = number of palette entries) |
| - Y: Image data |
| |
| Usage: ./sprite-builder packed input-file.png |
| "input-file.png" should contain no more than 255 colours! |
| |
| # SPRITERW header (Raw sprite - RGBA, RGB, P or M) |
| |
| An unpacked sprite is just a block of raw surface data |
| in either RGBA (4 bytes), RGB (3 bytes) or P/M (1 bytes) format. |
| |
| Unpacked sprites are *huge* but useful for streaming into the framebuffer from storage. |
| |
| The max power of two sprite sizes supported by this file format are- |
| |
| - 64x64 in RGBA and RGB mode |
| - 128x128 in P mode |
| |
| - 2: Sprite width |
| - 2: Sprite height |
| - 2: Sprite columns |
| - 2: Sprite rows |
| - 1: Format (RGBA, RGB, P or M) |
| - Y: Image data |
| |
| Usage: ./sprite-builder raw --format RGB565 input-file.png |
| |
| """ |
| |
| import argparse |
| import math |
| import sys |
| |
| from bitstring import BitArray |
| from construct import this, BitStruct, BitsInteger, Struct, Int8ul, Int16ul, Array, PrefixedArray, Computed, Switch, GreedyBytes, Enum, PaddedString |
| from PIL import Image, ImagePalette |
| |
| |
| def compute_bit_length(ctx): |
| """Compute the required bit length for image data. |
| |
| Uses the count of items in the palette to determine how |
| densely we can pack the image data. |
| |
| """ |
| if ctx.format == SpriteFormat.enum.p: |
| return (len(ctx.palette.colours) - 1).bit_length() |
| elif ctx.format == SpriteFormat.enum.rgba: |
| return 32 |
| elif ctx.format == SpriteFormat.enum.rgb: |
| return 24 |
| else: |
| return 0 |
| |
| |
| def compute_data_length(ctx): |
| """Compute the required data length for palette based images. |
| |
| We need this computation here so we can use `math.ceil` and |
| byte-align the result. |
| |
| """ |
| if ctx.format == SpriteFormat.enum.p: |
| return math.ceil((ctx.width * ctx.height * ctx.bit_length) / 8) |
| else: |
| return ctx.width * ctx.height |
| |
| |
| class SmartEnum(Enum): |
| """Fudge to support SpriteFormat.enum.p etc""" |
| def __init__(self, *args, **kwargs): |
| Enum.__init__(self, *args, **kwargs) |
| |
| class x(): |
| pass |
| self.enum = x() |
| for e in self.encmapping.keys(): |
| self.enum.__dict__[e] = e |
| |
| |
| AssetType = Enum(PaddedString(8, "utf8"), packed='SPRITEPK', raw='SPRITERW') |
| |
| SpriteFormat = SmartEnum( |
| Int8ul, |
| rgb=0, |
| rgba=1, |
| p=2, |
| m=3 |
| ) |
| |
| asset_rgba = Struct( |
| 'r' / Int8ul, |
| 'g' / Int8ul, |
| 'b' / Int8ul, |
| 'a' / Int8ul |
| ) |
| |
| asset_rgb = Struct( |
| 'r' / Int8ul, |
| 'g' / Int8ul, |
| 'b' / Int8ul |
| ) |
| |
| asset_packed = Struct( |
| 'width' / Int16ul, |
| 'height' / Int16ul, |
| 'columns' / Int16ul, |
| 'rows' / Int16ul, |
| 'format' / SpriteFormat, |
| 'palette' / Struct( |
| 'colours' / PrefixedArray(Int8ul, asset_rgba), |
| ), |
| 'bit_length' / Computed(compute_bit_length), |
| 'data_length' / Computed(compute_data_length), |
| 'data' / Array(this.data_length, Int8ul) |
| ) |
| |
| asset_raw = Struct( |
| 'width' / Int16ul, |
| 'height' / Int16ul, |
| 'columns' / Int16ul, |
| 'rows' / Int16ul, |
| 'format' / SpriteFormat, |
| 'bit_length' / Computed(compute_bit_length), |
| 'data_length' / Computed(compute_data_length), |
| 'data' / Switch(this.format, { |
| 'rgba': Array(this.data_length, asset_rgba), |
| 'rgb': Array(this.data_length, asset_rgb), |
| 'p': Array(this.data_length, Int8ul), |
| 'm': Array(this.data_length, Int8ul) |
| }) |
| ) |
| |
| asset = Struct( |
| 'header' / Struct( |
| 'type' / AssetType, |
| 'size' / Int16ul, |
| ), |
| 'data' / GreedyBytes |
| ) |
| |
| typemap = { |
| 'packed': asset_packed, |
| 'raw': asset_raw |
| } |
| |
| |
| def unpack_asset(asset_data): |
| unpacked = asset.parse(asset_data) |
| builder = typemap[unpacked.header.type] |
| return builder.parse(unpacked.data) |
| |
| |
| def build_asset(asset_type, asset_data): |
| builder = typemap[asset_type] |
| packed = builder.build(asset_data) |
| total_size = len(packed) + asset.header.sizeof() |
| if total_size > 65535: |
| raise RuntimeError("Sprite exceeds maximum filesize: {} > 65535".format(total_size)) |
| return asset.build(dict( |
| header=dict( |
| type=asset_type, |
| size=total_size, |
| ), |
| data=packed |
| )) |
| |
| |
| def color(r, g, b, a=None): |
| a = 0xff if a is None else a |
| return dict(r=r, g=g, b=b, a=a) |
| |
| |
| def rgba_string(c): |
| return "rgba({r}, {g}, {b}, {a})".format(**c) |
| |
| |
| def format_hex(data): |
| return ', '.join(['0x{:02x}'.format(x) for x in data]) |
| |
| |
| def load_palette(image): |
| palette_colours = [] |
| |
| if image.palette is None: |
| # Divine the palette from the individual pixels in the image |
| # they will be inserted into our target palette in the order |
| # found in the palette image |
| mode = image.mode |
| w, h = image.size |
| |
| if mode in ['RGB', 'RGBA']: |
| for y in range(h): |
| for x in range(w): |
| # The spread operator will take both RGB and RGBA |
| # tuples and pass them into color() properly. |
| c = color(*image.getpixel((x, y))) |
| if c not in palette_colours: |
| palette_colours.append(c) |
| |
| else: |
| raise RuntimeError(f"Unsupported image mode: {mode}") |
| |
| else: |
| mode, palette = image.palette.getdata() |
| palette = list(palette) |
| if mode == 'RGB': |
| for x in range(len(palette) // 3): |
| r, g, b = palette[x * 3:(x * 3) + 3] |
| palette_colours.append(color(r, g, b)) |
| |
| elif mode == 'RGBA': |
| for x in range(len(palette) // 4): |
| r, g, b, a = palette[x * 4:(x * 4) + 4] |
| palette_colours.append(color(r, g, b, a)) |
| |
| if len(palette_colours) > 255: |
| raise RuntimeError(f"Too many colours in palette! ({len(palette_colours)})") |
| |
| return palette_colours |
| |
| |
| if __name__ == '__main__': |
| parser = argparse.ArgumentParser() |
| subparsers = parser.add_subparsers(help='Commands', dest='command') |
| parser_packed = subparsers.add_parser('packed', help='Process an image file into a paletted 32blit sprite') |
| parser_raw = subparsers.add_parser('raw', help='Process an image file into a raw (rgba, rgb) 32blit sprite') |
| |
| parser.add_argument('--out', type=str, help='set output filename (default <inputfile>.blit)', default=None) |
| parser.add_argument('file', type=str, help='input file') |
| |
| # The engine presently only supports 8x8 tile sizes |
| parser.add_argument('--tilesizex', type=int, help='tile size x', default=8) |
| parser.add_argument('--tilesizey', type=int, help='tile size y', default=8) |
| |
| # Allow for naming of the resulting packed_data array |
| parser_packed.add_argument('--arrayname', type=str, help='set output array name (default <packed_data>)', default='packed_data') |
| |
| # Raw input can be given as any image type, and will be packed into: |
| # * RGBA (full fidelity) |
| # * RGB888 (discard alpha) |
| # * RGB565 (discard alpha, convert colours with bitshift: R >> 3, G >> 2 B >> 3) |
| parser_raw.add_argument('--format', type=str, choices=('RGBA', 'RGB'), help='raw data output format', default='RGBA') |
| |
| # A palette file should be a PNG file containing a series of pixels in the desired colours and order of the output palette. |
| # If it's indexed, 8bpp colour then its palette will be used directly, otherwise the pixel data will be converted automatically. |
| parser_packed.add_argument('--palette', type=str, help='image or palette file', default=None) |
| |
| args = parser.parse_args() |
| |
| source_image = Image.open(args.file) |
| |
| if args.command == 'raw': |
| # Make sure we're always working with the same type of image |
| source_image = source_image.convert('RGBA') |
| |
| raw_data = [] |
| |
| for y in range(source_image.height): |
| for x in range(source_image.width): |
| r, g, b, a = source_image.getpixel((x, y)) |
| if args.format == 'RGBA': |
| raw_data.append({'r': r, 'g': g, 'b': b, 'a': a}) |
| if args.format == 'RGB': |
| raw_data.append({'r': r, 'g': g, 'b': b}) |
| |
| raw = build_asset( |
| 'raw', |
| dict( |
| width=source_image.width, |
| height=source_image.height, |
| columns=source_image.width // args.tilesizex, |
| rows=source_image.height // args.tilesizey, |
| format=args.format.lower(), |
| data=raw_data |
| ) |
| ) |
| |
| if args.out is not None: |
| with open(args.out, 'wb+') as f: |
| f.write(raw) |
| sys.exit(0) |
| |
| if args.command == 'packed': |
| target_image = source_image |
| source_mode = source_image.mode |
| source_palette_colours = load_palette(source_image) |
| target_palette_colours = source_palette_colours |
| |
| bit_length = (len(source_palette_colours) - 1).bit_length() |
| |
| if source_mode == 'P': |
| if args.palette is not None: |
| palette_image = Image.open(args.palette) |
| target_palette_colours = load_palette(palette_image) |
| |
| w, h = source_image.size |
| for y in range(h): |
| for x in range(w): |
| p = source_image.getpixel((x, y)) |
| c = source_palette_colours[p] |
| try: |
| new_p = target_palette_colours.index(c) |
| target_image.putpixel((x, y), new_p) |
| except ValueError: |
| raise RuntimeError("Colour {},{},{},{} not found in target palette!".format(*c.rgba())) |
| |
| source_palette_colours = target_palette_colours |
| else: |
| target_image = Image.new('P', source_image.size) |
| w, h = source_image.size |
| for y in range(h): |
| for x in range(w): |
| if source_mode == 'RGBA': |
| r, g, b, a = source_image.getpixel((x, y)) |
| c = color(r, g, b, a) |
| elif source_mode == 'RGB': |
| r, g, b = source_image.getpixel((x, y)) |
| c = color(r, g, b) |
| |
| try: |
| new_p = target_palette_colours.index(c) |
| target_image.putpixel((x, y), new_p) |
| except ValueError: |
| raise RuntimeError("Colour {},{},{},{} not found in target palette!".format(*c.rgba())) |
| |
| |
| |
| packed_image_data = BitArray().join(BitArray(uint=x, length=bit_length) for x in target_image.tobytes()).tobytes() |
| |
| packed = build_asset( |
| 'packed', |
| dict( |
| width=target_image.width, |
| height=target_image.height, |
| columns=target_image.width // args.tilesizex, |
| rows=target_image.height // args.tilesizey, |
| format='p', |
| palette=dict( |
| colours=source_palette_colours |
| ), |
| data=packed_image_data |
| ) |
| ) |
| |
| if args.out is not None: |
| with open(args.out, 'wb+') as f: |
| f.write(packed) |
| sys.exit(0) |
| |
| packed = list(packed) |
| formatted_data = [] |
| formatted_palette = [] |
| row_width = 16 |
| |
| HEADER_SIZE = 20 |
| |
| PALETTE_SIZE = len(source_palette_colours) * 4 |
| |
| palette_data = packed[HEADER_SIZE:HEADER_SIZE + PALETTE_SIZE] |
| for x in range(0, math.ceil(len(palette_data) / 4)): |
| offset = x * 4 |
| current_row = palette_data[offset:offset + 4] |
| formatted_palette.append(format_hex(current_row)) |
| |
| packed_data = packed[HEADER_SIZE + PALETTE_SIZE:] |
| for x in range(0, math.ceil(len(packed_data) / float(row_width))): |
| offset = x * row_width |
| current_row = packed_data[offset:offset + row_width] |
| formatted_data.append(format_hex(current_row)) |
| |
| print(""" |
| uint8_t {array_name}[] = {{ |
| {type}, // type: spritepk (packed, paletted sprite) |
| {payload_size}, // payload size ({comment_payload_size}) |
| |
| {width}, // width ({comment_width}) |
| {height}, // height ({comment_height}) |
| {cols}, // cols ({comment_cols}) |
| {rows}, // rows ({comment_rows}) |
| |
| {format}, // format (paletted) |
| |
| {colours}, // number of palette colours ({number_of_colours}) |
| // r g b a |
| {palette}, |
| |
| {data} |
| }}; |
| """.format( |
| array_name=args.arrayname, |
| type=format_hex(packed[0:8]), |
| payload_size=format_hex(packed[8:10]), |
| comment_payload_size=len(packed), |
| width=format_hex(packed[10:12]), |
| comment_width=target_image.width, |
| height=format_hex(packed[12:14]), |
| comment_height=target_image.height, |
| cols=format_hex(packed[14:16]), |
| comment_cols=target_image.width // args.tilesizex, |
| rows=format_hex(packed[16:18]), |
| comment_rows=target_image.height // args.tilesizey, |
| format=format_hex(packed[18:19]), |
| colours=format_hex(packed[19:20]), |
| number_of_colours=packed[19:20][0], |
| data=',\n '.join(formatted_data), |
| palette=',\n '.join(formatted_palette) |
| )) |