Files
orisys-sensor-design/tests/test_render.py
T
2026-09-27 19:21:03 +08:00

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"]