| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- # /// script
- # requires-python = ">=3.9"
- # dependencies = ["pillow>=10.0"]
- # ///
- """QA gate for isometric tile assets — dimension, alpha-halo, edge-bleed, anchor, palette checks.
- Usage: uv run tile-validate.py [OPTIONS] <TILE.png> [<TILE2.png> ...]
- Input: one or more raster tile files (PNG/WebP/etc — anything Pillow opens with an
- alpha channel expected); a directory is NOT expanded automatically (pass a
- glob from the shell, e.g. `uv run tile-validate.py tiles/*.png`).
- Output: stdout = data only — one PASS/FAIL line per file (plain mode), or the
- --json envelope (schema claude-mods.isometric-ops.tile_validate/v1).
- Stderr: progress, per-check narration, warnings, errors.
- Exit: 0 all files clean
- 2 usage (bad/missing args, conflicting flags)
- 3 not-found (an input file does not exist)
- 4 validation (an input file exists but is not a readable image)
- 5 precondition (Pillow not installed / can't be imported)
- 10 domain signal — at least one file has at least one violation
- Checks (each individually toggleable with --skip-<name>; see --help):
- dimension Tile W×H are each an exact multiple of --tile-w/--tile-h (the tile
- module). Skipped automatically if --tile-w/--tile-h not given.
- halo % of semi-transparent pixels (0 < alpha < 255) exceeds --halo-threshold
- (default 0.5% of total pixels). Classic AI-generation edge fringe.
- bleed Any opaque pixel (alpha > --bleed-alpha, default 0) sits on the
- outermost row/column of the canvas — the sprite has no transparent
- margin and will visibly clip against neighbouring tiles/UI chrome.
- anchor Heuristic: the lowest (max-y) row containing an opaque pixel should be
- roughly horizontally centered — sprites are anchored at the visual feet
- (see references/tile-spec.md), and an off-center foot row usually means
- the source asset was cropped or padded asymmetrically. Reported as an
- offset percentage from center; flagged past --anchor-tolerance (default
- 15% of width).
- colors Distinct RGBA color count exceeds --max-colors (only run if set).
- Examples:
- uv run tile-validate.py tiles/grass_n.png
- uv run tile-validate.py --tile-w 64 --tile-h 32 tiles/*.png
- uv run tile-validate.py --max-colors 32 --json tiles/*.png | jq '.data[] | select(.violations | length > 0)'
- uv run tile-validate.py --skip-anchor --halo-threshold 1.0 wall_tiles/*.png
- uv run tile-validate.py --json tiles/crate.png | jq -r '.data[0].violations[].check'
- Notes:
- - Run via `uv run` so the PEP 723 block above resolves Pillow automatically. On
- Windows, avoid the Microsoft Store `python3` stub (it exits 49 doing nothing) —
- `uv run` or a real `python`/`py` on PATH both work.
- - The dimension check only fires when --tile-w AND --tile-h are both supplied,
- since not every asset in a set is a full ground tile (props/overlays vary).
- - This script performs read-only inspection; it never rewrites the input file.
- """
- from __future__ import annotations
- import argparse
- import json
- import sys
- from pathlib import Path
- from typing import Any
- # Semantic exit codes (SKILL-RESOURCE-PROTOCOL.md §5).
- EX_OK = 0
- EX_USAGE = 2
- EX_NOT_FOUND = 3
- EX_VALIDATION = 4
- EX_PRECONDITION = 5
- EX_DOMAIN = 10
- SCHEMA = "claude-mods.isometric-ops.tile_validate/v1"
- DEFAULT_HALO_THRESHOLD_PCT = 0.5 # % of total pixels allowed to be semi-transparent
- DEFAULT_BLEED_ALPHA = 0 # any alpha strictly greater than this on the border = bleed
- DEFAULT_ANCHOR_TOLERANCE_PCT = 15.0 # % of width the lowest-opaque-row centroid may drift
- def eprint(*args: Any, **kwargs: Any) -> None:
- print(*args, file=sys.stderr, **kwargs)
- def build_parser() -> argparse.ArgumentParser:
- p = argparse.ArgumentParser(
- prog="tile-validate.py",
- description=(
- "QA gate for isometric tile assets: dimension conformance, alpha-halo, "
- "edge-bleed, anchor-at-feet heuristic, and palette-size checks."
- ),
- epilog=(
- "Examples:\n"
- " uv run tile-validate.py tiles/grass_n.png\n"
- " uv run tile-validate.py --tile-w 64 --tile-h 32 tiles/*.png\n"
- " uv run tile-validate.py --max-colors 32 --json tiles/*.png | "
- "jq '.data[] | select(.violations | length > 0)'\n"
- " uv run tile-validate.py --skip-anchor --halo-threshold 1.0 wall_tiles/*.png\n"
- " uv run tile-validate.py --json tiles/crate.png | "
- "jq -r '.data[0].violations[].check'\n"
- ),
- formatter_class=argparse.RawDescriptionHelpFormatter,
- )
- p.add_argument("files", nargs="*", metavar="TILE", help="one or more image files to validate")
- p.add_argument("--tile-w", type=int, default=None, metavar="N", help="expected tile-module width in px (dimension check)")
- p.add_argument("--tile-h", type=int, default=None, metavar="N", help="expected tile-module height in px (dimension check)")
- p.add_argument(
- "--halo-threshold", type=float, default=DEFAULT_HALO_THRESHOLD_PCT, metavar="PCT",
- help=f"max %% of semi-transparent pixels before flagging halo (default {DEFAULT_HALO_THRESHOLD_PCT})",
- )
- p.add_argument(
- "--bleed-alpha", type=int, default=DEFAULT_BLEED_ALPHA, metavar="N",
- help=f"alpha value (0-255) above which a border pixel counts as bleed (default {DEFAULT_BLEED_ALPHA})",
- )
- p.add_argument(
- "--anchor-tolerance", type=float, default=DEFAULT_ANCHOR_TOLERANCE_PCT, metavar="PCT",
- help=f"max %% horizontal drift of the foot-row centroid from center (default {DEFAULT_ANCHOR_TOLERANCE_PCT})",
- )
- p.add_argument("--max-colors", type=int, default=None, metavar="N", help="flag if distinct RGBA color count exceeds N (unset = skip)")
- p.add_argument("--skip-dimension", action="store_true", help="skip the dimension-conformance check")
- p.add_argument("--skip-halo", action="store_true", help="skip the alpha-halo check")
- p.add_argument("--skip-bleed", action="store_true", help="skip the edge-bleed check")
- p.add_argument("--skip-anchor", action="store_true", help="skip the anchor-at-feet heuristic")
- p.add_argument("--json", action="store_true", help="emit the --json envelope to stdout instead of plain lines")
- p.add_argument("-q", "--quiet", action="store_true", help="suppress per-check stderr narration (still reports violations)")
- return p
- def load_image(path: Path):
- """Open path as RGBA. Raises FileNotFoundError / PIL.UnidentifiedImageError-family."""
- from PIL import Image # deferred: exit 5 with a clean message if this import fails
- img = Image.open(path)
- img.load() # force decode now so corrupt files fail here, not later mid-check
- return img.convert("RGBA")
- def check_dimension(img, tile_w: int | None, tile_h: int | None) -> list[dict[str, Any]]:
- if tile_w is None or tile_h is None:
- return []
- w, h = img.size
- violations = []
- if w % tile_w != 0 or h % tile_h != 0:
- violations.append({
- "check": "dimension",
- "message": f"{w}x{h} is not an exact multiple of the {tile_w}x{tile_h} tile module",
- "detail": {"width": w, "height": h, "tile_w": tile_w, "tile_h": tile_h},
- })
- return violations
- def check_halo(img, threshold_pct: float) -> list[dict[str, Any]]:
- alpha = img.getchannel("A")
- total = alpha.width * alpha.height
- if total == 0:
- return []
- histogram = alpha.histogram() # 256 buckets, index = alpha value
- semi_transparent = sum(histogram[1:255]) # exclude fully transparent (0) and fully opaque (255)
- pct = (semi_transparent / total) * 100.0
- if pct > threshold_pct:
- return [{
- "check": "halo",
- "message": f"{pct:.2f}% of pixels are semi-transparent (threshold {threshold_pct}%) — likely AI-generation edge fringe",
- "detail": {"semi_transparent_pct": round(pct, 4), "threshold_pct": threshold_pct, "semi_transparent_pixels": semi_transparent, "total_pixels": total},
- }]
- return []
- def check_bleed(img, bleed_alpha: int) -> list[dict[str, Any]]:
- alpha = img.getchannel("A")
- w, h = alpha.size
- if w == 0 or h == 0:
- return []
- px = alpha.load()
- bleeding_pixels = 0
- edge_coords = set()
- for x in range(w):
- for y in (0, h - 1):
- if px[x, y] > bleed_alpha:
- bleeding_pixels += 1
- edge_coords.add((x, y))
- for y in range(h):
- for x in (0, w - 1):
- if px[x, y] > bleed_alpha:
- bleeding_pixels += 1
- edge_coords.add((x, y))
- if edge_coords:
- return [{
- "check": "bleed",
- "message": f"{len(edge_coords)} border pixel(s) exceed alpha {bleed_alpha} — no transparent margin, will clip against neighbours",
- "detail": {"bleeding_border_pixels": len(edge_coords), "bleed_alpha_threshold": bleed_alpha},
- }]
- return []
- def check_anchor(img, tolerance_pct: float) -> list[dict[str, Any]]:
- alpha = img.getchannel("A")
- w, h = alpha.size
- if w == 0 or h == 0:
- return []
- px = alpha.load()
- # Find the lowest (max-y) row containing at least one opaque-ish pixel (alpha > 0),
- # then compute that row's opaque-pixel horizontal centroid vs. the canvas center.
- foot_y = None
- for y in range(h - 1, -1, -1):
- if any(px[x, y] > 0 for x in range(w)):
- foot_y = y
- break
- if foot_y is None:
- return [] # fully transparent image — nothing to anchor-check
- opaque_xs = [x for x in range(w) if px[x, foot_y] > 0]
- if not opaque_xs:
- return []
- centroid_x = sum(opaque_xs) / len(opaque_xs)
- canvas_center_x = w / 2.0
- drift_pct = (abs(centroid_x - canvas_center_x) / w) * 100.0
- if drift_pct > tolerance_pct:
- return [{
- "check": "anchor",
- "message": f"foot-row centroid drifts {drift_pct:.1f}% of width from center (tolerance {tolerance_pct}%) — asset may be asymmetrically cropped/padded",
- "detail": {
- "foot_row_y": foot_y,
- "centroid_x": round(centroid_x, 2),
- "canvas_center_x": canvas_center_x,
- "drift_pct": round(drift_pct, 2),
- "tolerance_pct": tolerance_pct,
- },
- }]
- return []
- def check_colors(img, max_colors: int | None) -> list[dict[str, Any]]:
- if max_colors is None:
- return []
- w, h = img.size
- # getcolors returns None if distinct-color count exceeds maxcolors; use total
- # pixel count as the ceiling so we always get an exact count back.
- colors = img.getcolors(maxcolors=w * h)
- count = len(colors) if colors is not None else w * h
- if count > max_colors:
- return [{
- "check": "colors",
- "message": f"{count} distinct RGBA colors exceeds --max-colors {max_colors}",
- "detail": {"distinct_colors": count, "max_colors": max_colors},
- }]
- return []
- def validate_file(path: Path, args: argparse.Namespace) -> dict[str, Any]:
- record: dict[str, Any] = {"file": str(path), "ok": False, "violations": [], "error": None}
- if not path.exists():
- record["error"] = {"code": "NOT_FOUND", "message": f"file does not exist: {path}"}
- return record
- if not path.is_file():
- record["error"] = {"code": "NOT_FOUND", "message": f"not a regular file: {path}"}
- return record
- try:
- img = load_image(path)
- except Exception as exc: # noqa: BLE001 - any Pillow/OS decode failure funnels to VALIDATION
- record["error"] = {"code": "VALIDATION", "message": f"could not open as an image: {exc}"}
- return record
- record["width"], record["height"] = img.size
- violations: list[dict[str, Any]] = []
- if not args.skip_dimension:
- violations += check_dimension(img, args.tile_w, args.tile_h)
- if not args.skip_halo:
- violations += check_halo(img, args.halo_threshold)
- if not args.skip_bleed:
- violations += check_bleed(img, args.bleed_alpha)
- if not args.skip_anchor:
- violations += check_anchor(img, args.anchor_tolerance)
- violations += check_colors(img, args.max_colors)
- record["violations"] = violations
- record["ok"] = len(violations) == 0
- return record
- def emit_plain(records: list[dict[str, Any]], quiet: bool) -> None:
- for rec in records:
- if rec["error"] is not None:
- print(f"ERROR\t{rec['file']}\t{rec['error']['code']}: {rec['error']['message']}")
- continue
- if rec["ok"]:
- print(f"PASS\t{rec['file']}\t{rec['width']}x{rec['height']}")
- else:
- print(f"FAIL\t{rec['file']}\t{len(rec['violations'])} violation(s)")
- for v in rec["violations"]:
- print(f" - {v['check']}: {v['message']}")
- def emit_json(records: list[dict[str, Any]], had_domain_hit: bool, had_hard_error: bool) -> None:
- payload = {
- "data": records,
- "meta": {
- "count": len(records),
- "schema": SCHEMA,
- "clean": not had_domain_hit and not had_hard_error,
- },
- }
- print(json.dumps(payload))
- def main(argv: list[str] | None = None) -> int:
- parser = build_parser()
- args = parser.parse_args(argv)
- if not args.files:
- eprint("ERROR: no input files given (try --help)")
- return EX_USAGE
- if (args.tile_w is None) != (args.tile_h is None):
- eprint("ERROR: --tile-w and --tile-h must be given together")
- return EX_USAGE
- if args.tile_w is not None and args.tile_w <= 0:
- eprint("ERROR: --tile-w must be a positive integer")
- return EX_USAGE
- if args.tile_h is not None and args.tile_h <= 0:
- eprint("ERROR: --tile-h must be a positive integer")
- return EX_USAGE
- if args.halo_threshold < 0:
- eprint("ERROR: --halo-threshold must be >= 0")
- return EX_USAGE
- if args.anchor_tolerance < 0:
- eprint("ERROR: --anchor-tolerance must be >= 0")
- return EX_USAGE
- if args.bleed_alpha < 0 or args.bleed_alpha > 255:
- eprint("ERROR: --bleed-alpha must be in [0, 255]")
- return EX_USAGE
- if args.max_colors is not None and args.max_colors <= 0:
- eprint("ERROR: --max-colors must be a positive integer")
- return EX_USAGE
- try:
- import PIL # noqa: F401
- except ImportError:
- eprint("ERROR: Pillow is not installed. Run this script via `uv run tile-validate.py ...`")
- eprint(" so the PEP 723 inline metadata resolves it automatically, or:")
- eprint(" uv pip install pillow")
- return EX_PRECONDITION
- if not args.quiet:
- eprint(f"tile-validate: checking {len(args.files)} file(s)...")
- records: list[dict[str, Any]] = []
- any_not_found = False
- any_validation_error = False
- for raw_path in args.files:
- path = Path(raw_path)
- if not args.quiet:
- eprint(f" {path}")
- rec = validate_file(path, args)
- records.append(rec)
- if rec["error"] is not None:
- if rec["error"]["code"] == "NOT_FOUND":
- any_not_found = True
- else:
- any_validation_error = True
- had_domain_hit = any(rec["error"] is None and not rec["ok"] for rec in records)
- had_hard_error = any_not_found or any_validation_error
- if args.json:
- emit_json(records, had_domain_hit, had_hard_error)
- else:
- emit_plain(records, args.quiet)
- if any_not_found:
- eprint(f"ERROR: {sum(1 for r in records if r['error'] and r['error']['code'] == 'NOT_FOUND')} input file(s) not found")
- return EX_NOT_FOUND
- if any_validation_error:
- 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")
- return EX_VALIDATION
- if had_domain_hit:
- if not args.quiet:
- eprint(f"tile-validate: {sum(1 for r in records if not r['ok'])} of {len(records)} file(s) have violations")
- return EX_DOMAIN
- if not args.quiet:
- eprint(f"tile-validate: all {len(records)} file(s) clean")
- return EX_OK
- if __name__ == "__main__":
- sys.exit(main())
|