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

90 lines
3.2 KiB
Python

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