first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a Codex pet spritesheet atlas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
COLUMNS = 8
|
||||
ROWS = 9
|
||||
CELL_WIDTH = 192
|
||||
CELL_HEIGHT = 208
|
||||
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
|
||||
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
|
||||
ROW_BY_INDEX = {
|
||||
0: ("idle", 6),
|
||||
1: ("running-right", 8),
|
||||
2: ("running-left", 8),
|
||||
3: ("waving", 4),
|
||||
4: ("jumping", 5),
|
||||
5: ("failed", 8),
|
||||
6: ("waiting", 6),
|
||||
7: ("running", 6),
|
||||
8: ("review", 6),
|
||||
}
|
||||
|
||||
|
||||
def alpha_nonzero_count(image: Image.Image) -> int:
|
||||
alpha = image.getchannel("A")
|
||||
return sum(alpha.histogram()[1:])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("atlas")
|
||||
parser.add_argument("--json-out")
|
||||
parser.add_argument("--min-used-pixels", type=int, default=50)
|
||||
parser.add_argument("--near-opaque-threshold", type=float, default=0.95)
|
||||
parser.add_argument("--allow-opaque", action="store_true")
|
||||
parser.add_argument("--allow-near-opaque-used-cells", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
atlas_path = Path(args.atlas).expanduser().resolve()
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
near_opaque_used_cells: dict[str, list[int]] = defaultdict(list)
|
||||
cells: list[dict[str, object]] = []
|
||||
|
||||
try:
|
||||
with Image.open(atlas_path) as opened:
|
||||
source_mode = opened.mode
|
||||
source_format = opened.format
|
||||
image = opened.convert("RGBA")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result = {"ok": False, "errors": [f"could not open atlas: {exc}"], "warnings": []}
|
||||
print(json.dumps(result, indent=2))
|
||||
raise SystemExit(1)
|
||||
|
||||
if image.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
|
||||
errors.append(f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {image.width}x{image.height}")
|
||||
|
||||
if source_format not in {"PNG", "WEBP"}:
|
||||
errors.append(f"expected PNG or WebP, got {source_format}")
|
||||
|
||||
if "A" not in source_mode and not args.allow_opaque:
|
||||
errors.append("atlas does not have an alpha channel")
|
||||
|
||||
for row_index in range(ROWS):
|
||||
state, frame_count = ROW_BY_INDEX[row_index]
|
||||
for column_index in range(COLUMNS):
|
||||
left = column_index * CELL_WIDTH
|
||||
top = row_index * CELL_HEIGHT
|
||||
cell = image.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
|
||||
nontransparent = alpha_nonzero_count(cell)
|
||||
used = column_index < frame_count
|
||||
cell_info = {
|
||||
"state": state,
|
||||
"row": row_index,
|
||||
"column": column_index,
|
||||
"used": used,
|
||||
"nontransparent_pixels": nontransparent,
|
||||
}
|
||||
cells.append(cell_info)
|
||||
if used and nontransparent < args.min_used_pixels:
|
||||
errors.append(
|
||||
f"{state} row {row_index} column {column_index} is empty or too sparse ({nontransparent} pixels)"
|
||||
)
|
||||
if used and nontransparent > CELL_WIDTH * CELL_HEIGHT * args.near_opaque_threshold:
|
||||
near_opaque_used_cells[f"{state} row {row_index}"].append(column_index)
|
||||
if not used and nontransparent != 0:
|
||||
errors.append(
|
||||
f"{state} row {row_index} unused column {column_index} is not transparent ({nontransparent} pixels)"
|
||||
)
|
||||
|
||||
for row_label, columns in near_opaque_used_cells.items():
|
||||
message = (
|
||||
f"{row_label} has {len(columns)} nearly opaque used cells; "
|
||||
"this usually means the sprite has a non-transparent background"
|
||||
)
|
||||
if args.allow_near_opaque_used_cells:
|
||||
warnings.append(message)
|
||||
else:
|
||||
errors.append(message)
|
||||
|
||||
alpha_count = alpha_nonzero_count(image)
|
||||
if alpha_count == ATLAS_WIDTH * ATLAS_HEIGHT:
|
||||
message = "atlas is fully opaque; custom pets require a transparent sprite background"
|
||||
if args.allow_opaque:
|
||||
warnings.append(message)
|
||||
else:
|
||||
errors.append(message)
|
||||
|
||||
result = {
|
||||
"ok": not errors,
|
||||
"file": str(atlas_path),
|
||||
"format": source_format,
|
||||
"mode": source_mode,
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"cells": cells,
|
||||
}
|
||||
|
||||
if args.json_out:
|
||||
Path(args.json_out).expanduser().resolve().write_text(
|
||||
json.dumps(result, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
print(json.dumps({k: v for k, v in result.items() if k != "cells"}, indent=2))
|
||||
raise SystemExit(0 if result["ok"] else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user