mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 18:47:04 +01:00
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T9rQ12yZ7sJxiPQTCS5dUF
This commit is contained in:
parent
691a228499
commit
e6e0ba5ab3
@ -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."
|
||||
}
|
||||
@ -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."
|
||||
]
|
||||
}
|
||||
@ -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:
|
||||
|
||||
@ -69,6 +69,7 @@ DECLARABLE: Final = frozenset(
|
||||
Code.CANVAS_SIZE,
|
||||
Code.SCALE_NOT_INVARIANT,
|
||||
Code.SILHOUETTE_LOW_CONTRAST,
|
||||
Code.MARGIN_TOO_SMALL,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"]
|
||||
|
||||
95
python_pkg/artgate/routes/blender.py
Normal file
95
python_pkg/artgate/routes/blender.py
Normal 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
|
||||
146
python_pkg/artgate/routes/blender_scene.py
Normal file
146
python_pkg/artgate/routes/blender_scene.py
Normal 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")
|
||||
@ -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:
|
||||
|
||||
@ -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'<circle cx="20" cy="22" r="11" fill="none" stroke="{PALETTE["gold"]}"'
|
||||
f' stroke-width="7"/>'
|
||||
f'<rect x="27" y="30" width="7" height="27" fill="{PALETTE["gold"]}" '
|
||||
f"{_STROKE}/>"
|
||||
f"{STROKE}/>"
|
||||
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"{_STROKE}/>"
|
||||
f"{STROKE}/>"
|
||||
),
|
||||
"gem": (
|
||||
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'opacity="0.65"/>'
|
||||
),
|
||||
"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'fill="{PALETTE["light"]}" {_STROKE}/>'
|
||||
f'fill="{PALETTE["light"]}" {STROKE}/>'
|
||||
f'<rect x="23" y="6" width="18" height="8" fill="{PALETTE["gold"]}" '
|
||||
f"{_STROKE}/>"
|
||||
f"{STROKE}/>"
|
||||
),
|
||||
"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'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 '
|
||||
@ -83,17 +83,42 @@ SHAPES: Final[dict[str, str]] = {
|
||||
),
|
||||
"shield": (
|
||||
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'<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):
|
||||
"""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,
|
||||
|
||||
95
python_pkg/artgate/routes/vector_shapes.py
Normal file
95
python_pkg/artgate/routes/vector_shapes.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
@ -30,4 +30,5 @@ gates = [
|
||||
"CANVAS_SIZE",
|
||||
"SCALE_NOT_INVARIANT",
|
||||
"SILHOUETTE_LOW_CONTRAST",
|
||||
"MARGIN_TOO_SMALL",
|
||||
]
|
||||
|
||||
139
python_pkg/artgate/tests/test_blender.py
Normal file
139
python_pkg/artgate/tests/test_blender.py
Normal 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
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user