testsAndMisc/python_pkg/artgate/tests/test_pixels.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

164 lines
6.1 KiB
Python

"""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) == []