artgate: procedural + vector routes, and the shared finishing stage
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

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
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 22:31:59 +02:00
parent da8ca6d827
commit 691a228499
10 changed files with 970 additions and 0 deletions

View File

@ -0,0 +1,20 @@
{
"title": "artgate Phase 3: procedural + code-as-art routes, and the shared finishing stage",
"objective": "Run the two CPU-only, download-free cells of the art bake-off - route (a) procedural/parametric and route (d) code-as-art via SVG - against the fixed twelve-subject set, and build the finishing stage that lets any continuous-tone route satisfy a pixel-art spec. Establishes measured cells in the decision table and the shared bridge that route (b) diffusion will reuse.",
"acceptance_criteria": [
"The twelve bake-off subjects are pinned in code with their measured easy/hard split, so every route is compared on the same task.",
"Route (a) renders all twelve subjects deterministically from a locked palette and its output passes the pixel_icon gate stack.",
"Route (d) renders vector subjects via rsvg-convert with no new Python dependency.",
"A finishing stage converts continuous-tone output (anti-aliased alpha, thousands of colours) into gate-passing pixel art, demonstrated on real rasteriser output rather than a synthetic stand-in.",
"Alpha is binarised BEFORE colour quantisation, mirroring the gate order, so transparent pixels never contribute RGB to the palette.",
"100% branch coverage, zero lint suppressions, all pre-commit hooks pass.",
"A contact sheet is produced per cell for the human gate."
],
"out_of_scope": [
"Route (b) diffusion. The GPU path is proven but no model is downloaded or run in this change.",
"The 3D cell of route (d). Blender 5.2 is present and scriptable but not yet wired.",
"Deciding which icons are GOOD. The machine gates establish 'not malformed' only; acceptance is the user's contact-sheet verdict.",
"Tiling and animation gates (SEAM_ENERGY, LOOP_POPS), which belong to cells not yet run."
],
"verifier": "python -m pytest python_pkg/artgate/tests/ --cov=python_pkg.artgate --cov-branch --cov-fail-under=100 (92 tests, 100% branch); pre-commit run over all artgate files; plus an end-to-end run generating all 12 procedural icons and gating them (12/12 machine-pass), and rendering 5 vector subjects before/after the finishing stage (0/5 -> 5/5)."
}

View File

@ -0,0 +1,54 @@
{
"intent": "Close the two CPU-only cells of the art bake-off (route (a) procedural, route (d) code-as-art/vector) and build the finishing stage that every continuous-tone route needs, so the diffusion cell has a proven bridge waiting for it.",
"scope": [
"python_pkg/artgate/subjects.py - the fixed 12-subject set with its measured easy/hard split",
"python_pkg/artgate/routes/{procedural,shapes,vector}.py - two route generators",
"python_pkg/artgate/finish.py - the shared downscale/binarize/quantize bridge",
"python_pkg/artgate/tests/{test_finish,test_routes}.py - 35 new tests",
"NON-GOAL: no diffusion model downloaded or run; no 3D cell; no judgement of which icons look good"
],
"changes": [
"Pinned the bake-off to the same twelve subjects item-icons measured (key/scroll/book/bone/gem/bomb passed; potion/sword/shield/coin/ring/meat failed), so routes are compared on one task rather than on a convenient subject list. This makes the subject-routing rule falsifiable: a generator route is predicted to lift specifically the six human-prior failures.",
"Route (a) draws all twelve subjects deterministically from a 7-colour locked palette, giving each shape a distinguishing feature up front because item-icons measured that plain primitives read as placeholders.",
"Route (d) emits SVG as text and rasterises via rsvg-convert, adding no Python dependency (cairosvg/svgwrite are absent and stay absent).",
"finish.py binarises alpha BEFORE quantising colour, mirroring the gate order: quantising first would blend transparent pixels' arbitrary RGB into the palette.",
"MAX_ROUNDS = 2 encoded in code, because item-icons ran two redraw rounds and stayed at 6/12, so a third is not evidence-backed."
],
"verification": [
{
"command": "python -m pytest python_pkg/artgate/tests/ --cov=python_pkg.artgate --cov-branch",
"result": "pass",
"evidence": "92 passed. 100% branch coverage on all ten modules including procedural.py, shapes.py, vector.py, finish.py, subjects.py. Zero suppressions."
},
{
"command": "generate 12 procedural icons and gate each against styles/pixel_icon.toml",
"result": "pass",
"evidence": "12/12 machine-gate PASS. Noted explicitly: this proves 'not malformed', NOT quality - a procedural generator satisfies palette/alpha/canvas constraints trivially. The human contact sheet is the real verdict."
},
{
"command": "render 5 vector subjects, gate raw, then gate after the finishing stage",
"result": "pass",
"evidence": "RAW: 0/5 pass - every one FAILs ALPHA_NOT_BINARY (+ TOO_MANY_COLORS on 4). AFTER finishing: 5/5 PASS. This is the structural finding: continuous-tone routes are generators feeding a finisher, not pixel-art generators."
},
{
"command": "byte-comparison of all 12 icons before/after the lint refactor",
"result": "pass",
"evidence": "12/12 byte-identical. The PLR2004 fixes (OPAQUE constant, _GEM_WAIST) changed no pixels - verified rather than assumed."
},
{
"command": "pre-commit run --files python_pkg/artgate/*.py python_pkg/artgate/routes/*.py python_pkg/artgate/tests/*.py",
"result": "pass",
"evidence": "ALL HOOKS PASS: ruff select=ALL, ruff-format, mypy strict, pylint, bandit, codespell, no-noqa, pytest+coverage."
}
],
"risks": [
"Route (a) output is gate-clean by construction, so its machine-gate column is uninformative by design. Reading a 12/12 as 'route (a) wins' would be exactly the misreading the plan warns about; the decision table must weight human accept rate.",
"The vector route currently covers 5 of the 12 subjects. The remaining 7 need SVG shapes before that cell's number is comparable to route (a)'s.",
"The finishing stage's quality depends on the downscale filter; BOX was chosen for straight-alpha safety, but a different filter may suit painterly sources better and is untested.",
"I reached for a `python - <<PY` heredoc twice for throwaway patch scripts, which this repo's CLAUDE.md forbids for real logic. Not committed, but the habit is worth breaking."
],
"rollback": [
"Delete python_pkg/artgate/routes/, finish.py, subjects.py and their tests; the gate harness from da8ca6d stands alone without them.",
"After rollback, confirm pytest still reports 100% branch coverage for the remaining artgate modules and that no other package regressed."
]
}

View File

@ -0,0 +1,100 @@
"""The finishing stage: turn continuous-tone output into gate-passing pixel art.
Routes that render continuously -- diffusion (b), SVG rasterisation and 3D
renders (d) -- cannot satisfy a pixel-art spec natively. Their output has
anti-aliased alpha and thousands of colours. This module is the shared bridge,
and its existence is itself a bake-off finding: for pixel work those routes are
*generators feeding a finisher*, not pixel-art generators.
Order matters and mirrors the gate order. Alpha is thresholded FIRST, so the
colour quantiser only ever sees pixels that will survive; quantising first
would blend transparent pixels' arbitrary RGB into the palette.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
from PIL import Image
if TYPE_CHECKING: # pragma: no cover
from numpy.typing import NDArray
# Alpha at or above this becomes fully opaque; below it becomes transparent.
ALPHA_CUTOFF = 128
def binarize_alpha(rgba: NDArray[np.uint8]) -> NDArray[np.uint8]:
"""Threshold alpha to exactly ``{0, 255}`` and canonicalise clear pixels.
Fully-transparent pixels are set to ``(0, 0, 0, 0)``. Without this their
leftover RGB inflates a distinct-colour count and rejects correct art --
the exact bug measured during design at 18 colours against a cap of 16.
Args:
rgba: An ``(h, w, 4)`` uint8 array.
Returns:
A new array with binary alpha.
"""
out = rgba.copy()
opaque = out[:, :, 3] >= ALPHA_CUTOFF
out[:, :, 3] = np.where(opaque, 255, 0).astype(np.uint8)
out[~opaque] = 0
return out
def downscale(rgba: NDArray[np.uint8], target: int) -> NDArray[np.uint8]:
"""Box-downscale to a square target, preserving straight alpha.
Args:
rgba: An ``(h, w, 4)`` uint8 array.
target: Target edge length in pixels.
Returns:
A ``(target, target, 4)`` uint8 array.
"""
img = Image.fromarray(rgba, "RGBA").resize((target, target), Image.Resampling.BOX)
return np.asarray(img, dtype=np.uint8)
def quantize(rgba: NDArray[np.uint8], max_colors: int) -> NDArray[np.uint8]:
"""Reduce the opaque palette to at most ``max_colors`` entries.
Quantisation runs over RGB only; the caller is expected to have binarised
alpha already, so transparent pixels contribute nothing to the palette.
Args:
rgba: An ``(h, w, 4)`` uint8 array with binary alpha.
max_colors: Maximum distinct opaque RGB values.
Returns:
A new array whose opaque pixels use at most ``max_colors`` colours.
"""
rgb = Image.fromarray(rgba[:, :, :3], "RGB").quantize(
colors=max_colors, method=Image.Quantize.MEDIANCUT
)
flat = np.asarray(rgb.convert("RGB"), dtype=np.uint8)
alpha = rgba[:, :, 3]
out = np.dstack([flat, alpha])
out[alpha == 0] = 0
return out.astype(np.uint8)
def finish(
rgba: NDArray[np.uint8],
target: int,
max_colors: int,
) -> NDArray[np.uint8]:
"""Run the full finishing pipeline.
Args:
rgba: Continuous-tone source, any size.
target: Target square edge length.
max_colors: Maximum distinct opaque colours.
Returns:
Pixel art satisfying the alpha, colour and canvas gates.
"""
return quantize(binarize_alpha(downscale(rgba, target)), max_colors)

View File

@ -0,0 +1 @@
"""Generation routes for the art bake-off."""

View File

@ -0,0 +1,148 @@
"""Route (a): procedural/parametric icon generation.
Draws the fixed subject set as 32x32 pixel art from a locked palette using
plain geometry -- no model, no randomness beyond a seed, fully deterministic
and CPU-only.
The honest expectation, from `item-icons`: geometry reliably reaches "not
wrong" and does not reach "good". Plain primitives (coin, ring, shield) are
predicted to read as placeholders no matter how correct the circle is, which
is why every shape here is given at least one distinguishing feature.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
import numpy as np
from python_pkg.artgate.pixels import OPAQUE
if TYPE_CHECKING: # pragma: no cover
from numpy.typing import NDArray
SIZE: Final = 32
# A compact ramp: dark outline, two mid tones, a highlight and three accents.
# Kept small deliberately -- the corpus median for real 32x32 icons is 8.
PALETTE: Final[dict[str, tuple[int, int, int]]] = {
"outline": (26, 20, 28),
"shadow": (72, 60, 66),
"mid": (124, 108, 104),
"light": (198, 186, 170),
"gold": (214, 164, 62),
"red": (176, 62, 60),
"cyan": (92, 176, 190),
}
def _blank() -> NDArray[np.uint8]:
"""Return a fully transparent 32x32 RGBA canvas.
Returns:
An ``(SIZE, SIZE, 4)`` uint8 array.
"""
return np.zeros((SIZE, SIZE, 4), dtype=np.uint8)
def _put(canvas: NDArray[np.uint8], x: int, y: int, colour: str) -> None:
"""Paint one opaque pixel, ignoring out-of-bounds writes.
Args:
canvas: The target canvas.
x: Column.
y: Row.
colour: A key of :data:`PALETTE`.
"""
if 0 <= x < SIZE and 0 <= y < SIZE:
canvas[y, x, :3] = PALETTE[colour]
canvas[y, x, 3] = OPAQUE
def _rect(
canvas: NDArray[np.uint8],
x0: int,
y0: int,
x1: int,
y1: int,
colour: str,
) -> None:
"""Fill an inclusive rectangle.
Args:
canvas: The target canvas.
x0: Left column.
y0: Top row.
x1: Right column, inclusive.
y1: Bottom row, inclusive.
colour: A key of :data:`PALETTE`.
"""
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
_put(canvas, x, y, colour)
def _disc(
canvas: NDArray[np.uint8],
cx: float,
cy: float,
radius: float,
colour: str,
) -> None:
"""Fill a filled circle using a squared-distance test.
Args:
canvas: The target canvas.
cx: Centre column.
cy: Centre row.
radius: Radius in pixels.
colour: A key of :data:`PALETTE`.
"""
limit = radius * radius
for y in range(SIZE):
for x in range(SIZE):
if (x - cx) ** 2 + (y - cy) ** 2 <= limit:
_put(canvas, x, y, colour)
def _ring(
canvas: NDArray[np.uint8],
cx: float,
cy: float,
outer: float,
inner: float,
colour: str,
) -> None:
"""Fill an annulus.
Args:
canvas: The target canvas.
cx: Centre column.
cy: Centre row.
outer: Outer radius.
inner: Inner radius.
colour: A key of :data:`PALETTE`.
"""
hi, lo = outer * outer, inner * inner
for y in range(SIZE):
for x in range(SIZE):
dist = (x - cx) ** 2 + (y - cy) ** 2
if lo <= dist <= hi:
_put(canvas, x, y, colour)
def outline(canvas: NDArray[np.uint8]) -> None:
"""Trace a one-pixel dark border around every opaque region.
A consistent outline is what makes a small sprite read against arbitrary
backgrounds; it is also what the silhouette gate rewards.
Args:
canvas: The canvas to outline in place.
"""
solid = canvas[:, :, 3] == OPAQUE
pad = np.pad(solid, 1, constant_values=False)
neighbours = pad[:-2, 1:-1] | pad[2:, 1:-1] | pad[1:-1, :-2] | pad[1:-1, 2:]
edge = (~solid) & neighbours
canvas[edge, :3] = PALETTE["outline"]
canvas[edge, 3] = OPAQUE

View File

@ -0,0 +1,171 @@
"""Route (a): the twelve subject drawings.
Each shape is given at least one distinguishing FEATURE, because the
`item-icons` measurement found that geometric correctness alone reads as a
placeholder -- a circle with a rim was rejected as "very barebones". The
features here are the fix that finding implies, applied up front.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from python_pkg.artgate.routes.procedural import (
_blank,
_disc,
_put,
_rect,
_ring,
outline,
)
if TYPE_CHECKING: # pragma: no cover
from collections.abc import Callable
import numpy as np
from numpy.typing import NDArray
def _key(c: NDArray[np.uint8]) -> None:
"""Draw a key: bow, shaft and two wards."""
_ring(c, 10.5, 11.5, 5.0, 2.6, "gold")
_rect(c, 14, 15, 16, 25, "gold")
_rect(c, 17, 22, 20, 23, "gold")
_rect(c, 17, 25, 19, 26, "gold")
def _scroll(c: NDArray[np.uint8]) -> None:
"""Draw a scroll: parchment body with rolled ends."""
_rect(c, 7, 8, 24, 23, "light")
_rect(c, 6, 6, 25, 9, "mid")
_rect(c, 6, 22, 25, 25, "mid")
for y in (12, 15, 18):
_rect(c, 10, y, 21, y, "shadow")
def _book(c: NDArray[np.uint8]) -> None:
"""Draw a book: cover, spine and a clasp."""
_rect(c, 6, 6, 25, 26, "red")
_rect(c, 6, 6, 9, 26, "shadow")
_rect(c, 11, 9, 23, 10, "light")
_rect(c, 22, 14, 25, 18, "gold")
def _bone(c: NDArray[np.uint8]) -> None:
"""Draw a bone: shaft with four knobs."""
_rect(c, 12, 8, 19, 23, "light")
for cx, cy in ((11, 8), (20, 8), (11, 23), (20, 23)):
_disc(c, cx, cy, 4.0, "light")
_rect(c, 14, 12, 15, 19, "mid")
# The gem's widest row; above it the silhouette flares, below it tapers.
_GEM_WAIST = 13
def _gem(c: NDArray[np.uint8]) -> None:
"""Draw a gem: faceted lozenge with a highlight facet."""
for row in range(6, 27):
offset = row - _GEM_WAIST
span = 11 - abs(offset) if offset < 1 else 11 - offset
if span > 0:
_rect(c, 16 - span, row, 15 + span, row, "cyan")
_rect(c, 12, 9, 15, 12, "light")
def _bomb(c: NDArray[np.uint8]) -> None:
"""Draw a bomb: sphere, fuse and a spark."""
_disc(c, 15.0, 19.0, 9.0, "shadow")
_disc(c, 12.0, 16.0, 3.0, "mid")
_rect(c, 16, 6, 17, 10, "mid")
_rect(c, 18, 4, 19, 6, "gold")
_put(c, 20, 3, "red")
def _potion(c: NDArray[np.uint8]) -> None:
"""Draw a potion: round flask, neck, stopper and a liquid line."""
_disc(c, 15.5, 20.0, 8.0, "cyan")
_rect(c, 13, 7, 18, 13, "light")
_rect(c, 12, 5, 19, 7, "gold")
_rect(c, 9, 19, 22, 20, "light")
def _sword(c: NDArray[np.uint8]) -> None:
"""Draw a sword: blade, fuller, crossguard and pommel."""
_rect(c, 14, 3, 17, 21, "light")
_rect(c, 15, 5, 16, 19, "mid")
_rect(c, 9, 22, 22, 23, "gold")
_rect(c, 14, 24, 17, 27, "shadow")
_disc(c, 15.5, 28.0, 2.4, "gold")
def _shield(c: NDArray[np.uint8]) -> None:
"""Draw a shield: heater shape with boss and a chevron."""
_rect(c, 6, 5, 25, 17, "mid")
for row in range(18, 28):
inset = row - 17
_rect(c, 6 + inset, row, 25 - inset, row, "mid")
_rect(c, 6, 5, 25, 7, "gold")
_disc(c, 15.5, 15.0, 3.2, "gold")
def _coin(c: NDArray[np.uint8]) -> None:
"""Draw a coin: disc, rim and a struck emblem."""
_disc(c, 15.5, 15.5, 11.0, "gold")
_ring(c, 15.5, 15.5, 11.0, 9.0, "shadow")
_rect(c, 13, 11, 18, 12, "shadow")
_rect(c, 15, 11, 16, 20, "shadow")
def _ring_item(c: NDArray[np.uint8]) -> None:
"""Draw a ring: band with a raised setting and stone."""
_ring(c, 15.5, 18.0, 9.0, 6.0, "gold")
_rect(c, 12, 6, 19, 9, "gold")
_disc(c, 15.5, 7.0, 3.6, "cyan")
_put(c, 14, 6, "light")
def _meat(c: NDArray[np.uint8]) -> None:
"""Draw a meat cut: ham body with a protruding bone."""
_disc(c, 16.0, 19.0, 9.5, "red")
_disc(c, 12.0, 16.0, 3.0, "gold")
_rect(c, 14, 4, 17, 11, "light")
_disc(c, 14.0, 4.0, 2.2, "light")
_disc(c, 17.5, 4.0, 2.2, "light")
DRAWINGS: Final[dict[str, Callable[[NDArray[np.uint8]], None]]] = {
"key": _key,
"scroll": _scroll,
"book": _book,
"bone": _bone,
"gem": _gem,
"bomb": _bomb,
"potion": _potion,
"sword": _sword,
"shield": _shield,
"coin": _coin,
"ring": _ring_item,
"meat": _meat,
}
def draw(name: str) -> NDArray[np.uint8]:
"""Render one subject as a 32x32 RGBA array.
Args:
name: One of the twelve fixed subject names.
Returns:
The rendered canvas, outlined.
Raises:
KeyError: If the subject has no drawing.
"""
if name not in DRAWINGS:
msg = f"no procedural drawing for {name!r}"
raise KeyError(msg)
canvas = _blank()
DRAWINGS[name](canvas)
outline(canvas)
return canvas

View File

@ -0,0 +1,130 @@
"""Route (d): code-as-art via SVG, rasterised with ``rsvg-convert``.
SVG is emitted as text and rendered by a system binary, so this route adds no
Python dependency. It is resolution-independent by construction, which is the
property that makes it the natural fit for flat vector/UI work: the same
source renders crisply at any target size.
Gate consequence worth stating: an SVG rasteriser anti-aliases by default, so
output fails ``ALPHA_NOT_BINARY`` unless alpha is explicitly thresholded. That
is a real property of the route, not a defect -- it is why flat-vector output
needs a finishing step before it can be gated as pixel art.
"""
from __future__ import annotations
import shutil
import subprocess
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING:
from pathlib import Path
# Same locked palette as the procedural route, so the two cells are comparable.
PALETTE: Final[dict[str, str]] = {
"outline": "#1a141c",
"shadow": "#483c42",
"mid": "#7c6c68",
"light": "#c6baaa",
"gold": "#d6a43e",
"red": "#b03e3c",
"cyan": "#5cb0be",
}
_STROKE: Final = 'stroke="#1a141c" stroke-width="1.5" stroke-linejoin="round"'
def _svg(body: str, size: int = 64) -> str:
"""Wrap shape markup in an SVG document.
Args:
body: The inner markup.
size: The viewBox edge length.
Returns:
A complete SVG document.
"""
return (
f'<svg xmlns="http://www.w3.org/2000/svg" width="{size}" '
f'height="{size}" viewBox="0 0 64 64">{body}</svg>'
)
SHAPES: Final[dict[str, str]] = {
"key": (
f'<circle cx="20" cy="22" r="11" fill="none" stroke="{PALETTE["gold"]}"'
f' stroke-width="7"/>'
f'<rect x="27" y="30" width="7" height="27" fill="{PALETTE["gold"]}" '
f"{_STROKE}/>"
f'<rect x="34" y="42" width="10" height="5" fill="{PALETTE["gold"]}" '
f"{_STROKE}/>"
f'<rect x="34" y="50" width="7" height="5" fill="{PALETTE["gold"]}" '
f"{_STROKE}/>"
),
"gem": (
f'<polygon points="32,6 54,26 32,58 10,26" fill="{PALETTE["cyan"]}" '
f"{_STROKE}/>"
f'<polygon points="32,6 42,26 32,34 22,26" fill="{PALETTE["light"]}" '
f'opacity="0.65"/>'
),
"potion": (
f'<circle cx="32" cy="42" r="17" fill="{PALETTE["cyan"]}" {_STROKE}/>'
f'<rect x="26" y="12" width="12" height="16" '
f'fill="{PALETTE["light"]}" {_STROKE}/>'
f'<rect x="23" y="6" width="18" height="8" fill="{PALETTE["gold"]}" '
f"{_STROKE}/>"
),
"coin": (
f'<circle cx="32" cy="32" r="24" fill="{PALETTE["gold"]}" {_STROKE}/>'
f'<circle cx="32" cy="32" r="18" fill="none" '
f'stroke="{PALETTE["shadow"]}" stroke-width="2"/>'
f'<polygon points="32,18 37,29 49,29 39,37 43,49 32,41 21,49 25,37 '
f'15,29 27,29" fill="{PALETTE["shadow"]}"/>'
),
"shield": (
f'<path d="M10 10 H54 V32 Q54 52 32 60 Q10 52 10 32 Z" '
f'fill="{PALETTE["mid"]}" {_STROKE}/>'
f'<path d="M10 10 H54 V20 H10 Z" fill="{PALETTE["gold"]}"/>'
f'<circle cx="32" cy="34" r="7" fill="{PALETTE["gold"]}" {_STROKE}/>'
),
}
class RenderError(RuntimeError):
"""Raised when the SVG rasteriser is unavailable or fails."""
def render(name: str, out: Path, size: int = 64) -> Path:
"""Rasterise one vector subject to PNG.
Args:
name: A key of :data:`SHAPES`.
out: Destination PNG path.
size: Output edge length in pixels.
Returns:
The written path.
Raises:
KeyError: If the subject has no vector shape.
RenderError: If ``rsvg-convert`` is missing or exits non-zero.
"""
if name not in SHAPES:
msg = f"no vector shape for {name!r}"
raise KeyError(msg)
binary = shutil.which("rsvg-convert")
if binary is None:
msg = "rsvg-convert is not installed"
raise RenderError(msg)
source = out.with_suffix(".svg")
source.write_text(_svg(SHAPES[name], size), encoding="utf-8")
result = subprocess.run(
[binary, "-w", str(size), "-h", str(size), str(source), "-o", str(out)],
capture_output=True,
check=False,
)
if result.returncode != 0:
msg = f"rsvg-convert failed for {name}: {result.stderr.decode()}"
raise RenderError(msg)
return out

View File

@ -0,0 +1,98 @@
"""The fixed subject set the bake-off is pinned to.
Every route and every applicable style is tested on the SAME twelve subjects,
because a route evaluated on easy subjects beats a 50% baseline for free and
the comparison would measure the subject list rather than the route.
The split comes from the `item-icons` skill's measured human verdicts: six
subjects passed and six failed on a first attempt, and the pattern predicting
failure was not drawing difficulty but whether the viewer holds a precise
mental image of the object. That makes the routing rule below falsifiable --
a generator route is predicted to lift specifically the HUMAN_PRIOR six.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Final
class Difficulty(str, Enum):
"""Why a subject is expected to be easy or hard, not how hard to draw."""
SAFE = "SAFE"
HUMAN_PRIOR = "HUMAN_PRIOR"
PRIMITIVE = "PRIMITIVE"
def __str__(self) -> str:
"""Return the bare value for report tables."""
return self.value
@dataclass(frozen=True)
class Subject:
"""One bake-off subject.
Attributes:
name: Lowercase identifier, used in filenames and report rows.
difficulty: The predicted failure mode, or SAFE.
note: Why this subject sits in that category.
"""
name: str
difficulty: Difficulty
note: str
# The six that PASSED in the item-icons measurement: distinctive silhouettes
# with no canonical proportions, so the viewer has a wide error budget.
_SAFE: Final = (
Subject("key", Difficulty.SAFE, "wards make it unmistakable"),
Subject("scroll", Difficulty.SAFE, "no canonical proportions"),
Subject("book", Difficulty.SAFE, "clasp gives it a feature"),
Subject("bone", Difficulty.SAFE, "organic, no fixed ratio"),
Subject("gem", Difficulty.SAFE, "facets read at any size"),
Subject("bomb", Difficulty.SAFE, "fuse is a strong feature"),
)
# The six that FAILED. Kept verbatim so the bake-off measures the same task.
_HARD: Final = (
Subject("potion", Difficulty.HUMAN_PRIOR, "flask shoulders are specific"),
Subject("sword", Difficulty.HUMAN_PRIOR, "blade/hilt ratio is known"),
Subject("shield", Difficulty.PRIMITIVE, "a slab reads as a placeholder"),
Subject("coin", Difficulty.PRIMITIVE, "a circle with a rim is barebones"),
Subject("ring", Difficulty.PRIMITIVE, "a torus with no feature"),
Subject("meat", Difficulty.HUMAN_PRIOR, "a ham has a specific shape"),
)
SUBJECTS: Final = _SAFE + _HARD
# The measured baseline this bake-off must beat: 6 of 12 accepted by a human,
# unchanged after a second redraw round. A cell that cannot beat this is
# reported as such rather than dressed up.
BASELINE_PASS = 6
BASELINE_TOTAL = 12
# item-icons ran two rounds and stayed at 6/12, so a third is not
# evidence-backed. Each cell gets this many rounds, then its number is recorded.
MAX_ROUNDS = 2
def by_name(name: str) -> Subject:
"""Look up a subject.
Args:
name: The subject's lowercase identifier.
Returns:
The matching subject.
Raises:
KeyError: If the name is not part of the fixed set.
"""
for subject in SUBJECTS:
if subject.name == name:
return subject
msg = f"{name!r} is not one of the {len(SUBJECTS)} fixed subjects"
raise KeyError(msg)

View File

@ -0,0 +1,102 @@
"""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()

View File

@ -0,0 +1,146 @@
"""Route generator tests.
These assert the *contract* a route must satisfy to enter the bake-off, not
that any particular icon looks good -- that is the human gate's job and cannot
be asserted in code.
"""
from __future__ import annotations
import shutil
from typing import TYPE_CHECKING
import numpy as np
import pytest
from python_pkg.artgate.pixels import check_alpha_binary, check_color_count
from python_pkg.artgate.routes import vector
from python_pkg.artgate.routes.procedural import PALETTE, SIZE, _put, outline
from python_pkg.artgate.routes.shapes import DRAWINGS, draw
from python_pkg.artgate.subjects import (
BASELINE_PASS,
BASELINE_TOTAL,
MAX_ROUNDS,
SUBJECTS,
Difficulty,
by_name,
)
if TYPE_CHECKING:
from pathlib import Path
class TestSubjectSet:
"""The bake-off is pinned to one subject set, or comparisons are void."""
def test_twelve_subjects(self) -> None:
"""The set matches the measured item-icons baseline size."""
assert len(SUBJECTS) == BASELINE_TOTAL
def test_six_safe_six_hard(self) -> None:
"""The easy/hard split is preserved exactly."""
safe = [s for s in SUBJECTS if s.difficulty is Difficulty.SAFE]
assert len(safe) == BASELINE_PASS
def test_lookup(self) -> None:
"""Subjects are addressable by name."""
assert by_name("sword").difficulty is Difficulty.HUMAN_PRIOR
def test_unknown_subject_raises(self) -> None:
"""A name outside the fixed set is an error, not a new subject."""
with pytest.raises(KeyError, match="not one of"):
by_name("dragon")
def test_round_cap_is_evidence_backed(self) -> None:
"""Two rounds, because a third produced no measured improvement."""
assert MAX_ROUNDS == 2
def test_difficulty_str(self) -> None:
"""Difficulty formats bare for report tables."""
assert f"{Difficulty.SAFE}" == "SAFE"
class TestProceduralRoute:
"""Every subject renders, and renders inside the spec."""
def test_all_subjects_have_drawings(self) -> None:
"""No subject is silently missing from the route."""
assert {s.name for s in SUBJECTS} == set(DRAWINGS)
@pytest.mark.parametrize("subject", [s.name for s in SUBJECTS])
def test_output_satisfies_pixel_spec(self, subject: str) -> None:
"""Procedural output is gate-clean by construction."""
art = draw(subject)
assert art.shape == (SIZE, SIZE, 4)
assert check_alpha_binary(art) == []
assert check_color_count(art, 32) == []
def test_output_is_not_blank(self) -> None:
"""A route that draws nothing would pass gates vacuously."""
assert (draw("key")[:, :, 3] == 255).sum() > 20
def test_unknown_subject_raises(self) -> None:
"""Asking for an undrawn subject fails loudly."""
with pytest.raises(KeyError, match="no procedural drawing"):
draw("dragon")
def test_out_of_bounds_writes_are_clipped(self) -> None:
"""Drawing past the canvas edge is ignored, not an error.
Shapes are written with plain arithmetic, so a radius or offset that
runs off the canvas must clip silently rather than raise or wrap.
"""
canvas = np.zeros((SIZE, SIZE, 4), dtype=np.uint8)
for x, y in ((-1, 0), (0, -1), (SIZE, 0), (0, SIZE)):
_put(canvas, x, y, "gold")
assert not canvas.any()
def test_outline_surrounds_content(self) -> None:
"""Outlining adds a dark border adjacent to opaque pixels."""
canvas = np.zeros((SIZE, SIZE, 4), dtype=np.uint8)
canvas[10:12, 10:12, :3] = PALETTE["gold"]
canvas[10:12, 10:12, 3] = 255
outline(canvas)
assert tuple(canvas[9, 10, :3]) == PALETTE["outline"]
assert canvas[9, 10, 3] == 255
class TestVectorRoute:
"""SVG rasterisation is available and produces real output."""
def test_unknown_shape_raises(self, tmp_path: Path) -> None:
"""An undefined vector subject fails loudly."""
with pytest.raises(KeyError, match="no vector shape"):
vector.render("dragon", tmp_path / "x.png")
@pytest.mark.skipif(
shutil.which("rsvg-convert") is None,
reason="rsvg-convert not installed",
)
def test_render_writes_png(self, tmp_path: Path) -> None:
"""A rendered subject lands on disk at the requested size."""
out = vector.render("gem", tmp_path / "gem.png", size=64)
assert out.exists()
assert out.stat().st_size > 0
def test_missing_binary_raises(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A missing rasteriser is reported, never silently skipped."""
monkeypatch.setattr(vector.shutil, "which", lambda _: None)
with pytest.raises(vector.RenderError, match="not installed"):
vector.render("gem", tmp_path / "gem.png")
def test_rasteriser_failure_raises(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A non-zero exit from the rasteriser is surfaced."""
class _Result:
returncode = 1
stderr = b"boom"
monkeypatch.setattr(vector.shutil, "which", lambda _: "/bin/true")
monkeypatch.setattr(vector.subprocess, "run", lambda *a, **k: _Result())
with pytest.raises(vector.RenderError, match="failed"):
vector.render("gem", tmp_path / "gem.png")