Files
aetherbound-guild/tools/verify_transparent_assets.py
T

99 lines
3.8 KiB
Python

#!/usr/bin/env python3
"""Verify fixed-size transparent PNG exports and detect green-key spill."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from PIL import Image
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-dir", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--expected-count", type=int, required=True)
parser.add_argument("--visible-fraction-min", type=float)
parser.add_argument("--visible-fraction-max", type=float)
return parser.parse_args()
def inspect(
path: Path, fraction_min: float | None, fraction_max: float | None
) -> dict[str, object]:
image = Image.open(path).convert("RGBA")
width, height = image.size
pixels = list(image.getdata())
visible = [(r, g, b, a) for r, g, b, a in pixels if a > 0]
corners = [
image.getpixel((0, 0))[3],
image.getpixel((width - 1, 0))[3],
image.getpixel((0, height - 1))[3],
image.getpixel((width - 1, height - 1))[3],
]
# Aqua keeps blue close to green. A keyed-background remnant is instead
# strongly green-dominant over both neighboring channels.
green_spill = sum(1 for r, g, b, _ in visible if g - r > 50 and g - b > 50)
alpha_bound = image.getchannel("A").getbbox()
longest_fraction = 0.0
if alpha_bound:
longest_fraction = max(
alpha_bound[2] - alpha_bound[0], alpha_bound[3] - alpha_bound[1]
) / max(width, height)
errors: list[str] = []
expected = 128 if path.stem.endswith("@1x") else 256 if path.stem.endswith("@2x") else 0
if expected == 0 or image.size != (expected, expected):
errors.append(f"unexpected dimensions {image.size}")
if not visible:
errors.append("empty alpha content")
if any(corners):
errors.append(f"opaque corner alpha {corners}")
if green_spill:
errors.append(f"green spill pixels {green_spill}")
if fraction_min is not None and longest_fraction < fraction_min:
errors.append(f"visible fraction {longest_fraction:.4f} below {fraction_min:.4f}")
if fraction_max is not None and longest_fraction > fraction_max:
errors.append(f"visible fraction {longest_fraction:.4f} above {fraction_max:.4f}")
return {
"path": path.as_posix(),
"ok": not errors,
"errors": errors,
"dimensions": [width, height],
"corner_alpha": corners,
"visible_pixels": len(visible),
"content_bound": list(alpha_bound) if alpha_bound else None,
"longest_visible_fraction": round(longest_fraction, 6),
"green_spill_pixels": green_spill,
}
def main() -> None:
args = parse_args()
files = sorted(args.input_dir.glob("*.png"))
if (
args.visible_fraction_min is not None
and args.visible_fraction_max is not None
and args.visible_fraction_min > args.visible_fraction_max
):
raise SystemExit("visible fraction min cannot exceed max")
items = [
inspect(path, args.visible_fraction_min, args.visible_fraction_max)
for path in files
]
errors: list[str] = []
if len(files) != args.expected_count:
errors.append(f"expected {args.expected_count} PNGs, found {len(files)}")
errors.extend(error for item in items for error in item["errors"])
report = {"ok": not errors, "count": len(files), "errors": errors, "items": items}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
if errors:
raise SystemExit("TRANSPARENT_ASSET_VERIFY_FAILED " + "; ".join(errors[:8]))
print(f"TRANSPARENT_ASSET_VERIFY_OK assets={len(files)} output={args.output}")
if __name__ == "__main__":
main()