c377c14474
2. added unwarp map export in current cli, don't need to generate by precompute in sensor running
802 lines
31 KiB
Python
802 lines
31 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
|
|
return subprocess.run(
|
|
[str(BLENDER), "-b", str(SOURCE_BLEND), "-P", str(script), "--", "--job", str(job)],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
)
|
|
|
|
|
|
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"]
|