mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 22:27:01 +01:00
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
164 lines
6.1 KiB
Python
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) == []
|