ref: 083d51fd588d9752b8f7c5d104cffc3896d51b5b
dir: /tools/pokemontools/gfx.py/
# -*- coding: utf-8 -*- import os import sys import png from math import sqrt, floor, ceil import argparse import operator from lz import Compressed, Decompressed def split(list_, interval): """ Split a list by length. """ for i in xrange(0, len(list_), interval): j = min(i + interval, len(list_)) yield list_[i:j] def hex_dump(data, length=0x10): """ just use hexdump -C """ margin = len('%x' % len(data)) output = [] address = 0 for line in split(data, length): output += [ hex(address)[2:].zfill(margin) + ' | ' + ' '.join('%.2x' % byte for byte in line) ] address += length return '\n'.join(output) def get_tiles(image): """ Split a 2bpp image into 8x8 tiles. """ return list(split(image, 0x10)) def connect(tiles): """ Combine 8x8 tiles into a 2bpp image. """ return [byte for tile in tiles for byte in tile] def transpose(tiles, width=None): """ Transpose a tile arrangement along line y=-x. 00 01 02 03 04 05 00 06 0c 12 18 1e 06 07 08 09 0a 0b 01 07 0d 13 19 1f 0c 0d 0e 0f 10 11 <-> 02 08 0e 14 1a 20 12 13 14 15 16 17 03 09 0f 15 1b 21 18 19 1a 1b 1c 1d 04 0a 10 16 1c 22 1e 1f 20 21 22 23 05 0b 11 17 1d 23 00 01 02 03 00 04 08 04 05 06 07 <-> 01 05 09 08 09 0a 0b 02 06 0a 03 07 0b """ if width == None: width = int(sqrt(len(tiles))) # assume square image tiles = sorted(enumerate(tiles), key= lambda (i, tile): i % width) return [tile for i, tile in tiles] def transpose_tiles(image, width=None): return connect(transpose(get_tiles(image), width)) def interleave(tiles, width): """ 00 01 02 03 04 05 00 02 04 06 08 0a 06 07 08 09 0a 0b 01 03 05 07 09 0b 0c 0d 0e 0f 10 11 --> 0c 0e 10 12 14 16 12 13 14 15 16 17 0d 0f 11 13 15 17 18 19 1a 1b 1c 1d 18 1a 1c 1e 20 22 1e 1f 20 21 22 23 19 1b 1d 1f 21 23 """ interleaved = [] left, right = split(tiles[::2], width), split(tiles[1::2], width) for l, r in zip(left, right): interleaved += l + r return interleaved def deinterleave(tiles, width): """ 00 02 04 06 08 0a 00 01 02 03 04 05 01 03 05 07 09 0b 06 07 08 09 0a 0b 0c 0e 10 12 14 16 --> 0c 0d 0e 0f 10 11 0d 0f 11 13 15 17 12 13 14 15 16 17 18 1a 1c 1e 20 22 18 19 1a 1b 1c 1d 19 1b 1d 1f 21 23 1e 1f 20 21 22 23 """ deinterleaved = [] rows = list(split(tiles, width)) for left, right in zip(rows[::2], rows[1::2]): for l, r in zip(left, right): deinterleaved += [l, r] return deinterleaved def interleave_tiles(image, width): return connect(interleave(get_tiles(image), width)) def deinterleave_tiles(image, width): return connect(deinterleave(get_tiles(image), width)) def condense_image_to_map(image, pic=0): """ Reduce an image of adjacent frames to an image containing a base frame and any unrepeated tiles. Returns the new image and the corresponding tilemap used to reconstruct the input image. If <pic> is 0, ignore the concept of frames. This behavior might be better off as another function. """ tiles = get_tiles(image) new_tiles, tilemap = condense_tiles_to_map(tiles, pic) new_image = connect(new_tiles) return new_image, tilemap def condense_tiles_to_map(tiles, pic=0): """ Reduce a sequence of tiles representing adjacent frames to a base frame and any unrepeated tiles. Returns the new tiles and the corresponding tilemap used to reconstruct the input tile sequence. If <pic> is 0, ignore the concept of frames. This behavior might be better off as another function. """ # Leave the first frame intact for pics. new_tiles = tiles[:pic] tilemap = range(pic) for i, tile in enumerate(tiles[pic:]): if tile not in new_tiles: new_tiles.append(tile) if pic: # Match the first frame exactly where possible. # This reduces the space needed to replace tiles in pic animations. # For example, if a tile is repeated twice in the first frame, # but at the same relative index as the second tile, use the second index. # When creating a bitmask later, the second index would not require a replacement, but the first index would have. pic_i = i % pic if tile == new_tiles[pic_i]: tilemap.append(pic_i) else: tilemap.append(new_tiles.index(tile)) else: tilemap.append(new_tiles.index(tile)) return new_tiles, tilemap def test_condense_tiles_to_map(): test = condense_tiles_to_map(list('abcadbae')) if test != (list('abcde'), [0, 1, 2, 0, 3, 1, 0, 4]): raise Exception(test) test = condense_tiles_to_map(list('abcadbae'), 2) if test != (list('abcde'), [0, 1, 2, 0, 3, 1, 0, 4]): raise Exception(test) test = condense_tiles_to_map(list('abcadbae'), 4) if test != (list('abcade'), [0, 1, 2, 3, 4, 1, 0, 5]): raise Exception(test) test = condense_tiles_to_map(list('abcadbea'), 4) if test != (list('abcade'), [0, 1, 2, 3, 4, 1, 5, 3]): raise Exception(test) def to_file(filename, data): """ Apparently open(filename, 'wb').write(bytearray(data)) won't work. """ file = open(filename, 'wb') for byte in data: file.write('%c' % byte) file.close() def decompress_file(filein, fileout=None): image = bytearray(open(filein).read()) de = Decompressed(image) if fileout == None: fileout = os.path.splitext(filein)[0] to_file(fileout, de.output) def compress_file(filein, fileout=None): image = bytearray(open(filein).read()) lz = Compressed(image) if fileout == None: fileout = filein + '.lz' to_file(fileout, lz.output) def bin_to_rgb(word): red = word & 0b11111 word >>= 5 green = word & 0b11111 word >>= 5 blue = word & 0b11111 return (red, green, blue) def convert_binary_pal_to_text_by_filename(filename): pal = bytearray(open(filename).read()) return convert_binary_pal_to_text(pal) def convert_binary_pal_to_text(pal): output = '' words = [hi * 0x100 + lo for lo, hi in zip(pal[::2], pal[1::2])] for word in words: red, green, blue = ['%.2d' % c for c in bin_to_rgb(word)] output += '\tRGB ' + ', '.join((red, green, blue)) output += '\n' return output def read_rgb_macros(lines): colors = [] for line in lines: macro = line.split(" ")[0].strip() if macro == 'RGB': params = ' '.join(line.split(" ")[1:]).split(',') red, green, blue = [int(v) for v in params] colors += [[red, green, blue]] return colors def rewrite_binary_pals_to_text(filenames): for filename in filenames: pal_text = convert_binary_pal_to_text_by_filename(filename) with open(filename, 'w') as out: out.write(pal_text) def flatten(planar): """ Flatten planar 2bpp image data into a quaternary pixel map. """ strips = [] for bottom, top in split(planar, 2): bottom = bottom top = top strip = [] for i in xrange(7,-1,-1): color = ( (bottom >> i & 1) + (top *2 >> i & 2) ) strip += [color] strips += strip return strips def to_lines(image, width): """ Convert a tiled quaternary pixel map to lines of quaternary pixels. """ tile_width = 8 tile_height = 8 num_columns = width / tile_width height = len(image) / width lines = [] for cur_line in xrange(height): tile_row = cur_line / tile_height line = [] for column in xrange(num_columns): anchor = ( num_columns * tile_row * tile_width * tile_height + column * tile_width * tile_height + cur_line % tile_height * tile_width ) line += image[anchor : anchor + tile_width] lines += [line] return lines def dmg2rgb(word): """ For PNGs. """ def shift(value): while True: yield value & (2**5 - 1) value >>= 5 word = shift(word) # distribution is less even w/ << 3 red, green, blue = [int(color * 8.25) for color in [word.next() for _ in xrange(3)]] alpha = 255 return (red, green, blue, alpha) def rgb_to_dmg(color): """ For PNGs. """ word = (color['r'] / 8) word += (color['g'] / 8) << 5 word += (color['b'] / 8) << 10 return word def pal_to_png(filename): """ Interpret a .pal file as a png palette. """ with open(filename) as rgbs: colors = read_rgb_macros(rgbs.readlines()) a = 255 palette = [] for color in colors: # even distribution over 000-255 r, g, b = [int(hue * 8.25) for hue in color] palette += [(r, g, b, a)] white = (255,255,255,255) black = (000,000,000,255) if white not in palette and len(palette) < 4: palette = [white] + palette if black not in palette and len(palette) < 4: palette = palette + [black] return palette def png_to_rgb(palette): """ Convert a png palette to rgb macros. """ output = '' for color in palette: r, g, b = [color[c] / 8 for c in 'rgb'] output += '\tRGB ' + ', '.join(['%.2d' % hue for hue in (r, g, b)]) output += '\n' return output def read_filename_arguments(filename): """ Infer graphics conversion arguments given a filename. Arguments are separated with '.'. """ parsed_arguments = {} int_arguments = { 'w': 'width', 'h': 'height', 't': 'tile_padding', } arguments = os.path.splitext(filename)[0].lstrip('.').split('.')[1:] for argument in arguments: # Check for integer arguments first (i.e. "w128"). arg = argument[0] param = argument[1:] if param.isdigit(): arg = int_arguments.get(arg, False) if arg: parsed_arguments[arg] = int(param) elif argument == 'arrange': parsed_arguments['norepeat'] = True parsed_arguments['tilemap'] = True # Pic dimensions (i.e. "6x6"). elif 'x' in argument and any(map(str.isdigit, argument)): w, h = argument.split('x') if w.isdigit() and h.isdigit(): parsed_arguments['pic_dimensions'] = (int(w), int(h)) else: parsed_arguments[argument] = True return parsed_arguments def export_2bpp_to_png(filein, fileout=None, pal_file=None, height=0, width=0, tile_padding=0, pic_dimensions=None, **kwargs): if fileout == None: fileout = os.path.splitext(filein)[0] + '.png' image = open(filein, 'rb').read() arguments = { 'width': width, 'height': height, 'pal_file': pal_file, 'tile_padding': tile_padding, 'pic_dimensions': pic_dimensions, } arguments.update(read_filename_arguments(filein)) if pal_file == None: if os.path.exists(os.path.splitext(fileout)[0]+'.pal'): arguments['pal_file'] = os.path.splitext(fileout)[0]+'.pal' result = convert_2bpp_to_png(image, **arguments) width, height, palette, greyscale, bitdepth, px_map = result w = png.Writer( width, height, palette=palette, compression=9, greyscale=greyscale, bitdepth=bitdepth ) with open(fileout, 'wb') as f: w.write(f, px_map) def convert_2bpp_to_png(image, **kwargs): """ Convert a planar 2bpp graphic to png. """ image = bytearray(image) pad_color = bytearray([0]) width = kwargs.get('width', 0) height = kwargs.get('height', 0) tile_padding = kwargs.get('tile_padding', 0) pic_dimensions = kwargs.get('pic_dimensions', None) pal_file = kwargs.get('pal_file', None) interleave = kwargs.get('interleave', False) # Width must be specified to interleave. if interleave and width: image = interleave_tiles(image, width / 8) # Pad the image by a given number of tiles if asked. image += pad_color * 0x10 * tile_padding # Some images are transposed in blocks. if pic_dimensions: w, h = pic_dimensions if not width: width = w * 8 pic_length = w * h * 0x10 trailing = len(image) % pic_length pic = [] for i in xrange(0, len(image) - trailing, pic_length): pic += transpose_tiles(image[i:i+pic_length], h) image = bytearray(pic) + image[len(image) - trailing:] # Pad out trailing lines. image += pad_color * 0x10 * ((w - (len(image) / 0x10) % h) % w) def px_length(img): return len(img) * 4 def tile_length(img): return len(img) * 4 / (8*8) if width and height: tile_width = width / 8 more_tile_padding = (tile_width - (tile_length(image) % tile_width or tile_width)) image += pad_color * 0x10 * more_tile_padding elif width and not height: tile_width = width / 8 more_tile_padding = (tile_width - (tile_length(image) % tile_width or tile_width)) image += pad_color * 0x10 * more_tile_padding height = px_length(image) / width elif height and not width: tile_height = height / 8 more_tile_padding = (tile_height - (tile_length(image) % tile_height or tile_height)) image += pad_color * 0x10 * more_tile_padding width = px_length(image) / height # at least one dimension should be given if width * height != px_length(image): # look for possible combos of width/height that would form a rectangle matches = [] # Height need not be divisible by 8, but width must. # See pokered gfx/minimize_pic.1bpp. for w in range(8, px_length(image) / 2 + 1, 8): h = px_length(image) / w if w * h == px_length(image): matches += [(w, h)] # go for the most square image if len(matches): width, height = sorted(matches, key= lambda (w, h): (h % 8 != 0, w + h))[0] # favor height else: raise Exception, 'Image can\'t be divided into tiles (%d px)!' % (px_length(image)) # convert tiles to lines lines = to_lines(flatten(image), width) if pal_file == None: palette = None greyscale = True bitdepth = 2 px_map = [[3 - pixel for pixel in line] for line in lines] else: # gbc color palette = pal_to_png(pal_file) greyscale = False bitdepth = 8 px_map = [[pixel for pixel in line] for line in lines] return width, height, palette, greyscale, bitdepth, px_map def get_pic_animation(tmap, w, h): """ Generate pic animation data from a combined tilemap of each frame. """ frame_text = '' bitmask_text = '' frames = list(split(tmap, w * h)) base = frames.pop(0) bitmasks = [] for i in xrange(len(frames)): frame_text += '\tdw .frame{}\n'.format(i + 1) for i, frame in enumerate(frames): bitmask = map(operator.ne, frame, base) if bitmask not in bitmasks: bitmasks.append(bitmask) which_bitmask = bitmasks.index(bitmask) mask = iter(bitmask) masked_frame = filter(lambda _: mask.next(), frame) frame_text += '.frame{}\n'.format(i + 1) frame_text += '\tdb ${:02x} ; bitmask\n'.format(which_bitmask) if masked_frame: frame_text += '\tdb {}\n'.format(', '.join( map('${:02x}'.format, masked_frame) )) for i, bitmask in enumerate(bitmasks): bitmask_text += '; {}\n'.format(i) for byte in split(bitmask, 8): byte = int(''.join(map(int.__repr__, reversed(byte))), 2) bitmask_text += '\tdb %{:08b}\n'.format(byte) return frame_text, bitmask_text def export_png_to_2bpp(filein, fileout=None, palout=None, **kwargs): arguments = { 'tile_padding': 0, 'pic_dimensions': None, 'animate': False, 'stupid_bitmask_hack': [], } arguments.update(kwargs) arguments.update(read_filename_arguments(filein)) image, arguments = png_to_2bpp(filein, **arguments) if fileout == None: fileout = os.path.splitext(filein)[0] + '.2bpp' to_file(fileout, image) tmap = arguments.get('tmap') if tmap != None and arguments['animate'] and arguments['pic_dimensions']: # Generate pic animation data. frame_text, bitmask_text = get_pic_animation(tmap, *arguments['pic_dimensions']) frames_path = os.path.join(os.path.split(fileout)[0], 'frames.asm') with open(frames_path, 'w') as out: out.write(frame_text) bitmask_path = os.path.join(os.path.split(fileout)[0], 'bitmask.asm') # The following Pokemon have a bitmask dummied out. for exception in arguments['stupid_bitmask_hack']: if exception in bitmask_path: bitmasks = bitmask_text.split(';') bitmasks[-1] = bitmasks[-1].replace('1', '0') bitmask_text = ';'.join(bitmasks) with open(bitmask_path, 'w') as out: out.write(bitmask_text) elif tmap != None and arguments.get('tilemap', False): tilemap_path = os.path.splitext(fileout)[0] + '.tilemap' to_file(tilemap_path, tmap) palette = arguments.get('palette') if palout == None: palout = os.path.splitext(fileout)[0] + '.pal' export_palette(palette, palout) def get_image_padding(width, height, wstep=8, hstep=8): padding = { 'left': 0, 'right': 0, 'top': 0, 'bottom': 0, } if width % wstep and width >= wstep: pad = float(width % wstep) / 2 padding['left'] = int(ceil(pad)) padding['right'] = int(floor(pad)) if height % hstep and height >= hstep: pad = float(height % hstep) / 2 padding['top'] = int(ceil(pad)) padding['bottom'] = int(floor(pad)) return padding def png_to_2bpp(filein, **kwargs): """ Convert a png image to planar 2bpp. """ arguments = { 'tile_padding': 0, 'pic_dimensions': False, 'interleave': False, 'norepeat': False, 'tilemap': False, } arguments.update(kwargs) if type(filein) is str: filein = open(filein) assert type(filein) is file width, height, rgba, info = png.Reader(filein).asRGBA8() # png.Reader returns flat pixel data. Nested is easier to work with len_px = len('rgba') image = [] palette = [] for line in rgba: newline = [] for px in xrange(0, len(line), len_px): color = dict(zip('rgba', line[px:px+len_px])) if color not in palette: if len(palette) < 4: palette += [color] else: # TODO Find the nearest match print 'WARNING: %s: Color %s truncated to' % (filein, color), color = sorted(palette, key=lambda x: sum(x.values()))[0] print color newline += [color] image += [newline] assert len(palette) <= 4, '%s: palette should be 4 colors, is really %d (%s)' % (filein, len(palette), palette) # Pad out smaller palettes with greyscale colors greyscale = { 'black': { 'r': 0x00, 'g': 0x00, 'b': 0x00, 'a': 0xff }, 'grey': { 'r': 0x55, 'g': 0x55, 'b': 0x55, 'a': 0xff }, 'gray': { 'r': 0xaa, 'g': 0xaa, 'b': 0xaa, 'a': 0xff }, 'white': { 'r': 0xff, 'g': 0xff, 'b': 0xff, 'a': 0xff }, } preference = 'white', 'black', 'grey', 'gray' for hue in map(greyscale.get, preference): if len(palette) >= 4: break if hue not in palette: palette += [hue] palette.sort(key=lambda x: sum(x.values())) # Game Boy palette order palette.reverse() # Map pixels to quaternary color ids padding = get_image_padding(width, height) width += padding['left'] + padding['right'] height += padding['top'] + padding['bottom'] pad = bytearray([0]) qmap = [] qmap += pad * width * padding['top'] for line in image: qmap += pad * padding['left'] for color in line: qmap += [palette.index(color)] qmap += pad * padding['right'] qmap += pad * width * padding['bottom'] # Graphics are stored in tiles instead of lines tile_width = 8 tile_height = 8 num_columns = max(width, tile_width) / tile_width num_rows = max(height, tile_height) / tile_height image = [] for row in xrange(num_rows): for column in xrange(num_columns): # Split it up into strips to convert to planar data for strip in xrange(min(tile_height, height)): anchor = ( row * num_columns * tile_width * tile_height + column * tile_width + strip * width ) line = qmap[anchor : anchor + tile_width] bottom, top = 0, 0 for bit, quad in enumerate(line): bottom += (quad & 1) << (7 - bit) top += (quad /2 & 1) << (7 - bit) image += [bottom, top] dim = arguments['pic_dimensions'] if dim: if type(dim) in (tuple, list): w, h = dim else: # infer dimensions based on width. w = width / tile_width h = height / tile_height if h % w == 0: h = w tiles = get_tiles(image) pic_length = w * h tile_width = width / 8 trailing = len(tiles) % pic_length new_image = [] for block in xrange(len(tiles) / pic_length): offset = (h * tile_width) * ((block * w) / tile_width) + ((block * w) % tile_width) pic = [] for row in xrange(h): index = offset + (row * tile_width) pic += tiles[index:index + w] new_image += transpose(pic, w) new_image += tiles[len(tiles) - trailing:] image = connect(new_image) # Remove any tile padding used to make the png rectangular. image = image[:len(image) - arguments['tile_padding'] * 0x10] tmap = None if arguments['interleave']: image = deinterleave_tiles(image, num_columns) if arguments['pic_dimensions']: image, tmap = condense_image_to_map(image, w * h) elif arguments['norepeat']: image, tmap = condense_image_to_map(image) if not arguments['tilemap']: tmap = None arguments.update({ 'palette': palette, 'tmap': tmap, }) return image, arguments def export_palette(palette, filename): """ Export a palette from png to rgb macros in a .pal file. """ if os.path.exists(filename): # Pic palettes are 2 colors (black/white are added later). with open(filename) as rgbs: colors = read_rgb_macros(rgbs.readlines()) if len(colors) == 2: palette = palette[1:3] text = png_to_rgb(palette) with open(filename, 'w') as out: out.write(text) def png_to_lz(filein): name = os.path.splitext(filein)[0] export_png_to_2bpp(filein) image = open(name+'.2bpp', 'rb').read() to_file(name+'.2bpp'+'.lz', Compressed(image).output) def convert_2bpp_to_1bpp(data): """ Convert planar 2bpp image data to 1bpp. Assume images are two colors. """ return data[::2] def convert_1bpp_to_2bpp(data): """ Convert 1bpp image data to planar 2bpp (black/white). """ output = [] for i in data: output += [i, i] return output def export_2bpp_to_1bpp(filename): name, extension = os.path.splitext(filename) image = open(filename, 'rb').read() image = convert_2bpp_to_1bpp(image) to_file(name + '.1bpp', image) def export_1bpp_to_2bpp(filename): name, extension = os.path.splitext(filename) image = open(filename, 'rb').read() image = convert_1bpp_to_2bpp(image) to_file(name + '.2bpp', image) def export_1bpp_to_png(filename, fileout=None): if fileout == None: fileout = os.path.splitext(filename)[0] + '.png' arguments = read_filename_arguments(filename) image = open(filename, 'rb').read() image = convert_1bpp_to_2bpp(image) result = convert_2bpp_to_png(image, **arguments) width, height, palette, greyscale, bitdepth, px_map = result w = png.Writer(width, height, palette=palette, compression=9, greyscale=greyscale, bitdepth=bitdepth) with open(fileout, 'wb') as f: w.write(f, px_map) def export_png_to_1bpp(filename, fileout=None): if fileout == None: fileout = os.path.splitext(filename)[0] + '.1bpp' arguments = read_filename_arguments(filename) image = png_to_1bpp(filename, **arguments) to_file(fileout, image) def png_to_1bpp(filename, **kwargs): image, kwargs = png_to_2bpp(filename, **kwargs) return convert_2bpp_to_1bpp(image) def convert_to_2bpp(filenames=[]): for filename in filenames: filename, name, extension = try_decompress(filename) if extension == '.1bpp': export_1bpp_to_2bpp(filename) elif extension == '.2bpp': pass elif extension == '.png': export_png_to_2bpp(filename) else: raise Exception, "Don't know how to convert {} to 2bpp!".format(filename) def convert_to_1bpp(filenames=[]): for filename in filenames: filename, name, extension = try_decompress(filename) if extension == '.1bpp': pass elif extension == '.2bpp': export_2bpp_to_1bpp(filename) elif extension == '.png': export_png_to_1bpp(filename) else: raise Exception, "Don't know how to convert {} to 1bpp!".format(filename) def convert_to_png(filenames=[]): for filename in filenames: filename, name, extension = try_decompress(filename) if extension == '.1bpp': export_1bpp_to_png(filename) elif extension == '.2bpp': export_2bpp_to_png(filename) elif extension == '.png': pass else: raise Exception, "Don't know how to convert {} to png!".format(filename) def compress(filenames=[]): for filename in filenames: data = open(filename, 'rb').read() lz_data = Compressed(data).output to_file(filename + '.lz', lz_data) def decompress(filenames=[]): for filename in filenames: name, extension = os.path.splitext(filename) lz_data = open(filename, 'rb').read() data = Decompressed(lz_data).output to_file(name, data) def try_decompress(filename): """ Try to decompress a graphic when determining the filetype. This skips the manual unlz step when attempting to convert lz-compressed graphics to png. """ name, extension = os.path.splitext(filename) if extension == '.lz': decompress([filename]) filename = name name, extension = os.path.splitext(filename) return filename, name, extension def main(): ap = argparse.ArgumentParser() ap.add_argument('mode') ap.add_argument('filenames', nargs='*') args = ap.parse_args() method = { '2bpp': convert_to_2bpp, '1bpp': convert_to_1bpp, 'png': convert_to_png, 'lz': compress, 'unlz': decompress, }.get(args.mode, None) if method == None: raise Exception, "Unknown conversion method!" method(args.filenames) if __name__ == "__main__": main()