sotn-decomp/tools/spritesheet.py
Jonathan Hohle 8287841ccd
Python virtualenv Support (#1620)
Ubuntu/Debian and Python recommend using virtual environments for
project-specific Python dependencies and as of Ubuntu 24 this is lightly
enforced when installing packages via pip. This is due to pip and the
system package manager installing files to the same location which may
cause either's internal state to no longer reflect what is actually
installed.

This updates the project to use a Python `virtualenv` for project
dependencies and updates internal scripts to support both global and
virtualenvs, but favors virtualenvs for new workspaces.

All tools that hardcode `/usr/bin/python3` now use `env(1)` to find the
first `python3` in the path. For those with a virtualenv configured,
this will be the Python managed there. For everyone else, this should be
the system Python or whatever other scheme they may have used previously
(assuming `python3` already existed in their `PATH`).

The `Makefile` has been updated to prepend `.venv/bin` to the `PATH` and
use `python3` from there if it exists and has been configured. It also
has a new `python-dependencies` target which will configure the venv and
install all python dependnecies there.

The `Dockerfile` has been updated to create an external `.venv` outside
of the project directory. Python's `virtualenv`s are not relocatable and
may hardcode paths which are mounted differently in the container and
host. To deal with differences in paths between the container (which
mounts the host's project directory to `/sotn`) host which may be at an
arbitarary directory the `VENV_PATH` environment variable is used to
override paths in the `Makefile`.

GitHub workflows have been updated to use `.venv`.
2024-09-17 23:19:20 -07:00

166 lines
5.5 KiB
Python
Executable File

#!/usr/bin/env python3
import argparse
import json
import os
from PIL import Image
import sys
max_sprite_per_row = 8
max_width = 96
max_height = 96
def is_spritesheet_desc_sane(spritesheet_desc):
for entry in spritesheet_desc:
if os.path.isfile(entry["name"]) == False:
return False, entry["name"]
return True, None
def merge(spritesheet_desc, out_path):
# performs an integrity check before committing to the algorithm
is_sane, missing_file_path = is_spritesheet_desc_sane(spritesheet_desc)
if is_sane == False:
raise ValueError(f"one or more files are missing: '{missing_file_path}'")
sprite_count = len(spritesheet_desc)
img_width = max_width * max_sprite_per_row
img_height = (
(sprite_count + max_sprite_per_row - 1) // max_sprite_per_row * max_height
)
spritesheet = Image.new("P", (img_width, img_height))
with Image.open(spritesheet_desc[0]["name"]) as first_sprite:
spritesheet.putpalette(first_sprite.palette)
spritesheet.info["transparency"] = 0
for n_sprite, entry in enumerate(spritesheet_desc):
sprite = Image.open(entry["name"])
width, height = sprite.size
if width > max_width or height > max_height:
raise ValueError(
f"All images must be {max_width}x{max_height} or smaller: '{entry['name']}' is {width}x{height}"
)
offset_x = int(n_sprite % max_sprite_per_row) * max_width
offset_y = n_sprite // max_sprite_per_row * max_height
center_x = entry["x"]
center_y = entry["y"]
if center_x + width > max_width or center_y + height > max_height:
raise ValueError(
(
f"Sprite '{entry['name']}' is out of bounds:\n"
f"cx:{center_x} + w:{width} > mw:{max_width}\n"
f"cy:{center_y} + h:{height} > mh:{max_height}"
)
)
spritesheet.paste(sprite, (offset_x + center_x, offset_y + center_y))
sprite.close()
spritesheet.save(out_path)
spritesheet.close()
def align_bbox(bbox):
sx, sy, ex, ey = bbox
w = ex - sx
h = ey - sy
x_excess = sx & 1
sx -= x_excess
w += x_excess
w = ((w + 3) // 4) * 4
return (sx, sy, sx + w, sy + h)
def split_sprite(spritesheet, x, y):
sprite = spritesheet.crop((x, y, x + max_width, y + max_height))
bbox = sprite.getbbox()
if bbox == None:
return (None, 0, 0)
aligned_bbox = align_bbox(bbox)
x, y, _, _ = aligned_bbox
return (sprite.crop(aligned_bbox), x, y)
def split(name, spritesheet, out_path):
width, height = spritesheet.size
if width != max_width * max_sprite_per_row:
raise ValueError(
f"The spritesheet must have a width of {max_width * max_sprite_per_row} but found {width}"
)
if (height % max_height) > 0:
raise ValueError(
f"The spritesheet must have a height multiple of {max_height} but found {height}"
)
sprite_count = (width // max_width) * (height // max_height)
spritesheet_desc = []
for n_sprite in range(0, sprite_count):
offset_x = int(n_sprite % max_sprite_per_row) * max_width
offset_y = n_sprite // max_sprite_per_row * max_height
sprite, x, y = split_sprite(spritesheet, offset_x, offset_y)
if sprite == None:
break
out_sprite_path = os.path.join(out_path, f"{name}_{n_sprite}.png")
sprite.save(out_sprite_path)
spritesheet_desc.append(
{
"x": x,
"y": y,
"name": out_sprite_path,
}
)
with open(os.path.join(out_path, f"{name}.spritesheet.json"), "w") as f:
json.dump(spritesheet_desc, f, indent=4)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Manipulate a spritesheet for modding")
subparsers = parser.add_subparsers(dest="command")
merge_parser = subparsers.add_parser(
"merge", description="Merge all the individual sprites into a single PNG."
)
merge_parser.add_argument(
"json_file",
type=argparse.FileType("r"),
help="The JSON file path that contains the spritesheet info (eg. 'assets/ric/richter.spritesheet.json')",
)
merge_parser.add_argument(
"output_path",
help="File path where to store the assembled spritesheet",
)
split_parser = subparsers.add_parser(
"split",
description=(
"Split a spritesheet PNG back to their individual sprites.\n"
"The individual sprites will be stored to the same path of "
"<json_file> and their names will follow the name of the input_path."
"\ne.g. sat_ric.png as input will produce sat_ric_0.png and so on."
),
)
split_parser.add_argument(
"input_file_path",
help="Spritesheet file path to split",
)
split_parser.add_argument(
"output_path",
help="Path where to store the JSON descriptor and the spliited sprites",
)
args = parser.parse_args()
if args.command == "merge":
with args.json_file as file_in:
merge(json.loads(file_in.read()), args.output_path)
elif args.command == "split":
file_name, ext = os.path.splitext(os.path.basename(args.input_file_path))
with Image.open(args.input_file_path) as spritesheet:
if os.path.exists(args.output_path) == False:
os.mkdir(args.output_path)
split(file_name, spritesheet, args.output_path)