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

170 lines
5.2 KiB
Python

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