testsAndMisc/python_pkg/artgate/tests/test_finish.py
Krzysztof kuhy Rudnicki 691a228499
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: procedural + vector routes, and the shared finishing stage
Closes the two CPU-only cells of the art bake-off and builds the bridge the
diffusion cell will reuse.

Pins the bake-off to the SAME twelve subjects item-icons measured, with its
easy/hard split intact. Without this a route tested on distinctive silhouettes
beats the 6/12 baseline for free and the decision table means nothing. It also
makes the subject-routing rule falsifiable: a generator route is predicted to
lift specifically the six human-prior failures, and if it does not, the rule is
recorded as wrong.

Route (a) procedural: all twelve subjects drawn deterministically from a
7-colour locked palette. Every shape gets a distinguishing feature up front,
because item-icons measured that plain primitives read as placeholders however
correct the geometry is. Machine gate: 12/12 -- which proves "not malformed",
NOT quality. A procedural generator satisfies palette/alpha/canvas constraints
trivially; the human contact sheet is the real verdict.

Route (d) code-as-art: SVG emitted as text and rasterised by rsvg-convert, so
no new Python dependency (cairosvg/svgwrite stay absent).

The structural finding, measured on real rasteriser output rather than a
synthetic stand-in: raw vector renders pass 0/5 -- every one fails
ALPHA_NOT_BINARY, most also TOO_MANY_COLORS. After the finishing stage, 5/5.
Continuous-tone routes are therefore GENERATORS FEEDING A FINISHER, not
pixel-art generators. finish.py binarises alpha before quantising colour,
mirroring the gate order: quantising first blends transparent pixels' arbitrary
RGB into the palette.

Verified: 92 tests, 100% branch coverage across all ten modules, zero
suppressions, all hooks pass. The PLR2004 lint refactor was confirmed
pixel-neutral by byte-comparing all 12 icons before and after (12/12 identical).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9rQ12yZ7sJxiPQTCS5dUF
2026-08-15 22:31:59 +02:00

103 lines
3.4 KiB
Python

"""Finishing-stage tests.
The finisher is the bridge every continuous-tone route depends on, so its
contract is asserted directly: whatever goes in, what comes out satisfies the
alpha, colour and canvas gates.
"""
from __future__ import annotations
import numpy as np
from PIL import Image, ImageFilter
import pytest
from python_pkg.artgate.finish import (
ALPHA_CUTOFF,
binarize_alpha,
downscale,
finish,
quantize,
)
from python_pkg.artgate.pixels import (
check_alpha_binary,
check_color_count,
opaque_rgb,
)
@pytest.fixture(name="smooth")
def fixture_smooth() -> np.ndarray:
"""Return a blurred, many-coloured, soft-alpha source image.
Returns:
A ``(256, 256, 4)`` uint8 array standing in for generator output.
"""
rng = np.random.default_rng(5)
arr = rng.integers(0, 255, (256, 256, 4), dtype=np.uint8)
img = Image.fromarray(arr, "RGBA").filter(ImageFilter.GaussianBlur(6))
return np.asarray(img, dtype=np.uint8)
class TestContract:
"""Finished output satisfies the gates its route could not satisfy."""
def test_finished_output_passes_gates(self, smooth: np.ndarray) -> None:
"""The end-to-end pipeline yields gate-passing pixel art."""
out = finish(smooth, target=32, max_colors=16)
assert out.shape == (32, 32, 4)
assert check_alpha_binary(out) == []
assert check_color_count(out, 16) == []
def test_raw_input_would_have_failed(self, smooth: np.ndarray) -> None:
"""The source genuinely fails, so the test is not vacuous."""
assert check_alpha_binary(smooth) != []
class TestBinarizeAlpha:
"""Alpha thresholding also canonicalises transparent pixels."""
def test_threshold_splits_at_cutoff(self) -> None:
"""Values at the cutoff round up, below it round down."""
arr = np.zeros((1, 2, 4), dtype=np.uint8)
arr[0, 0] = (9, 9, 9, ALPHA_CUTOFF)
arr[0, 1] = (9, 9, 9, ALPHA_CUTOFF - 1)
out = binarize_alpha(arr)
assert out[0, 0, 3] == 255
assert out[0, 1, 3] == 0
def test_transparent_rgb_is_zeroed(self) -> None:
"""Leftover RGB under transparency would inflate colour counts."""
arr = np.zeros((1, 1, 4), dtype=np.uint8)
arr[0, 0] = (200, 100, 50, 0)
assert tuple(binarize_alpha(arr)[0, 0]) == (0, 0, 0, 0)
def test_source_is_not_mutated(self) -> None:
"""The finisher never edits its caller's array in place."""
arr = np.full((2, 2, 4), 200, dtype=np.uint8)
binarize_alpha(arr)
assert arr[0, 0, 3] == 200
class TestStages:
"""Individual stages behave as documented."""
def test_downscale_hits_target(self, smooth: np.ndarray) -> None:
"""Box downscaling produces the requested square."""
assert downscale(smooth, 16).shape == (16, 16, 4)
def test_quantize_caps_opaque_colours(self) -> None:
"""Quantisation respects the cap over opaque pixels."""
rng = np.random.default_rng(2)
arr = rng.integers(0, 255, (16, 16, 4), dtype=np.uint8)
arr[:, :, 3] = 255
out = quantize(arr, 8)
assert np.unique(opaque_rgb(out), axis=0).shape[0] <= 8
def test_quantize_keeps_transparency_clean(self) -> None:
"""Transparent pixels stay canonical through quantisation."""
arr = np.zeros((4, 4, 4), dtype=np.uint8)
arr[0, :, :3] = (10, 20, 30)
arr[0, :, 3] = 255
out = quantize(arr, 4)
assert not out[1:, :, :].any()