mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:07: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
|
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:
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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, 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:
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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'")
|
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)
|
||||||
|
|||||||
@ -30,4 +30,5 @@ gates = [
|
|||||||
"CANVAS_SIZE",
|
"CANVAS_SIZE",
|
||||||
"SCALE_NOT_INVARIANT",
|
"SCALE_NOT_INVARIANT",
|
||||||
"SILHOUETTE_LOW_CONTRAST",
|
"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]
|
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.
|
||||||
|
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|
||||||
|
|||||||
@ -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"):
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user