图像-用于生成雪碧图

#!/usr/bin/env python3
"""Generate a sprite sheet from grid-named images.

脚本,用于生成雪碧图
python img_sprite.py -i /document/images -o /document/output/sprite.png -s 0

images里面的图片命名是这样的,检查如果图片首尾不是以"数字_" 和 “_数字”结尾则报错:
1_home_1.png: 代表第一行的第一个图片
1_hospitol_2.png:代表第一行的第二个图片
2_store_1.png:代表第二行的第一个图片

参数说明:
 -i 代表输入文件夹
-o 代表输出文件
-s 代表图片之间的间距,默认为0px, 可不传


"""

from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

from PIL import Image

IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
NAME_PATTERN = re.compile(r"^(\d+)_.+_(\d+)$")


class SpriteError(Exception):
    pass


def validate_and_parse(filename: str) -> tuple[int, int]:
    """Validate filename format and return (row, col)."""
    stem = Path(filename).stem
    if not re.match(r"^\d+_", stem):
        raise SpriteError(
            f"Invalid filename '{filename}': must start with '<row>_', e.g. '1_home_1.png'"
        )
    if not re.search(r"_\d+$", stem):
        raise SpriteError(
            f"Invalid filename '{filename}': must end with '_<col>', e.g. '1_home_1.png'"
        )

    match = NAME_PATTERN.match(stem)
    if not match:
        raise SpriteError(
            f"Invalid filename '{filename}': expected format '<row>_<name>_<col>.ext'"
        )

    return int(match.group(1)), int(match.group(2))


def collect_images(input_dir: Path) -> dict[tuple[int, int], Path]:
    if not input_dir.is_dir():
        raise SpriteError(f"Input directory not found: {input_dir}")

    images: dict[tuple[int, int], Path] = {}
    for path in sorted(input_dir.iterdir()):
        if not path.is_file() or path.suffix.lower() not in IMAGE_EXTENSIONS:
            continue

        row, col = validate_and_parse(path.name)
        key = (row, col)
        if key in images:
            raise SpriteError(
                f"Duplicate grid position ({row}, {col}): "
                f"'{images[key].name}' and '{path.name}'"
            )
        images[key] = path

    if not images:
        raise SpriteError(f"No image files found in: {input_dir}")

    return images


def build_sprite(
    images: dict[tuple[int, int], Path],
    spacing: int,
) -> Image.Image:
    opened: dict[tuple[int, int], Image.Image] = {}
    rows = sorted({row for row, _ in images})
    cols = sorted({col for _, col in images})

    try:
        for (row, col), path in images.items():
            opened[(row, col)] = Image.open(path).convert("RGBA")

        row_heights = {
            row: max(opened[(row, col)].height for col in cols if (row, col) in opened)
            for row in rows
        }
        col_widths = {
            col: max(opened[(row, col)].width for row in rows if (row, col) in opened)
            for col in cols
        }

        sprite_width = sum(col_widths[col] for col in cols) + spacing * (len(cols) - 1)
        sprite_height = sum(row_heights[row] for row in rows) + spacing * (len(rows) - 1)

        sprite = Image.new("RGBA", (sprite_width, sprite_height), (0, 0, 0, 0))

        y = 0
        for row in rows:
            x = 0
            for col in cols:
                key = (row, col)
                if key in opened:
                    img = opened[key]
                    cell_w = col_widths[col]
                    cell_h = row_heights[row]
                    paste_x = x + (cell_w - img.width) // 2
                    paste_y = y + (cell_h - img.height) // 2
                    sprite.paste(img, (paste_x, paste_y), img)
                x += col_widths[col] + spacing
            y += row_heights[row] + spacing

        return sprite
    finally:
        for img in opened.values():
            img.close()


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Generate a sprite sheet from grid-named images.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Image naming convention:\n"
            "  <row>_<name>_<col>.png\n"
            "Examples:\n"
            "  1_home_1.png      -> row 1, column 1\n"
            "  1_hospitol_2.png  -> row 1, column 2\n"
            "  2_store_1.png     -> row 2, column 1\n"
            "\n"
            "Example:\n"
            "  python img_sprite.py -i ./images -o ./output/sprite.png -s 0"
        ),
    )
    parser.add_argument("-i", "--input", required=True, help="Input image directory")
    parser.add_argument("-o", "--output", required=True, help="Output sprite PNG path")
    parser.add_argument(
        "-s",
        "--spacing",
        type=int,
        default=0,
        help="Spacing between images in pixels (default: 0)",
    )
    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    if args.spacing < 0:
        print("Error: spacing must be >= 0", file=sys.stderr)
        return 1

    input_dir = Path(args.input).expanduser().resolve()
    output_path = Path(args.output).expanduser().resolve()

    try:
        images = collect_images(input_dir)
        sprite = build_sprite(images, args.spacing)
        output_path.parent.mkdir(parents=True, exist_ok=True)
        sprite.save(output_path)
        print(
            f"Sprite saved: {output_path} "
            f"({sprite.width}x{sprite.height}, {len(images)} images, spacing={args.spacing}px)"
        )
        return 0
    except SpriteError as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())