Setting the file. One moment. Validate Atlas · Hatch Pet · openai/skills · Skills Docs16.11
Validate Atlas
scripts/validate_atlas.py
scripts/validate_atlas.py
Python·157 lines·5 KB
=
8
14ROWS = 9
15CELL_WIDTH = 192
16CELL_HEIGHT = 208
17ATLAS_WIDTH = COLUMNS * CELL_WIDTH
18ATLAS_HEIGHT = ROWS * CELL_HEIGHT
19ROW_BY_INDEX = {
20 0: ("idle", 6),
21 1: ("running-right", 8),
22 2: ("running-left", 8),
23 3: ("waving", 4),
24 4: ("jumping", 5),
25 5: ("failed", 8),
26 6: ("waiting", 6),
27 7: ("running", 6),
28 8: ("review", 6),
29}
30
31
32def alpha_nonzero_count(image: Image.Image) -> int:
33 alpha = image.getchannel("A")
34 return sum(alpha.histogram()[1:])
35
36
37def transparent_rgb_residue_count(image: Image.Image) -> int:
38 rgba = image.convert("RGBA")
39 data = rgba.tobytes()
40 count = 0
41 for index in range(0, len(data), 4):
42 red, green, blue, alpha = data[index : index + 4]
43 if alpha == 0 and (red or green or blue):
44 count += 1
45 return count
46
47
48def main() -> None:
49 parser = argparse.ArgumentParser(description=__doc__)
50 parser.add_argument("atlas")
51 parser.add_argument("--json-out")
52 parser.add_argument("--min-used-pixels", type=int, default=50)
53 parser.add_argument("--near-opaque-threshold", type=float, default=0.95)
54 parser.add_argument("--allow-opaque", action="store_true")
55 parser.add_argument("--allow-near-opaque-used-cells", action="store_true")
56 args = parser.parse_args()
57
58 atlas_path = Path(args.atlas).expanduser().resolve()
59 errors: list[str] = []
60 warnings: list[str] = []
61 near_opaque_used_cells: dict[str, list[int]] = defaultdict(list)
62 cells: list[dict[str, object]] = []
63
64 try:
65 with Image.open(atlas_path) as opened:
66 source_mode = opened.mode
67 source_format = opened.format
68 image = opened.convert("RGBA")
69 except Exception as exc: # noqa: BLE001
70 result = {"ok": False, "errors": [f"could not open atlas: {exc}"], "warnings": []}
71 print(json.dumps(result, indent=2))
72 raise SystemExit(1)
73
74 if image.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
75 errors.append(f"expected {ATLAS_WIDTH}x{ATLAS_HEIGHT}, got {image.width}x{image.height}")
76
77 if source_format not in {"PNG", "WEBP"}:
78 errors.append(f"expected PNG or WebP, got {source_format}")
79
80 if "A" not in source_mode and not args.allow_opaque:
81 errors.append("atlas does not have an alpha channel")
82
83 for row_index in range(ROWS):
84 state, frame_count = ROW_BY_INDEX[row_index]
85 for column_index in range(COLUMNS):
86 left = column_index * CELL_WIDTH
87 top = row_index * CELL_HEIGHT
88 cell = image.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
89 nontransparent = alpha_nonzero_count(cell)
90 used = column_index < frame_count
91 cell_info = {
92 "state": state,
93 "row": row_index,
94 "column": column_index,
95 "used": used,
96 "nontransparent_pixels": nontransparent,
97 }
98 cells.append(cell_info)
99 if used and nontransparent < args.min_used_pixels:
100 errors.append(
101 f"{state} row {row_index} column {column_index} is empty or too sparse ({nontransparent} pixels)"
102 )
103 if used and nontransparent > CELL_WIDTH * CELL_HEIGHT * args.near_opaque_threshold:
104 near_opaque_used_cells[f"{state} row {row_index}"].append(column_index)
105 if not used and nontransparent != 0:
106 errors.append(
107 f"{state} row {row_index} unused column {column_index} is not transparent ({nontransparent} pixels)"
108 )
109
110 for row_label, columns in near_opaque_used_cells.items():
111 message = (
112 f"{row_label} has {len(columns)} nearly opaque used cells; "
113 "this usually means the sprite has a non-transparent background"
114 )
115 if args.allow_near_opaque_used_cells:
116 warnings.append(message)
117 else:
118 errors.append(message)
119
120 alpha_count = alpha_nonzero_count(image)
121 if alpha_count == ATLAS_WIDTH * ATLAS_HEIGHT:
122 message = "atlas is fully opaque; custom pets require a transparent sprite background"
123 if args.allow_opaque:
124 warnings.append(message)
125 else:
126 errors.append(message)
127
128 transparent_rgb_residue = transparent_rgb_residue_count(image)
129 if transparent_rgb_residue:
130 errors.append(
131 f"atlas has {transparent_rgb_residue} fully transparent pixels with non-zero RGB residue"
132 )
133
134 result = {
135 "ok": not errors,
136 "file": str(atlas_path),
137 "format": source_format,
138 "mode": source_mode,
139 "width": image.width,
140 "height": image.height,
141 "transparent_rgb_residue_pixels": transparent_rgb_residue,
142 "errors": errors,
143 "warnings": warnings,
144 "cells": cells,
145 }
146
147 if args.json_out:
148 Path(args.json_out).expanduser().resolve().write_text(
149 json.dumps(result, indent=2) + "\n", encoding="utf-8"
150 )
151
152 print(json.dumps({k: v for k, v in result.items() if k != "cells"}, indent=2))
153 raise SystemExit(0 if result["ok"] else 1)
154
155
156if __name__ == "__main__":
157 main()