"""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 -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": 3.80165, "p50": 4.87396, "max": 20.94209} 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: """Generate deformation from the current scene so mesh edits cannot reuse stale frames.""" if BLENDER is None: pytest.skip("no Blender to build probe_frames.npz") 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" assert manifest["camera"] == "cam_sensor" 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 250x250, 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 / 250.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 7590 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"]