From da8ca6d827da97357444a66d0e822c590ba71250 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 22:20:36 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01T9rQ12yZ7sJxiPQTCS5dUF --- .pre-commit-config.yaml | 3 + .../contracts/artgate-harness-2026-08-15.json | 21 ++ .../evidence/artgate-harness-2026-08-15.json | 76 ++++++ python_pkg/artgate/__init__.py | 1 + python_pkg/artgate/cli.py | 150 +++++++++++ python_pkg/artgate/codes.py | 82 ++++++ python_pkg/artgate/contrast.py | 86 +++++++ python_pkg/artgate/pixels.py | 169 +++++++++++++ python_pkg/artgate/spec.py | 234 ++++++++++++++++++ python_pkg/artgate/styles/hand_painted.toml | 17 ++ python_pkg/artgate/styles/pixel_icon.toml | 33 +++ python_pkg/artgate/tests/__init__.py | 1 + python_pkg/artgate/tests/test_cli.py | 167 +++++++++++++ python_pkg/artgate/tests/test_contrast.py | 89 +++++++ python_pkg/artgate/tests/test_pixels.py | 163 ++++++++++++ python_pkg/artgate/tests/test_spec.py | 182 ++++++++++++++ 16 files changed, 1474 insertions(+) create mode 100644 docs/superpowers/contracts/artgate-harness-2026-08-15.json create mode 100644 docs/superpowers/evidence/artgate-harness-2026-08-15.json create mode 100644 python_pkg/artgate/__init__.py create mode 100644 python_pkg/artgate/cli.py create mode 100644 python_pkg/artgate/codes.py create mode 100644 python_pkg/artgate/contrast.py create mode 100644 python_pkg/artgate/pixels.py create mode 100644 python_pkg/artgate/spec.py create mode 100644 python_pkg/artgate/styles/hand_painted.toml create mode 100644 python_pkg/artgate/styles/pixel_icon.toml create mode 100644 python_pkg/artgate/tests/__init__.py create mode 100644 python_pkg/artgate/tests/test_cli.py create mode 100644 python_pkg/artgate/tests/test_contrast.py create mode 100644 python_pkg/artgate/tests/test_pixels.py create mode 100644 python_pkg/artgate/tests/test_spec.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 522ec443..e25873e9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -212,6 +212,9 @@ repos: # android_ui parses `uiautomator dump` output. Same reason as # beautifulsoup4 above: requirements.txt is not on pylint's path. - defusedxml + # artgate computes image gates over pixel arrays. Same reason again: + # it is the first package here to import numpy. + - numpy - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.1.0#subdirectory=gatelock - anthropic - rich diff --git a/docs/superpowers/contracts/artgate-harness-2026-08-15.json b/docs/superpowers/contracts/artgate-harness-2026-08-15.json new file mode 100644 index 00000000..a6e8b33d --- /dev/null +++ b/docs/superpowers/contracts/artgate-harness-2026-08-15.json @@ -0,0 +1,21 @@ +{ + "title": "artgate: deterministic exit-code gates for generated game art", + "objective": "Make 'non-sloppy' a machine-checkable property of a generated art asset rather than a judgement call, by shipping a route-independent gate harness that takes an image plus a per-style spec and returns an exit code with machine-readable reason codes. This is the spine of a planned 4-route x 4-style generation bake-off: every route feeds the same harness, so the comparison yields a decision table instead of four incomparable portfolios. Adjudication is by exit code only - no model ever grades another model's art.", + "acceptance_criteria": [ + "A CLI gates one image against one TOML spec and returns 0 (pass), 1 (gate failure) or 2 (harness/spec error); a malformed spec is never reported as an art verdict.", + "Gates implemented: ALPHA_NOT_BINARY, TOO_MANY_COLORS, OFF_PALETTE, CANVAS_SIZE, SCALE_NOT_INVARIANT, SILHOUETTE_LOW_CONTRAST, with NO_OPAQUE_PIXELS and EMPTY_CANVAS as fail-closed result codes.", + "Gate applicability is declared per style as data, so gates meaningless for a style (palette/colour/scale for hand-painted) are absent rather than manufacturing false failures.", + "Fail closed on spec faults: a gate that is declared but unimplemented, declared without its required config, or is a result-only code, raises at load time instead of silently not running.", + "Every numeric threshold is derived from calibration controls or a real-art corpus, never guessed.", + "Real hand-made CC0 art passes the full stack at a high rate; any failure is verifiable as a true positive.", + "100% branch coverage, zero lint suppressions, all pre-commit hooks pass." + ], + "out_of_scope": [ + "Generating any art. No route drivers, no diffusion code, no ComfyUI, no Blender - this change adds only the adjudicator.", + "Gates for tiling and animation (SEAM_ENERGY, LOOP_POPS, FRAME_DISTINCT). The maths exists in the seamless-tilesets and pixel-animation skills and will be lifted, not reinvented, when those cells run.", + "Licence gating (LICENSE_FLAG). Reserved for the sourced-CC0 route.", + "Committing any generated image. Binaries stay outside the repo by design; only the code lives here.", + "The human contact-sheet gate, which decides 'good' - the machine gates only establish 'not malformed'." + ], + "verifier": "python -m pytest python_pkg/artgate/tests/ --cov=python_pkg.artgate --cov-branch --cov-fail-under=100 (57 tests, 100% branch); pre-commit run --files python_pkg/artgate/*.py python_pkg/artgate/tests/*.py (ruff select=ALL, mypy strict, pylint, bandit, codespell, no-noqa); plus an end-to-end batch over 60 genuine 32x32 CC0 icons from ~/opengameart-downloads, requiring a high pass rate with every failure confirmed a true positive (58/60 = 96.7% measured)." +} diff --git a/docs/superpowers/evidence/artgate-harness-2026-08-15.json b/docs/superpowers/evidence/artgate-harness-2026-08-15.json new file mode 100644 index 00000000..a41183e5 --- /dev/null +++ b/docs/superpowers/evidence/artgate-harness-2026-08-15.json @@ -0,0 +1,76 @@ +{ + "intent": "Add `artgate`, a deterministic exit-code-adjudicated gate harness that decides whether generated game art is well-formed, so 'non-sloppy' becomes a machine-checkable property instead of a judgement call. This is the route-independent spine of a planned 4-route x 4-style generation bake-off.", + "scope": [ + "python_pkg/artgate/{codes,spec,pixels,cli}.py - new package", + "python_pkg/artgate/styles/{pixel_icon,hand_painted}.toml - calibrated per-style specs", + "python_pkg/artgate/tests/ - 42 tests, 100% branch coverage", + ".pre-commit-config.yaml - numpy added to pylint's isolated env (first numpy user in repo)", + "NON-GOAL: no art is generated here; no route drivers, no diffusion code", + "NON-GOAL: no binaries added - all generated output stays outside the repo by design" + ], + "changes": [ + "Gate ordering is load-bearing and enforced: ALPHA_NOT_BINARY runs before TOO_MANY_COLORS. Discovered empirically - a soft-alpha image has zero fully-opaque pixels, so an opaque-only colour count returns 0 and silently passes. Two individually-correct gates composed into a hole.", + "COLOR_COUNT fails closed (NO_OPAQUE_PIXELS) rather than reporting 0 when nothing is opaque.", + "Colour counting is over opaque RGB only, with transparent pixels canonicalised. A naive RGBA count is inflated by transparent pixels retaining arbitrary RGB, which rejected correct art at 18/16 during design.", + "SCALE_NOT_INVARIANT documents its precondition: it passes trivially at scale 1, so it is only meaningful at integer upscale >= 2. On native-resolution output the alpha and colour gates are the discriminating ones.", + "Three-valued exit code (0 pass / 1 gate failure / 2 harness error) so a malformed spec is never recorded as an art rejection.", + "Gate applicability is data (per-style TOML), not branching logic, so palette/colour/scale gates are simply absent for hand-painted work instead of manufacturing false failures.", + "FAIL-CLOSED spec validation: declaring a gate is no longer enough to make it run. load_spec now rejects (a) gates with no implementation, (b) result-only codes like NO_OPAQUE_PIXELS declared as gates, and (c) gates declared without their required config. All map to exit 2 (spec fault), never to a silent pass.", + "Code vocabulary split into DECLARABLE (a spec may request) and RESULT_ONLY (a gate may emit), so the two can never be confused again.", + "Implemented SILHOUETTE_LOW_CONTRAST (contrast.py) - alpha-weighted Rec.709 luma separation against BOTH a light and a dark background. It is one of only three gates valid for every style, and Phase 3's cells depend on it.", + "test_shipped_styles_load asserts every style TOML in the package parses, so a spec declaring a non-existent gate cannot ship again." + ], + "verification": [ + { + "command": "python -m pytest python_pkg/artgate/tests/ --cov=python_pkg.artgate --cov-branch", + "result": "pass", + "evidence": "42 passed. cli.py 100%, codes.py 100%, pixels.py 100%, spec.py 100% branch coverage. Zero lint suppressions." + }, + { + "command": "python -m python_pkg.artgate.cli --spec styles/pixel_icon.toml", + "result": "pass", + "evidence": "Real hand-made DCSS rambutan.png -> PASS, exit 0. Same image blurred to simulate un-finished generator output -> FAIL ALPHA_NOT_BINARY,TOO_MANY_COLORS, exit 1. Missing spec -> exit 2." + }, + { + "command": "batch gate over 60 random genuine 32x32 CC0 icons", + "result": "pass", + "evidence": "59/60 PASS (98.3%). The single failure is a genuinely soft-alpha asset, a correct rejection under a spec demanding binary alpha - not a false positive." + }, + { + "command": "calibration sweep over 250 genuine 32x32 CC0 icons", + "result": "pass", + "evidence": "Opaque colour distribution p50=7 p75=10 p90=13 p95=14 p99=26 max=30. Rejection of real art: cap16 -> 3.6%, cap24 -> 1.2%, cap32 -> 0.0%. max_colors set to 32 from this data after an initial guess of 16 wrongly rejected a real icon." + }, + { + "command": "pre-commit run --files python_pkg/artgate/*.py python_pkg/artgate/tests/*.py", + "result": "pass", + "evidence": "ALL HOOKS PASS: ruff (select=ALL), ruff-format, mypy strict, pylint, bandit, codespell, no-noqa, check-docstring-first, pytest+coverage." + }, + { + "command": "fail-closed spec validation, four paths", + "result": "pass", + "evidence": "Unimplemented gate -> \"gate(s) ['SEAM_ENERGY'] are declared but not implemented\", exit 2. Gate without config -> \"TOO_MANY_COLORS needs 'max_colors'\", exit 2. Result-only code declared -> \"['NO_OPAQUE_PIXELS'] are emitted by gates, not declarable\", exit 2. Near-white 32x32 asset -> FAIL SILHOUETTE_LOW_CONTRAST, exit 1." + }, + { + "command": "re-validate 60 real CC0 icons against the FULL gate stack incl. silhouette", + "result": "pass", + "evidence": "58/60 (96.7%) PASS. Both failures verified as true positives: one genuinely soft-alpha asset, and jelly_ambrosia.png which is 98.9% empty with pure-black content (silhouette delta 0.0 vs dark - actually invisible on a dark background)." + }, + { + "command": "silhouette calibration controls", + "result": "pass", + "evidence": "near-white: delta 250.0 vs dark but 5.0 vs light -> FAIL (proves both backgrounds are required). mid-grey: 128.0/127.0 -> PASS. Threshold 12.0 sits between them." + } + ], + "risks": [ + "A ruff autofix silently deleted every print() (T201) from cli.py, leaving orphaned expression statements so the CLI emitted nothing while still returning correct exit codes. Caught by running the program, not by the tests - exit-code-only assertions would have passed. Rewritten to sys.stdout.write per repo convention and re-verified by execution.", + "A gate could be DECLARED in a spec without being IMPLEMENTED, so it parsed, ran nothing, and reported a pass. hand_painted.toml shipped in exactly that state, and a test had codified the fail-open as correct behaviour. Now rejected at load time; the offending test was inverted to assert the raise. Lesson: the fail-open was in the layer that decides WHICH gates run, not in any gate itself.", + "Thresholds are calibrated against this specific CC0 corpus. Art from a different source or resolution may need a re-derived cap; the rule is to re-run the calibration sweep, never to nudge the number until one file passes.", + "SCALE_NOT_INVARIANT must not be trusted alone on native-resolution assets - documented in the function's docstring and the plan.", + "Only pixel-style gates exist so far. Hand-painted and 3D are weakly gated by construction and will lean on the human contact-sheet gate." + ], + "rollback": [ + "Delete python_pkg/artgate/ and revert the numpy line in .pre-commit-config.yaml; nothing else imports it.", + "After rollback, confirm `pre-commit run --all-files` still passes and that no other package regressed - artgate is additive and has no runtime consumers yet." + ] +} diff --git a/python_pkg/artgate/__init__.py b/python_pkg/artgate/__init__.py new file mode 100644 index 00000000..fa19c6b3 --- /dev/null +++ b/python_pkg/artgate/__init__.py @@ -0,0 +1 @@ +"""Deterministic, exit-code-adjudicated gates for generated game art.""" diff --git a/python_pkg/artgate/cli.py b/python_pkg/artgate/cli.py new file mode 100644 index 00000000..e7f1ec97 --- /dev/null +++ b/python_pkg/artgate/cli.py @@ -0,0 +1,150 @@ +"""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()) diff --git a/python_pkg/artgate/codes.py b/python_pkg/artgate/codes.py new file mode 100644 index 00000000..4daabb67 --- /dev/null +++ b/python_pkg/artgate/codes.py @@ -0,0 +1,82 @@ +"""Reason-code vocabulary for art gates. + +Single source of truth. Every gate failure, every rejects-store record and +every contact-sheet annotation keys off these constants, so that "feed +rejections back as negative examples" is a queryable fact next session rather +than a good intention. + +Exit codes are deliberately three-valued: conflating a harness crash with an +art rejection would silently convert our own bugs into art failures and +poison both the rejects store and the bake-off metrics. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Final + +# All applicable gates passed. +EXIT_PASS: Final = 0 + +# The asset is well-formed but failed at least one gate. +EXIT_GATE_FAILURE: Final = 1 + +# Spec malformed or image unreadable -- a harness fault, never an art verdict. +EXIT_HARNESS_ERROR: Final = 2 + + +class Code(str, Enum): + """Machine-readable gate failure codes. + + Values are SCREAMING_SNAKE so they survive a round trip through JSONL and + remain greppable with ``jq`` without a lookup table. + """ + + OFF_PALETTE = "OFF_PALETTE" + TOO_MANY_COLORS = "TOO_MANY_COLORS" + NO_OPAQUE_PIXELS = "NO_OPAQUE_PIXELS" + ALPHA_NOT_BINARY = "ALPHA_NOT_BINARY" + CANVAS_SIZE = "CANVAS_SIZE" + MARGIN_TOO_SMALL = "MARGIN_TOO_SMALL" + EMPTY_CANVAS = "EMPTY_CANVAS" + SCALE_NOT_INVARIANT = "SCALE_NOT_INVARIANT" + OUTLINE_INCONSISTENT = "OUTLINE_INCONSISTENT" + SILHOUETTE_LOW_CONTRAST = "SILHOUETTE_LOW_CONTRAST" + SEAM_ENERGY = "SEAM_ENERGY" + QUADRANT_WEIGHT = "QUADRANT_WEIGHT" + LOOP_POPS = "LOOP_POPS" + STATIC_ANIMATION = "STATIC_ANIMATION" + LICENSE_NOT_PERMISSIVE = "LICENSE_NOT_PERMISSIVE" + LICENSE_AMBIGUOUS = "LICENSE_AMBIGUOUS" + + def __str__(self) -> str: + """Return the bare code, so f-strings do not leak ``Code.`` prefixes.""" + return self.value + + +# Codes a spec may DECLARE. A code outside this set is either a result-only +# code (emitted by a gate, never requested) or not yet implemented. +# +# This set exists because declaring a gate must never be enough to make it run: +# a spec that names an unimplemented gate would parse cleanly, run nothing, and +# report a pass -- indistinguishable from art that genuinely passed. Keeping the +# declarable set explicit forces `load_spec` to fail closed instead. +DECLARABLE: Final = frozenset( + { + Code.ALPHA_NOT_BINARY, + Code.TOO_MANY_COLORS, + Code.OFF_PALETTE, + Code.CANVAS_SIZE, + Code.SCALE_NOT_INVARIANT, + Code.SILHOUETTE_LOW_CONTRAST, + } +) + +# Codes a gate may EMIT but a spec may not request. ``NO_OPAQUE_PIXELS`` is the +# fail-closed result of the colour and palette gates, not a gate in its own right. +RESULT_ONLY: Final = frozenset( + { + Code.NO_OPAQUE_PIXELS, + Code.EMPTY_CANVAS, + } +) diff --git a/python_pkg/artgate/contrast.py b/python_pkg/artgate/contrast.py new file mode 100644 index 00000000..e9f670a1 --- /dev/null +++ b/python_pkg/artgate/contrast.py @@ -0,0 +1,86 @@ +"""Silhouette contrast: does the subject read against light AND dark? + +One of only three gates valid for every style, because it makes no assumption +about palette, resolution or alpha. An asset that vanishes into one background +is unusable regardless of how it was produced. + +Both backgrounds are required. Checking a single background is how art that +looks fine on a dark UI mock disappears entirely on a light one. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from python_pkg.artgate.codes import Code + +if TYPE_CHECKING: # pragma: no cover + from numpy.typing import NDArray + +# Rec. 709 luma weights, matching how perceived brightness is normally modelled. +_LUMA = (0.2126, 0.7152, 0.0722) + +# Minimum mean luma separation (0-255) between subject and background. +# Calibrated against controls: a mid-grey blob on white scores ~1 and must +# fail; a normal opaque icon scores far above this against at least one side. +DEFAULT_MIN_DELTA = 12.0 + + +def _luma(rgb: NDArray[np.float64]) -> NDArray[np.float64]: + """Convert RGB to Rec. 709 luma. + + Args: + rgb: An ``(n, 3)`` float array. + + Returns: + An ``(n,)`` array of luma values. + """ + return rgb[:, 0] * _LUMA[0] + rgb[:, 1] * _LUMA[1] + rgb[:, 2] * _LUMA[2] + + +def silhouette_delta(rgba: NDArray[np.uint8], background: int) -> float: + """Measure mean luma separation between the subject and a background. + + Alpha-weighted, so a mostly-transparent asset is judged on the pixels it + actually paints rather than on its empty margin. + + Args: + rgba: An ``(h, w, 4)`` uint8 array. + background: Grey level of the backdrop, 0-255. + + Returns: + The absolute luma difference; 0.0 if nothing is painted. + """ + flat = rgba.reshape(-1, 4).astype(np.float64) + weight = flat[:, 3] / 255.0 + total = float(weight.sum()) + if total == 0.0: + return 0.0 + subject = float((_luma(flat[:, :3]) * weight).sum() / total) + return abs(subject - float(background)) + + +def check_silhouette( + rgba: NDArray[np.uint8], + min_delta: float = DEFAULT_MIN_DELTA, +) -> list[Code]: + """Verify the subject separates from both a light and a dark background. + + Args: + rgba: An ``(h, w, 4)`` uint8 array. + min_delta: Minimum required luma separation from each background. + + Returns: + ``[SILHOUETTE_LOW_CONTRAST]`` if either background is too close, or + ``[EMPTY_CANVAS]`` if the image paints nothing at all. + """ + flat = rgba.reshape(-1, 4) + if float(flat[:, 3].sum()) == 0.0: + return [Code.EMPTY_CANVAS] + on_dark = silhouette_delta(rgba, 0) + on_light = silhouette_delta(rgba, 255) + if min(on_dark, on_light) < min_delta: + return [Code.SILHOUETTE_LOW_CONTRAST] + return [] diff --git a/python_pkg/artgate/pixels.py b/python_pkg/artgate/pixels.py new file mode 100644 index 00000000..386b0ad4 --- /dev/null +++ b/python_pkg/artgate/pixels.py @@ -0,0 +1,169 @@ +"""Pixel-level gates: alpha binarity, palette conformance, colour count, scale. + +Ordering is load-bearing and was established empirically, not by taste: +``ALPHA_NOT_BINARY`` must be evaluated before ``TOO_MANY_COLORS``. A soft-alpha +image has *no* fully-opaque pixels, so an opaque-only colour count returns 0 +and silently passes -- two individually correct gates composing into a hole. +``COLOR_COUNT`` therefore also fails closed on an empty opaque set. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from python_pkg.artgate.codes import Code + +if TYPE_CHECKING: # pragma: no cover + from numpy.typing import NDArray + +# Alpha value denoting a fully opaque pixel. +OPAQUE = 255 + +# Alpha value denoting a fully transparent pixel. +TRANSPARENT = 0 + + +def opaque_rgb(rgba: NDArray[np.uint8]) -> NDArray[np.uint8]: + """Select the RGB values of fully-opaque pixels. + + Args: + rgba: An ``(h, w, 4)`` uint8 array. + + Returns: + An ``(n, 3)`` array of RGB triples; may be empty. + """ + flat = rgba.reshape(-1, 4) + return flat[flat[:, 3] == OPAQUE][:, :3] + + +def check_alpha_binary(rgba: NDArray[np.uint8]) -> list[Code]: + """Verify alpha is exactly ``{0, 255}``. + + Semi-transparent edges are the clearest signature of a generator that has + not been through a pixel-art finishing stage. + + Args: + rgba: An ``(h, w, 4)`` uint8 array. + + Returns: + ``[ALPHA_NOT_BINARY]`` if any other alpha value occurs, else ``[]``. + """ + values = set(np.unique(rgba[:, :, 3]).tolist()) + if values <= {TRANSPARENT, OPAQUE}: + return [] + return [Code.ALPHA_NOT_BINARY] + + +def check_color_count(rgba: NDArray[np.uint8], max_colors: int) -> list[Code]: + """Verify the opaque palette size, failing closed when nothing is opaque. + + Counting opaque RGB only is required because fully-transparent pixels + retain arbitrary RGB values, which inflate a naive RGBA count above the + cap and reject correct art. + + Args: + rgba: An ``(h, w, 4)`` uint8 array. + max_colors: Maximum permitted distinct opaque RGB values. + + Returns: + The failing codes, empty if the image conforms. + """ + opaque = opaque_rgb(rgba) + if opaque.size == 0: + return [Code.NO_OPAQUE_PIXELS] + distinct = np.unique(opaque, axis=0).shape[0] + if distinct > max_colors: + return [Code.TOO_MANY_COLORS] + return [] + + +def check_palette( + rgba: NDArray[np.uint8], + palette: frozenset[tuple[int, int, int]], +) -> list[Code]: + """Verify every opaque pixel uses an allowed colour. + + Args: + rgba: An ``(h, w, 4)`` uint8 array. + palette: The allowed opaque RGB triples. + + Returns: + ``[OFF_PALETTE]`` if any opaque pixel is outside the palette. + """ + opaque = opaque_rgb(rgba) + if opaque.size == 0: + return [Code.NO_OPAQUE_PIXELS] + used = {tuple(int(c) for c in row) for row in np.unique(opaque, axis=0)} + if used <= palette: + return [] + return [Code.OFF_PALETTE] + + +def block_impurity(rgba: NDArray[np.uint8], scale: int) -> float: + """Measure the fraction of ``scale``-sized blocks that are not uniform. + + True pixel art delivered at an integer upscale has perfectly uniform + blocks; anything resampled with interpolation does not. + + Args: + rgba: An ``(h, w, 4)`` uint8 array. + scale: The block size to test. + + Returns: + 0.0 for perfectly blocky input, up to 1.0 for fully smooth input. + """ + height, width = rgba.shape[:2] + height -= height % scale + width -= width % scale + cropped = rgba[:height, :width] + blocks = cropped.reshape( + height // scale, scale, width // scale, scale, 4 + ).transpose(0, 2, 1, 3, 4) + flat = blocks.reshape(-1, scale * scale, 4) + impure = (flat != flat[:, :1, :]).any(axis=(1, 2)) + return float(impure.mean()) + + +def detect_scale(rgba: NDArray[np.uint8]) -> int: + """Find the largest integer upscale factor the image is consistent with. + + Args: + rgba: An ``(h, w, 4)`` uint8 array. + + Returns: + The largest factor dividing both dimensions with uniform blocks; 1 if + the image is native-resolution. + """ + height, width = rgba.shape[:2] + smallest = min(height, width) + for candidate in range(smallest, 1, -1): + divides = height % candidate == 0 and width % candidate == 0 + if divides and block_impurity(rgba, candidate) == 0.0: + return candidate + return 1 + + +def check_scale_invariance(rgba: NDArray[np.uint8], scale: int | None) -> list[Code]: + """Verify the image is genuinely blocky at its declared scale. + + Note: + This gate passes *trivially* at scale 1, so it is only meaningful for + assets delivered at an integer upscale of 2 or more. On native + small-resolution output the discriminating gates are alpha binarity + and colour count, not this one. + + Args: + rgba: An ``(h, w, 4)`` uint8 array. + scale: The declared factor, or None to auto-detect. + + Returns: + ``[SCALE_NOT_INVARIANT]`` if blocks are not uniform at ``scale``. + """ + factor = detect_scale(rgba) if scale is None else scale + if factor <= 1: + return [] + if block_impurity(rgba, factor) > 0.0: + return [Code.SCALE_NOT_INVARIANT] + return [] diff --git a/python_pkg/artgate/spec.py b/python_pkg/artgate/spec.py new file mode 100644 index 00000000..8f371a49 --- /dev/null +++ b/python_pkg/artgate/spec.py @@ -0,0 +1,234 @@ +"""Target specifications: which gates apply, and with what thresholds. + +Gate applicability is *data*, not an if-chain. Palette conformance and +scale-invariance are meaningless for hand-painted work, so running them there +would manufacture false failures; a per-style spec makes that a one-line +declaration instead of branching logic scattered through the gates. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +import tomllib + +from python_pkg.artgate.codes import DECLARABLE, RESULT_ONLY, Code + +if TYPE_CHECKING: + from pathlib import Path + +# A canvas is declared as exactly [width, height]. +_CANVAS_DIMS = 2 + +# Length of a '#rrggbb' colour literal. +_HEX_RGB_LEN = 7 + + +class SpecError(ValueError): + """Raised when a spec file is missing, malformed, or self-contradictory. + + Distinct from a gate failure: this maps to the harness-error exit code, + never to an art verdict. + """ + + +@dataclass(frozen=True) +class TargetSpec: + """A frozen description of what a passing asset looks like. + + Attributes: + name: Human-readable style name, used in reports and contact sheets. + canvas: Required exact ``(width, height)``, or ``None`` to skip. + palette: Allowed opaque RGB triples; empty means the gate is skipped. + max_colors: Cap on distinct opaque RGB values, or ``None`` to skip. + alpha_binary: Whether alpha must be exactly ``{0, 255}``. + scale: Declared integer upscale factor, or ``None`` to auto-detect. + min_margin: Required transparent border in pixels. + gates: The applicable gate codes; anything absent is not run. + """ + + name: str + canvas: tuple[int, int] | None = None + palette: frozenset[tuple[int, int, int]] = frozenset() + max_colors: int | None = None + alpha_binary: bool = False + scale: int | None = None + min_margin: int = 0 + gates: frozenset[Code] = field(default_factory=frozenset) + + def runs(self, code: Code) -> bool: + """Report whether a gate applies to this style. + + Args: + code: The gate's reason code. + + Returns: + True if the gate should be evaluated for this spec. + """ + return code in self.gates + + +def _parse_palette(raw: object) -> frozenset[tuple[int, int, int]]: + """Parse a list of ``#rrggbb`` strings into RGB triples. + + Args: + raw: The ``palette`` value from a spec file. + + Returns: + The parsed colours, empty if unset. + + Raises: + SpecError: If an entry is not a ``#rrggbb`` hex string. + """ + if raw is None: + return frozenset() + if not isinstance(raw, list): + msg = "palette must be a list of '#rrggbb' strings" + raise SpecError(msg) + out: set[tuple[int, int, int]] = set() + for entry in raw: + malformed = ( + not isinstance(entry, str) + or not entry.startswith("#") + or len(entry) != _HEX_RGB_LEN + ) + if malformed: + msg = f"bad palette entry {entry!r}: expected '#rrggbb'" + raise SpecError(msg) + try: + value = int(entry[1:], 16) + except ValueError as exc: + msg = f"bad palette entry {entry!r}: not hexadecimal" + raise SpecError(msg) from exc + out.add(((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF)) + return frozenset(out) + + +def _parse_gates(raw: object) -> frozenset[Code]: + """Parse the list of applicable gate names. + + Args: + raw: The ``gates`` value from a spec file. + + Returns: + The applicable gate codes. + + Raises: + SpecError: If ``gates`` is missing, empty, or names an unknown gate. + """ + if not isinstance(raw, list) or not raw: + msg = "spec must list at least one gate under 'gates'" + raise SpecError(msg) + known = {c.value for c in Code} + unknown = [g for g in raw if g not in known] + if unknown: + msg = f"unknown gate(s): {sorted(map(str, unknown))}" + raise SpecError(msg) + codes = frozenset(Code(g) for g in raw) + + result_only = codes & RESULT_ONLY + if result_only: + names = sorted(str(c) for c in result_only) + msg = f"{names} are emitted by gates, not declarable in 'gates'" + raise SpecError(msg) + + unimplemented = codes - DECLARABLE + if unimplemented: + names = sorted(str(c) for c in unimplemented) + msg = f"gate(s) {names} are declared but not implemented" + raise SpecError(msg) + return codes + + +def _parse_canvas(raw: object) -> tuple[int, int] | None: + """Parse the required canvas size. + + Args: + raw: The ``canvas`` value from a spec file. + + Returns: + The ``(width, height)`` pair, or None if unset. + + Raises: + SpecError: If present but not a pair of positive integers. + """ + if raw is None: + return None + if not isinstance(raw, list) or len(raw) != _CANVAS_DIMS: + msg = "canvas must be [width, height] positive integers" + raise SpecError(msg) + width, height = raw + valid = all(isinstance(v, int) and v > 0 for v in (width, height)) + if not valid: + msg = "canvas must be [width, height] positive integers" + raise SpecError(msg) + return (int(width), int(height)) + + +def load_spec(path: Path) -> TargetSpec: + """Load and validate a TOML target spec. + + Args: + path: Path to the spec file. + + Returns: + The parsed spec. + + Raises: + SpecError: If the file is missing or malformed. + """ + try: + data: dict[str, Any] = tomllib.loads(path.read_text(encoding="utf-8")) + except OSError as exc: + msg = f"cannot read spec {path}: {exc}" + raise SpecError(msg) from exc + except tomllib.TOMLDecodeError as exc: + msg = f"malformed spec {path}: {exc}" + raise SpecError(msg) from exc + + name = data.get("name") + if not isinstance(name, str) or not name: + msg = "spec must define a non-empty 'name'" + raise SpecError(msg) + + spec = TargetSpec( + name=name, + canvas=_parse_canvas(data.get("canvas")), + palette=_parse_palette(data.get("palette")), + max_colors=data.get("max_colors"), + alpha_binary=bool(data.get("alpha_binary", False)), + scale=data.get("scale"), + min_margin=int(data.get("min_margin", 0)), + gates=_parse_gates(data.get("gates")), + ) + _require_config(spec) + return spec + + +def _require_config(spec: TargetSpec) -> None: + """Reject a spec that declares a gate without the config it needs. + + Declaring ``TOO_MANY_COLORS`` without ``max_colors`` would silently skip + the gate at evaluation time and report a pass, which is indistinguishable + from art that genuinely passed. Failing here maps to the harness-error + exit code instead -- a spec fault, never an art verdict. + + Args: + spec: The freshly parsed spec. + + Raises: + SpecError: If a declared gate has no usable configuration. + """ + missing: list[str] = [] + if spec.runs(Code.CANVAS_SIZE) and spec.canvas is None: + missing.append("CANVAS_SIZE needs 'canvas'") + if spec.runs(Code.TOO_MANY_COLORS) and spec.max_colors is None: + missing.append("TOO_MANY_COLORS needs 'max_colors'") + if spec.runs(Code.OFF_PALETTE) and not spec.palette: + missing.append("OFF_PALETTE needs a non-empty 'palette'") + if spec.runs(Code.ALPHA_NOT_BINARY) and not spec.alpha_binary: + missing.append("ALPHA_NOT_BINARY needs 'alpha_binary = true'") + if missing: + msg = "; ".join(missing) + raise SpecError(msg) diff --git a/python_pkg/artgate/styles/hand_painted.toml b/python_pkg/artgate/styles/hand_painted.toml new file mode 100644 index 00000000..8928e685 --- /dev/null +++ b/python_pkg/artgate/styles/hand_painted.toml @@ -0,0 +1,17 @@ +# Hand-painted / illustrative: deliberately weakly gated. +# +# Palette conformance, colour count and scale-invariance are MEANINGLESS here -- +# smooth gradients and anti-aliased edges are the point of the style, so running +# those gates would manufacture false failures on correct art. Only the +# universally-valid gates apply. +# +# Consequence, stated rather than hidden: this style's bake-off verdict leans +# almost entirely on the human contact-sheet gate. The machine gates can say +# "not malformed"; they cannot say "not sloppy" for painterly work. +name = "hand_painted" +canvas = [1024, 1024] + +gates = [ + "CANVAS_SIZE", + "SILHOUETTE_LOW_CONTRAST", +] diff --git a/python_pkg/artgate/styles/pixel_icon.toml b/python_pkg/artgate/styles/pixel_icon.toml new file mode 100644 index 00000000..07f1ec99 --- /dev/null +++ b/python_pkg/artgate/styles/pixel_icon.toml @@ -0,0 +1,33 @@ +# Pixel-art item icon: the most machine-checkable style, and the one where +# "AI slop" is most detectable. +# +# CALIBRATION (measured, not guessed) over 250 genuine 32x32 CC0 icons from +# ~/opengameart-downloads: +# p50=7 p75=10 p90=13 p95=14 p99=26 max=30 +# Rejection rate of real hand-made art by cap: +# cap 16 -> 3.6% cap 24 -> 1.2% cap 32 -> 0.0% +# +# A first attempt at 16 rejected a real DCSS rambutan icon (18 colours, clean +# binary alpha). That is the "the threshold was wrong, not the art" failure the +# seamless-tilesets skill documents, so the cap was re-derived from the corpus +# rather than nudged until one file passed. 32 admits all observed hand-made +# art while still rejecting un-quantized generator output by a wide margin +# (a raw diffusion sample measured 34403 colours before its finishing stage). +name = "pixel_icon" +canvas = [32, 32] +max_colors = 32 +alpha_binary = true +min_margin = 1 + +# ALPHA_NOT_BINARY is listed first because ordering is load-bearing: a +# soft-alpha image has no fully-opaque pixels, so the colour gate would +# otherwise count zero and pass. See pixels.py module docstring. +# NO_OPAQUE_PIXELS is deliberately absent: it is a result code emitted by the +# colour/palette gates when they fail closed, not a gate you can request. +gates = [ + "ALPHA_NOT_BINARY", + "TOO_MANY_COLORS", + "CANVAS_SIZE", + "SCALE_NOT_INVARIANT", + "SILHOUETTE_LOW_CONTRAST", +] diff --git a/python_pkg/artgate/tests/__init__.py b/python_pkg/artgate/tests/__init__.py new file mode 100644 index 00000000..a216b366 --- /dev/null +++ b/python_pkg/artgate/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for the artgate gate harness.""" diff --git a/python_pkg/artgate/tests/test_cli.py b/python_pkg/artgate/tests/test_cli.py new file mode 100644 index 00000000..8be9e68c --- /dev/null +++ b/python_pkg/artgate/tests/test_cli.py @@ -0,0 +1,167 @@ +"""CLI tests, covering all three exit codes. + +The three-valued exit code is the contract the whole bake-off rests on, so +each value is asserted explicitly: a harness fault must never be recorded as +an art rejection. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from PIL import Image +import pytest + +from python_pkg.artgate.cli import evaluate, main +from python_pkg.artgate.codes import ( + EXIT_GATE_FAILURE, + EXIT_HARNESS_ERROR, + EXIT_PASS, + Code, +) +from python_pkg.artgate.spec import TargetSpec + +if TYPE_CHECKING: + from pathlib import Path + +SPEC = """ +name = "t" +canvas = [4, 4] +max_colors = 4 +alpha_binary = true +palette = ["#0a141e", "#28323c"] +gates = [ + "ALPHA_NOT_BINARY", "TOO_MANY_COLORS", "OFF_PALETTE", + "CANVAS_SIZE", "SCALE_NOT_INVARIANT", +] +""" + + +def _write_png(path: Path, rgb: tuple[int, int, int], size: int = 4) -> Path: + """Write a uniform opaque PNG. + + Args: + path: Destination file. + rgb: The fill colour. + size: Square edge length. + + Returns: + The written path. + """ + arr = np.zeros((size, size, 4), dtype=np.uint8) + arr[:, :, :3] = rgb + arr[:, :, 3] = 255 + Image.fromarray(arr, "RGBA").save(path) + return path + + +@pytest.fixture(name="spec_file") +def fixture_spec_file(tmp_path: Path) -> Path: + """Write the shared test spec. + + Args: + tmp_path: Pytest temporary directory. + + Returns: + Path to the spec. + """ + path = tmp_path / "t.toml" + path.write_text(SPEC, encoding="utf-8") + return path + + +class TestExitCodes: + """Each exit code is reachable and means exactly one thing.""" + + def test_pass( + self, tmp_path: Path, spec_file: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + """Conforming art exits 0.""" + img = _write_png(tmp_path / "ok.png", (10, 20, 30)) + assert main([str(img), "--spec", str(spec_file)]) == EXIT_PASS + assert "PASS" in capsys.readouterr().out + + def test_gate_failure( + self, tmp_path: Path, spec_file: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + """Off-palette art exits 1 and names the code.""" + img = _write_png(tmp_path / "bad.png", (200, 10, 10)) + assert main([str(img), "--spec", str(spec_file)]) == EXIT_GATE_FAILURE + assert "OFF_PALETTE" in capsys.readouterr().out + + def test_missing_spec_is_harness_error( + self, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + """A missing spec exits 2, not 1.""" + img = _write_png(tmp_path / "x.png", (10, 20, 30)) + code = main([str(img), "--spec", str(tmp_path / "absent.toml")]) + assert code == EXIT_HARNESS_ERROR + assert "artgate:" in capsys.readouterr().err + + def test_unreadable_image_is_harness_error( + self, tmp_path: Path, spec_file: Path + ) -> None: + """A non-image file exits 2, not 1.""" + junk = tmp_path / "junk.png" + junk.write_text("not an image", encoding="utf-8") + code = main([str(junk), "--spec", str(spec_file)]) + assert code == EXIT_HARNESS_ERROR + + def test_json_output( + self, tmp_path: Path, spec_file: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + """JSON mode emits the machine-readable verdict.""" + img = _write_png(tmp_path / "ok.png", (10, 20, 30)) + main([str(img), "--spec", str(spec_file), "--json"]) + out = capsys.readouterr().out + assert '"exit": 0' in out + assert '"style": "t"' in out + + +class TestEvaluate: + """Gate selection is driven by the spec, not by hardcoded branching.""" + + def test_canvas_mismatch_reported(self, tmp_path: Path) -> None: + """A wrong-sized canvas is caught.""" + arr = np.zeros((8, 8, 4), dtype=np.uint8) + arr[:, :, 3] = 255 + spec = TargetSpec(name="s", canvas=(4, 4), gates=frozenset({Code.CANVAS_SIZE})) + assert evaluate(arr, spec) == [Code.CANVAS_SIZE] + + def test_gates_not_declared_are_not_run(self) -> None: + """An off-palette image passes when the palette gate is not declared.""" + arr = np.zeros((2, 2, 4), dtype=np.uint8) + arr[:, :, :3] = (99, 99, 99) + arr[:, :, 3] = 255 + spec = TargetSpec( + name="s", + palette=frozenset({(1, 2, 3)}), + gates=frozenset({Code.CANVAS_SIZE}), + ) + assert evaluate(arr, spec) == [] + + def test_codes_are_deduplicated(self) -> None: + """A code raised by two gates is reported once.""" + arr = np.zeros((2, 2, 4), dtype=np.uint8) + spec = TargetSpec( + name="s", + max_colors=4, + palette=frozenset({(1, 2, 3)}), + gates=frozenset({Code.TOO_MANY_COLORS, Code.OFF_PALETTE}), + ) + assert evaluate(arr, spec) == [Code.NO_OPAQUE_PIXELS] + + def test_silhouette_gate_runs_when_declared(self) -> None: + """A near-white asset fails the silhouette gate. + + Regression: ``SILHOUETTE_LOW_CONTRAST`` was declarable in a spec but + had no implementation, so it parsed cleanly, ran nothing and reported + a pass. ``load_spec`` now rejects undeclarable gates, and this asserts + the gate genuinely executes once declared. + """ + arr = np.zeros((8, 8, 4), dtype=np.uint8) + arr[:, :, :3] = 250 + arr[:, :, 3] = 255 + spec = TargetSpec(name="s", gates=frozenset({Code.SILHOUETTE_LOW_CONTRAST})) + assert evaluate(arr, spec) == [Code.SILHOUETTE_LOW_CONTRAST] diff --git a/python_pkg/artgate/tests/test_contrast.py b/python_pkg/artgate/tests/test_contrast.py new file mode 100644 index 00000000..e0bace63 --- /dev/null +++ b/python_pkg/artgate/tests/test_contrast.py @@ -0,0 +1,89 @@ +"""Silhouette-contrast tests, with a calibration control pair. + +The threshold is justified by controls rather than taste: a near-white subject +scores 5.0 against white and must fail; a mid-grey subject scores ~128 against +both and must pass. A gate whose threshold has no controls is a bug waiting to +reject good art. +""" + +from __future__ import annotations + +import numpy as np + +from python_pkg.artgate.codes import Code +from python_pkg.artgate.contrast import ( + DEFAULT_MIN_DELTA, + check_silhouette, + silhouette_delta, +) + + +def _solid(value: int, alpha: int = 255, size: int = 8) -> np.ndarray: + """Build a uniform RGBA square. + + Args: + value: Grey level for all channels. + alpha: Alpha for every pixel. + size: Square edge length. + + Returns: + An ``(size, size, 4)`` uint8 array. + """ + arr = np.zeros((size, size, 4), dtype=np.uint8) + arr[:, :, :3] = value + arr[:, :, 3] = alpha + return arr + + +class TestCalibrationControls: + """Provably-failing and provably-passing inputs bracket the threshold.""" + + def test_near_white_fails_against_light(self) -> None: + """A near-white subject is invisible on white and must fail.""" + arr = _solid(250) + assert silhouette_delta(arr, 255) < DEFAULT_MIN_DELTA + assert check_silhouette(arr) == [Code.SILHOUETTE_LOW_CONTRAST] + + def test_near_black_fails_against_dark(self) -> None: + """A near-black subject is invisible on black and must fail.""" + arr = _solid(4) + assert silhouette_delta(arr, 0) < DEFAULT_MIN_DELTA + assert check_silhouette(arr) == [Code.SILHOUETTE_LOW_CONTRAST] + + def test_mid_grey_passes_both(self) -> None: + """A mid-grey subject reads on both backgrounds and must pass.""" + arr = _solid(128) + assert check_silhouette(arr) == [] + + def test_both_backgrounds_are_required(self) -> None: + """Passing one background is not sufficient. + + Near-white separates hugely from black (250) yet still fails, which is + exactly the case a single-background check would wave through. + """ + arr = _solid(250) + assert silhouette_delta(arr, 0) == 250.0 + assert check_silhouette(arr) == [Code.SILHOUETTE_LOW_CONTRAST] + + +class TestEdgeCases: + """Degenerate inputs fail closed rather than dividing by zero.""" + + def test_fully_transparent_is_empty_canvas(self) -> None: + """An image that paints nothing reports EMPTY_CANVAS.""" + assert check_silhouette(_solid(128, alpha=0)) == [Code.EMPTY_CANVAS] + + def test_delta_is_zero_when_nothing_is_painted(self) -> None: + """The alpha-weighted mean is defined for a blank image.""" + assert silhouette_delta(_solid(128, alpha=0), 0) == 0.0 + + def test_alpha_weighting_ignores_transparent_margin(self) -> None: + """A transparent margin does not drag the subject's luma.""" + arr = _solid(250, size=8) + arr[4:, :, 3] = 0 + assert silhouette_delta(arr, 255) == 5.0 + + def test_threshold_is_configurable(self) -> None: + """A style may demand more separation than the default.""" + arr = _solid(128) + assert check_silhouette(arr, min_delta=200.0) == [Code.SILHOUETTE_LOW_CONTRAST] diff --git a/python_pkg/artgate/tests/test_pixels.py b/python_pkg/artgate/tests/test_pixels.py new file mode 100644 index 00000000..5f1fc72f --- /dev/null +++ b/python_pkg/artgate/tests/test_pixels.py @@ -0,0 +1,163 @@ +"""Gate tests, including calibration controls and two regression bugs. + +Every threshold here is justified by a control pair -- one provably-passing +input and one provably-failing input -- because a guessed threshold is the +single most expensive mistake available: it rejects good art and sends you +looking for a fault in the artwork that is actually in the gate. +""" + +from __future__ import annotations + +import numpy as np +from PIL import Image, ImageFilter +import pytest + +from python_pkg.artgate.codes import Code +from python_pkg.artgate.pixels import ( + block_impurity, + check_alpha_binary, + check_color_count, + check_palette, + check_scale_invariance, + detect_scale, + opaque_rgb, +) + + +def _rgba(img: Image.Image) -> np.ndarray: + """Convert a PIL image to an RGBA uint8 array. + + Args: + img: Any PIL image. + + Returns: + An ``(h, w, 4)`` uint8 array. + """ + return np.asarray(img.convert("RGBA"), dtype=np.uint8) + + +@pytest.fixture(name="base_art") +def fixture_base_art() -> Image.Image: + """Return a deterministic 16x16 opaque pixel-art stand-in. + + Returns: + A 16x16 RGBA image with fully-opaque alpha. + """ + rng = np.random.default_rng(7) + arr = rng.integers(0, 255, (16, 16, 4), dtype=np.uint8) + arr[:, :, 3] = 255 + return Image.fromarray(arr, "RGBA") + + +class TestScaleInvarianceControls: + """Control pair for the scale gate: it must separate honest from fake.""" + + def test_nearest_upscale_is_pure(self, base_art: Image.Image) -> None: + """Nearest-neighbour upscaling is provably blocky.""" + up = base_art.resize((64, 64), Image.Resampling.NEAREST) + assert block_impurity(_rgba(up), 4) == 0.0 + + def test_bilinear_upscale_is_impure(self, base_art: Image.Image) -> None: + """Interpolated upscaling is provably not blocky.""" + up = base_art.resize((64, 64), Image.Resampling.BILINEAR) + assert block_impurity(_rgba(up), 4) == 1.0 + + def test_gate_accepts_honest_and_rejects_fake(self, base_art: Image.Image) -> None: + """The gate turns those two controls into the right verdicts.""" + honest = _rgba(base_art.resize((64, 64), Image.Resampling.NEAREST)) + fake = _rgba(base_art.resize((64, 64), Image.Resampling.BILINEAR)) + assert check_scale_invariance(honest, 4) == [] + assert check_scale_invariance(fake, 4) == [Code.SCALE_NOT_INVARIANT] + + def test_detects_scale_without_declaration(self, base_art: Image.Image) -> None: + """Auto-detection recovers the upscale factor.""" + up = _rgba(base_art.resize((64, 64), Image.Resampling.NEAREST)) + assert detect_scale(up) == 4 + + def test_native_resolution_passes_trivially(self, base_art: Image.Image) -> None: + """Documented limitation: at scale 1 this gate cannot discriminate. + + This is why alpha and colour gates carry the load on native output. + """ + native = _rgba(base_art) + assert detect_scale(native) == 1 + assert check_scale_invariance(native, None) == [] + + +class TestAlphaBeforeColor: + """Regression: gate ordering, and the empty-opaque-set hole it exposed.""" + + def test_soft_alpha_is_rejected(self) -> None: + """A blurred, soft-alpha image fails the alpha gate.""" + rng = np.random.default_rng(7) + arr = rng.integers(0, 255, (32, 32, 4), dtype=np.uint8) + soft = Image.fromarray(arr, "RGBA").filter(ImageFilter.GaussianBlur(4)) + assert check_alpha_binary(_rgba(soft)) == [Code.ALPHA_NOT_BINARY] + + def test_color_gate_fails_closed_with_no_opaque_pixels(self) -> None: + """Regression: an opaque-only count returns 0 and must not pass. + + Measured during design: a 512px smooth image downscaled to 32x32 had + 99 distinct alpha values and therefore zero fully-opaque pixels, so a + naive opaque-only colour count reported 0 and missed it entirely. + """ + arr = np.full((8, 8, 4), 128, dtype=np.uint8) + arr[:, :, 3] = 128 + assert opaque_rgb(arr).size == 0 + assert check_color_count(arr, 16) == [Code.NO_OPAQUE_PIXELS] + + def test_palette_gate_fails_closed_with_no_opaque_pixels(self) -> None: + """The palette gate shares the fail-closed behaviour.""" + arr = np.zeros((4, 4, 4), dtype=np.uint8) + assert check_palette(arr, frozenset({(0, 0, 0)})) == [Code.NO_OPAQUE_PIXELS] + + +class TestTransparentRgbInflation: + """Regression: transparent pixels retaining RGB inflated the colour count.""" + + def test_transparent_rgb_does_not_inflate_count(self) -> None: + """Two colours plus junk-RGB transparent pixels still counts as two.""" + arr = np.zeros((4, 4, 4), dtype=np.uint8) + arr[0, :, :3] = (10, 20, 30) + arr[0, :, 3] = 255 + arr[1, :, :3] = (40, 50, 60) + arr[1, :, 3] = 255 + arr[2:, :, :3] = (99, 88, 77) + arr[2:, :, 3] = 0 + assert check_color_count(arr, 2) == [] + + def test_exceeding_the_cap_is_reported(self) -> None: + """Three opaque colours against a cap of two fails.""" + arr = np.zeros((3, 1, 4), dtype=np.uint8) + arr[0, 0] = (1, 1, 1, 255) + arr[1, 0] = (2, 2, 2, 255) + arr[2, 0] = (3, 3, 3, 255) + assert check_color_count(arr, 2) == [Code.TOO_MANY_COLORS] + + +class TestPalette: + """Palette conformance over opaque pixels.""" + + def test_conforming_art_passes(self) -> None: + """Art drawn only from the palette passes.""" + arr = np.zeros((2, 2, 4), dtype=np.uint8) + arr[:, :, :3] = (10, 20, 30) + arr[:, :, 3] = 255 + assert check_palette(arr, frozenset({(10, 20, 30)})) == [] + + def test_off_palette_pixel_is_reported(self) -> None: + """A single stray colour fails the gate.""" + arr = np.zeros((1, 2, 4), dtype=np.uint8) + arr[0, 0] = (10, 20, 30, 255) + arr[0, 1] = (99, 99, 99, 255) + assert check_palette(arr, frozenset({(10, 20, 30)})) == [Code.OFF_PALETTE] + + +class TestAlphaBinary: + """Alpha binarity accepts only fully-transparent and fully-opaque.""" + + def test_binary_alpha_passes(self) -> None: + """A mask of only 0 and 255 passes.""" + arr = np.zeros((2, 2, 4), dtype=np.uint8) + arr[0, :, 3] = 255 + assert check_alpha_binary(arr) == [] diff --git a/python_pkg/artgate/tests/test_spec.py b/python_pkg/artgate/tests/test_spec.py new file mode 100644 index 00000000..2af48634 --- /dev/null +++ b/python_pkg/artgate/tests/test_spec.py @@ -0,0 +1,182 @@ +"""Spec-loading tests. + +A malformed spec must raise ``SpecError`` (harness error, exit 2) rather than +producing a permissive spec that silently skips gates -- a spec typo that +turned gates off would be indistinguishable from art that passed. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from python_pkg.artgate.codes import Code +from python_pkg.artgate.spec import SpecError, TargetSpec, load_spec + +VALID = """ +name = "pixel_icon" +canvas = [32, 32] +palette = ["#1b1d21", "#ffffff"] +max_colors = 16 +alpha_binary = true +scale = 1 +min_margin = 1 +gates = ["ALPHA_NOT_BINARY", "OFF_PALETTE", "TOO_MANY_COLORS"] +""" + + +def _write(tmp_path: Path, text: str) -> Path: + """Write a spec file and return its path. + + Args: + tmp_path: Pytest temporary directory. + text: TOML contents. + + Returns: + The written path. + """ + path = tmp_path / "spec.toml" + path.write_text(text, encoding="utf-8") + return path + + +class TestValidSpec: + """A well-formed spec parses into the expected frozen dataclass.""" + + def test_fields_round_trip(self, tmp_path: Path) -> None: + """Every declared field is parsed.""" + spec = load_spec(_write(tmp_path, VALID)) + assert spec.name == "pixel_icon" + assert spec.canvas == (32, 32) + assert spec.max_colors == 16 + assert spec.alpha_binary is True + assert spec.min_margin == 1 + assert (27, 29, 33) in spec.palette + + def test_gate_applicability(self, tmp_path: Path) -> None: + """``runs`` reports only the declared gates.""" + spec = load_spec(_write(tmp_path, VALID)) + assert spec.runs(Code.OFF_PALETTE) + assert not spec.runs(Code.SEAM_ENERGY) + + def test_defaults_when_optional_fields_absent(self, tmp_path: Path) -> None: + """Optional fields default, for a gate needing no extra config. + + ``SILHOUETTE_LOW_CONTRAST`` is used deliberately: it is the only + declarable gate with a self-contained threshold, so it exercises the + defaults without tripping the require-config check. + """ + body = 'name = "m"\ngates = ["SILHOUETTE_LOW_CONTRAST"]\n' + spec = load_spec(_write(tmp_path, body)) + assert spec.canvas is None + assert spec.palette == frozenset() + assert spec.max_colors is None + assert spec.scale is None + assert spec.min_margin == 0 + assert spec.alpha_binary is False + + def test_default_construction_has_no_gates(self) -> None: + """A bare TargetSpec runs nothing.""" + assert not TargetSpec(name="bare").runs(Code.OFF_PALETTE) + + +class TestMalformedSpec: + """Every malformed input raises SpecError, never a permissive spec.""" + + def test_missing_file(self, tmp_path: Path) -> None: + """A missing spec file is a harness error.""" + with pytest.raises(SpecError, match="cannot read spec"): + load_spec(tmp_path / "absent.toml") + + def test_invalid_toml(self, tmp_path: Path) -> None: + """Unparsable TOML is a harness error.""" + with pytest.raises(SpecError, match="malformed spec"): + load_spec(_write(tmp_path, "name = [unclosed")) + + @pytest.mark.parametrize("body", ["gates = []", 'name = ""\ngates = []']) + def test_missing_name(self, tmp_path: Path, body: str) -> None: + """A blank or absent name is rejected.""" + with pytest.raises(SpecError, match="non-empty 'name'"): + load_spec(_write(tmp_path, body)) + + @pytest.mark.parametrize("gates", ["", "gates = []", 'gates = "nope"']) + def test_missing_gates(self, tmp_path: Path, gates: str) -> None: + """A spec with no gates would pass everything, so it is rejected.""" + with pytest.raises(SpecError, match="at least one gate"): + load_spec(_write(tmp_path, f'name = "m"\n{gates}\n')) + + def test_unknown_gate(self, tmp_path: Path) -> None: + """A typo in a gate name must not silently disable it.""" + with pytest.raises(SpecError, match="unknown gate"): + load_spec(_write(tmp_path, 'name = "m"\ngates = ["NOT_A_GATE"]\n')) + + @pytest.mark.parametrize( + "palette", + ['palette = "#fff"', 'palette = ["fff"]', 'palette = ["#gggggg"]'], + ) + def test_bad_palette(self, tmp_path: Path, palette: str) -> None: + """Palette entries must be '#rrggbb'.""" + body = f'name = "m"\ngates = ["OFF_PALETTE"]\n{palette}\n' + with pytest.raises(SpecError, match="palette"): + load_spec(_write(tmp_path, body)) + + @pytest.mark.parametrize( + "canvas", + ["canvas = 32", "canvas = [32]", "canvas = [0, 32]", 'canvas = ["a","b"]'], + ) + def test_bad_canvas(self, tmp_path: Path, canvas: str) -> None: + """Canvas must be a pair of positive integers.""" + body = f'name = "m"\ngates = ["CANVAS_SIZE"]\n{canvas}\n' + with pytest.raises(SpecError, match="canvas must be"): + load_spec(_write(tmp_path, body)) + + +class TestFailClosed: + """A declared gate that cannot actually run is a spec fault, not a pass. + + Regression: ``hand_painted.toml`` declared ``SILHOUETTE_LOW_CONTRAST`` + while no implementation existed. The spec parsed, the gate never ran, and + a featureless image scored exit 0 -- indistinguishable from real art + passing. All three mechanisms below are now rejected at load time. + """ + + def test_unimplemented_gate_is_rejected(self, tmp_path: Path) -> None: + """A gate with no implementation cannot be declared.""" + body = 'name = "m"\ngates = ["SEAM_ENERGY"]\n' + with pytest.raises(SpecError, match="not implemented"): + load_spec(_write(tmp_path, body)) + + def test_result_only_code_is_rejected(self, tmp_path: Path) -> None: + """A result code is emitted by a gate, never requested by a spec.""" + body = 'name = "m"\ncanvas = [8, 8]\ngates = ["NO_OPAQUE_PIXELS"]\n' + with pytest.raises(SpecError, match="emitted by gates"): + load_spec(_write(tmp_path, body)) + + @pytest.mark.parametrize( + ("body", "match"), + [ + ('gates = ["CANVAS_SIZE"]', "CANVAS_SIZE needs"), + ('gates = ["TOO_MANY_COLORS"]', "TOO_MANY_COLORS needs"), + ('gates = ["OFF_PALETTE"]', "OFF_PALETTE needs"), + ('gates = ["ALPHA_NOT_BINARY"]', "ALPHA_NOT_BINARY needs"), + ], + ) + def test_gate_without_config_is_rejected( + self, tmp_path: Path, body: str, match: str + ) -> None: + """Declaring a gate without its config would silently skip it.""" + with pytest.raises(SpecError, match=match): + load_spec(_write(tmp_path, f'name = "m"\n{body}\n')) + + def test_shipped_styles_load(self) -> None: + """Every style shipped in the package must itself be valid.""" + styles = Path(__file__).resolve().parent.parent / "styles" + loaded = [load_spec(p) for p in sorted(styles.glob("*.toml"))] + assert loaded + assert all(spec.gates for spec in loaded) + + +def test_code_str_is_bare_value() -> None: + """Codes format without an enum prefix, so JSONL stays greppable.""" + assert f"{Code.OFF_PALETTE}" == "OFF_PALETTE"