artgate: diffusion + 3D routes, full vector coverage, and the margin gate
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 remaining bake-off cells. The phase's real result was not a route
score but a MISSING GATE.

All twelve SDXL icons measured 100% opaque coverage -- square tiles with no
sprite silhouette -- and passed every gate in the harness. Gate-pass was
measuring pipeline hygiene, not usability. MARGIN_TOO_SMALL inverts the table
from a meaningless 12/12/12 tie to:

    diffusion   0/12      (every output full-bleed)
    vector     10/12
    procedural 11/12

The two non-diffusion failures were true positives in MY shapes, not gate
error: the procedural sword pommel ran off the bottom edge and the vector meat
bone clipped the top. Both fixed; both routes back to 12/12.

Diffusion recovers to 12/12 with flood-fill background removal, but visual
inspection shows the fill eats into the art (bone reduced to a fragment, sword
nearly erased). Reported as inadequate rather than shipped -- proper matting is
needed before route (b) is a usable pipeline.

Blender exits 0 even when its embedded script raises: BLENDER_EEVEE_NEXT does
not exist in 5.2, and the failure produced a clean exit and no file. render()
now asserts the output exists, or the route would report phantom successes.

Model gating verified at execution time, never recalled: SDXL base and
nerijs/pixel-art-xl are ungated, FLUX.1-dev is gated=auto, and
runwayml/stable-diffusion-v1-5 now 307-redirects.

The finishing stage now generalises across three continuous-tone sources
(SVG raster, Blender render, SDXL): each is 0/N raw and N/N finished.

Verified: 114 tests, 100% branch coverage on 13 modules, zero suppressions,
all hooks pass. Note: a local pytest run masked a missing branch that the
pre-commit hook caught -- the hook's run is authoritative.

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:51:31 +02:00
parent 691a228499
commit e6e0ba5ab3
18 changed files with 760 additions and 37 deletions

View File

@ -0,0 +1,20 @@
{
"title": "artgate Phase 4: diffusion route, 3D cell, full vector coverage, and the margin gate",
"objective": "Close the remaining bake-off cells - route (b) local diffusion on the 3090, route (d) 3D via headless Blender, and the other seven vector subjects - so all four routes are measured on the same twelve subjects. The phase's real outcome was a missing gate: diffusion output passed everything while being unusable, which forced MARGIN_TOO_SMALL into existence and inverted the decision table.",
"acceptance_criteria": [
"diffusers runs on the GPU in the isolated uv 3.12 venv, with model gating status verified at execution time rather than recalled.",
"Route (b) generates all twelve subjects locally with no gated download on the critical path.",
"Route (d) covers all twelve vector subjects, making its number comparable to route (a)'s.",
"Route (d) 3D renders via headless Blender, with a phantom-success guard: Blender exits 0 even when its embedded script raises.",
"A margin gate rejects full-bleed assets, because an item icon must be a sprite with a silhouette, not a square tile.",
"Every gate failure observed on real output is verified as a true positive before any threshold or shape is changed.",
"100% branch coverage, zero lint suppressions, all pre-commit hooks pass."
],
"out_of_scope": [
"Deciding which route WINS. That needs the human contact-sheet verdicts, which are the user's call; this change only produces the machine column and the sheets.",
"A production matting solution for diffusion output. The flood-fill background removal demonstrated recoverability but visibly damages the art, and is reported as inadequate rather than shipped as the answer.",
"Modelling all twelve subjects in 3D. Five meshes answer whether render->finish clears the gates; twelve would be modelling work, not a route comparison.",
"Gated model families (FLUX.1-dev, SD3.x). Verified gated at execution time and deliberately kept off the critical path."
],
"verifier": "python -m pytest python_pkg/artgate/tests/ --cov=python_pkg.artgate --cov-branch --cov-fail-under=100 (114 tests, 100% branch on 13 modules); pre-commit run over all artgate files; plus end-to-end generation and gating of all four routes against styles/pixel_icon.toml, with each observed failure traced to a specific measurable defect."
}

View File

@ -0,0 +1,61 @@
{
"intent": "Run the diffusion route on the 3090, wire the Blender 3D cell, and complete vector coverage to twelve subjects - then report what the four routes actually measure against each other.",
"scope": [
"python_pkg/artgate/contrast.py - new check_margin gate",
"python_pkg/artgate/routes/{vector_shapes,blender,blender_scene}.py - 7 more vector subjects, 3D route",
"python_pkg/artgate/{cli,codes,spec}.py - margin gate wired, evaluate() refactored to a gate table",
"python_pkg/artgate/routes/{shapes,vector_shapes}.py - sword/meat shape fixes for edge clipping",
"python_pkg/artgate/tests/test_blender.py + margin tests - 22 new tests",
"NON-GOAL: no route declared the winner; that needs the human verdicts"
],
"changes": [
"THE FINDING OF THIS PHASE: all twelve diffusion icons measured 100% opaque coverage - square tiles with no sprite silhouette - and passed every gate. Gate-pass was measuring hygiene, not usability. Added MARGIN_TOO_SMALL, which inverted the table from a meaningless 12/12/12 tie to diffusion 0/12, vector 10/12, procedural 11/12.",
"The two non-diffusion failures were true positives in MY shapes, not gate error: the procedural sword pommel ran off the bottom edge and the vector meat bone clipped the top. Fixed the shapes; both routes returned to 12/12.",
"Blender exits 0 even when its embedded script raises - measured, not assumed: a wrong render-engine enum (BLENDER_EEVEE_NEXT does not exist in 5.2) produced a clean exit and no file. render() now asserts the output exists, or the route would report phantom successes.",
"Model gating checked at execution time, not from memory: SDXL base and nerijs/pixel-art-xl are ungated (HTTP 200, gated=False), FLUX.1-dev is gated=auto, and runwayml/stable-diffusion-v1-5 now 307-redirects - which is exactly why the plan forbids recalling gating status.",
"evaluate() became an ordered gate table rather than an if-chain, keeping the load-bearing alpha-before-colour ordering explicit.",
"routes/__init__.py imports vector_shapes so all_shapes() cannot silently return a partial registry depending on import order."
],
"verification": [
{
"command": "GPU + diffusers smoke test in the uv 3.12 venv",
"result": "pass",
"evidence": "torch 2.11.0+cu128, diffusers 0.39.0, transformers 5.15.0, cuda True, RTX 3090, 22.1 GiB free."
},
{
"command": "generate 12 subjects with SDXL + pixel-art LoRA, gate raw then finished",
"result": "pass",
"evidence": "RAW 1024px: 0/12 (CANVAS_SIZE, TOO_MANY_COLORS). After finishing: 12/12 on the pre-margin gate stack. After adding MARGIN_TOO_SMALL: 0/12, because every output was fully opaque."
},
{
"command": "measure opaque coverage across routes",
"result": "pass",
"evidence": "diffusion mean 100% (12/12 fully opaque, no transparency at all); vector mean 35% (min 18%, max 47%, 0/12 fully opaque). This is the quantified reason diffusion output is not usable as an item sprite."
},
{
"command": "diffusion + flood-fill background removal, re-gated",
"result": "pass",
"evidence": "12/12 machine-pass, so the route IS recoverable - but visual inspection shows the fill eats into the subjects (bone reduced to a fragment, sword nearly erased). Reported as inadequate; a real matting step is needed."
},
{
"command": "headless Blender render of 5 subjects, gate raw then finished",
"result": "pass",
"evidence": "RAW: 0/5 (CANVAS_SIZE, ALPHA_NOT_BINARY, TOO_MANY_COLORS). After the same finishing stage: 5/5. The finisher now generalises across three different continuous-tone sources."
},
{
"command": "python -m pytest python_pkg/artgate/tests/ --cov --cov-branch; pre-commit run",
"result": "pass",
"evidence": "114 tests, 100% branch coverage on all 13 modules, zero suppressions, ALL HOOKS PASS."
}
],
"risks": [
"The flood-fill background removal is a demonstration, not a solution - it damages the art. Do not treat diffusion's recovered 12/12 as a usable pipeline until proper matting exists.",
"The 3D cell covers 5 of 12 subjects and its materials render washed out; its contact-sheet row is visibly the weakest and should not be read as a fair 3D result.",
"A local pytest run masked a missing branch (spec.py:233) that the pre-commit hook caught. The hook's run is authoritative; a green local run is not sufficient evidence.",
"All four routes now sit at or near 12/12 on the machine gate again, which continues to mean 'not malformed'. The decision table still needs the human accept column before any route can be called the winner."
],
"rollback": [
"Revert this commit; the Phase 3 harness and routes at 691a228 stand alone. The margin gate is the only change that alters existing verdicts, and removing it restores the previous (misleadingly permissive) results.",
"After rollback, re-run the full suite and confirm 100% branch coverage, then re-gate the stored route outputs to confirm the expected pre-margin numbers return."
]
}

View File

@ -15,6 +15,7 @@ import argparse
import json import json
from pathlib import Path from pathlib import Path
import sys import sys
from typing import TYPE_CHECKING
import numpy as np import numpy as np
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
@ -25,7 +26,7 @@ from python_pkg.artgate.codes import (
EXIT_PASS, EXIT_PASS,
Code, Code,
) )
from python_pkg.artgate.contrast import check_silhouette from python_pkg.artgate.contrast import check_margin, check_silhouette
from python_pkg.artgate.pixels import ( from python_pkg.artgate.pixels import (
check_alpha_binary, check_alpha_binary,
check_color_count, check_color_count,
@ -34,6 +35,27 @@ from python_pkg.artgate.pixels import (
) )
from python_pkg.artgate.spec import SpecError, TargetSpec, load_spec from python_pkg.artgate.spec import SpecError, TargetSpec, load_spec
if TYPE_CHECKING:
from collections.abc import Callable
def _check_canvas(rgba: np.ndarray, spec: TargetSpec) -> list[Code]:
"""Verify the image matches the spec's exact canvas size.
Args:
rgba: An ``(h, w, 4)`` uint8 array.
spec: The target specification.
Returns:
``[CANVAS_SIZE]`` on a mismatch, else empty.
"""
if spec.canvas is None:
return []
height, width = rgba.shape[:2]
if (width, height) != spec.canvas:
return [Code.CANVAS_SIZE]
return []
def evaluate(rgba: np.ndarray, spec: TargetSpec) -> list[Code]: def evaluate(rgba: np.ndarray, spec: TargetSpec) -> list[Code]:
"""Run every gate the spec declares applicable. """Run every gate the spec declares applicable.
@ -49,27 +71,29 @@ def evaluate(rgba: np.ndarray, spec: TargetSpec) -> list[Code]:
Returns: Returns:
The failing reason codes, empty if the asset passes. The failing reason codes, empty if the asset passes.
""" """
# A table rather than an if-chain: adding a gate is one entry, and the
# ORDER is explicit and load-bearing -- alpha must precede the colour
# count, or a soft-alpha image yields zero opaque pixels and passes.
checks: list[tuple[Code, Callable[[], list[Code]]]] = [
(Code.CANVAS_SIZE, lambda: _check_canvas(rgba, spec)),
(Code.ALPHA_NOT_BINARY, lambda: check_alpha_binary(rgba)),
(
Code.TOO_MANY_COLORS,
lambda: check_color_count(rgba, spec.max_colors or 0),
),
(Code.OFF_PALETTE, lambda: check_palette(rgba, spec.palette)),
(
Code.SCALE_NOT_INVARIANT,
lambda: check_scale_invariance(rgba, spec.scale),
),
(Code.SILHOUETTE_LOW_CONTRAST, lambda: check_silhouette(rgba)),
(Code.MARGIN_TOO_SMALL, lambda: check_margin(rgba, spec.min_margin)),
]
failures: list[Code] = [] failures: list[Code] = []
for code, run in checks:
if spec.runs(Code.CANVAS_SIZE) and spec.canvas is not None: if spec.runs(code):
height, width = rgba.shape[:2] failures.extend(run())
if (width, height) != spec.canvas:
failures.append(Code.CANVAS_SIZE)
if spec.runs(Code.ALPHA_NOT_BINARY) and spec.alpha_binary:
failures.extend(check_alpha_binary(rgba))
if spec.runs(Code.TOO_MANY_COLORS) and spec.max_colors is not None:
failures.extend(check_color_count(rgba, spec.max_colors))
if spec.runs(Code.OFF_PALETTE) and spec.palette:
failures.extend(check_palette(rgba, spec.palette))
if spec.runs(Code.SCALE_NOT_INVARIANT):
failures.extend(check_scale_invariance(rgba, spec.scale))
if spec.runs(Code.SILHOUETTE_LOW_CONTRAST):
failures.extend(check_silhouette(rgba))
deduped: list[Code] = [] deduped: list[Code] = []
for code in failures: for code in failures:

View File

@ -69,6 +69,7 @@ DECLARABLE: Final = frozenset(
Code.CANVAS_SIZE, Code.CANVAS_SIZE,
Code.SCALE_NOT_INVARIANT, Code.SCALE_NOT_INVARIANT,
Code.SILHOUETTE_LOW_CONTRAST, Code.SILHOUETTE_LOW_CONTRAST,
Code.MARGIN_TOO_SMALL,
} }
) )

View File

@ -62,6 +62,37 @@ def silhouette_delta(rgba: NDArray[np.uint8], background: int) -> float:
return abs(subject - float(background)) return abs(subject - float(background))
def check_margin(rgba: NDArray[np.uint8], min_margin: int) -> list[Code]:
"""Verify a transparent border surrounds the subject.
An item icon must be a *sprite*, not a square tile: without a transparent
margin it cannot sit on an inventory slot or a world background. This gate
exists because it was missed -- twelve diffusion icons scored 100% opaque
coverage and passed every other gate, because nothing checked whether the
asset had a silhouette at all.
Args:
rgba: An ``(h, w, 4)`` uint8 array.
min_margin: Required clear border width in pixels.
Returns:
``[MARGIN_TOO_SMALL]`` if any edge band contains opaque pixels.
"""
if min_margin <= 0:
return []
alpha = rgba[:, :, 3]
band = min_margin
edges = (
alpha[:band, :],
alpha[-band:, :],
alpha[:, :band],
alpha[:, -band:],
)
if any(int(edge.max(initial=0)) > 0 for edge in edges):
return [Code.MARGIN_TOO_SMALL]
return []
def check_silhouette( def check_silhouette(
rgba: NDArray[np.uint8], rgba: NDArray[np.uint8],
min_delta: float = DEFAULT_MIN_DELTA, min_delta: float = DEFAULT_MIN_DELTA,

View File

@ -1 +1,14 @@
"""Generation routes for the art bake-off.""" """Generation routes for the art bake-off.
Importing this package registers every vector subject, so ``all_shapes()``
never depends on the caller having imported the right module first. That
import-order hazard is exactly the kind of silent partial result the gates
exist to prevent -- a half-registered route would score a real-looking but
wrong number in the decision table.
"""
from __future__ import annotations
from python_pkg.artgate.routes import vector_shapes as _vector_shapes
__all__ = ["_vector_shapes"]

View File

@ -0,0 +1,95 @@
"""Route (d) 3D: drive Blender headless and render one subject.
Blender runs as a subprocess with its own interpreter, so the scene is passed
as a generated script file rather than imported. Failures are surfaced as
exceptions -- a missing Blender or a non-zero exit is reported, never silently
skipped, because a quietly-absent route would show up in the decision table as
a legitimate zero.
"""
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import tempfile
from python_pkg.artgate.routes.blender_scene import (
RENDER_SIZE,
SUBJECTS_3D,
scene_script,
)
# Blender can take a while on first run (shader compilation); give it room.
RENDER_TIMEOUT_S = 300
class BlenderError(RuntimeError):
"""Raised when Blender is unavailable or the render fails."""
def render(name: str, out: Path, size: int = RENDER_SIZE) -> Path:
"""Render one 3D subject to a transparent PNG.
Args:
name: One of :data:`SUBJECTS_3D`.
out: Destination PNG path.
size: Square render edge length.
Returns:
The written path.
Raises:
KeyError: If the subject has no 3D build.
BlenderError: If Blender is missing, times out, or exits non-zero.
"""
if name not in SUBJECTS_3D:
msg = f"no 3D scene for {name!r}"
raise KeyError(msg)
binary = shutil.which("blender")
if binary is None:
msg = "blender is not installed"
raise BlenderError(msg)
out.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
"w", suffix=".py", delete=False, encoding="utf-8"
) as handle:
handle.write(scene_script(size))
script = Path(handle.name)
try:
result = subprocess.run(
[
binary,
"--background",
"--factory-startup",
"--python",
str(script),
"--",
name,
str(out.with_suffix("")),
],
capture_output=True,
check=False,
timeout=RENDER_TIMEOUT_S,
)
except subprocess.TimeoutExpired as exc:
msg = f"blender timed out rendering {name}"
raise BlenderError(msg) from exc
finally:
script.unlink(missing_ok=True)
if result.returncode != 0:
detail = result.stderr.decode(errors="replace")[-400:]
msg = f"blender failed for {name}: {detail}"
raise BlenderError(msg)
# Blender exits 0 even when the embedded script raises -- measured against
# 5.2, where a bad render-engine enum produced a clean exit and no file.
# Trusting the return code alone would report a phantom success.
if not out.exists():
detail = result.stderr.decode(errors="replace")[-400:]
msg = f"blender wrote no file for {name} despite exit 0: {detail}"
raise BlenderError(msg)
return out

View File

@ -0,0 +1,146 @@
"""Route (d) 3D: a Blender scene script, run headless.
This module is *emitted* and executed by ``blender --background --python``, so
it must not import anything from this package -- the Blender interpreter has
no path to it. It is kept here as a data file rather than a library so the
repo's linters still see it as Python.
The 3D cell exists to answer one question: does a rendered mesh, put through
the same finishing stage as the vector route, clear the pixel gates? The
render is continuous-tone by nature, so the expectation is the same as every
other continuous route -- it needs the finisher.
"""
from __future__ import annotations
from typing import Final
# Rendered square edge length before downscaling. Kept small: the finisher
# reduces to 32px anyway, and Cycles time scales with pixel count.
RENDER_SIZE: Final = 256
# Executed inside Blender's own Python. Written as a template rather than
# imported, because Blender cannot see this package.
SCENE_TEMPLATE: Final = """
import sys
import bpy
import mathutils
SUBJECT = sys.argv[-2]
OUT = sys.argv[-1]
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = {size}
scene.render.resolution_y = {size}
scene.render.film_transparent = True
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.render.filepath = OUT
def material(name, rgba):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = rgba
bsdf.inputs["Roughness"].default_value = 0.45
return mat
GOLD = (0.84, 0.64, 0.24, 1.0)
CYAN = (0.36, 0.69, 0.75, 1.0)
RED = (0.69, 0.24, 0.24, 1.0)
BONE = (0.78, 0.73, 0.67, 1.0)
def add(obj, rgba):
obj.data.materials.append(material(obj.name, rgba))
return obj
def build_coin():
bpy.ops.mesh.primitive_cylinder_add(radius=1.1, depth=0.16, vertices=48)
add(bpy.context.object, GOLD)
bpy.ops.mesh.primitive_torus_add(major_radius=1.0, minor_radius=0.08)
add(bpy.context.object, GOLD)
def build_gem():
bpy.ops.mesh.primitive_cone_add(radius1=1.0, depth=1.2, vertices=8)
add(bpy.context.object, CYAN)
bpy.ops.mesh.primitive_cone_add(radius1=1.0, depth=0.7, vertices=8)
bpy.context.object.rotation_euler = (3.14159, 0, 0)
bpy.context.object.location = (0, 0, 0.95)
add(bpy.context.object, CYAN)
def build_ring():
bpy.ops.mesh.primitive_torus_add(major_radius=0.85, minor_radius=0.16)
add(bpy.context.object, GOLD)
bpy.ops.mesh.primitive_ico_sphere_add(radius=0.34, subdivisions=1)
bpy.context.object.location = (0, 0, 0.95)
add(bpy.context.object, CYAN)
def build_bone():
bpy.ops.mesh.primitive_cylinder_add(radius=0.22, depth=1.8, vertices=16)
add(bpy.context.object, BONE)
for z in (0.9, -0.9):
for x in (-0.22, 0.22):
bpy.ops.mesh.primitive_uv_sphere_add(radius=0.34, segments=16)
bpy.context.object.location = (x, 0, z)
add(bpy.context.object, BONE)
def build_bomb():
bpy.ops.mesh.primitive_uv_sphere_add(radius=1.0, segments=32)
add(bpy.context.object, (0.16, 0.15, 0.17, 1.0))
bpy.ops.mesh.primitive_cylinder_add(radius=0.16, depth=0.6, vertices=12)
bpy.context.object.location = (0.25, 0, 1.15)
add(bpy.context.object, RED)
BUILDERS = {{
"coin": build_coin,
"gem": build_gem,
"ring": build_ring,
"bone": build_bone,
"bomb": build_bomb,
}}
BUILDERS[SUBJECT]()
bpy.ops.object.camera_add(location=(0, -4.2, 2.6))
camera = bpy.context.object
camera.rotation_euler = mathutils.Euler((1.02, 0, 0))
camera.data.type = "ORTHO"
camera.data.ortho_scale = 3.0
scene.camera = camera
bpy.ops.object.light_add(type="SUN", location=(2.5, -3.0, 5.0))
bpy.context.object.data.energy = 4.0
bpy.ops.object.light_add(type="AREA", location=(-3.0, -2.0, 2.0))
bpy.context.object.data.energy = 90.0
bpy.ops.render.render(write_still=True)
"""
def scene_script(size: int = RENDER_SIZE) -> str:
"""Return the Blender scene script for a given render size.
Args:
size: Square render edge length in pixels.
Returns:
Python source to hand to ``blender --python``.
"""
return SCENE_TEMPLATE.format(size=size)
# Subjects with a 3D build. Deliberately a subset: a mesh for "scroll" or
# "meat" would be modelling work, not a route comparison, and the cell's
# purpose is to test whether render->finish clears the gates at all.
SUBJECTS_3D: Final = ("coin", "gem", "ring", "bone", "bomb")

View File

@ -95,8 +95,8 @@ def _sword(c: NDArray[np.uint8]) -> None:
_rect(c, 14, 3, 17, 21, "light") _rect(c, 14, 3, 17, 21, "light")
_rect(c, 15, 5, 16, 19, "mid") _rect(c, 15, 5, 16, 19, "mid")
_rect(c, 9, 22, 22, 23, "gold") _rect(c, 9, 22, 22, 23, "gold")
_rect(c, 14, 24, 17, 27, "shadow") _rect(c, 14, 23, 17, 26, "shadow")
_disc(c, 15.5, 28.0, 2.4, "gold") _disc(c, 15.5, 27.0, 2.4, "gold")
def _shield(c: NDArray[np.uint8]) -> None: def _shield(c: NDArray[np.uint8]) -> None:

View File

@ -31,7 +31,7 @@ PALETTE: Final[dict[str, str]] = {
"cyan": "#5cb0be", "cyan": "#5cb0be",
} }
_STROKE: Final = 'stroke="#1a141c" stroke-width="1.5" stroke-linejoin="round"' STROKE: Final = 'stroke="#1a141c" stroke-width="1.5" stroke-linejoin="round"'
def _svg(body: str, size: int = 64) -> str: def _svg(body: str, size: int = 64) -> str:
@ -55,27 +55,27 @@ SHAPES: Final[dict[str, str]] = {
f'<circle cx="20" cy="22" r="11" fill="none" stroke="{PALETTE["gold"]}"' f'<circle cx="20" cy="22" r="11" fill="none" stroke="{PALETTE["gold"]}"'
f' stroke-width="7"/>' f' stroke-width="7"/>'
f'<rect x="27" y="30" width="7" height="27" fill="{PALETTE["gold"]}" ' f'<rect x="27" y="30" width="7" height="27" fill="{PALETTE["gold"]}" '
f"{_STROKE}/>" f"{STROKE}/>"
f'<rect x="34" y="42" width="10" height="5" fill="{PALETTE["gold"]}" ' f'<rect x="34" y="42" width="10" height="5" fill="{PALETTE["gold"]}" '
f"{_STROKE}/>" f"{STROKE}/>"
f'<rect x="34" y="50" width="7" height="5" fill="{PALETTE["gold"]}" ' f'<rect x="34" y="50" width="7" height="5" fill="{PALETTE["gold"]}" '
f"{_STROKE}/>" f"{STROKE}/>"
), ),
"gem": ( "gem": (
f'<polygon points="32,6 54,26 32,58 10,26" fill="{PALETTE["cyan"]}" ' f'<polygon points="32,6 54,26 32,58 10,26" fill="{PALETTE["cyan"]}" '
f"{_STROKE}/>" f"{STROKE}/>"
f'<polygon points="32,6 42,26 32,34 22,26" fill="{PALETTE["light"]}" ' f'<polygon points="32,6 42,26 32,34 22,26" fill="{PALETTE["light"]}" '
f'opacity="0.65"/>' f'opacity="0.65"/>'
), ),
"potion": ( "potion": (
f'<circle cx="32" cy="42" r="17" fill="{PALETTE["cyan"]}" {_STROKE}/>' f'<circle cx="32" cy="42" r="17" fill="{PALETTE["cyan"]}" {STROKE}/>'
f'<rect x="26" y="12" width="12" height="16" ' f'<rect x="26" y="12" width="12" height="16" '
f'fill="{PALETTE["light"]}" {_STROKE}/>' f'fill="{PALETTE["light"]}" {STROKE}/>'
f'<rect x="23" y="6" width="18" height="8" fill="{PALETTE["gold"]}" ' f'<rect x="23" y="6" width="18" height="8" fill="{PALETTE["gold"]}" '
f"{_STROKE}/>" f"{STROKE}/>"
), ),
"coin": ( "coin": (
f'<circle cx="32" cy="32" r="24" fill="{PALETTE["gold"]}" {_STROKE}/>' f'<circle cx="32" cy="32" r="24" fill="{PALETTE["gold"]}" {STROKE}/>'
f'<circle cx="32" cy="32" r="18" fill="none" ' f'<circle cx="32" cy="32" r="18" fill="none" '
f'stroke="{PALETTE["shadow"]}" stroke-width="2"/>' 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'<polygon points="32,18 37,29 49,29 39,37 43,49 32,41 21,49 25,37 '
@ -83,17 +83,42 @@ SHAPES: Final[dict[str, str]] = {
), ),
"shield": ( "shield": (
f'<path d="M10 10 H54 V32 Q54 52 32 60 Q10 52 10 32 Z" ' f'<path d="M10 10 H54 V32 Q54 52 32 60 Q10 52 10 32 Z" '
f'fill="{PALETTE["mid"]}" {_STROKE}/>' f'fill="{PALETTE["mid"]}" {STROKE}/>'
f'<path d="M10 10 H54 V20 H10 Z" fill="{PALETTE["gold"]}"/>' f'<path d="M10 10 H54 V20 H10 Z" fill="{PALETTE["gold"]}"/>'
f'<circle cx="32" cy="34" r="7" fill="{PALETTE["gold"]}" {_STROKE}/>' f'<circle cx="32" cy="34" r="7" fill="{PALETTE["gold"]}" {STROKE}/>'
), ),
} }
_REGISTRY: dict[str, str] = dict(SHAPES)
class RenderError(RuntimeError): class RenderError(RuntimeError):
"""Raised when the SVG rasteriser is unavailable or fails.""" """Raised when the SVG rasteriser is unavailable or fails."""
def all_shapes() -> dict[str, str]:
"""Return every vector subject registered so far.
``vector_shapes`` calls :func:`register` at import time, so the extra seven
subjects appear here without this module importing them -- which keeps the
dependency one-directional and avoids a circular import.
Returns:
A mapping of subject name to SVG markup.
"""
return dict(_REGISTRY)
def register(shapes: dict[str, str]) -> None:
"""Add subjects to the vector registry.
Args:
shapes: A mapping of subject name to SVG markup.
"""
_REGISTRY.update(shapes)
def render(name: str, out: Path, size: int = 64) -> Path: def render(name: str, out: Path, size: int = 64) -> Path:
"""Rasterise one vector subject to PNG. """Rasterise one vector subject to PNG.
@ -109,7 +134,8 @@ def render(name: str, out: Path, size: int = 64) -> Path:
KeyError: If the subject has no vector shape. KeyError: If the subject has no vector shape.
RenderError: If ``rsvg-convert`` is missing or exits non-zero. RenderError: If ``rsvg-convert`` is missing or exits non-zero.
""" """
if name not in SHAPES: shapes = all_shapes()
if name not in shapes:
msg = f"no vector shape for {name!r}" msg = f"no vector shape for {name!r}"
raise KeyError(msg) raise KeyError(msg)
binary = shutil.which("rsvg-convert") binary = shutil.which("rsvg-convert")
@ -118,7 +144,7 @@ def render(name: str, out: Path, size: int = 64) -> Path:
raise RenderError(msg) raise RenderError(msg)
source = out.with_suffix(".svg") source = out.with_suffix(".svg")
source.write_text(_svg(SHAPES[name], size), encoding="utf-8") source.write_text(_svg(shapes[name], size), encoding="utf-8")
result = subprocess.run( result = subprocess.run(
[binary, "-w", str(size), "-h", str(size), str(source), "-o", str(out)], [binary, "-w", str(size), "-h", str(size), str(source), "-o", str(out)],
capture_output=True, capture_output=True,

View File

@ -0,0 +1,95 @@
"""Route (d): the remaining seven vector subjects.
Split from ``vector.py`` to stay under the 250-line file cap. Together the two
modules cover all twelve bake-off subjects, which is what makes this cell's
number comparable to route (a)'s -- a cell measured on five easy subjects
would not be.
Each shape carries a distinguishing feature for the same reason the procedural
route does: geometric correctness alone reads as a placeholder.
"""
from __future__ import annotations
from typing import Final
from python_pkg.artgate.routes.vector import PALETTE, STROKE, register
EXTRA_SHAPES: Final[dict[str, str]] = {
"scroll": (
f'<rect x="12" y="14" width="40" height="36" '
f'fill="{PALETTE["light"]}" {STROKE}/>'
f'<rect x="8" y="10" width="48" height="8" rx="4" '
f'fill="{PALETTE["mid"]}" {STROKE}/>'
f'<rect x="8" y="46" width="48" height="8" rx="4" '
f'fill="{PALETTE["mid"]}" {STROKE}/>'
f'<g stroke="{PALETTE["shadow"]}" stroke-width="2">'
f'<line x1="19" y1="26" x2="45" y2="26"/>'
f'<line x1="19" y1="32" x2="45" y2="32"/>'
f'<line x1="19" y1="38" x2="38" y2="38"/></g>'
),
"book": (
f'<rect x="12" y="8" width="40" height="48" rx="2" '
f'fill="{PALETTE["red"]}" {STROKE}/>'
f'<rect x="12" y="8" width="9" height="48" '
f'fill="{PALETTE["shadow"]}"/>'
f'<rect x="26" y="16" width="20" height="3" '
f'fill="{PALETTE["light"]}"/>'
f'<rect x="46" y="26" width="8" height="12" rx="2" '
f'fill="{PALETTE["gold"]}" {STROKE}/>'
),
"bone": (
f'<rect x="24" y="16" width="16" height="32" '
f'fill="{PALETTE["light"]}" {STROKE}/>'
f'<circle cx="22" cy="16" r="9" fill="{PALETTE["light"]}" {STROKE}/>'
f'<circle cx="42" cy="16" r="9" fill="{PALETTE["light"]}" {STROKE}/>'
f'<circle cx="22" cy="48" r="9" fill="{PALETTE["light"]}" {STROKE}/>'
f'<circle cx="42" cy="48" r="9" fill="{PALETTE["light"]}" {STROKE}/>'
),
"bomb": (
f'<circle cx="30" cy="40" r="19" fill="{PALETTE["shadow"]}" '
f"{STROKE}/>"
f'<circle cx="23" cy="33" r="5" fill="{PALETTE["mid"]}"/>'
f'<rect x="34" y="14" width="7" height="10" '
f'fill="{PALETTE["mid"]}" {STROKE}/>'
f'<path d="M38 14 Q46 8 50 12" fill="none" '
f'stroke="{PALETTE["mid"]}" stroke-width="3"/>'
f'<circle cx="52" cy="10" r="4" fill="{PALETTE["gold"]}"/>'
f'<circle cx="53" cy="8" r="2" fill="{PALETTE["red"]}"/>'
),
"sword": (
f'<polygon points="32,4 38,18 38,44 26,44 26,18" '
f'fill="{PALETTE["light"]}" {STROKE}/>'
f'<line x1="32" y1="12" x2="32" y2="42" '
f'stroke="{PALETTE["mid"]}" stroke-width="3"/>'
f'<rect x="16" y="44" width="32" height="6" rx="2" '
f'fill="{PALETTE["gold"]}" {STROKE}/>'
f'<rect x="28" y="48" width="8" height="8" '
f'fill="{PALETTE["shadow"]}" {STROKE}/>'
f'<circle cx="32" cy="56" r="4" fill="{PALETTE["gold"]}" {STROKE}/>'
),
"ring": (
f'<circle cx="32" cy="40" r="17" fill="none" '
f'stroke="{PALETTE["gold"]}" stroke-width="7"/>'
f'<circle cx="32" cy="40" r="17" fill="none" '
f'stroke="{PALETTE["outline"]}" stroke-width="1"/>'
f'<rect x="25" y="10" width="14" height="8" '
f'fill="{PALETTE["gold"]}" {STROKE}/>'
f'<polygon points="32,2 41,11 32,20 23,11" '
f'fill="{PALETTE["cyan"]}" {STROKE}/>'
),
"meat": (
f'<path d="M18 34 Q18 16 34 16 Q52 16 52 36 Q52 56 34 56 '
f'Q18 56 18 34 Z" fill="{PALETTE["red"]}" {STROKE}/>'
f'<path d="M24 32 Q24 22 33 22" fill="none" '
f'stroke="{PALETTE["gold"]}" stroke-width="4"/>'
f'<rect x="28" y="8" width="8" height="12" '
f'fill="{PALETTE["light"]}" {STROKE}/>'
f'<circle cx="28" cy="9" r="5" fill="{PALETTE["light"]}" {STROKE}/>'
f'<circle cx="37" cy="9" r="5" fill="{PALETTE["light"]}" {STROKE}/>'
),
}
# Registering at import time keeps the dependency one-directional: vector.py
# never imports this module, so there is no cycle and no lazy import.
register(EXTRA_SHAPES)

View File

@ -229,6 +229,8 @@ def _require_config(spec: TargetSpec) -> None:
missing.append("OFF_PALETTE needs a non-empty 'palette'") missing.append("OFF_PALETTE needs a non-empty 'palette'")
if spec.runs(Code.ALPHA_NOT_BINARY) and not spec.alpha_binary: if spec.runs(Code.ALPHA_NOT_BINARY) and not spec.alpha_binary:
missing.append("ALPHA_NOT_BINARY needs 'alpha_binary = true'") missing.append("ALPHA_NOT_BINARY needs 'alpha_binary = true'")
if spec.runs(Code.MARGIN_TOO_SMALL) and spec.min_margin <= 0:
missing.append("MARGIN_TOO_SMALL needs a positive 'min_margin'")
if missing: if missing:
msg = "; ".join(missing) msg = "; ".join(missing)
raise SpecError(msg) raise SpecError(msg)

View File

@ -30,4 +30,5 @@ gates = [
"CANVAS_SIZE", "CANVAS_SIZE",
"SCALE_NOT_INVARIANT", "SCALE_NOT_INVARIANT",
"SILHOUETTE_LOW_CONTRAST", "SILHOUETTE_LOW_CONTRAST",
"MARGIN_TOO_SMALL",
] ]

View File

@ -0,0 +1,139 @@
"""Blender route tests.
The important one is ``test_exit_zero_without_output_raises``: Blender exits 0
even when the embedded script raises, so a return-code check alone reports a
phantom success. That was measured, not hypothesised -- a wrong render-engine
enum on Blender 5.2 produced exit 0 and no file.
"""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING
import pytest
from python_pkg.artgate.routes import blender
from python_pkg.artgate.routes.blender_scene import (
RENDER_SIZE,
SUBJECTS_3D,
scene_script,
)
if TYPE_CHECKING:
from pathlib import Path
class _Result:
"""Stand-in for a completed subprocess."""
def __init__(self, returncode: int = 0, stderr: bytes = b"") -> None:
"""Store the fields the caller inspects.
Args:
returncode: Simulated exit status.
stderr: Simulated error output.
"""
self.returncode = returncode
self.stderr = stderr
class TestSceneScript:
"""The generated scene script is well-formed and parameterised."""
def test_render_size_is_substituted(self) -> None:
"""The template takes the requested resolution."""
assert "resolution_x = 128" in scene_script(128)
def test_default_size(self) -> None:
"""The default matches the documented constant."""
assert f"resolution_x = {RENDER_SIZE}" in scene_script()
def test_every_subject_has_a_builder(self) -> None:
"""No 3D subject is silently missing a mesh."""
script = scene_script()
for name in SUBJECTS_3D:
assert f'"{name}": build_' in script
def test_script_is_valid_python(self) -> None:
"""The emitted template compiles, so a typo fails here not in Blender."""
compile(scene_script(), "<scene>", "exec")
def test_transparent_film(self) -> None:
"""Icons need an alpha channel, not a background."""
assert "film_transparent = True" in scene_script()
class TestRenderErrors:
"""Every failure mode is surfaced, never silently skipped."""
def test_unknown_subject_raises(self, tmp_path: Path) -> None:
"""A subject with no mesh fails loudly."""
with pytest.raises(KeyError, match="no 3D scene"):
blender.render("dragon", tmp_path / "x.png")
def test_missing_binary_raises(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A missing Blender is reported rather than treated as zero output."""
monkeypatch.setattr(blender.shutil, "which", lambda _: None)
with pytest.raises(blender.BlenderError, match="not installed"):
blender.render("coin", tmp_path / "x.png")
def test_nonzero_exit_raises(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A hard failure is surfaced with Blender's own message."""
monkeypatch.setattr(blender.shutil, "which", lambda _: "/bin/true")
monkeypatch.setattr(
blender.subprocess,
"run",
lambda *a, **k: _Result(returncode=1, stderr=b"bad mesh"),
)
with pytest.raises(blender.BlenderError, match="bad mesh"):
blender.render("coin", tmp_path / "x.png")
def test_exit_zero_without_output_raises(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Exit 0 with no file is a failure, not a success.
Regression for the measured Blender 5.2 behaviour: an exception inside
the embedded script still exits 0. Without this check the route would
report a phantom success and the cell would score a false zero.
"""
monkeypatch.setattr(blender.shutil, "which", lambda _: "/bin/true")
monkeypatch.setattr(
blender.subprocess,
"run",
lambda *a, **k: _Result(returncode=0, stderr=b"enum not found"),
)
with pytest.raises(blender.BlenderError, match="wrote no file"):
blender.render("coin", tmp_path / "missing.png")
def test_timeout_raises(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A hung render is bounded, not waited on forever."""
def _boom(*_args: object, **_kwargs: object) -> _Result:
raise subprocess.TimeoutExpired(cmd="blender", timeout=1)
monkeypatch.setattr(blender.shutil, "which", lambda _: "/bin/true")
monkeypatch.setattr(blender.subprocess, "run", _boom)
with pytest.raises(blender.BlenderError, match="timed out"):
blender.render("coin", tmp_path / "x.png")
def test_success_returns_path(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A real output file is accepted and returned."""
out = tmp_path / "coin.png"
def _write(*_args: object, **_kwargs: object) -> _Result:
out.write_bytes(b"png")
return _Result()
monkeypatch.setattr(blender.shutil, "which", lambda _: "/bin/true")
monkeypatch.setattr(blender.subprocess, "run", _write)
assert blender.render("coin", out) == out

View File

@ -152,6 +152,20 @@ class TestEvaluate:
) )
assert evaluate(arr, spec) == [Code.NO_OPAQUE_PIXELS] assert evaluate(arr, spec) == [Code.NO_OPAQUE_PIXELS]
def test_margin_gate_runs_when_declared(self) -> None:
"""A full-bleed asset fails once the margin gate is declared.
Regression: twelve diffusion icons were 100% opaque -- square tiles,
not sprites -- and passed every other gate, because no gate asked
whether the asset had a silhouette at all.
"""
arr = np.zeros((8, 8, 4), dtype=np.uint8)
arr[:, :, 3] = 255
spec = TargetSpec(
name="s", min_margin=1, gates=frozenset({Code.MARGIN_TOO_SMALL})
)
assert evaluate(arr, spec) == [Code.MARGIN_TOO_SMALL]
def test_silhouette_gate_runs_when_declared(self) -> None: def test_silhouette_gate_runs_when_declared(self) -> None:
"""A near-white asset fails the silhouette gate. """A near-white asset fails the silhouette gate.

View File

@ -9,10 +9,12 @@ reject good art.
from __future__ import annotations from __future__ import annotations
import numpy as np import numpy as np
import pytest
from python_pkg.artgate.codes import Code from python_pkg.artgate.codes import Code
from python_pkg.artgate.contrast import ( from python_pkg.artgate.contrast import (
DEFAULT_MIN_DELTA, DEFAULT_MIN_DELTA,
check_margin,
check_silhouette, check_silhouette,
silhouette_delta, silhouette_delta,
) )
@ -66,6 +68,50 @@ class TestCalibrationControls:
assert check_silhouette(arr) == [Code.SILHOUETTE_LOW_CONTRAST] assert check_silhouette(arr) == [Code.SILHOUETTE_LOW_CONTRAST]
class TestMargin:
"""A sprite needs a clear border; a square tile is not an item icon.
Regression: twelve SDXL icons measured 100% opaque coverage and passed
every other gate, because nothing checked whether the asset had a
silhouette at all. Gate-pass measured hygiene, not usability.
"""
def test_full_bleed_is_rejected(self) -> None:
"""A fully-opaque canvas has no silhouette and must fail."""
assert check_margin(_solid(128), 1) == [Code.MARGIN_TOO_SMALL]
def test_clear_border_passes(self) -> None:
"""Content inset from every edge passes."""
arr = _solid(128, alpha=0)
arr[2:6, 2:6, 3] = 255
assert check_margin(arr, 1) == []
@pytest.mark.parametrize("edge", ["top", "bottom", "left", "right"])
def test_any_touched_edge_is_rejected(self, edge: str) -> None:
"""Touching a single edge is enough to fail."""
arr = _solid(128, alpha=0)
arr[2:6, 2:6, 3] = 255
index = {
"top": (0, 3),
"bottom": (-1, 3),
"left": (3, 0),
"right": (3, -1),
}[edge]
arr[index[0], index[1], 3] = 255
assert check_margin(arr, 1) == [Code.MARGIN_TOO_SMALL]
def test_zero_margin_disables_the_gate(self) -> None:
"""A style that permits full bleed opts out explicitly."""
assert check_margin(_solid(128), 0) == []
def test_wider_margin_is_stricter(self) -> None:
"""A 2px requirement rejects art that only clears 1px."""
arr = _solid(128, alpha=0)
arr[1:7, 1:7, 3] = 255
assert check_margin(arr, 1) == []
assert check_margin(arr, 2) == [Code.MARGIN_TOO_SMALL]
class TestEdgeCases: class TestEdgeCases:
"""Degenerate inputs fail closed rather than dividing by zero.""" """Degenerate inputs fail closed rather than dividing by zero."""

View File

@ -108,6 +108,14 @@ class TestProceduralRoute:
class TestVectorRoute: class TestVectorRoute:
"""SVG rasterisation is available and produces real output.""" """SVG rasterisation is available and produces real output."""
def test_all_twelve_subjects_have_shapes(self) -> None:
"""The vector cell covers the same 12 subjects as every other route.
A cell measured on five easy subjects is not comparable to one
measured on all twelve.
"""
assert {s.name for s in SUBJECTS} <= set(vector.all_shapes())
def test_unknown_shape_raises(self, tmp_path: Path) -> None: def test_unknown_shape_raises(self, tmp_path: Path) -> None:
"""An undefined vector subject fails loudly.""" """An undefined vector subject fails loudly."""
with pytest.raises(KeyError, match="no vector shape"): with pytest.raises(KeyError, match="no vector shape"):

View File

@ -160,6 +160,7 @@ class TestFailClosed:
('gates = ["TOO_MANY_COLORS"]', "TOO_MANY_COLORS needs"), ('gates = ["TOO_MANY_COLORS"]', "TOO_MANY_COLORS needs"),
('gates = ["OFF_PALETTE"]', "OFF_PALETTE needs"), ('gates = ["OFF_PALETTE"]', "OFF_PALETTE needs"),
('gates = ["ALPHA_NOT_BINARY"]', "ALPHA_NOT_BINARY needs"), ('gates = ["ALPHA_NOT_BINARY"]', "ALPHA_NOT_BINARY needs"),
('gates = ["MARGIN_TOO_SMALL"]', "MARGIN_TOO_SMALL needs"),
], ],
) )
def test_gate_without_config_is_rejected( def test_gate_without_config_is_rejected(