tile-validate.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. # /// script
  2. # requires-python = ">=3.9"
  3. # dependencies = ["pillow>=10.0"]
  4. # ///
  5. """QA gate for isometric tile assets — dimension, alpha-halo, edge-bleed, anchor, palette checks.
  6. Usage: uv run tile-validate.py [OPTIONS] <TILE.png> [<TILE2.png> ...]
  7. Input: one or more raster tile files (PNG/WebP/etc — anything Pillow opens with an
  8. alpha channel expected); a directory is NOT expanded automatically (pass a
  9. glob from the shell, e.g. `uv run tile-validate.py tiles/*.png`).
  10. Output: stdout = data only — one PASS/FAIL line per file (plain mode), or the
  11. --json envelope (schema claude-mods.isometric-ops.tile_validate/v1).
  12. Stderr: progress, per-check narration, warnings, errors.
  13. Exit: 0 all files clean
  14. 2 usage (bad/missing args, conflicting flags)
  15. 3 not-found (an input file does not exist)
  16. 4 validation (an input file exists but is not a readable image)
  17. 5 precondition (Pillow not installed / can't be imported)
  18. 10 domain signal — at least one file has at least one violation
  19. Checks (each individually toggleable with --skip-<name>; see --help):
  20. dimension Tile W×H are each an exact multiple of --tile-w/--tile-h (the tile
  21. module). Skipped automatically if --tile-w/--tile-h not given.
  22. halo % of semi-transparent pixels (0 < alpha < 255) exceeds --halo-threshold
  23. (default 0.5% of total pixels). Classic AI-generation edge fringe.
  24. bleed Any opaque pixel (alpha > --bleed-alpha, default 0) sits on the
  25. outermost row/column of the canvas — the sprite has no transparent
  26. margin and will visibly clip against neighbouring tiles/UI chrome.
  27. anchor Heuristic: the lowest (max-y) row containing an opaque pixel should be
  28. roughly horizontally centered — sprites are anchored at the visual feet
  29. (see references/tile-spec.md), and an off-center foot row usually means
  30. the source asset was cropped or padded asymmetrically. Reported as an
  31. offset percentage from center; flagged past --anchor-tolerance (default
  32. 15% of width).
  33. colors Distinct RGBA color count exceeds --max-colors (only run if set).
  34. Examples:
  35. uv run tile-validate.py tiles/grass_n.png
  36. uv run tile-validate.py --tile-w 64 --tile-h 32 tiles/*.png
  37. uv run tile-validate.py --max-colors 32 --json tiles/*.png | jq '.data[] | select(.violations | length > 0)'
  38. uv run tile-validate.py --skip-anchor --halo-threshold 1.0 wall_tiles/*.png
  39. uv run tile-validate.py --json tiles/crate.png | jq -r '.data[0].violations[].check'
  40. Notes:
  41. - Run via `uv run` so the PEP 723 block above resolves Pillow automatically. On
  42. Windows, avoid the Microsoft Store `python3` stub (it exits 49 doing nothing) —
  43. `uv run` or a real `python`/`py` on PATH both work.
  44. - The dimension check only fires when --tile-w AND --tile-h are both supplied,
  45. since not every asset in a set is a full ground tile (props/overlays vary).
  46. - This script performs read-only inspection; it never rewrites the input file.
  47. """
  48. from __future__ import annotations
  49. import argparse
  50. import json
  51. import sys
  52. from pathlib import Path
  53. from typing import Any
  54. # Semantic exit codes (SKILL-RESOURCE-PROTOCOL.md §5).
  55. EX_OK = 0
  56. EX_USAGE = 2
  57. EX_NOT_FOUND = 3
  58. EX_VALIDATION = 4
  59. EX_PRECONDITION = 5
  60. EX_DOMAIN = 10
  61. SCHEMA = "claude-mods.isometric-ops.tile_validate/v1"
  62. DEFAULT_HALO_THRESHOLD_PCT = 0.5 # % of total pixels allowed to be semi-transparent
  63. DEFAULT_BLEED_ALPHA = 0 # any alpha strictly greater than this on the border = bleed
  64. DEFAULT_ANCHOR_TOLERANCE_PCT = 15.0 # % of width the lowest-opaque-row centroid may drift
  65. def eprint(*args: Any, **kwargs: Any) -> None:
  66. print(*args, file=sys.stderr, **kwargs)
  67. def build_parser() -> argparse.ArgumentParser:
  68. p = argparse.ArgumentParser(
  69. prog="tile-validate.py",
  70. description=(
  71. "QA gate for isometric tile assets: dimension conformance, alpha-halo, "
  72. "edge-bleed, anchor-at-feet heuristic, and palette-size checks."
  73. ),
  74. epilog=(
  75. "Examples:\n"
  76. " uv run tile-validate.py tiles/grass_n.png\n"
  77. " uv run tile-validate.py --tile-w 64 --tile-h 32 tiles/*.png\n"
  78. " uv run tile-validate.py --max-colors 32 --json tiles/*.png | "
  79. "jq '.data[] | select(.violations | length > 0)'\n"
  80. " uv run tile-validate.py --skip-anchor --halo-threshold 1.0 wall_tiles/*.png\n"
  81. " uv run tile-validate.py --json tiles/crate.png | "
  82. "jq -r '.data[0].violations[].check'\n"
  83. ),
  84. formatter_class=argparse.RawDescriptionHelpFormatter,
  85. )
  86. p.add_argument("files", nargs="*", metavar="TILE", help="one or more image files to validate")
  87. p.add_argument("--tile-w", type=int, default=None, metavar="N", help="expected tile-module width in px (dimension check)")
  88. p.add_argument("--tile-h", type=int, default=None, metavar="N", help="expected tile-module height in px (dimension check)")
  89. p.add_argument(
  90. "--halo-threshold", type=float, default=DEFAULT_HALO_THRESHOLD_PCT, metavar="PCT",
  91. help=f"max %% of semi-transparent pixels before flagging halo (default {DEFAULT_HALO_THRESHOLD_PCT})",
  92. )
  93. p.add_argument(
  94. "--bleed-alpha", type=int, default=DEFAULT_BLEED_ALPHA, metavar="N",
  95. help=f"alpha value (0-255) above which a border pixel counts as bleed (default {DEFAULT_BLEED_ALPHA})",
  96. )
  97. p.add_argument(
  98. "--anchor-tolerance", type=float, default=DEFAULT_ANCHOR_TOLERANCE_PCT, metavar="PCT",
  99. help=f"max %% horizontal drift of the foot-row centroid from center (default {DEFAULT_ANCHOR_TOLERANCE_PCT})",
  100. )
  101. p.add_argument("--max-colors", type=int, default=None, metavar="N", help="flag if distinct RGBA color count exceeds N (unset = skip)")
  102. p.add_argument("--skip-dimension", action="store_true", help="skip the dimension-conformance check")
  103. p.add_argument("--skip-halo", action="store_true", help="skip the alpha-halo check")
  104. p.add_argument("--skip-bleed", action="store_true", help="skip the edge-bleed check")
  105. p.add_argument("--skip-anchor", action="store_true", help="skip the anchor-at-feet heuristic")
  106. p.add_argument("--json", action="store_true", help="emit the --json envelope to stdout instead of plain lines")
  107. p.add_argument("-q", "--quiet", action="store_true", help="suppress per-check stderr narration (still reports violations)")
  108. return p
  109. def load_image(path: Path):
  110. """Open path as RGBA. Raises FileNotFoundError / PIL.UnidentifiedImageError-family."""
  111. from PIL import Image # deferred: exit 5 with a clean message if this import fails
  112. img = Image.open(path)
  113. img.load() # force decode now so corrupt files fail here, not later mid-check
  114. return img.convert("RGBA")
  115. def check_dimension(img, tile_w: int | None, tile_h: int | None) -> list[dict[str, Any]]:
  116. if tile_w is None or tile_h is None:
  117. return []
  118. w, h = img.size
  119. violations = []
  120. if w % tile_w != 0 or h % tile_h != 0:
  121. violations.append({
  122. "check": "dimension",
  123. "message": f"{w}x{h} is not an exact multiple of the {tile_w}x{tile_h} tile module",
  124. "detail": {"width": w, "height": h, "tile_w": tile_w, "tile_h": tile_h},
  125. })
  126. return violations
  127. def check_halo(img, threshold_pct: float) -> list[dict[str, Any]]:
  128. alpha = img.getchannel("A")
  129. total = alpha.width * alpha.height
  130. if total == 0:
  131. return []
  132. histogram = alpha.histogram() # 256 buckets, index = alpha value
  133. semi_transparent = sum(histogram[1:255]) # exclude fully transparent (0) and fully opaque (255)
  134. pct = (semi_transparent / total) * 100.0
  135. if pct > threshold_pct:
  136. return [{
  137. "check": "halo",
  138. "message": f"{pct:.2f}% of pixels are semi-transparent (threshold {threshold_pct}%) — likely AI-generation edge fringe",
  139. "detail": {"semi_transparent_pct": round(pct, 4), "threshold_pct": threshold_pct, "semi_transparent_pixels": semi_transparent, "total_pixels": total},
  140. }]
  141. return []
  142. def check_bleed(img, bleed_alpha: int) -> list[dict[str, Any]]:
  143. alpha = img.getchannel("A")
  144. w, h = alpha.size
  145. if w == 0 or h == 0:
  146. return []
  147. px = alpha.load()
  148. bleeding_pixels = 0
  149. edge_coords = set()
  150. for x in range(w):
  151. for y in (0, h - 1):
  152. if px[x, y] > bleed_alpha:
  153. bleeding_pixels += 1
  154. edge_coords.add((x, y))
  155. for y in range(h):
  156. for x in (0, w - 1):
  157. if px[x, y] > bleed_alpha:
  158. bleeding_pixels += 1
  159. edge_coords.add((x, y))
  160. if edge_coords:
  161. return [{
  162. "check": "bleed",
  163. "message": f"{len(edge_coords)} border pixel(s) exceed alpha {bleed_alpha} — no transparent margin, will clip against neighbours",
  164. "detail": {"bleeding_border_pixels": len(edge_coords), "bleed_alpha_threshold": bleed_alpha},
  165. }]
  166. return []
  167. def check_anchor(img, tolerance_pct: float) -> list[dict[str, Any]]:
  168. alpha = img.getchannel("A")
  169. w, h = alpha.size
  170. if w == 0 or h == 0:
  171. return []
  172. px = alpha.load()
  173. # Find the lowest (max-y) row containing at least one opaque-ish pixel (alpha > 0),
  174. # then compute that row's opaque-pixel horizontal centroid vs. the canvas center.
  175. foot_y = None
  176. for y in range(h - 1, -1, -1):
  177. if any(px[x, y] > 0 for x in range(w)):
  178. foot_y = y
  179. break
  180. if foot_y is None:
  181. return [] # fully transparent image — nothing to anchor-check
  182. opaque_xs = [x for x in range(w) if px[x, foot_y] > 0]
  183. if not opaque_xs:
  184. return []
  185. centroid_x = sum(opaque_xs) / len(opaque_xs)
  186. canvas_center_x = w / 2.0
  187. drift_pct = (abs(centroid_x - canvas_center_x) / w) * 100.0
  188. if drift_pct > tolerance_pct:
  189. return [{
  190. "check": "anchor",
  191. "message": f"foot-row centroid drifts {drift_pct:.1f}% of width from center (tolerance {tolerance_pct}%) — asset may be asymmetrically cropped/padded",
  192. "detail": {
  193. "foot_row_y": foot_y,
  194. "centroid_x": round(centroid_x, 2),
  195. "canvas_center_x": canvas_center_x,
  196. "drift_pct": round(drift_pct, 2),
  197. "tolerance_pct": tolerance_pct,
  198. },
  199. }]
  200. return []
  201. def check_colors(img, max_colors: int | None) -> list[dict[str, Any]]:
  202. if max_colors is None:
  203. return []
  204. w, h = img.size
  205. # getcolors returns None if distinct-color count exceeds maxcolors; use total
  206. # pixel count as the ceiling so we always get an exact count back.
  207. colors = img.getcolors(maxcolors=w * h)
  208. count = len(colors) if colors is not None else w * h
  209. if count > max_colors:
  210. return [{
  211. "check": "colors",
  212. "message": f"{count} distinct RGBA colors exceeds --max-colors {max_colors}",
  213. "detail": {"distinct_colors": count, "max_colors": max_colors},
  214. }]
  215. return []
  216. def validate_file(path: Path, args: argparse.Namespace) -> dict[str, Any]:
  217. record: dict[str, Any] = {"file": str(path), "ok": False, "violations": [], "error": None}
  218. if not path.exists():
  219. record["error"] = {"code": "NOT_FOUND", "message": f"file does not exist: {path}"}
  220. return record
  221. if not path.is_file():
  222. record["error"] = {"code": "NOT_FOUND", "message": f"not a regular file: {path}"}
  223. return record
  224. try:
  225. img = load_image(path)
  226. except Exception as exc: # noqa: BLE001 - any Pillow/OS decode failure funnels to VALIDATION
  227. record["error"] = {"code": "VALIDATION", "message": f"could not open as an image: {exc}"}
  228. return record
  229. record["width"], record["height"] = img.size
  230. violations: list[dict[str, Any]] = []
  231. if not args.skip_dimension:
  232. violations += check_dimension(img, args.tile_w, args.tile_h)
  233. if not args.skip_halo:
  234. violations += check_halo(img, args.halo_threshold)
  235. if not args.skip_bleed:
  236. violations += check_bleed(img, args.bleed_alpha)
  237. if not args.skip_anchor:
  238. violations += check_anchor(img, args.anchor_tolerance)
  239. violations += check_colors(img, args.max_colors)
  240. record["violations"] = violations
  241. record["ok"] = len(violations) == 0
  242. return record
  243. def emit_plain(records: list[dict[str, Any]], quiet: bool) -> None:
  244. for rec in records:
  245. if rec["error"] is not None:
  246. print(f"ERROR\t{rec['file']}\t{rec['error']['code']}: {rec['error']['message']}")
  247. continue
  248. if rec["ok"]:
  249. print(f"PASS\t{rec['file']}\t{rec['width']}x{rec['height']}")
  250. else:
  251. print(f"FAIL\t{rec['file']}\t{len(rec['violations'])} violation(s)")
  252. for v in rec["violations"]:
  253. print(f" - {v['check']}: {v['message']}")
  254. def emit_json(records: list[dict[str, Any]], had_domain_hit: bool, had_hard_error: bool) -> None:
  255. payload = {
  256. "data": records,
  257. "meta": {
  258. "count": len(records),
  259. "schema": SCHEMA,
  260. "clean": not had_domain_hit and not had_hard_error,
  261. },
  262. }
  263. print(json.dumps(payload))
  264. def main(argv: list[str] | None = None) -> int:
  265. parser = build_parser()
  266. args = parser.parse_args(argv)
  267. if not args.files:
  268. eprint("ERROR: no input files given (try --help)")
  269. return EX_USAGE
  270. if (args.tile_w is None) != (args.tile_h is None):
  271. eprint("ERROR: --tile-w and --tile-h must be given together")
  272. return EX_USAGE
  273. if args.tile_w is not None and args.tile_w <= 0:
  274. eprint("ERROR: --tile-w must be a positive integer")
  275. return EX_USAGE
  276. if args.tile_h is not None and args.tile_h <= 0:
  277. eprint("ERROR: --tile-h must be a positive integer")
  278. return EX_USAGE
  279. if args.halo_threshold < 0:
  280. eprint("ERROR: --halo-threshold must be >= 0")
  281. return EX_USAGE
  282. if args.anchor_tolerance < 0:
  283. eprint("ERROR: --anchor-tolerance must be >= 0")
  284. return EX_USAGE
  285. if args.bleed_alpha < 0 or args.bleed_alpha > 255:
  286. eprint("ERROR: --bleed-alpha must be in [0, 255]")
  287. return EX_USAGE
  288. if args.max_colors is not None and args.max_colors <= 0:
  289. eprint("ERROR: --max-colors must be a positive integer")
  290. return EX_USAGE
  291. try:
  292. import PIL # noqa: F401
  293. except ImportError:
  294. eprint("ERROR: Pillow is not installed. Run this script via `uv run tile-validate.py ...`")
  295. eprint(" so the PEP 723 inline metadata resolves it automatically, or:")
  296. eprint(" uv pip install pillow")
  297. return EX_PRECONDITION
  298. if not args.quiet:
  299. eprint(f"tile-validate: checking {len(args.files)} file(s)...")
  300. records: list[dict[str, Any]] = []
  301. any_not_found = False
  302. any_validation_error = False
  303. for raw_path in args.files:
  304. path = Path(raw_path)
  305. if not args.quiet:
  306. eprint(f" {path}")
  307. rec = validate_file(path, args)
  308. records.append(rec)
  309. if rec["error"] is not None:
  310. if rec["error"]["code"] == "NOT_FOUND":
  311. any_not_found = True
  312. else:
  313. any_validation_error = True
  314. had_domain_hit = any(rec["error"] is None and not rec["ok"] for rec in records)
  315. had_hard_error = any_not_found or any_validation_error
  316. if args.json:
  317. emit_json(records, had_domain_hit, had_hard_error)
  318. else:
  319. emit_plain(records, args.quiet)
  320. if any_not_found:
  321. eprint(f"ERROR: {sum(1 for r in records if r['error'] and r['error']['code'] == 'NOT_FOUND')} input file(s) not found")
  322. return EX_NOT_FOUND
  323. if any_validation_error:
  324. eprint(f"ERROR: {sum(1 for r in records if r['error'] and r['error']['code'] == 'VALIDATION')} input file(s) failed to open as images")
  325. return EX_VALIDATION
  326. if had_domain_hit:
  327. if not args.quiet:
  328. eprint(f"tile-validate: {sum(1 for r in records if not r['ok'])} of {len(records)} file(s) have violations")
  329. return EX_DOMAIN
  330. if not args.quiet:
  331. eprint(f"tile-validate: all {len(records)} file(s) clean")
  332. return EX_OK
  333. if __name__ == "__main__":
  334. sys.exit(main())