From 2d64d3804d8904dc9385bb611feae0b8a22a1113 Mon Sep 17 00:00:00 2001 From: vaporvee Date: Thu, 19 Jun 2025 19:29:10 +0200 Subject: [PATCH] first commit --- .gitignore | 5 ++ LICENSE.md | 21 +++++++ README.md | 25 ++++++++ config.json | 10 ++++ main.py | 150 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + 6 files changed, 212 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 config.json create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e98cc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +output/ +processed/ +spritesheets/ +*.zip +*.tar.gz \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..ddf9138 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Yannik Ain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c028b9 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Spritesheet Frame Rebuilder + +Extract individual frames from spritesheet PNG images by detecting connected non-transparent regions and rebuild new spritesheets with options for scaling, padding, layout, and frame repetition. + +--- + +## Features + +- Automatically detects connected non-transparent regions as frames +- Supports scaling individual frames +- Pads frames to uniform size +- Sorts frames by their position in the original image (optional) +- Customizable output grid layout (columns & rows) +- Repeat frames multiple times for animation or sprite reuse +- Configurable via JSON config file and/or CLI arguments (`--help` for more info) + +--- + +## Installation + +Requires [Python 3.7+](https://www.python.org/downloads/) and [Pillow](https://pypi.org/project/pillow/): + +```bash +pip install Pillow +``` \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..ee0924a --- /dev/null +++ b/config.json @@ -0,0 +1,10 @@ +{ + "input_folder": "spritesheets", + "output_folder": "output", + "scale_factor": 64, + "target_padding": 2, + "output_columns": 3, + "output_rows": 2, + "sort_by_position": true, + "frame_repeat": 2 +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..f6d5a05 --- /dev/null +++ b/main.py @@ -0,0 +1,150 @@ +import os +import json +import argparse +from PIL import Image +from collections import deque + +def load_config_from_file(config_path): + if not os.path.isfile(config_path): + print(f"āš ļø Config file '{config_path}' not found.") + return {} + with open(config_path, 'r') as f: + return json.load(f) + +def parse_args(): + parser = argparse.ArgumentParser(description="Spritesheet frame extractor + rebuilder") + parser.add_argument("--config", type=str, default=None, help="Optional path to JSON config file") + parser.add_argument("--input-folder", type=str, help="Folder with input images") + parser.add_argument("--output-folder", type=str, help="Target folder for new spritemaps") + parser.add_argument("--scale", type=float, help="Scaling factor for individual frames") + parser.add_argument("--padding", type=int, help="Pixel spacing between frames in the new spritemap") + parser.add_argument("--columns", type=int, help="Number of columns in the new spritemap") + parser.add_argument("--rows", type=int, help="Number of rows in the new spritemap") + parser.add_argument("--sort", action="store_true", help="Sort frames by position in original image") + parser.add_argument("--frame-repeat", type=int, help="Number of times to repeat each frame in the output") + return parser.parse_args() + +def merge_config(args, file_config): + config = { + "input_folder": "./spritesheets", + "output_folder": "processed", + "scale_factor": 1, + "target_padding": 1, + "output_columns": None, + "output_rows": None, + "sort_by_position": True, + "frame_repeat": 1, + } + config.update(file_config) + if args.input_folder: config["input_folder"] = args.input_folder + if args.output_folder: config["output_folder"] = args.output_folder + if args.scale is not None: config["scale_factor"] = args.scale + if args.padding is not None: config["target_padding"] = args.padding + if args.columns is not None: config["output_columns"] = args.columns + if args.rows is not None: config["output_rows"] = args.rows + if args.sort: config["sort_by_position"] = True + if args.frame_repeat is not None: config["frame_repeat"] = args.frame_repeat + return config + +def get_connected_regions(img): + width, height = img.size + pixels = img.load() + visited = [[False] * height for _ in range(width)] + regions = [] + def is_valid(x, y): + return 0 <= x < width and 0 <= y < height + def is_nontransparent(x, y): + return pixels[x, y][3] != 0 + for y in range(height): + for x in range(width): + if not visited[x][y] and is_nontransparent(x, y): + q = deque() + q.append((x, y)) + visited[x][y] = True + xmin, xmax, ymin, ymax = x, x, y, y + while q: + cx, cy = q.popleft() + for dx in [-1, 0, 1]: + for dy in [-1, 0, 1]: + nx, ny = cx + dx, cy + dy + if (dx != 0 or dy != 0) and is_valid(nx, ny): + if not visited[nx][ny] and is_nontransparent(nx, ny): + visited[nx][ny] = True + q.append((nx, ny)) + xmin = min(xmin, nx) + xmax = max(xmax, nx) + ymin = min(ymin, ny) + ymax = max(ymax, ny) + regions.append((xmin, ymin, xmax + 1, ymax + 1)) + return regions + +def pad_frame_to_size(frame, target_size): + target_w, target_h = target_size + w, h = frame.size + new_img = Image.new("RGBA", (target_w, target_h), (0, 0, 0, 0)) + x = (target_w - w) // 2 + y = (target_h - h) // 2 + new_img.paste(frame, (x, y)) + return new_img + +def crop_and_process(img, regions, config): + frames = [img.crop(box) for box in regions] + if config["scale_factor"] != 1: + scaled_frames = [] + for frame in frames: + w, h = frame.size + new_size = (int(w * config["scale_factor"]), int(h * config["scale_factor"])) + scaled_frames.append(frame.resize(new_size, Image.NEAREST)) + frames = scaled_frames + if config["sort_by_position"]: + regions, frames = zip(*sorted(zip(regions, frames), key=lambda pair: (pair[0][1], pair[0][0]))) + max_w = max(f.width for f in frames) + max_h = max(f.height for f in frames) + frames = [pad_frame_to_size(f, (max_w, max_h)) for f in frames] + repeated_frames = [] + for frame in frames: + repeated_frames.extend([frame] * config["frame_repeat"]) + frames = repeated_frames + total = len(frames) + cols = config["output_columns"] or int(total**0.5) + rows = config["output_rows"] or ((total + cols - 1) // cols) + new_w = cols * (max_w + config["target_padding"]) - config["target_padding"] + new_h = rows * (max_h + config["target_padding"]) - config["target_padding"] + new_img = Image.new("RGBA", (new_w, new_h), (0, 0, 0, 0)) + for i, frame in enumerate(frames): + col = i % cols + row = i // cols + x = col * (max_w + config["target_padding"]) + y = row * (max_h + config["target_padding"]) + new_img.paste(frame, (x, y)) + return new_img + +def process_spritesheet(filepath, config): + img = Image.open(filepath).convert("RGBA") + regions = get_connected_regions(img) + print(f"{os.path.basename(filepath)}: {len(regions)} frames recognized") + new_img = crop_and_process(img, regions, config) + os.makedirs(config["output_folder"], exist_ok=True) + filename = os.path.basename(filepath) + output_path = os.path.join(config["output_folder"], filename) + new_img.save(output_path) + print(f"āœ… Saved: {output_path}") + +def main(): + args = parse_args() + config_path = args.config if args.config else "config.json" + file_config = load_config_from_file(config_path) if os.path.isfile(config_path) else {} + config = merge_config(args, file_config) + print(f"\nšŸ“ Input: {config['input_folder']} → šŸ“ Output: {config['output_folder']}\n") + for root, _, files in os.walk(config["input_folder"]): + for filename in files: + if filename.lower().endswith(".png"): + filepath = os.path.join(root, filename) + relative_path = os.path.relpath(filepath, config["input_folder"]) + output_path = os.path.join(config["output_folder"], os.path.dirname(relative_path)) + os.makedirs(output_path, exist_ok=True) + file_config = config.copy() + file_config["output_folder"] = output_path + process_spritesheet(filepath, file_config) + +main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3868fb1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pillow