From e6e0ba5ab3e6dca9a1e578cde9a6766c9c13018c Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 22:51:31 +0200 Subject: [PATCH] artgate: diffusion + 3D routes, full vector coverage, and the margin gate 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) Claude-Session: https://claude.ai/code/session_01T9rQ12yZ7sJxiPQTCS5dUF --- .../artgate-phase4-diffusion-2026-08-15.json | 20 +++ .../artgate-phase4-diffusion-2026-08-15.json | 61 ++++++++ python_pkg/artgate/cli.py | 66 +++++--- python_pkg/artgate/codes.py | 1 + python_pkg/artgate/contrast.py | 31 ++++ python_pkg/artgate/routes/__init__.py | 15 +- python_pkg/artgate/routes/blender.py | 95 ++++++++++++ python_pkg/artgate/routes/blender_scene.py | 146 ++++++++++++++++++ python_pkg/artgate/routes/shapes.py | 4 +- python_pkg/artgate/routes/vector.py | 52 +++++-- python_pkg/artgate/routes/vector_shapes.py | 95 ++++++++++++ python_pkg/artgate/spec.py | 2 + python_pkg/artgate/styles/pixel_icon.toml | 1 + python_pkg/artgate/tests/test_blender.py | 139 +++++++++++++++++ python_pkg/artgate/tests/test_cli.py | 14 ++ python_pkg/artgate/tests/test_contrast.py | 46 ++++++ python_pkg/artgate/tests/test_routes.py | 8 + python_pkg/artgate/tests/test_spec.py | 1 + 18 files changed, 760 insertions(+), 37 deletions(-) create mode 100644 docs/superpowers/contracts/artgate-phase4-diffusion-2026-08-15.json create mode 100644 docs/superpowers/evidence/artgate-phase4-diffusion-2026-08-15.json create mode 100644 python_pkg/artgate/routes/blender.py create mode 100644 python_pkg/artgate/routes/blender_scene.py create mode 100644 python_pkg/artgate/routes/vector_shapes.py create mode 100644 python_pkg/artgate/tests/test_blender.py diff --git a/docs/superpowers/contracts/artgate-phase4-diffusion-2026-08-15.json b/docs/superpowers/contracts/artgate-phase4-diffusion-2026-08-15.json new file mode 100644 index 00000000..73f9c7db --- /dev/null +++ b/docs/superpowers/contracts/artgate-phase4-diffusion-2026-08-15.json @@ -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." +} diff --git a/docs/superpowers/evidence/artgate-phase4-diffusion-2026-08-15.json b/docs/superpowers/evidence/artgate-phase4-diffusion-2026-08-15.json new file mode 100644 index 00000000..2494e8c6 --- /dev/null +++ b/docs/superpowers/evidence/artgate-phase4-diffusion-2026-08-15.json @@ -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." + ] +} diff --git a/python_pkg/artgate/cli.py b/python_pkg/artgate/cli.py index e7f1ec97..9713e535 100644 --- a/python_pkg/artgate/cli.py +++ b/python_pkg/artgate/cli.py @@ -15,6 +15,7 @@ import argparse import json from pathlib import Path import sys +from typing import TYPE_CHECKING import numpy as np from PIL import Image, UnidentifiedImageError @@ -25,7 +26,7 @@ from python_pkg.artgate.codes import ( EXIT_PASS, 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 ( check_alpha_binary, check_color_count, @@ -34,6 +35,27 @@ from python_pkg.artgate.pixels import ( ) 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]: """Run every gate the spec declares applicable. @@ -49,27 +71,29 @@ def evaluate(rgba: np.ndarray, spec: TargetSpec) -> list[Code]: Returns: 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] = [] - - if spec.runs(Code.CANVAS_SIZE) and spec.canvas is not None: - height, width = rgba.shape[:2] - 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)) + for code, run in checks: + if spec.runs(code): + failures.extend(run()) deduped: list[Code] = [] for code in failures: diff --git a/python_pkg/artgate/codes.py b/python_pkg/artgate/codes.py index 4daabb67..1408ebb4 100644 --- a/python_pkg/artgate/codes.py +++ b/python_pkg/artgate/codes.py @@ -69,6 +69,7 @@ DECLARABLE: Final = frozenset( Code.CANVAS_SIZE, Code.SCALE_NOT_INVARIANT, Code.SILHOUETTE_LOW_CONTRAST, + Code.MARGIN_TOO_SMALL, } ) diff --git a/python_pkg/artgate/contrast.py b/python_pkg/artgate/contrast.py index e9f670a1..5cb82e2d 100644 --- a/python_pkg/artgate/contrast.py +++ b/python_pkg/artgate/contrast.py @@ -62,6 +62,37 @@ def silhouette_delta(rgba: NDArray[np.uint8], background: int) -> float: 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( rgba: NDArray[np.uint8], min_delta: float = DEFAULT_MIN_DELTA, diff --git a/python_pkg/artgate/routes/__init__.py b/python_pkg/artgate/routes/__init__.py index 8b117d8f..657aa225 100644 --- a/python_pkg/artgate/routes/__init__.py +++ b/python_pkg/artgate/routes/__init__.py @@ -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"] diff --git a/python_pkg/artgate/routes/blender.py b/python_pkg/artgate/routes/blender.py new file mode 100644 index 00000000..c8dfa8b4 --- /dev/null +++ b/python_pkg/artgate/routes/blender.py @@ -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 diff --git a/python_pkg/artgate/routes/blender_scene.py b/python_pkg/artgate/routes/blender_scene.py new file mode 100644 index 00000000..d0ec51fd --- /dev/null +++ b/python_pkg/artgate/routes/blender_scene.py @@ -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") diff --git a/python_pkg/artgate/routes/shapes.py b/python_pkg/artgate/routes/shapes.py index 973cf677..5b43b1f2 100644 --- a/python_pkg/artgate/routes/shapes.py +++ b/python_pkg/artgate/routes/shapes.py @@ -95,8 +95,8 @@ def _sword(c: NDArray[np.uint8]) -> None: _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") + _rect(c, 14, 23, 17, 26, "shadow") + _disc(c, 15.5, 27.0, 2.4, "gold") def _shield(c: NDArray[np.uint8]) -> None: diff --git a/python_pkg/artgate/routes/vector.py b/python_pkg/artgate/routes/vector.py index 4a81721d..6aa66b9b 100644 --- a/python_pkg/artgate/routes/vector.py +++ b/python_pkg/artgate/routes/vector.py @@ -31,7 +31,7 @@ PALETTE: Final[dict[str, str]] = { "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: @@ -55,27 +55,27 @@ SHAPES: Final[dict[str, str]] = { f'' f'" + f"{STROKE}/>" f'" + f"{STROKE}/>" f'" + f"{STROKE}/>" ), "gem": ( f'" + f"{STROKE}/>" f'' ), "potion": ( - f'' + f'' f'' + f'fill="{PALETTE["light"]}" {STROKE}/>' f'" + f"{STROKE}/>" ), "coin": ( - f'' + f'' f'' f'' + f'fill="{PALETTE["mid"]}" {STROKE}/>' f'' - f'' + f'' ), } +_REGISTRY: dict[str, str] = dict(SHAPES) + + class RenderError(RuntimeError): """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: """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. 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}" raise KeyError(msg) binary = shutil.which("rsvg-convert") @@ -118,7 +144,7 @@ def render(name: str, out: Path, size: int = 64) -> Path: raise RenderError(msg) 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( [binary, "-w", str(size), "-h", str(size), str(source), "-o", str(out)], capture_output=True, diff --git a/python_pkg/artgate/routes/vector_shapes.py b/python_pkg/artgate/routes/vector_shapes.py new file mode 100644 index 00000000..e68e5030 --- /dev/null +++ b/python_pkg/artgate/routes/vector_shapes.py @@ -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'' + f'' + f'' + f'' + f'' + f'' + f'' + ), + "book": ( + f'' + f'' + f'' + f'' + ), + "bone": ( + f'' + f'' + f'' + f'' + f'' + ), + "bomb": ( + f'" + f'' + f'' + f'' + f'' + f'' + ), + "sword": ( + f'' + f'' + f'' + f'' + f'' + ), + "ring": ( + f'' + f'' + f'' + f'' + ), + "meat": ( + f'' + f'' + f'' + f'' + f'' + ), +} + +# 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) diff --git a/python_pkg/artgate/spec.py b/python_pkg/artgate/spec.py index 8f371a49..4fffdc30 100644 --- a/python_pkg/artgate/spec.py +++ b/python_pkg/artgate/spec.py @@ -229,6 +229,8 @@ def _require_config(spec: TargetSpec) -> None: missing.append("OFF_PALETTE needs a non-empty 'palette'") if spec.runs(Code.ALPHA_NOT_BINARY) and not spec.alpha_binary: 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: msg = "; ".join(missing) raise SpecError(msg) diff --git a/python_pkg/artgate/styles/pixel_icon.toml b/python_pkg/artgate/styles/pixel_icon.toml index 07f1ec99..fea3ba04 100644 --- a/python_pkg/artgate/styles/pixel_icon.toml +++ b/python_pkg/artgate/styles/pixel_icon.toml @@ -30,4 +30,5 @@ gates = [ "CANVAS_SIZE", "SCALE_NOT_INVARIANT", "SILHOUETTE_LOW_CONTRAST", + "MARGIN_TOO_SMALL", ] diff --git a/python_pkg/artgate/tests/test_blender.py b/python_pkg/artgate/tests/test_blender.py new file mode 100644 index 00000000..f85784a9 --- /dev/null +++ b/python_pkg/artgate/tests/test_blender.py @@ -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(), "", "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 diff --git a/python_pkg/artgate/tests/test_cli.py b/python_pkg/artgate/tests/test_cli.py index 8be9e68c..85e64f42 100644 --- a/python_pkg/artgate/tests/test_cli.py +++ b/python_pkg/artgate/tests/test_cli.py @@ -152,6 +152,20 @@ class TestEvaluate: ) 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: """A near-white asset fails the silhouette gate. diff --git a/python_pkg/artgate/tests/test_contrast.py b/python_pkg/artgate/tests/test_contrast.py index e0bace63..0dd7f8f1 100644 --- a/python_pkg/artgate/tests/test_contrast.py +++ b/python_pkg/artgate/tests/test_contrast.py @@ -9,10 +9,12 @@ reject good art. from __future__ import annotations import numpy as np +import pytest from python_pkg.artgate.codes import Code from python_pkg.artgate.contrast import ( DEFAULT_MIN_DELTA, + check_margin, check_silhouette, silhouette_delta, ) @@ -66,6 +68,50 @@ class TestCalibrationControls: 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: """Degenerate inputs fail closed rather than dividing by zero.""" diff --git a/python_pkg/artgate/tests/test_routes.py b/python_pkg/artgate/tests/test_routes.py index d381e51f..a427d7e8 100644 --- a/python_pkg/artgate/tests/test_routes.py +++ b/python_pkg/artgate/tests/test_routes.py @@ -108,6 +108,14 @@ class TestProceduralRoute: class TestVectorRoute: """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: """An undefined vector subject fails loudly.""" with pytest.raises(KeyError, match="no vector shape"): diff --git a/python_pkg/artgate/tests/test_spec.py b/python_pkg/artgate/tests/test_spec.py index 2af48634..ee86693c 100644 --- a/python_pkg/artgate/tests/test_spec.py +++ b/python_pkg/artgate/tests/test_spec.py @@ -160,6 +160,7 @@ class TestFailClosed: ('gates = ["TOO_MANY_COLORS"]', "TOO_MANY_COLORS needs"), ('gates = ["OFF_PALETTE"]', "OFF_PALETTE needs"), ('gates = ["ALPHA_NOT_BINARY"]', "ALPHA_NOT_BINARY needs"), + ('gates = ["MARGIN_TOO_SMALL"]', "MARGIN_TOO_SMALL needs"), ], ) def test_gate_without_config_is_rejected(