first commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
output/
|
||||
processed/
|
||||
spritesheets/
|
||||
*.zip
|
||||
*.tar.gz
|
21
LICENSE.md
Normal file
21
LICENSE.md
Normal file
@@ -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.
|
25
README.md
Normal file
25
README.md
Normal file
@@ -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
|
||||
```
|
10
config.json
Normal file
10
config.json
Normal file
@@ -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
|
||||
}
|
150
main.py
Normal file
150
main.py
Normal file
@@ -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()
|
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
pillow
|
Reference in New Issue
Block a user