805 lines
32 KiB
Python
805 lines
32 KiB
Python
"""Tests for the probe render stage: frame ordering, job I/O, manifest, refusal.
|
|
|
|
Host-side tests import only ``orisys_sensor_design`` (no bpy). The Blender-backed
|
|
tests shell out to ``blender -b <blend> -P blender/scripts/probe_render.py`` and
|
|
are skipped when no Blender executable can be found.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import dataclasses
|
|
import importlib.util
|
|
import json
|
|
import math
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import types
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from orisys_sensor_design import deform, render
|
|
from orisys_sensor_design.manifest import RevisionPaths, repo_root, sha256_file
|
|
|
|
MANIFEST_SCHEMA = "orisys-sensor-design/probe-render-manifest-v1"
|
|
|
|
#: Frozen revision facts (docs/probe-pipeline.md). The blend is the user's file; these
|
|
#: fail loudly if it changes, which is the point. The blend's own sha256 is intentionally
|
|
#: NOT pinned here: the user changes its render resolution as the bench is re-targeted
|
|
#: (500x500 -> 250x250), and a literal would call that legitimate edit a regression. What
|
|
#: the end-to-end test guards instead is that the pipeline never writes the blend.
|
|
MESH_PX_PER_MM = {"min": 10.4994, "p50": 13.4302, "max": 38.5943}
|
|
FRAME_COUNT = 31
|
|
REFERENCE_NAME = "000_reference"
|
|
REFERENCE_NULL_NAME = "reference_null"
|
|
|
|
|
|
def _repo() -> Path:
|
|
try:
|
|
return repo_root()
|
|
except FileNotFoundError:
|
|
return Path(__file__).resolve().parents[1]
|
|
|
|
|
|
REPO = _repo()
|
|
REVISION_DIR = REPO / "sensors" / "finger-xynova" / "r01"
|
|
SOURCE_BLEND = REVISION_DIR / "source" / "geometry.blend"
|
|
RENDER_SCRIPT = REPO / "blender" / "scripts" / "probe_render.py"
|
|
DEFORM_SCRIPT = REPO / "blender" / "scripts" / "probe_deform.py"
|
|
|
|
|
|
def _blender_executable() -> Path | None:
|
|
for variable in ("ORISYS_BLENDER", "BLENDER_EXE"):
|
|
value = os.environ.get(variable)
|
|
if value and Path(value).is_file():
|
|
return Path(value)
|
|
if os.name == "nt":
|
|
root = Path(r"C:\Program Files\Blender Foundation")
|
|
candidates = sorted(root.glob("Blender */blender.exe"), reverse=True)
|
|
if candidates:
|
|
return candidates[0]
|
|
which = shutil.which("blender")
|
|
return Path(which) if which else None
|
|
|
|
|
|
BLENDER = _blender_executable()
|
|
requires_blender = pytest.mark.skipif(
|
|
BLENDER is None, reason="no Blender executable (set ORISYS_BLENDER or install Blender)"
|
|
)
|
|
|
|
|
|
def _run_blender(script: Path, job: Path, timeout: int = 900) -> subprocess.CompletedProcess:
|
|
assert BLENDER is not None
|
|
# check=False is deliberate: several tests assert on non-zero Blender exit
|
|
# codes (2/3), so a non-zero status is data, not an error to raise on.
|
|
return subprocess.run(
|
|
[str(BLENDER), "-b", str(SOURCE_BLEND), "-P", str(script), "--", "--job", str(job)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def _png_size(path: Path) -> tuple[int, int]:
|
|
data = path.read_bytes()
|
|
assert data[:8] == b"\x89PNG\r\n\x1a\n", f"not a PNG: {path}"
|
|
width, height = struct.unpack(">II", data[16:24])
|
|
return int(width), int(height)
|
|
|
|
|
|
def _png_chunk(ctype: bytes, payload: bytes) -> bytes:
|
|
return (
|
|
struct.pack(">I", len(payload))
|
|
+ ctype
|
|
+ payload
|
|
+ struct.pack(">I", zlib.crc32(ctype + payload) & 0xFFFFFFFF)
|
|
)
|
|
|
|
|
|
def _paeth(a: int, b: int, c: int) -> int:
|
|
p = a + b - c
|
|
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
|
if pa <= pb and pa <= pc:
|
|
return a
|
|
return b if pb <= pc else c
|
|
|
|
|
|
def _write_png(path: Path, pixels: np.ndarray, filter_type: int = 0) -> Path:
|
|
"""Encode an 8-bit RGBA PNG, applying one PNG filter per row.
|
|
|
|
Used to exercise the shipped decoder and the determinism gate with synthetic
|
|
pairs, including 1-LSB differences the old whole-file byte gate rejected.
|
|
"""
|
|
pixels = np.asarray(pixels, dtype=np.uint8)
|
|
height, width, channels = pixels.shape
|
|
assert channels == 4
|
|
stride = width * 4
|
|
raw = bytearray()
|
|
previous = np.zeros(stride, dtype=np.int16)
|
|
for y in range(height):
|
|
line = pixels[y].reshape(-1).astype(np.int16)
|
|
if filter_type == 0:
|
|
filtered = bytes(line.astype(np.uint8).tobytes())
|
|
else:
|
|
result = bytearray(stride)
|
|
for i in range(stride):
|
|
left = line[i - 4] if i >= 4 else 0
|
|
up = previous[i]
|
|
up_left = previous[i - 4] if i >= 4 else 0
|
|
if filter_type == 1:
|
|
predictor = left
|
|
elif filter_type == 2:
|
|
predictor = up
|
|
elif filter_type == 3:
|
|
predictor = (left + up) >> 1
|
|
elif filter_type == 4:
|
|
predictor = _paeth(left, up, up_left)
|
|
else:
|
|
raise ValueError(f"unsupported test filter {filter_type}")
|
|
result[i] = (line[i] - predictor) & 0xFF
|
|
filtered = bytes(result)
|
|
raw.append(filter_type)
|
|
raw += filtered
|
|
previous = line
|
|
data = (
|
|
b"\x89PNG\r\n\x1a\n"
|
|
+ _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
|
|
+ _png_chunk(b"IDAT", zlib.compress(bytes(raw)))
|
|
+ _png_chunk(b"IEND", b"")
|
|
)
|
|
path.write_bytes(data)
|
|
return path
|
|
|
|
|
|
def _load_probe_render_module():
|
|
"""Import blender/scripts/probe_render.py with bpy stubbed out.
|
|
|
|
The module only touches bpy/mathutils at call time, so empty stub modules are
|
|
enough to unit-test its stdlib+numpy helpers (the determinism gate) without a
|
|
Blender process.
|
|
"""
|
|
stubs: dict[str, types.ModuleType] = {}
|
|
for name in ("bpy", "bpy_extras", "bpy_extras.object_utils", "mathutils"):
|
|
stubs[name] = sys.modules.setdefault(name, types.ModuleType(name))
|
|
stubs["bpy_extras.object_utils"].world_to_camera_view = object
|
|
stubs["bpy_extras"].object_utils = stubs["bpy_extras.object_utils"]
|
|
stubs["mathutils"].Vector = object
|
|
stubs["bpy"].data = None
|
|
spec = importlib.util.spec_from_file_location("probe_render_under_test", RENDER_SCRIPT)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def probe_render():
|
|
return _load_probe_render_module()
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def paths() -> RevisionPaths:
|
|
return RevisionPaths.from_revision_dir(REVISION_DIR)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def settings(paths: RevisionPaths) -> deform.ProbeSettings:
|
|
return deform.resolve_probe(paths)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Host-side: frame ordering and job content
|
|
# ----------------------------------------------------------------------
|
|
def test_job_frames_follow_frame_plan_exactly(paths, settings, tmp_path):
|
|
job = render.build_render_job(paths, settings, tmp_path)
|
|
plan = deform.frame_plan(settings)
|
|
|
|
assert job["schema"] == render.RENDER_JOB_SCHEMA
|
|
assert len(job["frames"]) == FRAME_COUNT
|
|
assert job["frames"] == [frame.to_job_entry() for frame in plan]
|
|
assert [frame["name"] for frame in job["frames"]] == [frame.name for frame in plan]
|
|
assert job["frames"][0] == {
|
|
"index": 0,
|
|
"name": REFERENCE_NAME,
|
|
"axis": None,
|
|
"step_mm": 0.0,
|
|
}
|
|
assert job["frames"][-1]["name"] == "030_z_+0.10mm"
|
|
assert job["frames"][-1]["axis"] == "z"
|
|
# Axis-major, step-minor: x, then y, then z.
|
|
assert [frame["axis"] for frame in job["frames"][1:11]] == ["x"] * 10
|
|
assert [frame["axis"] for frame in job["frames"][11:21]] == ["y"] * 10
|
|
assert [frame["axis"] for frame in job["frames"][21:31]] == ["z"] * 10
|
|
assert job["frames"][1]["step_mm"] == pytest.approx(0.01)
|
|
assert job["frames"][10]["step_mm"] == pytest.approx(0.10)
|
|
|
|
|
|
def test_job_png_names_are_plan_names(paths, settings, tmp_path):
|
|
job = render.build_render_job(paths, settings, tmp_path)
|
|
for entry in job["frames"]:
|
|
assert render.frame_png_name(entry) == f"{entry['name']}.png"
|
|
assert render.frame_png_path(tmp_path, entry) == (
|
|
Path(tmp_path).resolve() / render.FRAMES_DIRNAME / f"{entry['name']}.png"
|
|
)
|
|
assert render.frame_png_name(REFERENCE_NULL_NAME) == "reference_null.png"
|
|
assert render.frame_png_name(deform.frame_plan(settings)[0]) == "000_reference.png"
|
|
|
|
|
|
def test_job_carries_the_hard_render_settings(paths, settings, tmp_path):
|
|
job = render.build_render_job(paths, settings, tmp_path)
|
|
block = job["settings"]
|
|
|
|
assert block["engine"] == "CYCLES"
|
|
assert block["samples"] == 16
|
|
assert block["denoise"] is False
|
|
assert block["view_transform"] == "Standard"
|
|
assert block["interpolate"] == "Closest"
|
|
assert block["seed"] == 0
|
|
assert (block["width"], block["height"]) == tuple(settings.resolution)
|
|
assert block["uv_map"] == "compute"
|
|
assert block["radius_mm"] == pytest.approx(0.5)
|
|
texture = Path(block["texture"])
|
|
assert texture.is_absolute() and texture.is_file()
|
|
|
|
assert job["objects"] == {"mesh": "sensing_surface", "camera": "cam_sensor"}
|
|
assert job["null_frame"] == {"name": REFERENCE_NULL_NAME, "seed": 1}
|
|
assert job["artifacts"]["report"] == str(
|
|
Path(tmp_path).resolve() / render.MANIFEST_FILENAME
|
|
)
|
|
assert job["artifacts"]["frames_npz"] == str(
|
|
deform.probe_artifact_paths(Path(tmp_path))["frames"]
|
|
)
|
|
assert job["source_blend"] == str(SOURCE_BLEND.resolve())
|
|
|
|
|
|
def test_job_seed_override_moves_the_null_seed(paths, settings, tmp_path):
|
|
job = render.build_render_job(paths, settings, tmp_path, seed=7, samples=4)
|
|
assert job["settings"]["seed"] == 7
|
|
assert job["settings"]["samples"] == 4
|
|
assert job["null_frame"]["seed"] == 8
|
|
|
|
|
|
def test_job_rejects_invalid_overrides(paths, settings, tmp_path):
|
|
for kwargs in (
|
|
{"samples": 0},
|
|
{"width": 0},
|
|
{"height": -1},
|
|
{"seed": 1.5},
|
|
{"seed": True},
|
|
{"uv_map": ""},
|
|
):
|
|
with pytest.raises(render.RenderConfigError):
|
|
render.build_render_job(paths, settings, tmp_path, **kwargs)
|
|
|
|
|
|
def test_write_render_job_round_trips(paths, settings, tmp_path):
|
|
job = render.build_render_job(paths, settings, tmp_path)
|
|
job_path = render.write_render_job(job, tmp_path)
|
|
assert job_path == render.render_job_path(tmp_path)
|
|
assert job_path.name == render.RENDER_JOB_FILENAME
|
|
assert json.loads(job_path.read_text(encoding="utf-8")) == job
|
|
|
|
|
|
def test_render_output_paths_include_frames(paths, settings, tmp_path):
|
|
plan = deform.frame_plan(settings)
|
|
paths_map = render.render_output_paths(tmp_path, plan)
|
|
for key in ("output_dir", "job", "manifest", "frames_npz", "frames_dir"):
|
|
assert key in paths_map
|
|
pngs = paths_map["frames"]
|
|
assert len(pngs) == FRAME_COUNT + 1
|
|
assert pngs[REFERENCE_NAME].name == "000_reference.png"
|
|
assert pngs[REFERENCE_NULL_NAME].name == "reference_null.png"
|
|
assert pngs[REFERENCE_NAME].parent == paths_map["frames_dir"]
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Host-side: marker texture resolution
|
|
# ----------------------------------------------------------------------
|
|
def test_texture_resolves_relative_to_texture_output_dir(paths, settings):
|
|
expected = (
|
|
REPO / "build" / "textures" / "finger-xynova" / "r01" / "patch_texture_compute.png"
|
|
).resolve()
|
|
assert render.resolve_marker_texture(REPO, paths, settings) == expected
|
|
assert expected.is_file()
|
|
|
|
|
|
def test_texture_honours_explicit_texture_dir(paths, settings, tmp_path):
|
|
texture_dir = tmp_path / "textures"
|
|
texture_dir.mkdir()
|
|
texture = texture_dir / settings.texture
|
|
texture.write_bytes(b"\x89PNG\r\n\x1a\n")
|
|
resolved = render.marker_texture_path(
|
|
REPO, paths, settings.texture, texture_dir=texture_dir
|
|
)
|
|
assert resolved == texture.resolve()
|
|
|
|
|
|
def test_missing_texture_reports_exit_3(paths, settings, tmp_path):
|
|
texture_dir = tmp_path / "textures"
|
|
texture_dir.mkdir()
|
|
with pytest.raises(render.RenderArtifactError) as excinfo:
|
|
render.resolve_marker_texture(
|
|
REPO, paths, dataclasses.replace(settings, texture="absent.png"), texture_dir=texture_dir
|
|
)
|
|
assert excinfo.value.exit_code == 3
|
|
assert "absent.png" in str(excinfo.value)
|
|
|
|
|
|
def test_empty_texture_reports_exit_3(paths, settings, tmp_path):
|
|
texture_dir = tmp_path / "textures"
|
|
texture_dir.mkdir()
|
|
(texture_dir / settings.texture).write_bytes(b"")
|
|
with pytest.raises(render.RenderArtifactError) as excinfo:
|
|
render.resolve_marker_texture(REPO, paths, settings, texture_dir=texture_dir)
|
|
assert excinfo.value.exit_code == 3
|
|
assert "empty" in str(excinfo.value)
|
|
|
|
|
|
def test_build_render_job_requires_the_texture(paths, settings, tmp_path):
|
|
missing = dataclasses.replace(settings, texture="not_generated.png")
|
|
with pytest.raises(render.RenderArtifactError) as excinfo:
|
|
render.build_render_job(paths, missing, tmp_path)
|
|
assert excinfo.value.exit_code == 3
|
|
|
|
|
|
def test_require_artifact_on_empty_and_missing(tmp_path):
|
|
empty = tmp_path / "empty.bin"
|
|
empty.write_bytes(b"")
|
|
with pytest.raises(render.RenderArtifactError):
|
|
render.require_artifact(empty, "thing")
|
|
with pytest.raises(render.RenderArtifactError):
|
|
render.require_artifact(tmp_path / "nope.bin", "thing")
|
|
real = tmp_path / "real.bin"
|
|
real.write_bytes(b"x")
|
|
assert render.require_artifact(real, "thing") == real
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Host-side: CLI overrides
|
|
# ----------------------------------------------------------------------
|
|
def test_parse_resolution_variants():
|
|
assert render.parse_resolution("640x480") == (640, 480)
|
|
assert render.parse_resolution("640,480") == (640, 480)
|
|
assert render.parse_resolution("640X480") == (640, 480)
|
|
assert render.parse_resolution([250, 250]) == (250, 250)
|
|
for bad in ("640", "640x", "0x100", "-4x5", "axb", None, 500):
|
|
with pytest.raises(render.RenderConfigError):
|
|
render.parse_resolution(bad)
|
|
|
|
|
|
def test_render_overrides_from_args():
|
|
args = argparse.Namespace(
|
|
resolution="640x480",
|
|
samples=8,
|
|
seed=3,
|
|
uv_map="produce",
|
|
texture=None,
|
|
width=None,
|
|
height=None,
|
|
)
|
|
assert render.render_overrides_from_args(args) == {
|
|
"samples": 8,
|
|
"seed": 3,
|
|
"uv_map": "produce",
|
|
"width": 640,
|
|
"height": 480,
|
|
}
|
|
args.width, args.height = 320, 240
|
|
overrides = render.render_overrides_from_args(args)
|
|
assert (overrides["width"], overrides["height"]) == (320, 240)
|
|
empty = render.render_overrides_from_args(argparse.Namespace())
|
|
assert empty == {}
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Host-side: manifest loading
|
|
# ----------------------------------------------------------------------
|
|
def _write_manifest(directory: Path, payload: object) -> Path:
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
path = directory / render.MANIFEST_FILENAME
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def test_load_render_manifest_accepts_a_valid_manifest(tmp_path):
|
|
payload = {
|
|
"schema": MANIFEST_SCHEMA,
|
|
"width": 500,
|
|
"height": 500,
|
|
"frames": [{"index": 0, "name": REFERENCE_NAME, "png": "frames/000_reference.png"}],
|
|
}
|
|
_write_manifest(tmp_path, payload)
|
|
assert render.load_render_manifest(tmp_path) == payload
|
|
|
|
|
|
def test_load_render_manifest_rejects_missing_empty_and_invalid(tmp_path):
|
|
with pytest.raises(render.RenderArtifactError) as excinfo:
|
|
render.load_render_manifest(tmp_path / "absent")
|
|
assert excinfo.value.exit_code == 3
|
|
|
|
empty_dir = tmp_path / "empty"
|
|
empty_dir.mkdir()
|
|
(empty_dir / render.MANIFEST_FILENAME).write_bytes(b"")
|
|
with pytest.raises(render.RenderArtifactError):
|
|
render.load_render_manifest(empty_dir)
|
|
|
|
bad_dir = tmp_path / "bad"
|
|
_write_manifest(bad_dir, {"schema": "wrong", "frames": []})
|
|
with pytest.raises(render.RenderArtifactError):
|
|
render.load_render_manifest(bad_dir)
|
|
|
|
no_frames = tmp_path / "noframes"
|
|
_write_manifest(no_frames, {"schema": MANIFEST_SCHEMA, "frames": []})
|
|
with pytest.raises(render.RenderArtifactError):
|
|
render.load_render_manifest(no_frames)
|
|
|
|
not_json = tmp_path / "notjson"
|
|
not_json.mkdir()
|
|
(not_json / render.MANIFEST_FILENAME).write_text("{", encoding="utf-8")
|
|
with pytest.raises(render.RenderArtifactError):
|
|
render.load_render_manifest(not_json)
|
|
|
|
|
|
def test_manifest_frame_entries_make_png_paths_absolute(tmp_path):
|
|
manifest = {
|
|
"frames": [
|
|
{"name": REFERENCE_NAME, "png": "frames/000_reference.png"},
|
|
{"name": "001_x_+0.01mm", "png": "frames/001_x_+0.01mm.png"},
|
|
]
|
|
}
|
|
entries = render.manifest_frame_entries(manifest, tmp_path)
|
|
assert entries[0]["png_path"] == (
|
|
Path(tmp_path).resolve() / "frames" / "000_reference.png"
|
|
)
|
|
assert entries[1]["png_path"].name == "001_x_+0.01mm.png"
|
|
with pytest.raises(render.RenderArtifactError):
|
|
render.manifest_frame_entries({"frames": [{"name": "x"}]}, tmp_path)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Determinism gate: decoded-pixel tolerance
|
|
#
|
|
# Regression for the real nx=36 defect: Cycles CPU thread-order rounding moved a
|
|
# boundary pixel by 1 LSB, the old whole-file byte comparison rejected candidate
|
|
# nx=36, and a content-dependent gate silently biases the pattern search.
|
|
# ----------------------------------------------------------------------
|
|
def _baseline(height: int = 250, width: int = 250) -> np.ndarray:
|
|
rng = np.random.default_rng(20260924)
|
|
return rng.integers(0, 256, size=(height, width, 4), dtype=np.uint8)
|
|
|
|
|
|
def _gate(probe_render, tmp_path: Path, before: np.ndarray, after: np.ndarray) -> dict:
|
|
return probe_render.compare_reference_renders(
|
|
_write_png(tmp_path / "a.png", before),
|
|
_write_png(tmp_path / "b.png", after),
|
|
)
|
|
|
|
|
|
def test_determinism_gate_accepts_identical_renders(probe_render, tmp_path):
|
|
image = _baseline()
|
|
result = _gate(probe_render, tmp_path, image, image.copy())
|
|
assert result["passed"] is True
|
|
assert result["max_abs_delta"] == 0
|
|
assert result["differing_pixels"] == 0
|
|
assert result["differing_fraction"] == 0.0
|
|
assert result["total_pixels"] == 250 * 250
|
|
|
|
|
|
def test_determinism_gate_accepts_one_lsb_on_a_couple_of_pixels(probe_render, tmp_path):
|
|
"""Exactly the nx=36 case: 2 pixels tip by one 8-bit step. Must PASS."""
|
|
before = _baseline()
|
|
after = before.copy()
|
|
after[17, 23, 1] = (int(after[17, 23, 1]) + 1) % 256
|
|
after[201, 88, 3] = (int(after[201, 88, 3]) - 1) % 256
|
|
|
|
result = _gate(probe_render, tmp_path, before, after)
|
|
|
|
assert result["passed"] is True
|
|
assert result["max_abs_delta"] == 1
|
|
assert result["max_abs_delta_tolerance"] == 1
|
|
assert result["differing_pixels"] == 2
|
|
assert result["differing_fraction"] == pytest.approx(2 / (250 * 250))
|
|
assert result["differing_fraction"] <= probe_render.DETERMINISM_MAX_DIFFERING_FRACTION
|
|
|
|
|
|
def test_determinism_gate_rejects_more_than_one_lsb(probe_render, tmp_path):
|
|
before = _baseline()
|
|
after = before.copy()
|
|
after[5, 5, 0] = int(after[5, 5, 0]) + 2
|
|
|
|
result = _gate(probe_render, tmp_path, before, after)
|
|
|
|
assert result["passed"] is False
|
|
assert result["max_abs_delta"] == 2
|
|
assert result["differing_pixels"] == 1
|
|
|
|
|
|
def test_determinism_gate_rejects_too_many_differing_pixels(probe_render, tmp_path):
|
|
"""1 LSB is fine per pixel, but content-dependent drift on >0.05% is not."""
|
|
before = _baseline(height=200, width=200)
|
|
after = before.copy()
|
|
total = 200 * 200
|
|
over_threshold = int(total * probe_render.DETERMINISM_MAX_DIFFERING_FRACTION) + 1
|
|
for index in range(over_threshold):
|
|
y, x = divmod(index, 200)
|
|
after[y, x, 0] = (int(after[y, x, 0]) + 1) % 256
|
|
|
|
result = _gate(probe_render, tmp_path, before, after)
|
|
|
|
assert result["passed"] is False
|
|
assert result["max_abs_delta"] == 1
|
|
assert result["differing_fraction"] > probe_render.DETERMINISM_MAX_DIFFERING_FRACTION
|
|
assert result["differing_pixels"] == over_threshold
|
|
|
|
|
|
def test_determinism_gate_accepts_the_exact_fraction_boundary(probe_render, tmp_path):
|
|
before = _baseline(height=200, width=200)
|
|
after = before.copy()
|
|
exactly = 20 # 20/40000 == 0.05%
|
|
assert 20 / (200 * 200) == probe_render.DETERMINISM_MAX_DIFFERING_FRACTION
|
|
for index in range(exactly):
|
|
y, x = divmod(index, 200)
|
|
after[y, x, 2] = (int(after[y, x, 2]) + 1) % 256
|
|
|
|
result = _gate(probe_render, tmp_path, before, after)
|
|
|
|
assert result["passed"] is True
|
|
assert result["differing_pixels"] == exactly
|
|
|
|
|
|
def test_determinism_gate_rejects_a_shape_mismatch(probe_render, tmp_path):
|
|
a = _write_png(tmp_path / "a.png", _baseline(height=250, width=250))
|
|
b = _write_png(tmp_path / "b.png", _baseline(height=249, width=250))
|
|
with pytest.raises(probe_render.SceneError):
|
|
probe_render.compare_reference_renders(a, b)
|
|
|
|
|
|
@pytest.mark.parametrize("filter_type", [0, 1, 2, 3, 4])
|
|
def test_decoder_round_trips_every_png_filter(probe_render, tmp_path, filter_type):
|
|
image = _baseline(height=13, width=9)
|
|
path = _write_png(tmp_path / f"f{filter_type}.png", image, filter_type=filter_type)
|
|
decoded = probe_render.decode_png_rgba8(path)
|
|
assert decoded.shape == image.shape
|
|
assert np.array_equal(decoded, image)
|
|
|
|
|
|
def test_decoder_rejects_non_rgba_png(probe_render, tmp_path):
|
|
width, height = 4, 2
|
|
raw = b"".join(b"\x00" + bytes(width * 3) for _ in range(height))
|
|
path = tmp_path / "rgb.png"
|
|
path.write_bytes(
|
|
b"\x89PNG\r\n\x1a\n"
|
|
+ _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
|
|
+ _png_chunk(b"IDAT", zlib.compress(raw))
|
|
+ _png_chunk(b"IEND", b"")
|
|
)
|
|
with pytest.raises(probe_render.SceneError):
|
|
probe_render.decode_png_rgba8(path)
|
|
|
|
|
|
def test_strip_removes_only_volatile_text_chunks(probe_render, tmp_path):
|
|
image = _baseline(height=6, width=5)
|
|
path = _write_png(tmp_path / "with_meta.png", image)
|
|
chunks = path.read_bytes()
|
|
# Rebuild with volatile and non-volatile tEXt chunks interleaved.
|
|
extra = (
|
|
_png_chunk(b"tEXt", b"Date\x0001/01/2026")
|
|
+ _png_chunk(b"tEXt", b"cycles.ViewLayer.total_time\x001.23")
|
|
+ _png_chunk(b"tEXt", b"cycles.ViewLayer.samples\x0016")
|
|
+ _png_chunk(b"tEXt", b"Time\x0012:00:00")
|
|
)
|
|
ihdr_end = 8 + 12 + struct.unpack(">I", chunks[8:12])[0]
|
|
path.write_bytes(chunks[:ihdr_end] + extra + chunks[ihdr_end:])
|
|
|
|
dropped = probe_render.strip_volatile_text_chunks(path)
|
|
|
|
assert dropped == 3
|
|
remaining = path.read_bytes()
|
|
assert b"cycles.ViewLayer.samples\x0016" in remaining # deterministic, kept
|
|
assert b"Date\x00" not in remaining
|
|
assert b"Time\x0012" not in remaining
|
|
assert b"total_time" not in remaining
|
|
assert np.array_equal(probe_render.decode_png_rgba8(path), image)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Blender-backed: refusals and end to end
|
|
# ----------------------------------------------------------------------
|
|
@requires_blender
|
|
def test_render_refuses_a_non_standard_view_transform(paths, settings, tmp_path):
|
|
job = render.build_render_job(paths, settings, tmp_path)
|
|
job["settings"]["view_transform"] = "AgX"
|
|
# Proves the refusal runs before any artifact is loaded: this path is absent.
|
|
job["artifacts"]["frames_npz"] = str(tmp_path / "absent.npz")
|
|
job_path = render.write_render_job(job, tmp_path)
|
|
|
|
proc = _run_blender(RENDER_SCRIPT, job_path)
|
|
|
|
assert proc.returncode == 2, proc.stdout + proc.stderr
|
|
assert "view_transform" in (proc.stdout + proc.stderr)
|
|
assert not (tmp_path / render.FRAMES_DIRNAME).exists()
|
|
|
|
|
|
@requires_blender
|
|
@pytest.mark.parametrize("missing", ["frames_npz", "texture"])
|
|
def test_render_exits_3_when_a_required_artifact_is_missing(
|
|
paths, settings, tmp_path, missing, real_npz
|
|
):
|
|
out = tmp_path / missing
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
job = render.build_render_job(paths, settings, out, samples=1)
|
|
shutil.copy2(real_npz, out / deform.FRAMES_NPZ_FILENAME)
|
|
absent = out / "absent.png"
|
|
if missing == "frames_npz":
|
|
job["artifacts"]["frames_npz"] = str(out / "absent.npz")
|
|
else:
|
|
job["settings"]["texture"] = str(absent)
|
|
job_path = render.write_render_job(job, out)
|
|
|
|
proc = _run_blender(RENDER_SCRIPT, job_path)
|
|
|
|
assert proc.returncode == 3, proc.stdout + proc.stderr
|
|
assert "not found" in (proc.stdout + proc.stderr)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def real_npz(tmp_path_factory) -> Path:
|
|
"""The deform stage's contract artifact, produced by T1's script if absent."""
|
|
canonical = deform.probe_artifact_paths(deform.probe_output_dir(REPO, paths_for_deform()))[
|
|
"frames"
|
|
]
|
|
if canonical.is_file() and canonical.stat().st_size > 0:
|
|
return canonical
|
|
if BLENDER is None:
|
|
pytest.skip("probe_frames.npz absent and no Blender to build it")
|
|
scratch = tmp_path_factory.mktemp("t2_deform")
|
|
rev = RevisionPaths.from_revision_dir(REVISION_DIR)
|
|
probe = deform.resolve_probe(rev)
|
|
job = deform.build_deform_job(rev, probe, scratch)
|
|
job_path = deform.write_deform_job(job, scratch)
|
|
proc = _run_blender(DEFORM_SCRIPT, job_path)
|
|
assert proc.returncode == 0, proc.stdout + proc.stderr
|
|
npz = scratch / deform.FRAMES_NPZ_FILENAME
|
|
assert npz.is_file() and npz.stat().st_size > 0
|
|
return npz
|
|
|
|
|
|
def paths_for_deform() -> RevisionPaths:
|
|
return RevisionPaths.from_revision_dir(REVISION_DIR)
|
|
|
|
|
|
@requires_blender
|
|
def test_end_to_end_render_manifest(paths, settings, real_npz, tmp_path):
|
|
out = tmp_path / "render"
|
|
plan = deform.frame_plan(settings)
|
|
job = render.build_render_job(paths, settings, out, samples=4)
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(real_npz, out / deform.FRAMES_NPZ_FILENAME)
|
|
job_path = render.write_render_job(job, out)
|
|
|
|
proc = _run_blender(RENDER_SCRIPT, job_path)
|
|
assert proc.returncode == 0, proc.stdout + proc.stderr
|
|
|
|
manifest_path = render.render_manifest_path(out)
|
|
assert manifest_path.is_file()
|
|
manifest = render.load_render_manifest(out)
|
|
|
|
# --- identity and settings that must never silently change ---
|
|
assert manifest["schema"] == MANIFEST_SCHEMA
|
|
assert manifest["uv_map"] == "compute"
|
|
width, height = tuple(settings.resolution)
|
|
assert (manifest["width"], manifest["height"]) == (width, height)
|
|
applied = manifest["settings_applied"]
|
|
assert applied["engine"] == "CYCLES"
|
|
assert applied["samples"] == 4
|
|
assert applied["denoise"] is False
|
|
assert applied["view_transform"] == "Standard"
|
|
assert applied["look"] == "None"
|
|
assert applied["exposure"] == 0.0
|
|
assert applied["interpolate"] == "Closest"
|
|
assert applied["seed"] == 0
|
|
assert manifest["reference_index"] == 0
|
|
assert manifest["reference_deterministic"] is True
|
|
determinism = manifest["reference_determinism"]
|
|
assert determinism["passed"] is True
|
|
assert determinism["max_abs_delta"] <= determinism["max_abs_delta_tolerance"] == 1
|
|
assert determinism["differing_fraction"] <= determinism["differing_fraction_tolerance"]
|
|
assert determinism["differing_fraction_tolerance"] == 0.0005
|
|
assert determinism["total_pixels"] == width * height
|
|
assert determinism["shape"] == [width, height]
|
|
|
|
# --- the source blend is untouched (opened read-only, never saved) ---
|
|
# The invariant is that the pipeline did not modify the blend, so the manifest's
|
|
# recorded hash must equal the hash on disk. It is deliberately NOT pinned to a
|
|
# literal: the blend is the user's file and they change its resolution (this repo
|
|
# moved 500x500 -> 250x250), which is a legitimate edit, not a regression.
|
|
assert manifest["source_blend"]["path"] == str(SOURCE_BLEND.resolve())
|
|
assert manifest["source_blend"]["sha256"].lower() == sha256_file(SOURCE_BLEND).lower()
|
|
|
|
texture = render.resolve_marker_texture(REPO, paths, settings)
|
|
assert manifest["texture"]["path"] == str(texture)
|
|
assert manifest["texture"]["sha256"] == sha256_file(texture)
|
|
|
|
# --- measured scales ---
|
|
mesh_scale = manifest["mesh_px_per_mm"]
|
|
assert (
|
|
mesh_scale["min"]
|
|
<= mesh_scale["p05"]
|
|
<= mesh_scale["p50"]
|
|
<= mesh_scale["p95"]
|
|
<= mesh_scale["max"]
|
|
)
|
|
# MESH_PX_PER_MM is measured at 500x500, and px/mm is linear in the render width, so
|
|
# scale it to the resolution the design declares rather than re-pinning it every time
|
|
# the bench is re-targeted. Halving the width must halve the scale.
|
|
factor = width / 500.0
|
|
assert mesh_scale["min"] == pytest.approx(MESH_PX_PER_MM["min"] * factor, abs=0.02)
|
|
assert mesh_scale["p50"] == pytest.approx(MESH_PX_PER_MM["p50"] * factor, abs=0.02)
|
|
assert mesh_scale["max"] == pytest.approx(MESH_PX_PER_MM["max"] * factor, abs=0.05)
|
|
assert mesh_scale["basis"] == "per-mesh-edge projected px / 3D mm, all 7341 edges"
|
|
|
|
probe_scale = manifest["probe_px_per_mm"]
|
|
axis_scales = [probe_scale["x"], probe_scale["y"], probe_scale["z"]]
|
|
assert probe_scale["p50"] == pytest.approx(sorted(axis_scales)[1])
|
|
# Normal motion is largely out of the image plane: a real sensor property.
|
|
assert probe_scale["z"] < probe_scale["x"]
|
|
assert probe_scale["z"] < probe_scale["y"]
|
|
assert probe_scale["basis"] == (
|
|
"probe-local: |centroid_px(step) - centroid_px(0)| / step_mm, "
|
|
"per axis at its largest step"
|
|
)
|
|
|
|
roi = manifest["probe_roi_px"]
|
|
assert roi["radius"] == pytest.approx(0.5 * probe_scale["p50"], rel=1e-12)
|
|
reference_centroid = manifest["frames"][0]["centroid_px"]
|
|
assert reference_centroid[0] == pytest.approx(roi["u"], abs=1e-9)
|
|
assert reference_centroid[1] == pytest.approx(roi["v"], abs=1e-9)
|
|
|
|
# --- every planned frame, in order, with matching bytes ---
|
|
assert len(manifest["frames"]) == FRAME_COUNT
|
|
assert [entry["name"] for entry in manifest["frames"]] == [frame.name for frame in plan]
|
|
assert [entry["axis"] for entry in manifest["frames"]] == [
|
|
frame.axis for frame in plan
|
|
]
|
|
for entry in manifest["frames"]:
|
|
png = out / entry["png"]
|
|
assert png.is_file() and png.stat().st_size > 0
|
|
assert entry["sha256"] == sha256_file(png)
|
|
assert _png_size(png) == (width, height)
|
|
assert (entry["width"], entry["height"]) == (width, height)
|
|
assert len(entry["centroid_px"]) == 2
|
|
assert all(isinstance(value, float) for value in entry["centroid_px"])
|
|
|
|
# frames/ holds exactly the plan plus the null frame, nothing else
|
|
written = {path.name for path in (out / render.FRAMES_DIRNAME).glob("*.png")}
|
|
assert written == {f"{frame.name}.png" for frame in plan} | {
|
|
f"{REFERENCE_NULL_NAME}.png"
|
|
}
|
|
|
|
# the per-frame centroids reproduce probe_px_per_mm
|
|
for axis in ("x", "y", "z"):
|
|
axis_entries = [entry for entry in manifest["frames"] if entry["axis"] == axis]
|
|
assert axis_entries
|
|
last = max(axis_entries, key=lambda entry: entry["step_mm"])
|
|
measured = math.dist(last["centroid_px"], reference_centroid) / last["step_mm"]
|
|
assert measured == pytest.approx(probe_scale[axis], rel=1e-9)
|
|
|
|
# --- null frame ---
|
|
null = manifest["null"]
|
|
assert null["name"] == REFERENCE_NULL_NAME
|
|
assert null["seed"] == 1
|
|
null_png = out / null["png"]
|
|
assert null_png.is_file() and null_png.stat().st_size > 0
|
|
assert null["sha256"] == sha256_file(null_png)
|
|
|
|
# The whole 0.1 mm sweep moves the centroid only ~1.3 px, so a distinct
|
|
# texture at the last frame must differ from the reference.
|
|
assert manifest["frames"][-1]["sha256"] != manifest["frames"][0]["sha256"]
|