testsAndMisc/python_pkg/artgate/cli.py
Krzysztof kuhy Rudnicki da8ca6d827
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
artgate: deterministic exit-code gates for generated game art
Adds the route-independent spine of the planned art-generation bake-off: a
harness that decides whether generated art is well-formed, so "non-sloppy"
becomes machine-checkable instead of a judgement call.

Findings that shaped the design, all measured rather than assumed:

- Integer-scale invariance separates true pixel art (impurity 0.0000) from
  interpolated fakes (1.0000) exactly -- but passes trivially at scale 1, so
  it must never be trusted alone on native-resolution output.
- Gate ORDER is load-bearing. A soft-alpha image has no fully-opaque pixels,
  so an opaque-only colour count returns 0 and silently passes. ALPHA_NOT_BINARY
  now runs first and COLOR_COUNT fails closed on an empty opaque set.
- Counting must be over opaque RGB only: transparent pixels retain arbitrary
  RGB and inflated a naive RGBA count, rejecting correct art at 18/16.
- Declaring a gate was not enough to run it. hand_painted.toml shipped
  declaring SILHOUETTE_LOW_CONTRAST with no implementation, so it parsed, ran
  nothing, and reported a pass. load_spec now fails closed on unimplemented
  gates, result-only codes, and gates missing their config.

Thresholds are calibrated against 250 genuine 32x32 CC0 icons, not guessed:
p50=7 p90=13 p99=26 opaque colours; cap 32 rejects 0.0% of real art while raw
generator output measured 34403 colours. An initial cap of 16 wrongly rejected
a real DCSS icon -- re-derived from the corpus rather than nudged.

Verified: 57 tests, 100% branch coverage, zero suppressions, all hooks pass.
58/60 (96.7%) of real CC0 icons pass the full stack; both failures confirmed
true positives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9rQ12yZ7sJxiPQTCS5dUF
2026-08-15 22:20:36 +02:00

151 lines
4.3 KiB
Python

"""Command-line entry point: image + spec in, exit code and JSON out.
Adjudication is by exit code, never by a model judging a model. Three values,
because conflating a harness fault with an art rejection would convert our own
bugs into art verdicts and poison the bake-off metrics:
* 0 -- every applicable gate passed
* 1 -- the asset is well-formed but failed at least one gate
* 2 -- the spec or the image could not be read
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
import numpy as np
from PIL import Image, UnidentifiedImageError
from python_pkg.artgate.codes import (
EXIT_GATE_FAILURE,
EXIT_HARNESS_ERROR,
EXIT_PASS,
Code,
)
from python_pkg.artgate.contrast import check_silhouette
from python_pkg.artgate.pixels import (
check_alpha_binary,
check_color_count,
check_palette,
check_scale_invariance,
)
from python_pkg.artgate.spec import SpecError, TargetSpec, load_spec
def evaluate(rgba: np.ndarray, spec: TargetSpec) -> list[Code]:
"""Run every gate the spec declares applicable.
Gate order matters: alpha binarity is evaluated before the colour count,
because a soft-alpha image has no fully-opaque pixels and would otherwise
yield a colour count of zero and pass.
Args:
rgba: An ``(h, w, 4)`` uint8 array.
spec: The target specification.
Returns:
The failing reason codes, empty if the asset passes.
"""
failures: list[Code] = []
if spec.runs(Code.CANVAS_SIZE) and spec.canvas is not None:
height, width = rgba.shape[:2]
if (width, height) != spec.canvas:
failures.append(Code.CANVAS_SIZE)
if spec.runs(Code.ALPHA_NOT_BINARY) and spec.alpha_binary:
failures.extend(check_alpha_binary(rgba))
if spec.runs(Code.TOO_MANY_COLORS) and spec.max_colors is not None:
failures.extend(check_color_count(rgba, spec.max_colors))
if spec.runs(Code.OFF_PALETTE) and spec.palette:
failures.extend(check_palette(rgba, spec.palette))
if spec.runs(Code.SCALE_NOT_INVARIANT):
failures.extend(check_scale_invariance(rgba, spec.scale))
if spec.runs(Code.SILHOUETTE_LOW_CONTRAST):
failures.extend(check_silhouette(rgba))
deduped: list[Code] = []
for code in failures:
if code not in deduped:
deduped.append(code)
return deduped
def _load_image(path: Path) -> np.ndarray:
"""Read an image as an RGBA uint8 array.
Args:
path: Path to the image.
Returns:
An ``(h, w, 4)`` uint8 array.
Raises:
SpecError: If the file is missing or not a readable image.
"""
try:
with Image.open(path) as img:
return np.asarray(img.convert("RGBA"), dtype=np.uint8)
except (OSError, UnidentifiedImageError) as exc:
msg = f"cannot read image {path}: {exc}"
raise SpecError(msg) from exc
def main(argv: list[str] | None = None) -> int:
"""Gate one image against one spec.
Args:
argv: Argument vector, defaulting to ``sys.argv[1:]``.
Returns:
The process exit code.
"""
parser = argparse.ArgumentParser(
prog="artgate",
description="Gate generated game art deterministically.",
)
parser.add_argument("image", type=Path, help="PNG to check")
parser.add_argument("--spec", type=Path, required=True, help="TOML spec")
parser.add_argument(
"--json",
action="store_true",
help="emit a machine-readable verdict on stdout",
)
args = parser.parse_args(argv)
try:
spec = load_spec(args.spec)
rgba = _load_image(args.image)
except SpecError as exc:
sys.stderr.write(f"artgate: {exc}\n")
return EXIT_HARNESS_ERROR
codes = evaluate(rgba, spec)
status = EXIT_PASS if not codes else EXIT_GATE_FAILURE
if args.json:
verdict = {
"image": str(args.image),
"style": spec.name,
"exit": status,
"codes": [str(c) for c in codes],
}
sys.stdout.write(f"{json.dumps(verdict, sort_keys=True)}\n")
else:
label = "PASS" if status == EXIT_PASS else "FAIL"
detail = "" if not codes else " " + ",".join(str(c) for c in codes)
sys.stdout.write(f"{label} {args.image}{detail}\n")
return status
if __name__ == "__main__": # pragma: no cover
sys.exit(main())