diff --git a/docs/superpowers/contracts/artgate-phase3-routes-2026-08-15.json b/docs/superpowers/contracts/artgate-phase3-routes-2026-08-15.json new file mode 100644 index 00000000..bdaa0607 --- /dev/null +++ b/docs/superpowers/contracts/artgate-phase3-routes-2026-08-15.json @@ -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)." +} diff --git a/docs/superpowers/evidence/artgate-phase3-routes-2026-08-15.json b/docs/superpowers/evidence/artgate-phase3-routes-2026-08-15.json new file mode 100644 index 00000000..a4621a96 --- /dev/null +++ b/docs/superpowers/evidence/artgate-phase3-routes-2026-08-15.json @@ -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 - < 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) diff --git a/python_pkg/artgate/routes/__init__.py b/python_pkg/artgate/routes/__init__.py new file mode 100644 index 00000000..8b117d8f --- /dev/null +++ b/python_pkg/artgate/routes/__init__.py @@ -0,0 +1 @@ +"""Generation routes for the art bake-off.""" diff --git a/python_pkg/artgate/routes/procedural.py b/python_pkg/artgate/routes/procedural.py new file mode 100644 index 00000000..562f7ee3 --- /dev/null +++ b/python_pkg/artgate/routes/procedural.py @@ -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 diff --git a/python_pkg/artgate/routes/shapes.py b/python_pkg/artgate/routes/shapes.py new file mode 100644 index 00000000..973cf677 --- /dev/null +++ b/python_pkg/artgate/routes/shapes.py @@ -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 diff --git a/python_pkg/artgate/routes/vector.py b/python_pkg/artgate/routes/vector.py new file mode 100644 index 00000000..4a81721d --- /dev/null +++ b/python_pkg/artgate/routes/vector.py @@ -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'{body}' + ) + + +SHAPES: Final[dict[str, str]] = { + "key": ( + f'' + f'" + f'" + f'" + ), + "gem": ( + f'" + f'' + ), + "potion": ( + f'' + f'' + f'" + ), + "coin": ( + f'' + f'' + f'' + ), + "shield": ( + f'' + f'' + f'' + ), +} + + +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 diff --git a/python_pkg/artgate/subjects.py b/python_pkg/artgate/subjects.py new file mode 100644 index 00000000..d64ca842 --- /dev/null +++ b/python_pkg/artgate/subjects.py @@ -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) diff --git a/python_pkg/artgate/tests/test_finish.py b/python_pkg/artgate/tests/test_finish.py new file mode 100644 index 00000000..db1f9d42 --- /dev/null +++ b/python_pkg/artgate/tests/test_finish.py @@ -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() diff --git a/python_pkg/artgate/tests/test_routes.py b/python_pkg/artgate/tests/test_routes.py new file mode 100644 index 00000000..d381e51f --- /dev/null +++ b/python_pkg/artgate/tests/test_routes.py @@ -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")