blob: 5cc4c070fc50d784bf57f49aaf71f81eedace1e6 [file] [log] [blame] [edit]
#!/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)
))