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

87 lines
2.7 KiB
Python

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