Uses PIL/Pillow exclusively now, without imagemagick, since we don't really need 16bpc support. Overrides have been replaced with more flexible sprite configs. There is a conversion mechanism for migration, to be applied in the following commit.
632 lines
19 KiB
Python
Executable file
632 lines
19 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import functools
|
|
import itertools
|
|
import re
|
|
import rectpack
|
|
import shutil
|
|
import subprocess
|
|
|
|
from pathlib import (
|
|
Path,
|
|
)
|
|
|
|
from tempfile import (
|
|
TemporaryDirectory,
|
|
)
|
|
|
|
from contextlib import (
|
|
ExitStack,
|
|
)
|
|
|
|
from concurrent.futures import (
|
|
ProcessPoolExecutor,
|
|
ThreadPoolExecutor,
|
|
as_completed,
|
|
)
|
|
|
|
from PIL import (
|
|
Image,
|
|
)
|
|
|
|
from taiseilib.common import (
|
|
TaiseiError,
|
|
run_main,
|
|
update_text_file,
|
|
wait_for_futures,
|
|
)
|
|
|
|
import taiseilib.atlas as atlas
|
|
import taiseilib.keyval as keyval
|
|
|
|
|
|
texture_formats = [s[1:] for s in atlas.SPRITE_SUFFIXES]
|
|
|
|
|
|
def ceildiv(x, y):
|
|
return x // y + int(x % y != 0)
|
|
|
|
|
|
def align_size(size, alignment=4):
|
|
return tuple(ceildiv(x, alignment) * alignment for x in size)
|
|
|
|
|
|
def write_sprite_def(dst, data):
|
|
dst.parent.mkdir(exist_ok=True, parents=True)
|
|
text = '# Autogenerated by the atlas packer, do not modify\n\n'
|
|
text += keyval.dump(data)
|
|
update_text_file(dst, text)
|
|
|
|
|
|
def write_texture_def(dst, texture, texture_fmt, global_overrides=None, local_overrides=None, alphamap_tex_fmt=None):
|
|
dst.parent.mkdir(exist_ok=True, parents=True)
|
|
|
|
data = {
|
|
'source': f'res/gfx/{texture}.{texture_fmt}',
|
|
}
|
|
|
|
if alphamap_tex_fmt is not None:
|
|
data['alphamap'] = f'res/gfx/{texture}.alphamap.{alphamap_tex_fmt}'
|
|
|
|
if global_overrides is not None:
|
|
data.update(global_overrides)
|
|
|
|
if local_overrides is not None:
|
|
data.update(local_overrides)
|
|
|
|
text = '# Autogenerated by the atlas packer, do not modify\n\n'
|
|
text += keyval.dump(data)
|
|
update_text_file(dst, text)
|
|
|
|
|
|
def get_bin_occupied_bounds(bin):
|
|
xmin, xmax, ymin, ymax = 0xffffffffffffffff, 0, 0xffffffffffffffff, 0
|
|
|
|
for rect in bin:
|
|
if rect.bottom < ymin:
|
|
ymin = rect.bottom
|
|
|
|
if rect.top > ymax:
|
|
ymax = rect.top
|
|
|
|
if rect.left < xmin:
|
|
xmin = rect.left
|
|
|
|
if rect.right > xmax:
|
|
xmax = rect.right
|
|
|
|
w = xmax - xmin
|
|
h = ymax - ymin
|
|
w, h = align_size((w, h))
|
|
|
|
return rectpack.geometry.Rectangle(xmin, ymin, w, h)
|
|
|
|
|
|
@functools.total_ordering
|
|
class PackResult:
|
|
def __init__(self, packer, rects):
|
|
self.bins = list(packer)
|
|
self.num_images_packed = sum(map(len, self.bins))
|
|
|
|
self.success = self.num_images_packed == len(rects)
|
|
assert(self.num_images_packed <= len(rects))
|
|
|
|
self.total_area = self._calculate_total_area()
|
|
|
|
@property
|
|
def num_bins(self):
|
|
return len(self.bins)
|
|
|
|
def __repr__(self):
|
|
return f'PackResult(num_bins={self.num_bins}, num_images_packed={self.num_images_packed}, total_area={self.total_area}, success={self.success})'
|
|
|
|
def _calculate_total_area(self):
|
|
return sum(get_bin_occupied_bounds(bin).area() for bin in self.bins)
|
|
|
|
def __eq__(self, other):
|
|
return (
|
|
self.num_bins == other.num_bins and
|
|
self.num_images_packed == other.num_images_packed and
|
|
self.total_area == other.total_area
|
|
)
|
|
|
|
# less == better-than
|
|
def __lt__(self, other):
|
|
if self.num_images_packed > other.num_images_packed:
|
|
return True
|
|
|
|
if self.num_images_packed < other.num_images_packed:
|
|
return False
|
|
|
|
if self.num_bins < other.num_bins:
|
|
return True
|
|
|
|
if self.num_bins > other.num_bins:
|
|
return False
|
|
|
|
if self.total_area < other.total_area:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def pack_rects(rects, bin_size, packer_factory, single_bin):
|
|
bin_size = list(bin_size)
|
|
|
|
if single_bin:
|
|
while True:
|
|
packer = packer_factory()
|
|
packer.add_bin(*bin_size)
|
|
|
|
for rect in rects:
|
|
packer.add_rect(*rect)
|
|
|
|
packer.pack()
|
|
|
|
if sum(len(bin) for bin in packer) == len(rects):
|
|
break
|
|
|
|
if bin_size[1] < bin_size[0]:
|
|
bin_size[1] *= 2
|
|
else:
|
|
bin_size[0] *= 2
|
|
else:
|
|
packer = packer_factory()
|
|
|
|
for rect in rects:
|
|
packer.add_rect(*rect)
|
|
packer.add_bin(*bin_size)
|
|
|
|
packer.pack()
|
|
|
|
return PackResult(packer, rects)
|
|
|
|
|
|
def bruteforcer_pack(params):
|
|
import rectpack
|
|
global subprocess_args
|
|
rects = subprocess_args['rects']
|
|
bin_size = subprocess_args['bin_size']
|
|
single_bin = subprocess_args['single_bin']
|
|
|
|
algo_class, (sort_func, sort_name) = params
|
|
algo_class = getattr(rectpack, algo_class)
|
|
sort_func = getattr(rectpack, sort_func)
|
|
|
|
def packer_factory(algo_class=algo_class, sort_func=sort_func):
|
|
return rectpack.newPacker(
|
|
rotation=False, # No rotation support in Taisei yet
|
|
pack_algo=algo_class,
|
|
sort_algo=sort_func,
|
|
bin_algo=rectpack.PackingBin.BFF,
|
|
)
|
|
|
|
result = pack_rects(
|
|
rects=rects,
|
|
packer_factory=packer_factory,
|
|
bin_size=bin_size,
|
|
single_bin=single_bin
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def bruteforcer_init(rects, bin_size, single_bin):
|
|
global subprocess_args
|
|
subprocess_args = {
|
|
'rects': rects,
|
|
'bin_size': bin_size,
|
|
'single_bin': single_bin,
|
|
}
|
|
|
|
|
|
def pack_rects_brute_force(rects, bin_size, single_bin):
|
|
algos_guillotine = [
|
|
'GuillotineBafLas',
|
|
'GuillotineBafLlas',
|
|
'GuillotineBafMaxas',
|
|
'GuillotineBafMinas',
|
|
'GuillotineBafSas',
|
|
'GuillotineBafSlas',
|
|
'GuillotineBlsfLas',
|
|
'GuillotineBlsfLlas',
|
|
'GuillotineBlsfMaxas',
|
|
'GuillotineBlsfMinas',
|
|
'GuillotineBlsfSas',
|
|
'GuillotineBlsfSlas',
|
|
'GuillotineBssfLas',
|
|
'GuillotineBssfLlas',
|
|
'GuillotineBssfMaxas',
|
|
'GuillotineBssfMinas',
|
|
'GuillotineBssfSas',
|
|
'GuillotineBssfSlas',
|
|
]
|
|
|
|
algos_maxrects = [
|
|
'MaxRectsBaf',
|
|
'MaxRectsBl',
|
|
'MaxRectsBlsf',
|
|
'MaxRectsBssf',
|
|
]
|
|
|
|
algos_skyline = [
|
|
'SkylineBl',
|
|
'SkylineBlWm',
|
|
'SkylineMwf',
|
|
'SkylineMwfWm',
|
|
'SkylineMwfl',
|
|
'SkylineMwflWm',
|
|
]
|
|
|
|
algos = []
|
|
# MaxRects is the slowest algorithm, but the results blow the other two out of the water (most of the time).
|
|
algos += algos_guillotine
|
|
algos += algos_maxrects
|
|
algos += algos_skyline
|
|
|
|
sorts = [
|
|
('SORT_AREA', 'area'),
|
|
('SORT_DIFF', 'difference'),
|
|
('SORT_LSIDE', 'longest-side'),
|
|
('SORT_NONE', 'no'),
|
|
('SORT_PERI', 'perimeter'),
|
|
('SORT_RATIO', 'ratio'),
|
|
('SORT_SSIDE', 'shortest-side'),
|
|
]
|
|
|
|
best = None
|
|
best_algos = None
|
|
|
|
def verbose(*msg):
|
|
print('[bruteforce]', *msg)
|
|
|
|
with ProcessPoolExecutor(initializer=bruteforcer_init, initargs=(rects, bin_size, single_bin)) as ex:
|
|
variants = tuple(itertools.product(algos, sorts))
|
|
futures = {}
|
|
|
|
for variant in variants:
|
|
f = ex.submit(bruteforcer_pack, variant)
|
|
futures[f] = variant
|
|
|
|
for future in as_completed(futures):
|
|
algo_class, (sort_func, sort_name) = futures[future]
|
|
result = future.result()
|
|
|
|
if best is None or result < best:
|
|
verbose(' * {:>20} with {:20} | {!s}'.format(algo_class, sort_name + ' sort', result))
|
|
best = result
|
|
best_algos = (algo_class, sort_name)
|
|
else:
|
|
verbose(' {:>20} with {:20} | {!s}'.format(algo_class, sort_name + ' sort', result))
|
|
|
|
verbose('*' * 64)
|
|
verbose(f'WINNER: {best_algos[0]} with {best_algos[1]} sort')
|
|
verbose(f'\tBest result: {best}')
|
|
verbose('*' * 64)
|
|
return best
|
|
|
|
|
|
def sprite_sources_iter(srcroot):
|
|
def imgfilter(path):
|
|
return (
|
|
path.is_file() and path.suffix.lower() in atlas.SPRITE_SUFFIXES and
|
|
path.with_suffix('').suffix != '.alphamap'
|
|
)
|
|
|
|
for path in sorted(filter(imgfilter, srcroot.glob('**/*.*'))):
|
|
yield path
|
|
|
|
|
|
def gen_atlas(config_dir, src, dst, binsize, atlasname, tex_format=texture_formats[0], border=1, force_single=False, crop=True, leanify=True):
|
|
def get_sprite_border(sprite, default_border=border):
|
|
return max(default_border, int(sprite.config.get('border', default_border)))
|
|
|
|
texture_local_overrides = keyval.parse(src / 'atlas.tex', missing_ok=True)
|
|
texture_global_overrides = keyval.parse(config_dir / 'atlas.tex', missing_ok=True)
|
|
|
|
total_images = 0
|
|
packed_images = 0
|
|
rects = []
|
|
|
|
sprites = {}
|
|
|
|
for path in sprite_sources_iter(src):
|
|
sprite_name = path.relative_to(src).with_suffix('').as_posix()
|
|
sprite_config_path = config_dir / atlas.get_sprite_config_file_name(sprite_name)
|
|
sprite = atlas.Sprite.load(sprite_name, path, sprite_config_path)
|
|
sprites[sprite.name] = sprite
|
|
border = get_sprite_border(sprite)
|
|
rects.append((
|
|
sprite.image.size[0] + border*2,
|
|
sprite.image.size[1] + border*2,
|
|
sprite.name,
|
|
))
|
|
|
|
pack_result = pack_rects_brute_force(rects=rects, bin_size=binsize, single_bin=force_single)
|
|
|
|
if not pack_result.success:
|
|
missing = len(rects) - pack_result.num_images_packed
|
|
raise TaiseiError(
|
|
f'{missing} sprite{"s were" if missing > 1 else " was"} not packed (bin size is too small?)'
|
|
)
|
|
|
|
futures = []
|
|
|
|
with ExitStack() as stack:
|
|
# Do everything in a temporary directory first
|
|
temp_dst = Path(stack.enter_context(TemporaryDirectory(prefix=f'taisei-atlas-{atlasname}')))
|
|
|
|
# Run multiple leanify processes in parallel, in case we end up with multiple pages
|
|
# Yeah I'm too lazy to use Popen properly
|
|
executor = stack.enter_context(ThreadPoolExecutor())
|
|
|
|
for i, bin in enumerate(pack_result.bins):
|
|
textureid = f'atlas_{atlasname}_{i}'
|
|
# NOTE: we always save PNG first and convert with an external tool later if needed.
|
|
dstfile = temp_dst / f'{textureid}.png'
|
|
dstfile_alphamap = temp_dst / f'{textureid}.alphamap.png'
|
|
dstfile_meta = temp_dst / f'{textureid}.tex'
|
|
|
|
if crop:
|
|
occupied_bounds = get_bin_occupied_bounds(bin)
|
|
actual_size = (occupied_bounds.width, occupied_bounds.height)
|
|
bin_offset = (-occupied_bounds.x, -occupied_bounds.y)
|
|
else:
|
|
actual_size = (bin.width, bin.height)
|
|
bin_offset = (0, 0)
|
|
|
|
bin_image = Image.new('RGBA', actual_size, (0, 0, 0, 0))
|
|
bin_alphamap_image = None
|
|
|
|
for rect in bin:
|
|
rect.move(rect.x + bin_offset[0], rect.y + bin_offset[1])
|
|
sprite = sprites[rect.rid]
|
|
border = get_sprite_border(sprite)
|
|
paste_coord = (rect.x + border, rect.y + border)
|
|
bin_image.paste(sprite.image, paste_coord)
|
|
|
|
if sprite.alphamap_image is not None:
|
|
if bin_alphamap_image is None:
|
|
bin_alphamap_image = Image.new('L', actual_size, 255)
|
|
bin_alphamap_image.paste(sprite.alphamap_image, paste_coord)
|
|
|
|
sprite.texture_id = textureid
|
|
sprite.texture_region = atlas.Geometry(
|
|
rect.width - border * 2,
|
|
rect.height - border * 2,
|
|
rect.x + border,
|
|
rect.y + border,
|
|
) / actual_size
|
|
|
|
write_sprite_def(temp_dst / f'{sprite.name}.spr', sprite.dump_spritedef_dict())
|
|
sprite.unload()
|
|
|
|
write_texture_def(
|
|
dstfile_meta,
|
|
textureid,
|
|
tex_format,
|
|
texture_global_overrides,
|
|
texture_local_overrides,
|
|
alphamap_tex_fmt=None if bin_alphamap_image is None else 'png'
|
|
)
|
|
|
|
def process(
|
|
bin_image=bin_image,
|
|
dstfile=dstfile,
|
|
tex_format=tex_format
|
|
):
|
|
bin_image.save(dstfile)
|
|
bin_image.close()
|
|
|
|
oldfmt = dstfile.suffix[1:].lower()
|
|
|
|
if oldfmt != tex_format:
|
|
new_dstfile = dstfile.with_suffix(f'.{tex_format}')
|
|
|
|
if tex_format == 'webp':
|
|
subprocess.check_call([
|
|
'cwebp',
|
|
'-progress',
|
|
'-preset', 'icon',
|
|
'-z', '9',
|
|
'-lossless',
|
|
'-q', '100',
|
|
'-m', '6',
|
|
str(dstfile),
|
|
'-o', str(new_dstfile),
|
|
])
|
|
else:
|
|
raise TaiseiError(f'Unhandled conversion {oldfmt} -> {tex_format}')
|
|
|
|
dstfile.unlink()
|
|
dstfile = new_dstfile
|
|
|
|
if leanify:
|
|
subprocess.check_call(['leanify', '-v', str(dstfile)])
|
|
|
|
futures.append(executor.submit(process))
|
|
|
|
def process_alphamap(
|
|
bin_alphamap_image=bin_alphamap_image,
|
|
dstfile_alphamap=dstfile_alphamap,
|
|
):
|
|
bin_alphamap_image.save(dstfile_alphamap)
|
|
bin_alphamap_image.close()
|
|
|
|
if leanify:
|
|
subprocess.check_call(['leanify', '-v', str(dstfile_alphamap)])
|
|
|
|
if bin_alphamap_image is not None:
|
|
futures.append(executor.submit(process_alphamap))
|
|
|
|
# Wait for subprocesses to complete.
|
|
wait_for_futures(futures)
|
|
executor.shutdown(wait=True)
|
|
|
|
# Only now, if everything is ok so far, copy everything to the destination, possibly overwriting previous results
|
|
pattern = re.compile(rf'^atlas_{re.escape(atlasname)}_\d+(?:\.alphamap)?.({"|".join(texture_formats + ["tex"])})$')
|
|
for path in dst.iterdir():
|
|
if pattern.match(path.name):
|
|
path.unlink()
|
|
|
|
targets = list(temp_dst.glob('**/*'))
|
|
|
|
for dir in (p.relative_to(temp_dst) for p in targets if p.is_dir()):
|
|
(dst / dir).mkdir(parents=True, exist_ok=True)
|
|
|
|
for file in (p.relative_to(temp_dst) for p in targets if not p.is_dir()):
|
|
shutil.copyfile(str(temp_dst / file), str(dst / file))
|
|
|
|
|
|
def upgrade_configs(overrides_dir, src_dir, config_dir):
|
|
for path in sprite_sources_iter(src_dir):
|
|
sprite_name = path.relative_to(src_dir).with_suffix('').as_posix()
|
|
config_fname = atlas.get_sprite_config_file_name(sprite_name)
|
|
sprite_config_path = config_dir / config_fname
|
|
sprite_override_path = overrides_dir / config_fname
|
|
|
|
if sprite_config_path.is_file():
|
|
continue
|
|
|
|
sprite = atlas.Sprite.load(sprite_name, path, sprite_override_path, force_autotrim=False)
|
|
sprite.upgrade_config()
|
|
|
|
if sprite.config:
|
|
sprite_config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
update_text_file(sprite_config_path, keyval.dump(sprite.config))
|
|
print('{}: {!r}'.format(sprite.name, sprite.config))
|
|
|
|
sprite.unload()
|
|
|
|
|
|
def main(args):
|
|
parser = argparse.ArgumentParser(description='Generate texture atlases and sprite definitions', prog=args[0])
|
|
|
|
parser.add_argument('config_dir',
|
|
help='Directory containing configuration files for individual sprites',
|
|
type=Path,
|
|
)
|
|
|
|
parser.add_argument('source_dir',
|
|
help='Directory containing input textures (searched recursively)',
|
|
type=Path,
|
|
)
|
|
|
|
parser.add_argument('dest_dir',
|
|
help='Directory to dump the results into',
|
|
type=Path,
|
|
)
|
|
|
|
parser.add_argument('--width', '-W',
|
|
help='Base width of a single atlas bin (default: 2048)',
|
|
default=2048,
|
|
type=int
|
|
)
|
|
|
|
parser.add_argument('--height', '-H',
|
|
help='Base height of a single atlas bin (default: 2048)',
|
|
default=2048,
|
|
type=int
|
|
)
|
|
|
|
parser.add_argument('--name', '-n',
|
|
help='Unique identifier for this atlas (used to form the texture name), default is inferred from directory name',
|
|
default=None,
|
|
type=str,
|
|
)
|
|
|
|
parser.add_argument('--border', '-b',
|
|
help='Add a protective border WIDTH pixels wide around each sprite (default: 1)',
|
|
metavar='WIDTH',
|
|
dest='border',
|
|
type=int,
|
|
default=1
|
|
)
|
|
|
|
parser.add_argument('--crop', '-c',
|
|
help='Remove unused space from bins (default)',
|
|
dest='crop',
|
|
action='store_true',
|
|
default=True,
|
|
)
|
|
|
|
parser.add_argument('--no-crop', '-C',
|
|
help='Do not remove unused space from atlases',
|
|
dest='crop',
|
|
action='store_false',
|
|
default=True,
|
|
)
|
|
|
|
parser.add_argument('--single', '-s',
|
|
help='Package everything into a single texture, possibly extending its size; this can be slow (default)',
|
|
dest='single',
|
|
action='store_true',
|
|
default=True
|
|
)
|
|
|
|
parser.add_argument('--multiple', '-m',
|
|
help='Split the atlas across multiple textures if the sprites won\'t fit otherwise',
|
|
dest='single',
|
|
action='store_false',
|
|
default=True
|
|
)
|
|
|
|
parser.add_argument('--leanify', '-l',
|
|
help='Leanify atlases to save space; very slow (default)',
|
|
dest='leanify',
|
|
action='store_true',
|
|
default=True
|
|
)
|
|
|
|
parser.add_argument('--no-leanify', '-L',
|
|
help='Do not leanify atlases',
|
|
dest='leanify',
|
|
action='store_false',
|
|
default=True
|
|
)
|
|
|
|
parser.add_argument('--format', '-f',
|
|
help=f'Format of the atlas textures (default: {texture_formats[0]})',
|
|
choices=texture_formats,
|
|
default=texture_formats[0],
|
|
)
|
|
|
|
parser.add_argument('--upgrade-configs',
|
|
help='Generate sprite configs from old-style "overrides". In this mode, config_dir specifies the old "overrides" root, and new configs will be dumped into dest_dir. Existing configs in dest_dir will not be replaced',
|
|
action='store_true',
|
|
default=False,
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.name is None:
|
|
args.name = args.source_dir.name
|
|
|
|
args.config_dir = args.config_dir.resolve()
|
|
args.source_dir = args.source_dir.resolve()
|
|
args.dest_dir = args.dest_dir.resolve()
|
|
|
|
if args.upgrade_configs:
|
|
upgrade_configs(
|
|
args.config_dir,
|
|
args.source_dir,
|
|
args.dest_dir,
|
|
)
|
|
else:
|
|
gen_atlas(
|
|
args.config_dir,
|
|
args.source_dir,
|
|
args.dest_dir,
|
|
align_size((args.width, args.height)),
|
|
tex_format=args.format,
|
|
atlasname=args.name,
|
|
border=args.border,
|
|
force_single=args.single,
|
|
crop=args.crop,
|
|
leanify=args.leanify,
|
|
)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
run_main(main)
|