1. added blender deformation probe for marker size picking and optimization

2. added unwarp map export in current cli, don't need to generate by precompute in sensor running
This commit is contained in:
ykcheungab
2026-09-25 15:44:44 +08:00
parent bc5a928852
commit c377c14474
34 changed files with 18032 additions and 1 deletions
+289
View File
@@ -0,0 +1,289 @@
"""Tests for `compare`: the settings sweep and its caching.
Every test injects a fake `stage_runner`, so nothing here needs Blender, cv2 or
matplotlib. The caching behaviour is the part worth testing hardest: if a `dis_mode`
variant re-rendered instead of reusing the frames, the sweep's runtime claim would be
false and the comparison would be comparing different pixels.
"""
from __future__ import annotations
import dataclasses
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from orisys_sensor_design import compare
class RecordingRunner:
"""A fake stage runner that records calls and writes minimal artifacts."""
def __init__(self, *, fail_on: str | None = None, scores: dict | None = None) -> None:
self.calls: list[tuple[str, Path, dict]] = []
self.settings_by_kind: dict[str, list] = {}
self.fail_on = fail_on
self.scores = scores or {"render": 10.0, "process": 4.0}
def __call__(self, kind: str, *, settings=None, output_dir=None, **kwargs) -> int:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
self.calls.append((kind, output_dir, dict(kwargs)))
self.settings_by_kind.setdefault(kind, []).append(settings)
if self.fail_on and kind == self.fail_on:
return 1
if kind == "texture":
(output_dir / "patch_texture_compute.png").write_bytes(b"png")
if kind == "deform":
(output_dir / "probe_frames.npz").write_bytes(b"npz")
if kind == "render":
(output_dir / "frames").mkdir(exist_ok=True)
manifest = {
"width": 500,
"height": 500,
"frames": [
{"name": "000_reference", "axis": None, "step_mm": 0.0,
"png": "frames/000_reference.png"},
],
"null": {"name": "reference_null", "seed": 1,
"png": "frames/reference_null.png"},
}
(output_dir / "render_manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
if kind == "sensitivity":
report = {
"overall": {"score": 12.0, "verdict": "resolvable"},
"scales": [
{"name": name, "score": score, "verdict": "resolvable",
"cell_px": 16.13, "effective_patch_px": 16.0}
for name, score in self.scores.items()
],
}
(output_dir / "sensitivity_report.json").write_text(json.dumps(report), encoding="utf-8")
return 0
def kinds(self) -> list[str]:
return [kind for kind, _directory, _kwargs in self.calls]
def dirs_for(self, kind: str) -> list[Path]:
return [directory for name, directory, _kwargs in self.calls if name == kind]
@dataclasses.dataclass(frozen=True)
class FakePattern:
"""Mirrors ``deform.PatternInfo``, which the sweep overrides per candidate."""
nx: int = 80
ny: int = 80
seed: int = 7
vector_width_mm: float = 18.5
vector_height_mm: float = 16.4
@dataclasses.dataclass(frozen=True)
class FakeSettings:
"""A frozen dataclass on purpose: ``run_compare`` uses ``dataclasses.replace``,
so a ``SimpleNamespace`` fixture would pass tests that the real
``deform.ProbeSettings`` path fails."""
radius_mm: float = 0.5
texture: str = "patch_texture_compute.png"
tracker: dict = dataclasses.field(
default_factory=lambda: {"dis_mode": 1, "dis_preset": "medium"}
)
resolution: tuple = (500, 500)
process_resolution: tuple = (250, 250)
pattern: FakePattern = dataclasses.field(default_factory=FakePattern)
def _settings() -> FakeSettings:
return FakeSettings()
def _run(tmp_path: Path, runner, **kwargs):
defaults = dict(
stage_runner=runner,
grid=(31, 80),
seeds=(7,),
dis_modes=(1, 0),
radii=(0.5,),
output_dir=tmp_path,
progress=lambda _message: None,
)
defaults.update(kwargs)
return compare.run_compare(SimpleNamespace(root=tmp_path), _settings(), **defaults)
# --------------------------------------------------------------------------- #
# Enumeration
# --------------------------------------------------------------------------- #
def test_enumerate_is_deterministic_and_sorted() -> None:
first = compare.enumerate_sweep(
grid=(80, 31), seeds=(7,), dis_modes=(0, 1), radii=(1.5, 0.5)
)
second = compare.enumerate_sweep(
grid=(31, 80), seeds=(7,), dis_modes=(1, 0), radii=(0.5, 1.5)
)
assert [entry.id for entry in first] == [entry.id for entry in second]
assert [entry.nx for entry in first] == sorted(entry.nx for entry in first)
# dis_mode 1 (the shipped setting) sorts before 0 within every n, rx block.
assert [entry.dis_mode for entry in first if entry.nx == 31 and entry.radius_mm == 0.5] == [1, 0]
def test_enumerate_rejects_bad_values() -> None:
with pytest.raises(compare.CompareError):
compare.enumerate_sweep(grid=(0,), seeds=(7,), dis_modes=(1,), radii=(0.5,))
with pytest.raises(compare.CompareError):
compare.enumerate_sweep(grid=(31,), seeds=(7,), dis_modes=(1,), radii=(0.0,))
with pytest.raises(compare.CompareError):
compare.enumerate_sweep(grid=(31,), seeds=(7,), dis_modes=(-1,), radii=(0.5,))
def test_dry_run_enumerates_and_writes_nothing(tmp_path: Path) -> None:
runner = RecordingRunner()
report, code = _run(tmp_path, runner, dry_run=True)
assert code == 0
assert runner.calls == []
assert len(report["runs"]) == 4 # ids, not outcomes
assert not (tmp_path / compare.COMPARISON_DIRNAME / compare.FILENAME).exists()
assert any("dry-run" in note for note in report["notes"])
def test_max_runs_refuses_a_large_matrix(tmp_path: Path) -> None:
with pytest.raises(compare.CompareError):
_run(tmp_path, RecordingRunner(), max_runs=2)
# --------------------------------------------------------------------------- #
# Caching: what each setting actually invalidates
# --------------------------------------------------------------------------- #
def test_texture_runs_once_per_grid_and_frames_once_per_radius(tmp_path: Path) -> None:
runner = RecordingRunner()
report, code = _run(tmp_path, runner)
assert code == 0
# 2 grids -> 2 textures; 2 (grid x radius) -> 2 deform + 2 render;
# 4 entries -> 4 track + 4 sensitivity.
assert runner.kinds().count("texture") == 2
assert runner.kinds().count("deform") == 2
assert runner.kinds().count("render") == 2
assert runner.kinds().count("track") == 4
assert runner.kinds().count("sensitivity") == 4
assert len(report["runs"]) == 4
def test_dis_mode_variant_reuses_frames_and_rewrites_the_manifest(tmp_path: Path) -> None:
"""The decisive experiment must not re-render: same pixels, different estimator."""
runner = RecordingRunner()
_run(tmp_path, runner)
render_dirs = runner.dirs_for("render")
assert len(render_dirs) == 2, "each (nx, radius) is rendered exactly once"
# The dm0 variant directory must exist, hold no frames of its own, and carry a
# manifest whose png entries are absolute and point into the rendered directory.
variants = [d for d in runner.dirs_for("track") if d.name.endswith("_dm0")]
assert variants, "expected dis_mode=0 variant directories"
for variant in variants:
assert not (variant / "frames").exists(), "the variant must not re-render"
manifest = json.loads((variant / "render_manifest.json").read_text(encoding="utf-8"))
for entry in list(manifest["frames"]) + [manifest["null"]]:
path = Path(entry["png"])
assert path.is_absolute()
assert path.parent.name == "frames"
# `png` is relative to the run directory, so the subdirectory must appear
# exactly once. Doubling it (`frames/frames/...`) failed every variant run
# with a missing-artifact exit 3.
assert "frames" + "\\" + "frames" not in str(path)
assert "frames/frames" not in str(path).replace("\\", "/")
def test_radius_change_reuses_the_texture_but_not_the_frames(tmp_path: Path) -> None:
runner = RecordingRunner()
_run(tmp_path, runner, radii=(0.5, 1.5))
# Two radii and two grids: still one texture per grid, but frames per radius.
assert runner.kinds().count("texture") == 2
assert runner.kinds().count("deform") == 4
assert runner.kinds().count("render") == 4
# Each deformation must carry its own radius, and the estimator its own dis_mode.
radii = {round(float(s.radius_mm), 3) for s in runner.settings_by_kind["deform"]}
assert radii == {0.5, 1.5}
dis_modes = {int(s.tracker["dis_mode"]) for s in runner.settings_by_kind["track"]}
assert dis_modes == {0, 1}
# A deform call must never be asked to redo a radius it already did.
assert runner.kinds().count("deform") == len({(s.radius_mm, s.texture) for s in runner.settings_by_kind["deform"]})
# --------------------------------------------------------------------------- #
# Report shape, robustness, determinism
# --------------------------------------------------------------------------- #
def test_report_records_per_setting_bests(tmp_path: Path) -> None:
report, code = _run(tmp_path, RecordingRunner())
assert code == 0
assert set(report) >= {
"schema", "search", "runs", "ranked", "best_per_dis_mode", "best_per_radius", "notes",
}
assert report["schema"] == compare.SCHEMA
assert set(report["best_per_dis_mode"]) == {"0", "1"}
assert set(report["best_per_radius"]) == {"0.5"}
assert report["ranked"]
# Notes must say the effective window is what dis_mode changes.
joined = " ".join(report["notes"])
assert "finest pyramid level" in joined
assert "patch_px * 2**dis_mode" in joined
def test_report_is_written_and_parses(tmp_path: Path) -> None:
_run(tmp_path, RecordingRunner())
path = tmp_path / compare.COMPARISON_DIRNAME / compare.FILENAME
assert path.is_file()
parsed = json.loads(path.read_text(encoding="utf-8"))
assert parsed["schema"] == compare.SCHEMA
def test_a_failing_run_is_recorded_without_aborting_the_sweep(tmp_path: Path) -> None:
runner = RecordingRunner(fail_on="sensitivity")
report, code = _run(tmp_path, runner)
assert all(run["error"] for run in report["runs"])
assert code == 1
assert any("failed and are recorded with an error" in note for note in report["notes"])
def test_two_runs_give_identical_reports(tmp_path: Path) -> None:
first, _ = _run(tmp_path, RecordingRunner())
second, _ = _run(tmp_path, RecordingRunner())
first.pop("revision", None)
second.pop("revision", None)
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
def test_build_figures_writes_three_figures(tmp_path: Path) -> None:
pytest.importorskip("matplotlib")
runner = RecordingRunner()
report, _code = _run(tmp_path, runner)
# Give one run real flow data so a panel can be drawn rather than placeholdered.
import numpy as np
for directory in runner.dirs_for("track")[:1]:
flow_dir = directory / "flow" / "render"
flow_dir.mkdir(parents=True, exist_ok=True)
np.save(flow_dir / "000_reference.npy", np.zeros((4, 4, 2), dtype=np.float32))
figures = compare.build_figures(
report, tmp_path / compare.COMPARISON_DIRNAME, progress=lambda _m: None
)
assert "tuning" in figures
assert figures["tuning"].is_file()
assert figures["tuning"].stat().st_size > 0
def test_build_figures_without_scored_runs_writes_nothing(tmp_path: Path) -> None:
pytest.importorskip("matplotlib")
figures = compare.build_figures(
{"runs": []}, tmp_path, progress=lambda _m: None
)
assert figures == {}
+625
View File
@@ -0,0 +1,625 @@
"""Tests for headless probe-deformation settings, frame plan, and job I/O."""
from __future__ import annotations
import argparse
import dataclasses
import json
from collections.abc import Mapping
from pathlib import Path
import pytest
from orisys_sensor_design.deform import (
DEFAULT_PROBE,
DEFORM_JOB_FILENAME,
DEFORM_JOB_SCHEMA,
FRAMES_NPZ_FILENAME,
DEFORM_REPORT_FILENAME,
ProbeConfigError,
ProbeFrame,
PatternInfo,
build_deform_job,
deform_job_path,
frame_plan,
load_probe_block,
merge_probe,
probe_artifact_paths,
probe_output_dir,
probe_overrides_from_args,
probe_revision_rel,
resolve_pattern,
resolve_probe,
resolve_probe_output_dir,
validate_probe,
write_deform_job,
)
from orisys_sensor_design.manifest import RevisionPaths, load_yaml, repo_root
XYNOVA_RENDER = {
"engine": "CYCLES",
"samples": 16,
"denoise": False,
"view_transform": "Standard",
"interpolate": "Closest",
"seed": 0,
}
XYNOVA_TRACKER = {"algorithm": "dis", "dis_preset": "medium", "dis_mode": 1}
@pytest.fixture()
def revision_dir() -> Path:
return repo_root() / "sensors" / "finger-xynova" / "r01"
@pytest.fixture()
def paths(revision_dir: Path) -> RevisionPaths:
return RevisionPaths.from_revision_dir(revision_dir)
def _write_revision(
tmp_path: Path,
probe_yaml: str = "",
*,
sensor_field: str = "finger",
sensor_dir: str = "finger",
revision_name: str = "r99",
) -> Path:
"""Create a minimal sensors/<sensor_dir>/<revision_name> revision under tmp_path."""
revision = tmp_path / "sensors" / sensor_dir / revision_name
(revision / "source").mkdir(parents=True)
(revision / "source" / "assembly.blend").write_bytes(b"")
design = (
f"sensor: {sensor_field}\n"
f"revision: {revision_name}\n"
"meters_per_source_unit: 0.001\n"
"source_blend: source/assembly.blend\n"
)
design += probe_yaml
(revision / "design.yaml").write_text(design, encoding="utf-8")
return revision
def _probe_namespace(**overrides: object) -> argparse.Namespace:
namespace = argparse.Namespace(
mesh=None,
group=None,
radius_mm=None,
falloff=None,
step_mm=None,
max_mm=None,
axes=None,
)
for key, value in overrides.items():
setattr(namespace, key, value)
return namespace
# ------------------------------------------------------------- settings
def _design_probe(paths: RevisionPaths) -> dict:
"""The ``probe`` block exactly as written in design.yaml.
Read rather than hard-coded. Resolution and ``dis_mode`` are deliberate choices that
change as the bench is re-targeted (250x250 needs ``dis_mode=0``; 500x500 does not),
and duplicating them here would fail on a legitimate edit while asserting nothing
extra about the pipeline. What the tests below must prove is that the resolved
settings *equal* what the design declares, at whatever value it declares.
"""
return load_yaml(paths.design_yaml)["probe"]
def _as_pair(value) -> tuple[int, int]:
return (int(value[0]), int(value[1]))
def test_defaults_come_from_design_yaml(paths: RevisionPaths) -> None:
settings = resolve_probe(paths)
design = _design_probe(paths)
assert settings.mesh == "sensing_surface"
assert settings.camera == "cam_sensor"
assert settings.group == "probe"
assert settings.uv_map == "compute"
assert settings.texture == "patch_texture_compute.png"
assert settings.radius_mm == pytest.approx(0.5)
assert settings.falloff == "smooth"
assert settings.step_mm == pytest.approx(0.01)
assert settings.max_mm == pytest.approx(0.10)
assert settings.axes == ("x", "y", "z")
assert settings.resolution == _as_pair(design["resolution"])
assert settings.process_resolution == _as_pair(design["process_resolution"])
assert settings.tracker == dict(design["tracker"])
assert settings.render == XYNOVA_RENDER
# The marker grid is resolved from the shared texture_patch block.
assert settings.pattern == PatternInfo(
nx=80, ny=80, seed=7, vector_width_mm=18.5, vector_height_mm=16.4
)
assert settings.pattern.cell_mm == pytest.approx((0.23125, 0.205))
def test_pattern_grid_resolves_from_texture_patch(paths: RevisionPaths) -> None:
pattern = resolve_pattern(paths)
assert pattern.to_job_entry() == {
"nx": 80,
"ny": 80,
"seed": 7,
"vector_width_mm": 18.5,
"vector_height_mm": 16.4,
"cell_mm": [0.23125, 0.205],
}
# The sensitivity stage duck-types settings.pattern as a mapping (.get).
assert isinstance(pattern, Mapping)
assert pattern.get("nx") == 80
assert pattern.get("seed") == 7
assert pattern.get("cell_mm") == [0.23125, 0.205]
assert dict(pattern) == pattern.to_job_entry()
def test_pattern_falls_back_to_texture_defaults(tmp_path: Path) -> None:
revision = _write_revision(tmp_path)
pattern = resolve_pattern(RevisionPaths.from_revision_dir(revision))
assert pattern == PatternInfo(
nx=80, ny=45, seed=None, vector_width_mm=164.0, vector_height_mm=160.0
)
@pytest.mark.parametrize(
("texture_yaml", "expected"),
[
("texture_patch:\n nx: 0\n", "texture_patch.nx"),
("texture_patch:\n ny: many\n", "texture_patch.ny"),
("texture_patch:\n seed: soon\n", "texture_patch.seed"),
("texture_patch:\n vector:\n width_mm: 0\n", "vector.width_mm"),
("texture_patch:\n vector:\n height_mm: -1\n", "vector.height_mm"),
("texture_patch:\n widht: 100\n", "widht"),
],
)
def test_pattern_rejects_bad_texture_patch(
tmp_path: Path, texture_yaml: str, expected: str
) -> None:
revision = _write_revision(tmp_path, texture_yaml)
with pytest.raises(ProbeConfigError) as excinfo:
resolve_pattern(RevisionPaths.from_revision_dir(revision))
assert expected in str(excinfo.value)
def test_builtin_defaults_when_probe_block_absent(tmp_path: Path) -> None:
revision = _write_revision(tmp_path)
paths = RevisionPaths.from_revision_dir(revision)
assert load_probe_block(paths) == {}
settings = resolve_probe(paths)
assert settings.radius_mm == DEFAULT_PROBE["radius_mm"]
assert settings.falloff == DEFAULT_PROBE["falloff"]
assert settings.step_mm == DEFAULT_PROBE["step_mm"]
assert settings.max_mm == DEFAULT_PROBE["max_mm"]
assert settings.axes == tuple(DEFAULT_PROBE["axes"])
# Object names fall back to design.yaml `objects` defaults.
assert settings.mesh == "sensing_surface"
assert settings.camera == "cam_sensor"
assert settings.group == "probe"
def test_design_yaml_overrides_builtin_defaults(tmp_path: Path) -> None:
revision = _write_revision(
tmp_path,
"probe:\n"
" radius_mm: 0.25\n"
" step_mm: 0.02\n"
" max_mm: 0.06\n"
" axes: [z, x]\n"
" tracker:\n"
" dis_preset: fast\n",
)
settings = resolve_probe(RevisionPaths.from_revision_dir(revision))
assert settings.radius_mm == pytest.approx(0.25)
assert settings.step_mm == pytest.approx(0.02)
assert settings.max_mm == pytest.approx(0.06)
assert settings.axes == ("z", "x")
assert settings.tracker["dis_preset"] == "fast"
# Untouched nested keys keep their defaults.
assert settings.tracker["dis_mode"] == 1
assert settings.render == XYNOVA_RENDER
def test_cli_overrides_beat_design_yaml(paths: RevisionPaths) -> None:
settings = resolve_probe(
paths,
{
"mesh": "other_mesh",
"group": "other_group",
"radius_mm": 0.25,
"falloff": "Sharp",
"step_mm": 0.02,
"max_mm": 0.08,
"axes": ["y", "z"],
},
)
assert settings.mesh == "other_mesh"
assert settings.group == "other_group"
assert settings.radius_mm == pytest.approx(0.25)
# Falloff is normalised to the contract's lowercase mode names.
assert settings.falloff == "sharp"
assert settings.step_mm == pytest.approx(0.02)
assert settings.max_mm == pytest.approx(0.08)
assert settings.axes == ("y", "z")
def test_unset_overrides_do_not_erase_configured_values(paths: RevisionPaths) -> None:
merged = merge_probe(
paths, {"radius_mm": None, "falloff": None, "axes": None, "tracker": None}
)
assert merged["radius_mm"] == 0.5
assert merged["falloff"] == "smooth"
assert merged["axes"] == ["x", "y", "z"]
# Compared against the design rather than a pinned literal: `dis_mode` is a
# deliberate per-resolution choice (250x250 needs 0, 500x500 uses 1), and the point
# of this test is that a None override does not erase what the design declares.
assert merged["tracker"] == dict(_design_probe(paths)["tracker"])
def test_probe_must_be_a_mapping(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, "probe: fast\n")
with pytest.raises(ProbeConfigError) as excinfo:
resolve_probe(RevisionPaths.from_revision_dir(revision))
assert "must be a mapping" in str(excinfo.value)
@pytest.mark.parametrize(
("probe_yaml", "expected"),
[
("probe:\n radus_mm: 0.4\n", "radus_mm"),
("probe:\n tracker:\n preset: medium\n", "preset"),
("probe:\n render:\n view_transfrom: Standard\n", "view_transfrom"),
("probe:\n tracker: fast\n", "tracker"),
("probe:\n render: cycles\n", "render"),
],
)
def test_unknown_or_mistyped_keys_are_rejected(
tmp_path: Path, probe_yaml: str, expected: str
) -> None:
revision = _write_revision(tmp_path, probe_yaml)
with pytest.raises(ProbeConfigError) as excinfo:
resolve_probe(RevisionPaths.from_revision_dir(revision))
assert expected in str(excinfo.value)
@pytest.mark.parametrize(
("overrides", "expected"),
[
({"mesh": ""}, "mesh"),
({"group": ""}, "group"),
({"uv_map": ""}, "uv_map"),
({"texture": "tex.jpg"}, "texture"),
({"texture": "nested/tex.png"}, "texture"),
({"texture": ""}, "texture"),
({"radius_mm": 0.0}, "radius_mm"),
({"radius_mm": -1.0}, "radius_mm"),
({"radius_mm": True}, "radius_mm"),
({"falloff": "bounce"}, "falloff"),
({"step_mm": 0.0}, "step_mm"),
({"step_mm": -0.01}, "step_mm"),
({"max_mm": 0.0}, "max_mm"),
({"step_mm": 0.2, "max_mm": 0.1}, "max_mm"),
({"axes": []}, "axes"),
({"axes": ["w"]}, "axes"),
({"axes": ["x", "x"]}, "axes"),
({"axes": "x,y"}, "axes"),
({"resolution": [0, 500]}, "resolution.width"),
({"resolution": [500]}, "resolution"),
({"resolution": [500, 32769]}, "resolution.height"),
({"process_resolution": ["a", "b"]}, "process_resolution"),
({"tracker": {"algorithm": ""}}, "tracker.algorithm"),
({"tracker": {"dis_preset": ""}}, "tracker.dis_preset"),
({"tracker": {"dis_mode": -1}}, "tracker.dis_mode"),
({"tracker": {"dis_mode": "1"}}, "tracker.dis_mode"),
({"render": {"engine": ""}}, "render.engine"),
({"render": {"samples": 0}}, "render.samples"),
({"render": {"denoise": "no"}}, "render.denoise"),
({"render": {"view_transform": "AgX"}}, "view_transform"),
({"render": {"interpolate": ""}}, "render.interpolate"),
({"render": {"seed": "x"}}, "render.seed"),
],
)
def test_invalid_overrides_are_rejected(
paths: RevisionPaths, overrides: dict, expected: str
) -> None:
with pytest.raises(ProbeConfigError) as excinfo:
resolve_probe(paths, overrides)
assert expected in str(excinfo.value)
def test_validation_reports_every_problem(paths: RevisionPaths) -> None:
block = merge_probe(paths, {"radius_mm": 0.0, "falloff": "bounce", "step_mm": -1.0})
errors = validate_probe(block, {"sensing_surface": "sensing_surface", "cam_sensor": "cam_sensor"})
assert len([line for line in errors if line]) == 3
with pytest.raises(ProbeConfigError) as excinfo:
resolve_probe(paths, {"radius_mm": 0.0, "falloff": "bounce"})
message = str(excinfo.value)
assert "radius_mm" in message
assert "falloff" in message
def test_agx_view_transform_fails_loudly(paths: RevisionPaths) -> None:
"""AgX/Filmic silently destroys marker values; the contract forbids it."""
with pytest.raises(ProbeConfigError) as excinfo:
resolve_probe(paths, {"render": {"view_transform": "Filmic"}})
assert "Standard" in str(excinfo.value)
# ------------------------------------------------------------ frame plan
def test_frame_plan_default_has_31_ordered_frames(paths: RevisionPaths) -> None:
frames = frame_plan(resolve_probe(paths))
assert len(frames) == 1 + 3 * 10 == 31
assert [frame.index for frame in frames] == list(range(31))
assert [frame.name for frame in frames] == [
f"{frame.index:03d}_" + (
"reference" if frame.axis is None else f"{frame.axis}_+{frame.step_mm:.2f}mm"
)
for frame in frames
]
assert frames[0] == ProbeFrame(0, "000_reference", None, 0.0)
assert frames[1] == ProbeFrame(1, "001_x_+0.01mm", "x", 0.01)
assert frames[10] == ProbeFrame(10, "010_x_+0.10mm", "x", 0.10)
assert frames[11] == ProbeFrame(11, "011_y_+0.01mm", "y", 0.01)
assert frames[20] == ProbeFrame(20, "020_y_+0.10mm", "y", 0.10)
assert frames[21] == ProbeFrame(21, "021_z_+0.01mm", "z", 0.01)
assert frames[30] == ProbeFrame(30, "030_z_+0.10mm", "z", 0.10)
def test_frame_plan_is_axis_major_then_step_minor(paths: RevisionPaths) -> None:
frames = frame_plan(resolve_probe(paths))
moved = frames[1:]
assert [frame.axis for frame in moved] == ["x"] * 10 + ["y"] * 10 + ["z"] * 10
for axis in ("x", "y", "z"):
steps = [frame.step_mm for frame in moved if frame.axis == axis]
assert steps == pytest.approx([0.01 * n for n in range(1, 11)])
def test_frame_plan_single_axis_and_custom_steps(paths: RevisionPaths) -> None:
settings = dataclasses.replace(
resolve_probe(paths), axes=("z",), step_mm=0.05, max_mm=0.2
)
frames = frame_plan(settings)
assert len(frames) == 1 + round(0.2 / 0.05) == 5
assert [frame.name for frame in frames] == [
"000_reference",
"001_z_+0.05mm",
"002_z_+0.10mm",
"003_z_+0.15mm",
"004_z_+0.20mm",
]
def test_frame_plan_never_exceeds_max_mm(paths: RevisionPaths) -> None:
settings = dataclasses.replace(
resolve_probe(paths), axes=("x",), step_mm=0.03, max_mm=0.10
)
frames = frame_plan(settings)
assert [frame.step_mm for frame in frames] == pytest.approx([0.0, 0.03, 0.06, 0.09])
assert all(frame.step_mm <= settings.max_mm for frame in frames)
def test_frame_plan_names_keep_sub_centistep_precision(paths: RevisionPaths) -> None:
settings = dataclasses.replace(
resolve_probe(paths), axes=("x",), step_mm=0.005, max_mm=0.01
)
assert [frame.name for frame in frame_plan(settings)] == [
"000_reference",
"001_x_+0.005mm",
"002_x_+0.010mm",
]
def test_frame_plan_to_job_entry() -> None:
frame = ProbeFrame(10, "010_x_+0.10mm", "x", 0.10)
assert frame.to_job_entry() == {
"index": 10,
"name": "010_x_+0.10mm",
"axis": "x",
"step_mm": 0.10,
}
@pytest.mark.parametrize(
"changes",
[
{"step_mm": 0.0},
{"max_mm": 0.0},
{"axes": ()},
],
)
def test_frame_plan_rejects_degenerate_settings(
paths: RevisionPaths, changes: dict
) -> None:
settings = dataclasses.replace(resolve_probe(paths), **changes)
with pytest.raises(ProbeConfigError):
frame_plan(settings)
# --------------------------------------------------------------- paths
def test_output_dir_is_revision_scoped(paths: RevisionPaths) -> None:
root = repo_root()
assert probe_revision_rel(root, paths).as_posix() == "finger-xynova/r01"
assert probe_output_dir(root, paths) == (
root / "build" / "probe" / "finger-xynova" / "r01"
).resolve()
def test_output_dir_explicit_override_wins(
paths: RevisionPaths, tmp_path: Path
) -> None:
explicit = tmp_path / "elsewhere"
assert resolve_probe_output_dir(repo_root(), paths, explicit) == explicit.resolve()
assert resolve_probe_output_dir(repo_root(), paths) == probe_output_dir(
repo_root(), paths
)
def test_output_dir_uses_directory_names_not_design_sensor_field(
tmp_path: Path,
) -> None:
revision = _write_revision(
tmp_path, sensor_field="finger", sensor_dir="finger-xynova", revision_name="r01"
)
paths = RevisionPaths.from_revision_dir(revision)
assert probe_revision_rel(tmp_path, paths) == Path("finger-xynova") / "r01"
def test_artifact_paths_and_job_path(tmp_path: Path) -> None:
artifacts = probe_artifact_paths(tmp_path / "out")
assert set(artifacts) == {"frames", "report"}
assert artifacts["frames"] == (tmp_path / "out" / FRAMES_NPZ_FILENAME).resolve()
assert artifacts["report"] == (tmp_path / "out" / DEFORM_REPORT_FILENAME).resolve()
assert deform_job_path(tmp_path / "out") == tmp_path / "out" / DEFORM_JOB_FILENAME
# --------------------------------------------------------------- job I/O
def test_job_round_trips_with_absolute_paths(
paths: RevisionPaths, tmp_path: Path
) -> None:
settings = resolve_probe(paths)
output_dir = tmp_path / "probe"
job = build_deform_job(paths, settings, output_dir)
assert job["schema"] == DEFORM_JOB_SCHEMA
assert set(job) == {
"schema",
"revision_dir",
"source_blend",
"output_dir",
"objects",
"settings",
"frames",
"artifacts",
}
assert Path(job["revision_dir"]).is_absolute()
assert Path(job["source_blend"]).is_absolute()
assert Path(job["output_dir"]).is_absolute()
assert job["objects"] == {"mesh": "sensing_surface", "group": "probe"}
assert job["settings"] == {
"radius_mm": 0.5,
"falloff": "smooth",
"step_mm": 0.01,
"max_mm": 0.10,
"axes": ["x", "y", "z"],
"pattern": {
"nx": 80,
"ny": 80,
"seed": 7,
"vector_width_mm": 18.5,
"vector_height_mm": 16.4,
"cell_mm": [0.23125, 0.205],
},
}
assert job["frames"] == [frame.to_job_entry() for frame in frame_plan(settings)]
assert len(job["frames"]) == 31
assert job["artifacts"] == {
"frames": str((output_dir / FRAMES_NPZ_FILENAME).resolve()),
"report": str((output_dir / DEFORM_REPORT_FILENAME).resolve()),
}
assert all(Path(path).is_absolute() for path in job["artifacts"].values())
job_path = write_deform_job(job, output_dir)
assert job_path == output_dir / DEFORM_JOB_FILENAME
loaded = json.loads(job_path.read_text(encoding="utf-8"))
assert loaded == job
assert loaded["frames"][10] == {
"index": 10,
"name": "010_x_+0.10mm",
"axis": "x",
"step_mm": 0.10,
}
def test_write_job_creates_output_directory(paths: RevisionPaths, tmp_path: Path) -> None:
settings = resolve_probe(paths)
nested = tmp_path / "a" / "b"
write_deform_job(build_deform_job(paths, settings, nested), nested)
assert (nested / DEFORM_JOB_FILENAME).is_file()
# -------------------------------------------------------- CLI overrides
def test_probe_overrides_from_args() -> None:
overrides = probe_overrides_from_args(
_probe_namespace(
mesh="m",
group="g",
radius_mm=0.25,
falloff="sharp",
step_mm=0.02,
max_mm=0.08,
axes="x, z",
)
)
assert overrides == {
"mesh": "m",
"group": "g",
"radius_mm": 0.25,
"falloff": "sharp",
"step_mm": 0.02,
"max_mm": 0.08,
"axes": ["x", "z"],
}
def test_probe_overrides_from_args_unset_stays_none() -> None:
overrides = probe_overrides_from_args(_probe_namespace())
assert overrides["axes"] is None
merged = merge_probe(None, overrides)
assert merged["radius_mm"] == DEFAULT_PROBE["radius_mm"]
assert merged["axes"] == ["x", "y", "z"]
def test_probe_overrides_from_args_accepts_axis_sequence() -> None:
overrides = probe_overrides_from_args(_probe_namespace(axes=["z", "y"]))
assert overrides["axes"] == ["z", "y"]
# ------------------------------------------------------ stage consumers
def test_stage_settings_blocks_match_the_contract(paths: RevisionPaths) -> None:
settings = resolve_probe(paths)
design = _design_probe(paths)
width, height = _as_pair(design["resolution"])
assert settings.to_render_settings(Path("textures") / "patch_texture_compute.png") == {
"uv_map": "compute",
"texture": str((Path("textures") / "patch_texture_compute.png").resolve()),
"width": width,
"height": height,
"engine": "CYCLES",
"samples": 16,
"denoise": False,
"view_transform": "Standard",
"interpolate": "Closest",
"seed": 0,
}
assert settings.to_track_settings() == {
"axes": ["x", "y", "z"],
"step_mm": 0.01,
"max_mm": 0.10,
"radius_mm": 0.5,
"process_resolution": list(_as_pair(design["process_resolution"])),
"tracker": dict(design["tracker"]),
# Additive: the render-seed floor is a reproducibility diagnostic, and this
# block is what lets the estimator measure signal and noise under one model.
"noise": {
"idle_footage": "../data/dd02-0703/190.mp4",
"roi_fraction": 0.18,
"max_frames": 120,
"sigmas": [4.0, 16.0],
"draws": 1,
"seed": 0,
"models": ["render_seed", "injected"],
"scales": ["render"],
},
}
+869
View File
@@ -0,0 +1,869 @@
"""Synthetic-image tests for the DIS optical-flow stage (no Blender required).
Every test here runs against constructed images: the estimator gate, the
reference-self check, the ROI geometry, the statistics and the job runner. The
one thing they cannot prove is how DIS behaves on the real Cycles renders -- that
is what the frozen ``known_answer`` gate in ``flow_report.json`` records for the
integration run.
"""
from __future__ import annotations
import hashlib
import json
import types
from pathlib import Path
import numpy as np
import pytest
from orisys_sensor_design import flow
RENDER = {"name": "render", "width": 500, "height": 500, "resample": False}
PROCESS = {"name": "process", "width": 250, "height": 250, "resample": True}
#: The probe.tracker block of the default design.yaml.
TRACKER = {"algorithm": "dis", "dis_preset": "medium", "dis_mode": 1}
# ---------------------------------------------------------------------------
# config / presets
# ---------------------------------------------------------------------------
def test_dis_preset_from_name_matches_sdk_mapping():
assert flow.dis_preset_from_name("ultrafast") == 0
assert flow.dis_preset_from_name("fast") == 1
assert flow.dis_preset_from_name("medium") == 2
# Mirrors DIS_no_integrate: unknown and None fall back to MEDIUM.
assert flow.dis_preset_from_name("FAST") == 1
assert flow.dis_preset_from_name(None) == 2
assert flow.dis_preset_from_name("bogus") == 2
def test_load_tracker_config_setdefaults_match_sdk():
config = flow.load_tracker_config({})
assert isinstance(config, flow.TrackerConfig)
assert dict(config) == {
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
}
# Attribute access, because the SDK reads config_tracker["dis_preset"].
assert config.dis_preset == "medium"
assert config.dis_mode == 3
assert config["algorithm"] == "dis"
def test_load_tracker_config_preserves_explicit_values():
config = flow.load_tracker_config({"dis_preset": "fast", "dis_mode": 1})
assert config["dis_preset"] == "fast"
assert config["dis_mode"] == 1
assert config["algorithm"] == "dis"
def test_load_tracker_config_unwraps_tracker_and_probe_blocks():
nested = flow.load_tracker_config({"tracker": {"dis_mode": 1, "dis_preset": "fast"}})
assert nested["dis_mode"] == 1 and nested["dis_preset"] == "fast"
design = flow.load_tracker_config(
{"probe": {"tracker": {"dis_mode": 1, "dis_preset": "fast"}}}
)
assert design["dis_mode"] == 1 and design["dis_preset"] == "fast"
def test_load_tracker_config_reads_json_and_yaml_files(tmp_path: Path):
json_path = tmp_path / "tracker.json"
json_path.write_text(json.dumps({"tracker": {"dis_mode": 1}}), encoding="utf-8")
assert flow.load_tracker_config(json_path)["dis_mode"] == 1
yaml_path = tmp_path / "design.yaml"
yaml_path.write_text("probe:\n tracker:\n dis_mode: 1\n dis_preset: fast\n", encoding="utf-8")
config = flow.load_tracker_config(str(yaml_path))
assert config["dis_mode"] == 1 and config["dis_preset"] == "fast"
def test_load_tracker_config_rejects_bad_sources(tmp_path: Path):
with pytest.raises(flow.FlowError, match="not found"):
flow.load_tracker_config(tmp_path / "nope.json")
with pytest.raises(flow.FlowError, match="mapping"):
flow.load_tracker_config(42) # type: ignore[arg-type]
with pytest.raises(flow.FlowError, match="only implements"):
flow.load_tracker_config({"algorithm": "raft"})
with pytest.raises(flow.FlowError, match="dis_mode"):
flow.load_tracker_config({"dis_mode": -1})
with pytest.raises(flow.FlowError, match="dis_mode"):
flow.load_tracker_config({"dis_mode": "1"})
# ---------------------------------------------------------------------------
# image helpers
# ---------------------------------------------------------------------------
def test_resample_shape_and_area_filter():
image = np.zeros((4, 4), dtype=np.uint8)
image[::2, ::2] = 255 # 2x2 blocks average to ~64 with INTER_AREA
out = flow.resample(image, (2, 2))
assert out.shape == (2, 2)
assert out.dtype == np.uint8
assert np.all((out > 0) & (out < 255)), f"INTER_NEAREST would give 0/255: {out.tolist()}"
assert np.allclose(out, 64, atol=2)
big = flow.resample(image, (8, 8))
assert big.shape == (8, 8)
with pytest.raises(flow.FlowError, match="positive"):
flow.resample(image, (0, 4))
def test_load_gray_roundtrip(tmp_path: Path):
cv2 = pytest.importorskip("cv2")
rng = np.random.default_rng(0)
image = rng.integers(0, 256, (32, 48), dtype=np.uint8)
path = tmp_path / "frame.png"
assert cv2.imwrite(str(path), image)
loaded = flow.load_gray(path)
assert loaded.shape == (32, 48)
assert np.array_equal(loaded, image)
with pytest.raises(flow.FlowError, match="cannot read"):
flow.load_gray(tmp_path / "missing.png")
def test_missing_cv2_raises_install_hint(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(flow, "cv2", None)
with pytest.raises(ImportError, match=r"pip install -e \.\[probe\]"):
flow.load_gray("whatever.png")
with pytest.raises(ImportError, match=r"pip install -e \.\[probe\]"):
flow.FixedReferenceFlow(TRACKER)
with pytest.raises(ImportError, match=r"pip install -e \.\[probe\]"):
flow.dis_preset_from_name("medium")
# ---------------------------------------------------------------------------
# ROI geometry
# ---------------------------------------------------------------------------
def test_region_masks_disc_and_control_ring():
roi, control = flow.region_masks((101, 101), (50.0, 50.0), 10.0, 2.0)
# Gauss-circle lattice counts, independent of this implementation:
# |{p in Z^2 : |p| <= 10}| = 317, |{p : 10 < |p| <= 20}| = 1257 - 317 = 940.
assert int(roi.sum()) == 317
assert int(control.sum()) == 940
assert not (roi & control).any()
assert roi[50, 50]
assert roi[50, 60] and not control[50, 60] # exactly on the ROI boundary
assert control[50, 65] and not roi[50, 65] # d = 15, inside the ring
assert not roi[50, 75] and not control[50, 75] # d = 25, outside both
assert roi.dtype == bool and control.dtype == bool
def test_region_masks_rejects_bad_geometry():
with pytest.raises(flow.FlowError, match="radius"):
flow.region_masks((101, 101), (50.0, 50.0), 0.0)
with pytest.raises(flow.FlowError, match="control_scale"):
flow.region_masks((101, 101), (50.0, 50.0), 10.0, 1.0)
with pytest.raises(flow.FlowError, match="finite"):
flow.region_masks((101, 101), (np.nan, 50.0), 10.0)
def test_region_masks_truncate_at_the_frame_border():
roi, _ = flow.region_masks((51, 51), (0.0, 0.0), 10.0)
assert roi[0, 0]
assert int(roi.sum()) < flow.expected_disc_pixels(10.0) / 2
# Quarter disc: by 4-fold symmetry 317 = 4*Q + 4*10 axis points + 1 origin
# with Q = 69 interior points, so the x >= 0, y >= 0 quarter holds 90.
assert int(roi.sum()) == 90
# ---------------------------------------------------------------------------
# flow statistics
# ---------------------------------------------------------------------------
def test_flow_stats_on_constant_flow():
height = width = 41
flow_field = np.zeros((height, width, 2), dtype=np.float32)
flow_field[:, :, 0] = 3.0
flow_field[:, :, 1] = -4.0
roi, _ = flow.region_masks((height, width), (20.0, 20.0), 8.0)
stats = flow.flow_stats(flow_field, roi)
assert stats["mean_u"] == pytest.approx(3.0)
assert stats["mean_v"] == pytest.approx(-4.0)
assert stats["mean_mag"] == pytest.approx(5.0)
assert stats["p50_mag"] == pytest.approx(5.0)
assert stats["p95_mag"] == pytest.approx(5.0)
assert stats["max_mag"] == pytest.approx(5.0)
assert stats["coverage"] == 1.0
def test_flow_stats_coverage_counts_nonzero_pixels():
flow_field = np.zeros((21, 21, 2), dtype=np.float32)
flow_field[0, 0, 0] = 1.0 # outside the ROI
roi, _ = flow.region_masks((21, 21), (10.0, 10.0), 3.0)
stats = flow.flow_stats(flow_field, roi)
assert stats["coverage"] == 0.0
assert stats["max_mag"] == 0.0
flow_field[10, 10, 1] = 2.0
assert flow.flow_stats(flow_field, roi)["coverage"] == pytest.approx(1.0 / int(roi.sum()))
def test_flow_stats_rejects_bad_input():
roi, _ = flow.region_masks((21, 21), (10.0, 10.0), 3.0)
with pytest.raises(flow.FlowError, match="mask selects no pixels"):
flow.flow_stats(np.zeros((21, 21, 2), np.float32), roi & False)
with pytest.raises(flow.FlowError, match="H, W, 2"):
flow.flow_stats(np.zeros((21, 21), np.float32), roi)
bad = np.zeros((21, 21, 2), dtype=np.float32)
bad[10, 10, 0] = np.nan
with pytest.raises(flow.FlowError, match="non-finite"):
flow.flow_stats(bad, roi)
with pytest.raises(flow.FlowError, match="mask shape"):
flow.flow_stats(np.zeros((21, 21, 2), np.float32), np.ones((5, 5), bool))
# ---------------------------------------------------------------------------
# forward/backward residual (Tracker.__getForwardBackwardError port)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def texture_200():
return flow.synthetic_texture(200, 200, seed=11)
def test_forward_backward_error_is_zero_for_identity(texture_200):
height, width = texture_200.shape
flow_field = np.zeros((height, width, 2), dtype=np.float32)
assert flow.forward_backward_error(texture_200, texture_200, flow_field) == 0.0
def test_forward_backward_error_grows_with_a_wrong_flow(texture_200):
height, width = texture_200.shape
shifted = flow.known_answer_shift(texture_200, 3, -2)
truth = np.zeros((height, width, 2), dtype=np.float32)
truth[:, :, 0] = 3.0
truth[:, :, 1] = -2.0
wrong = truth.copy()
wrong[:, :, 0] += 10.0
mask = np.zeros((height, width), dtype=bool)
mask[10:-10, 10:-10] = True
good = flow.forward_backward_error(texture_200, shifted, truth, mask)
bad = flow.forward_backward_error(texture_200, shifted, wrong, mask)
assert good < 5.0, f"a correct flow should leave almost no residual, got {good}"
assert bad > 5.0 * max(good, 1e-6), f"wrong flow residual {bad} vs {good}"
with pytest.raises(flow.FlowError, match="does not match"):
flow.forward_backward_error(texture_200, shifted, truth[:50, :50], mask)
def test_known_answer_shift_is_exact_for_integer_shifts(texture_200):
shifted = flow.known_answer_shift(texture_200, 2, 0)
assert shifted.shape == texture_200.shape
assert np.array_equal(shifted[:, 2:], texture_200[:, :-2])
assert np.array_equal(shifted[:, :2], texture_200[:, :1].repeat(2, axis=1))
# ---------------------------------------------------------------------------
# the estimator itself
# ---------------------------------------------------------------------------
def test_reference_self_is_exactly_zero():
estimator = flow.FixedReferenceFlow(TRACKER)
image = flow.synthetic_texture(160, 160, seed=2)
estimator.set_reference(image)
self_flow = estimator.compute(image)
assert self_flow.shape == (160, 160, 2)
assert self_flow.dtype == np.float32
assert float(np.abs(self_flow).max()) == 0.0
def test_compute_without_a_reference_behaves_like_fixed_ref_dis():
estimator = flow.FixedReferenceFlow(TRACKER)
image = flow.synthetic_texture(64, 64, seed=3)
first = estimator.compute(image) # becomes the reference, returns zeros
assert float(np.abs(first).max()) == 0.0
assert np.array_equal(estimator.reference, image)
with pytest.raises(flow.FlowError, match="does not match the fixed reference"):
estimator.compute(np.zeros((32, 32), np.uint8))
def test_patch_size_is_measured_from_the_dis_instance():
estimator = flow.FixedReferenceFlow(TRACKER)
assert estimator.patch_px == 8
assert estimator.finest_scale == 1
assert estimator.preset_name == "medium"
def test_known_answer_recovers_an_integer_shift_at_both_scales():
block = flow.validate_estimator(TRACKER, scales=[RENDER, PROCESS])
assert block["passed"] is True
assert block["tol"] == flow.DEFAULT_KNOWN_ANSWER_TOL_PX
assert [(case["dx"], case["dy"]) for case in block["cases"]] == [
(2, 0),
(0, 2),
(1, 0),
(0, 1),
]
for case in block["cases"]:
assert set(case) == {"dx", "dy", "measured_u", "measured_v", "tol"}
assert abs(case["measured_u"] - case["dx"]) <= case["tol"]
assert abs(case["measured_v"] - case["dy"]) <= case["tol"]
def test_known_answer_gate_rejects_a_wrong_expectation():
estimator = flow.FixedReferenceFlow(TRACKER)
reference = flow.synthetic_texture(200, 200, seed=4)
# Applied 2 px, but the gate is told to expect 5 px: it must refuse.
with pytest.raises(flow.FlowError, match="known-answer gate FAILED"):
flow.measure_known_answer(estimator, reference, 2, 0, expect=(5.0, 0.0))
# And it must accept the truth.
case = flow.measure_known_answer(estimator, reference, 2, 0)
assert abs(case["measured_u"] - 2.0) <= case["tol"]
class _StubEstimator:
"""Injectable estimator: returns a constant flow vector for every frame."""
patch_px = 8
def __init__(self, u: float, v: float) -> None:
self.u, self.v = float(u), float(v)
self._reference = None
def set_reference(self, gray: np.ndarray) -> np.ndarray:
self._reference = np.asarray(gray).copy()
return self._reference
def compute(self, gray: np.ndarray) -> np.ndarray:
shape = (*np.asarray(gray).shape[:2], 2)
return np.stack(
[np.full(shape[:2], self.u, np.float32), np.full(shape[:2], self.v, np.float32)],
axis=-1,
)
def test_known_answer_gate_rejects_a_broken_estimator(monkeypatch: pytest.MonkeyPatch):
# A stub that always reports zero flow cannot recover a 2 px shift.
monkeypatch.setattr(flow, "FixedReferenceFlow", lambda config=None: _StubEstimator(0.0, 0.0))
with pytest.raises(flow.FlowError, match="known-answer gate FAILED") as excinfo:
flow.validate_estimator(TRACKER, scales=[RENDER])
assert excinfo.value.report["passed"] is False
assert excinfo.value.report["cases"][0]["measured_u"] == 0.0
def test_known_answer_gate_accepts_a_correct_stub():
# The gate is not vacuous: an estimator that answers exactly right passes.
stub = _StubEstimator(2.0, 0.0)
case = flow.measure_known_answer(stub, flow.synthetic_texture(200, 200, seed=4), 2, 0)
assert case["measured_u"] == 2.0 and case["measured_v"] == 0.0
def test_reference_self_helper_rejects_nonzero_flow():
with pytest.raises(flow.FlowError, match="reference_self is not zero"):
flow._verify_reference_self(_StubEstimator(0.5, 0.0), np.zeros((16, 16), np.uint8), "render")
assert flow._verify_reference_self(_StubEstimator(0.0, 0.0), np.zeros((16, 16), np.uint8), "render") == 0.0
def test_evaluate_known_answer_scales_shifts_and_keeps_zero_components():
block, problems = flow.evaluate_known_answer([RENDER, PROCESS], TRACKER)
assert problems == []
assert [(case["dx"], case["dy"]) for case in block["cases"]] == [
(2, 0),
(0, 2),
(1, 0),
(0, 1),
]
# ---------------------------------------------------------------------------
# strategy / direction reading
# ---------------------------------------------------------------------------
def test_load_strategy_directions_accepts_contract_shapes(tmp_path: Path):
path = tmp_path / "strategy_probe.json"
path.write_text(
json.dumps({"axes": {"x": {"direction_px": [3.0, 4.0]}, "y": {"direction_px": [0, 2]}}}),
encoding="utf-8",
)
directions = flow.load_strategy_directions(path)
assert directions["x"] == pytest.approx((0.6, 0.8))
assert directions["y"] == pytest.approx((0.0, 1.0))
path.write_text(
json.dumps({"axes": [{"axis": "z", "unit_px": [0.0, -5.0]}]}), encoding="utf-8"
)
assert flow.load_strategy_directions(path)["z"] == pytest.approx((0.0, -1.0))
assert flow.load_strategy_directions(tmp_path / "absent.json") == {}
def test_directions_from_manifest_centroid_finite_difference():
manifest = {
"reference_index": 0,
"frames": [
{"index": 0, "axis": None, "step_mm": 0.0, "centroid_px": [250.0, 250.0]},
{"index": 1, "axis": "x", "step_mm": 0.01, "centroid_px": [250.01, 250.0]},
{"index": 2, "axis": "x", "step_mm": 0.10, "centroid_px": [250.13, 250.0]},
{"index": 3, "axis": "y", "step_mm": 0.10, "centroid_px": [250.0, 249.87]},
],
}
directions = flow.directions_from_manifest(manifest)
assert directions["x"] == pytest.approx((1.0, 0.0))
assert directions["y"] == pytest.approx((0.0, -1.0))
assert flow.directions_from_manifest({"frames": []}) == {}
# ---------------------------------------------------------------------------
# projection helpers (fallbacks; the render manifest is preferred)
# ---------------------------------------------------------------------------
def test_project_point_and_px_per_mm_at():
intrinsics = {"K": np.array([[500.0, 0, 500.0], [0, 500.0, 500.0], [0, 0, 1.0]]),
"width": 1000, "height": 1000}
scale = np.eye(4)
centre = flow.project_point(intrinsics, scale, (0.0, 0.0, 10.0), size=(500, 500))
assert centre == pytest.approx((249.5, 249.5), abs=1e-6)
px_per_mm = flow.px_per_mm_at(
intrinsics, scale, (0.0, 0.0, 10.0), (1.0, 0.0, 0.0), size=(500, 500)
)
assert px_per_mm == pytest.approx(25.0) # 500 px focal / 10 mm depth, halved by the resize
with pytest.raises(flow.FlowError, match="non-zero vector"):
flow.px_per_mm_at(intrinsics, scale, (0, 0, 10), (0, 0, 0), size=(500, 500))
# ---------------------------------------------------------------------------
# job building
# ---------------------------------------------------------------------------
def test_build_track_job_from_mapping_and_object(tmp_path: Path):
settings = {
"axes": ("x", "y", "z"),
"step_mm": 0.01,
"max_mm": 0.10,
"radius_mm": 0.5,
"resolution": (500, 500),
"process_resolution": (250, 250),
"tracker": dict(TRACKER),
}
job = flow.build_track_job(settings, tmp_path)
assert job["schema"] == flow.TRACK_JOB_SCHEMA
assert job["settings"]["axes"] == ["x", "y", "z"]
assert job["settings"]["tracker"]["dis_mode"] == 1
assert [scale["name"] for scale in job["scales"]] == ["render", "process"]
assert job["scales"][1]["resample"] is True
assert job["artifacts"]["manifest"] == str(tmp_path / flow.RENDER_MANIFEST_FILENAME)
assert job["artifacts"]["strategy"] == str(tmp_path / flow.STRATEGY_FILENAME)
namespace = types.SimpleNamespace(**settings)
assert flow.build_track_job(namespace, tmp_path) == job
assert flow.build_track_job(settings, tmp_path, include_process_scale=False)["scales"] == [
RENDER
]
same = dict(settings, process_resolution=(500, 500))
assert len(flow.build_track_job(same, tmp_path)["scales"]) == 1
with pytest.raises(flow.FlowError, match="resolution"):
flow.build_track_job({"resolution": (500, 500, 1)}, tmp_path)
def test_write_track_job_roundtrip(tmp_path: Path):
job = flow.build_track_job(
{"resolution": (500, 500), "process_resolution": (250, 250), "tracker": dict(TRACKER)},
tmp_path,
)
path = flow.write_track_job(job, tmp_path)
assert path.name == flow.TRACK_JOB_FILENAME
assert json.loads(path.read_text(encoding="utf-8")) == job
# ---------------------------------------------------------------------------
# run_track_job end to end on synthetic frames
# ---------------------------------------------------------------------------
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _write_scene(root: Path, base: np.ndarray | None = None) -> Path:
"""Write synthetic frames + a contract-shaped render_manifest.json."""
cv2 = pytest.importorskip("cv2")
frames_dir = root / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
if base is None:
base = flow.synthetic_texture(500, 500, seed=7)
# 13.43 px/mm: 0.01 mm = 0.1343 px, 0.10 mm = 1.343 px.
shifts = {
"000_reference": (0.0, 0.0),
"001_x_+0.01mm": (0.1343, 0.0),
"002_x_+0.10mm": (1.343, 0.0),
"003_y_+0.10mm": (0.0, 1.343),
}
entries = []
for index, (name, (dx, dy)) in enumerate(shifts.items()):
image = base if (dx, dy) == (0.0, 0.0) else flow.known_answer_shift(base, dx, dy)
path = frames_dir / f"{name}.png"
assert cv2.imwrite(str(path), image)
axis = None if index == 0 else ("x" if "x_" in name else "y")
step = 0.0 if index == 0 else (0.01 if "0.01" in name else 0.10)
entries.append(
{
"index": index,
"name": name,
"axis": axis,
"step_mm": step,
"png": f"frames/{name}.png",
"sha256": _sha256(path),
"width": 500,
"height": 500,
"centroid_px": [250.0 + dx, 250.0 + dy],
}
)
rng = np.random.default_rng(21)
null = np.clip(
base.astype(np.int16) + rng.integers(-2, 3, base.shape, dtype=np.int16), 0, 255
).astype(np.uint8)
null_path = frames_dir / "reference_null.png"
assert cv2.imwrite(str(null_path), null)
manifest = {
"schema": flow.RENDER_MANIFEST_SCHEMA,
"width": 500,
"height": 500,
"reference_index": 0,
"probe_px_per_mm": {"p50": 13.43, "min": 10.499, "max": 38.594},
"probe_roi_px": {"u": 250.0, "v": 250.0, "radius": 20.0},
"frames": entries,
"null": {
"name": "reference_null",
"seed": 1,
"png": "frames/reference_null.png",
"sha256": _sha256(null_path),
},
}
manifest_path = root / flow.RENDER_MANIFEST_FILENAME
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return manifest_path
def _synthetic_job(root: Path) -> dict:
settings = {
"axes": ["x", "y"],
"step_mm": 0.10,
"max_mm": 0.10,
"radius_mm": 0.5,
"resolution": (500, 500),
"process_resolution": (250, 250),
"tracker": dict(TRACKER),
}
return flow.build_track_job(settings, root)
@pytest.fixture(scope="module")
def scene(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, dict]:
root = tmp_path_factory.mktemp("scene")
manifest_path = _write_scene(root)
job = _synthetic_job(root)
strategy = root / flow.STRATEGY_FILENAME
strategy.write_text(
json.dumps({"axes": {"x": {"direction_px": [1.0, 0.0]}, "y": {"direction_px": [0.0, 1.0]}}}),
encoding="utf-8",
)
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_OK, report
return root, report
def test_run_track_job_report_matches_the_contract(scene):
root, report = scene
assert report["schema"] == flow.FLOW_REPORT_SCHEMA
assert (root / flow.FLOW_REPORT_FILENAME).is_file()
tracker = report["tracker"]
assert tracker["algorithm"] == "dis"
assert tracker["dis_preset"] == "medium"
assert tracker["dis_mode"] == 1
assert tracker["finest_scale"] == 1
assert tracker["patch_px"] == 8
assert report["known_answer"]["passed"] is True
assert report["known_answer"]["cases"]
assert report["reference_self"] == {"max_abs_flow_px": 0.0}
diagnostic = report["reference_texture_known_answer"]
assert diagnostic["available"] is True
assert diagnostic["gating"] is False
assert diagnostic["scale"] == "render"
assert [(case["dx"], case["dy"]) for case in diagnostic["cases"]] == [(2, 0), (0, 2)]
for case in diagnostic["cases"]:
assert set(case) >= {"dx", "dy", "measured_u", "measured_v", "tol", "passed"}
assert [scale["name"] for scale in report["scales"]] == ["render", "process"]
def test_run_track_job_measures_response_and_floor(scene):
_, report = scene
by_name = {scale["name"]: scale for scale in report["scales"]}
render = by_name["render"]
assert render["width"] == 500 and render["resample"] is False
assert render["px_per_mm"] == pytest.approx(13.43)
assert render["mm_per_px"] == pytest.approx(1.0 / 13.43)
assert render["px_per_mm_source"] == "render_manifest.probe_px_per_mm.p50"
assert render["probe_px_per_mm_manifest"] == {"p50": 13.43, "min": 10.499, "max": 38.594}
assert render["null"]["floor_source"] == "reference_null"
assert 0.0 < render["null"]["floor_px"] < 0.5
frames = {frame["name"]: frame for frame in render["frames"]}
assert set(frames) == {"001_x_+0.01mm", "002_x_+0.10mm", "003_y_+0.10mm"}
max_step = frames["002_x_+0.10mm"]
assert max_step["roi"] == {"u": 250.0, "v": 250.0, "radius": 20.0, "pixels": int(
flow.region_masks((500, 500), (250.0, 250.0), 20.0)[0].sum()
)}
# 1.343 px of applied shift at 13.43 px/mm -> 0.1 mm of response.
assert max_step["response_proj_px"] == pytest.approx(1.343, abs=0.2)
assert max_step["response_mm"] == pytest.approx(0.10, abs=0.015)
assert max_step["forward_backward_error"] < 20.0
# The 0.01 mm step is 0.134 px: it must still come out positive but small.
small = frames["001_x_+0.01mm"]
assert small["response_proj_px"] == pytest.approx(0.1343, abs=0.2)
assert small["response_proj_px"] < max_step["response_proj_px"]
# y response is orthogonal to x.
assert frames["003_y_+0.10mm"]["response_proj_px"] == pytest.approx(1.343, abs=0.2)
# The ROI is fixed at the reference centroid for every frame in a scale.
for frame in render["frames"]:
assert frame["roi"]["u"] == 250.0 and frame["roi"]["v"] == 250.0
assert set(frame["flow"]) == {
"mean_u", "mean_v", "mean_mag", "p50_mag", "p95_mag", "max_mag", "coverage",
}
process = by_name["process"]
assert process["width"] == 250 and process["resample"] is True
assert process["px_per_mm"] == pytest.approx(6.715)
assert process["frames"][0]["roi"]["u"] == pytest.approx(124.75)
assert process["frames"][0]["roi"]["v"] == pytest.approx(124.75)
process_max = {frame["name"]: frame for frame in process["frames"]}["002_x_+0.10mm"]
assert process_max["response_mm"] == pytest.approx(0.10, abs=0.015)
# The synthetic manifest carries 3 displaced frames while the job settings
# imply 2; the manifest wins and the mismatch is reported honestly.
assert any("displaced frames" in warning for warning in report["warnings"])
def test_run_track_job_writes_float32_flow_arrays(scene):
root, report = scene
reference = np.load(root / flow.FLOW_DIRNAME / "render" / "000_reference.npy")
assert reference.shape == (500, 500, 2)
assert reference.dtype == np.float32
assert float(np.abs(reference).max()) == 0.0
null = np.load(root / flow.FLOW_DIRNAME / "render" / "reference_null.npy")
assert null.shape == (500, 500, 2) and float(np.abs(null).max()) > 0.0
frame = np.load(root / flow.FLOW_DIRNAME / "render" / "002_x_+0.10mm.npy")
assert frame.dtype == np.float32 and frame.shape == (500, 500, 2)
small = np.load(root / flow.FLOW_DIRNAME / "process" / "003_y_+0.10mm.npy")
assert small.shape == (250, 250, 2)
assert report["response_direction_source"] == "strategy_probe.json"
def test_run_track_job_uses_manifest_centroids_when_strategy_is_absent(scene):
root, _ = scene
(root / flow.STRATEGY_FILENAME).unlink()
job = _synthetic_job(root)
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_OK
assert report["response_direction_source"] == "render_manifest.frames[].centroid_px"
assert report["reference_self"]["max_abs_flow_px"] == 0.0
# Regenerate the strategy file for any later test that shares the scene.
(root / flow.STRATEGY_FILENAME).write_text(
json.dumps({"axes": {"x": {"direction_px": [1.0, 0.0]}, "y": {"direction_px": [0.0, 1.0]}}}),
encoding="utf-8",
)
def test_run_track_job_missing_manifest_is_exit_3(scene):
root, _ = scene
job = _synthetic_job(root)
job["artifacts"]["manifest"] = str(root / "nope.json")
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_MISSING_ARTIFACT and report == {}
def test_run_track_job_missing_frame_is_exit_3(scene, tmp_path: Path):
root, _ = scene
manifest = json.loads((root / flow.RENDER_MANIFEST_FILENAME).read_text(encoding="utf-8"))
manifest["frames"][2]["png"] = "frames/does_not_exist.png"
broken = tmp_path / "broken"
broken.mkdir()
(broken / flow.RENDER_MANIFEST_FILENAME).write_text(json.dumps(manifest), encoding="utf-8")
job = _synthetic_job(broken)
report, code = flow.run_track_job(job, broken, progress=None)
assert code == flow.EXIT_MISSING_ARTIFACT and report == {}
def test_run_track_job_reference_self_failure_is_exit_1(scene, monkeypatch: pytest.MonkeyPatch):
root, _ = scene
job = _synthetic_job(root)
def _boom(estimator, reference, scale_name):
raise flow.FlowError(f"reference_self is not zero at scale {scale_name}: max |flow| = 0.5 px")
monkeypatch.setattr(flow, "_verify_reference_self", _boom)
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_SCENE_ERROR
assert report == {}
def test_run_track_job_failed_known_answer_is_exit_1(scene, monkeypatch: pytest.MonkeyPatch):
root, _ = scene
job = _synthetic_job(root)
monkeypatch.setattr(flow, "FixedReferenceFlow", lambda config=None: _StubEstimator(0.0, 0.0))
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_SCENE_ERROR
assert report["known_answer"]["passed"] is False
assert report["known_answer"]["cases"][0]["measured_u"] == 0.0
def test_run_track_job_without_cv2_is_exit_2(scene, monkeypatch: pytest.MonkeyPatch):
root, _ = scene
job = _synthetic_job(root)
monkeypatch.setattr(flow, "cv2", None)
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_CONFIG_ERROR and report == {}
def test_run_track_job_rejects_a_bad_schema(scene):
root, _ = scene
job = _synthetic_job(root)
job["schema"] = "something-else"
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_CONFIG_ERROR and report == {}
def test_roi_truncation_warns_and_truncates(scene, tmp_path: Path):
root, _ = scene
manifest = json.loads((root / flow.RENDER_MANIFEST_FILENAME).read_text(encoding="utf-8"))
manifest["probe_roi_px"] = {"u": 5.0, "v": 250.0, "radius": 20.0}
shifted = tmp_path / "truncated"
shifted.mkdir()
(shifted / flow.RENDER_MANIFEST_FILENAME).write_text(json.dumps(manifest), encoding="utf-8")
for name in ("frames",):
(shifted / name).mkdir(exist_ok=True)
for entry in manifest["frames"]:
source = root / entry["png"]
target = shifted / entry["png"]
target.write_bytes(source.read_bytes())
target = shifted / manifest["null"]["png"]
target.write_bytes((root / manifest["null"]["png"]).read_bytes())
job = _synthetic_job(shifted)
report, code = flow.run_track_job(job, shifted, progress=None)
assert code == flow.EXIT_OK
render = report["scales"][0]
assert render["frames"][0]["roi"]["pixels"] < flow.expected_disc_pixels(20.0)
assert any("truncated" in warning for warning in report["warnings"])
def test_px_per_mm_falls_back_and_is_labelled(tmp_path: Path):
root = tmp_path / "noscale"
manifest_path = _write_scene(root)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
del manifest["probe_px_per_mm"]
manifest["probe_roi_px"] = {"u": 250.0, "v": 250.0, "radius": 20.0}
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
report, code = flow.run_track_job(_synthetic_job(root), root, progress=None)
assert code == flow.EXIT_OK
scale = report["scales"][0]
# radius 20 px / 0.5 mm = 40 px/mm, labelled as the fallback it is.
assert scale["px_per_mm"] == pytest.approx(40.0)
assert scale["px_per_mm_source"] == (
"render_manifest.probe_roi_px.radius / settings.radius_mm"
)
assert scale["probe_px_per_mm_manifest"] is None
def _checker(size: int = 500, cell: float = 3.11) -> np.ndarray:
"""Periodic marker-like pattern: the real nx=80 cell is ~3.11 px at 500x500."""
rows, cols = np.mgrid[0:size, 0:size]
return (((cols // cell) + (rows // cell)) % 2 * 200 + 30).astype(np.uint8)
def test_reference_texture_diagnostic_reports_periodic_aliasing(tmp_path: Path):
"""The gate must still pass on a periodic scene, but say so honestly.
A 3.11 px checker has a 6.22 px period, so a 2 px shift aliases onto the
wrong period. The synthetic known-answer gate tests the estimator itself
and must still pass; the reference-texture diagnostic must report the
aliasing instead of hiding it.
"""
root = tmp_path / "periodic"
_write_scene(root, base=_checker())
report, code = flow.run_track_job(_synthetic_job(root), root, progress=None)
assert code == flow.EXIT_OK
assert report["known_answer"]["passed"] is True
diagnostic = report["reference_texture_known_answer"]
assert diagnostic["available"] is True
assert not all(case["passed"] for case in diagnostic["cases"])
aliased = [case for case in diagnostic["cases"] if not case["passed"]][0]
expected = aliased["dx"] if aliased["dx"] else aliased["dy"]
measured = aliased["measured_u"] if aliased["dx"] else aliased["measured_v"]
assert abs(measured - expected) > aliased["tol"]
warning = [w for w in report["warnings"] if "periodic" in w][0]
assert "applied" in warning and "measured" in warning and "marker period" in warning
def test_px_per_mm_prefers_probe_local_and_labels_mesh_wide_fallback(tmp_path: Path):
# Probe-local block with per-axis values and no summary key: the transverse
# (x, y) mean is used and the label says so.
root = tmp_path / "peraxis"
manifest_path = _write_scene(root)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["probe_px_per_mm"] = {"axes": {"x": 13.0, "y": 17.0, "z": 1.5}}
manifest["mesh_px_per_mm"] = {"p50": 13.43}
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
report, code = flow.run_track_job(_synthetic_job(root), root, progress=None)
assert code == flow.EXIT_OK
scale = report["scales"][0]
assert scale["px_per_mm"] == pytest.approx(15.0)
assert scale["px_per_mm_source"] == "render_manifest.probe_px_per_mm.axes(x,y mean)"
assert scale["probe_px_per_mm_manifest"] == {"axes": {"x": 13.0, "y": 17.0, "z": 1.5}}
# Only the mesh-wide statistic exists: use it, but label it as mesh-wide.
root2 = tmp_path / "meshonly"
manifest_path2 = _write_scene(root2)
manifest2 = json.loads(manifest_path2.read_text(encoding="utf-8"))
del manifest2["probe_px_per_mm"]
manifest2["mesh_px_per_mm"] = {"p50": 13.43}
manifest_path2.write_text(json.dumps(manifest2), encoding="utf-8")
report2, code2 = flow.run_track_job(_synthetic_job(root2), root2, progress=None)
assert code2 == flow.EXIT_OK
scale2 = report2["scales"][0]
assert scale2["px_per_mm"] == pytest.approx(13.43)
assert "MESH-WIDE" in scale2["px_per_mm_source"]
def test_missing_null_frame_warns_and_reports_no_floor(tmp_path: Path):
root = tmp_path / "nonull"
manifest_path = _write_scene(root)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
del manifest["null"]
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
report, code = flow.run_track_job(_synthetic_job(root), root, progress=None)
assert code == flow.EXIT_OK
assert report["scales"][0]["null"]["floor_px"] is None
assert report["scales"][0]["null"]["floor_source"] == "unavailable"
# The basis label is what keeps the render-seed floor from being read as a
# sensor-noise model after the reclassification.
assert report["scales"][0]["null"]["floor_basis"] == "render_seed_reproducibility"
assert any("declares no null frame" in warning for warning in report["warnings"])
assert not (root / flow.FLOW_DIRNAME / "render" / "reference_null.npy").exists()
def test_roi_outside_the_frame_fails_loudly(tmp_path: Path):
root = tmp_path / "offscreen"
manifest_path = _write_scene(root)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["probe_roi_px"] = {"u": -200.0, "v": -200.0, "radius": 5.0}
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
report, code = flow.run_track_job(_synthetic_job(root), root, progress=None)
assert code == flow.EXIT_SCENE_ERROR
assert report == {}
assert not (root / flow.FLOW_REPORT_FILENAME).exists()
+399
View File
@@ -0,0 +1,399 @@
"""Tests for the noise model: real idle calibration, injected noise, retention.
These cover the correction that matters most in this bench. The render-seed floor was
being used as a noise model, its ordering reverses under additive noise, and it
understates the real idle floor measured from hardware. Everything here needs only
numpy and cv2 — no Blender — so the noise path is testable on its own.
"""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from orisys_sensor_design import compare, floorcal, flow, sensitivity
cv2 = pytest.importorskip("cv2")
def _pattern(size: int = 96, cell: int = 8, seed: int = 3) -> np.ndarray:
rng = np.random.RandomState(seed)
grid = rng.rand(size // cell + 1, size // cell + 1) * 255.0
return cv2.resize(grid, (size, size), interpolation=cv2.INTER_NEAREST).astype(np.uint8)
def _write_frames(directory: Path, frames: list[np.ndarray]) -> Path:
directory.mkdir(parents=True, exist_ok=True)
for index, frame in enumerate(frames):
assert cv2.imwrite(str(directory / f"{index:04d}.png"), frame)
return directory
def _noisy_clip(directory: Path, sigma: float, count: int = 8, seed: int = 11) -> Path:
base = _pattern()
rng = np.random.RandomState(seed)
frames = []
for _ in range(count):
noisy = base.astype(np.float32) + rng.normal(0.0, sigma, base.shape)
frames.append(np.clip(noisy, 0, 255).astype(np.uint8))
return _write_frames(directory, frames)
# --------------------------------------------------------------------------- #
# Real idle calibration
# --------------------------------------------------------------------------- #
def test_idle_floor_grows_with_noise(tmp_path: Path) -> None:
quiet = floorcal.measure_idle_floor(_noisy_clip(tmp_path / "q", 1.0), max_frames=8)
loud = floorcal.measure_idle_floor(_noisy_clip(tmp_path / "l", 8.0), max_frames=8)
assert quiet.floor_px_short >= 0.0
assert loud.floor_px_short > quiet.floor_px_short
assert quiet.frames == 8
def test_idle_floor_reports_a_dominant_period(tmp_path: Path) -> None:
measured = floorcal.measure_idle_floor(_noisy_clip(tmp_path / "p", 2.0), max_frames=8)
assert measured.dominant_period_px > 0
assert measured.floor_per_period_short > 0
payload = measured.to_json()
assert payload["roi"]["pixels"] > 0
assert "short = consecutive frames" in payload["basis"]
def test_drift_shows_as_a_long_over_short_ratio(tmp_path: Path) -> None:
"""A drifting scene must show long > short; white noise must not."""
base = _pattern()
drifting = []
for index in range(8):
matrix = np.float32([[1, 0, 0.35 * index], [0, 1, 0]])
drifting.append(
cv2.warpAffine(base, matrix, (base.shape[1], base.shape[0]),
flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
)
drift = floorcal.measure_idle_floor(_write_frames(tmp_path / "d", drifting), max_frames=8)
white = floorcal.measure_idle_floor(_noisy_clip(tmp_path / "w", 4.0), max_frames=8)
assert drift.drift_ratio > 1.0
assert white.drift_ratio < drift.drift_ratio
def test_measure_idle_floor_rejects_missing_and_tiny_sources(tmp_path: Path) -> None:
with pytest.raises(floorcal.FloorCalibrationError):
floorcal.measure_idle_floor(tmp_path / "nope.mp4")
single = _write_frames(tmp_path / "one", [_pattern()])
with pytest.raises(floorcal.FloorCalibrationError):
floorcal.measure_idle_floor(single)
def test_resolve_idle_source_tolerates_absence(tmp_path: Path) -> None:
assert floorcal.resolve_idle_source(tmp_path, None) is None
assert floorcal.resolve_idle_source(tmp_path, "missing.mp4") is None
present = tmp_path / "clip.mp4"
present.write_bytes(b"not really a video")
assert floorcal.resolve_idle_source(tmp_path, "clip.mp4") == present.resolve()
# --------------------------------------------------------------------------- #
# Injected noise
# --------------------------------------------------------------------------- #
def test_inject_noise_is_deterministic_and_zero_sigma_is_identity() -> None:
base = _pattern()
assert np.array_equal(flow.inject_noise(base, 0.0, 1), base)
first = flow.inject_noise(base, 5.0, 7)
second = flow.inject_noise(base, 5.0, 7)
assert np.array_equal(first, second)
assert not np.array_equal(first, flow.inject_noise(base, 5.0, 8))
assert first.dtype == np.uint8
def _axis_frames(size: int = 96) -> dict:
base = _pattern(size)
frames = {}
for axis, matrix in (
("x", np.float32([[1, 0, 0.8], [0, 1, 0]])),
("y", np.float32([[1, 0, 0], [0, 1, 0.8]])),
):
steps = []
for step_mm in (0.01, 0.05, 0.10):
shifted = cv2.warpAffine(
base, np.float32(matrix * (1, 1, step_mm / 0.10)), (size, size),
flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE,
)
steps.append((step_mm, shifted))
frames[axis] = steps
return {"base": base, "frames": frames}
def test_noise_model_pass_reports_gain_floor_and_samples() -> None:
data = _axis_frames()
reference = data["base"]
mask, _ = flow.region_masks(reference.shape, (48.0, 48.0), 20.0)
result = flow.noise_model_pass(
reference=reference,
axis_frames=data["frames"],
directions={"x": (1.0, 0.0), "y": (0.0, 1.0)},
roi_mask=mask,
config={"dis_preset": "medium", "dis_mode": 1},
sigmas=(2.0, 8.0),
draws=1,
)
assert result["sigmas"] == [2.0, 8.0]
for axis in ("x", "y"):
entries = result["axes"][axis]
assert [entry["sigma"] for entry in entries] == [2.0, 8.0]
for entry in entries:
assert entry["gain_px_per_mm"] is not None
assert entry["floor_px"] is not None and entry["floor_px"] > 0.0
assert entry["floor_samples"] == 1
assert entry["points"] == 3
# A larger sigma must not produce a quieter floor.
x_entries = {entry["sigma"]: entry for entry in result["axes"]["x"]}
assert x_entries[8.0]["floor_px"] >= x_entries[2.0]["floor_px"]
def test_noise_model_pass_is_deterministic() -> None:
data = _axis_frames()
mask, _ = flow.region_masks(data["base"].shape, (48.0, 48.0), 20.0)
kwargs = dict(
reference=data["base"],
axis_frames=data["frames"],
directions={"x": (1.0, 0.0)},
roi_mask=mask,
config={"dis_preset": "medium", "dis_mode": 1},
sigmas=(4.0,),
draws=1,
)
first = flow.noise_model_pass(**kwargs)
second = flow.noise_model_pass(**kwargs)
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
def test_resolve_noise_settings_defaults_and_overrides() -> None:
default = flow._resolve_noise_settings({})
assert default["enabled"] is True
assert default["scales"] == ["render"]
assert default["sigmas"] == list(flow.DEFAULT_NOISE_SIGMAS)
off = flow._resolve_noise_settings(
{"settings": {"noise": {"models": ["render_seed"], "sigmas": [4.0]}}}
)
assert off["enabled"] is False
bad = flow._resolve_noise_settings(
{"settings": {"noise": {"sigmas": [-1.0, 0.0, 999.0], "draws": 0}}}
)
assert bad["sigmas"] == list(flow.DEFAULT_NOISE_SIGMAS)
assert bad["draws"] == 1
def test_track_job_carries_the_noise_block(tmp_path: Path) -> None:
"""Regression: `build_track_job` omitted `noise`, so every --noise-* flag and the
design.yaml block were silently ignored and the pass ran on built-in defaults (which
happened to match, hiding it). The job must carry what the caller asked for."""
settings = SimpleNamespace(
resolution=(64, 64),
process_resolution=(32, 32),
axes=("x",),
step_mm=0.01,
max_mm=0.10,
radius_mm=0.5,
tracker={"dis_mode": 1, "dis_preset": "medium"},
noise={"sigmas": [3.0], "draws": 7, "scales": ["process"], "models": ["injected"]},
)
job = flow.build_track_job(settings, tmp_path)
assert job["settings"]["noise"] == {
"sigmas": [3.0],
"draws": 7,
"scales": ["process"],
"models": ["injected"],
}
resolved = flow._resolve_noise_settings(job)
assert resolved["draws"] == 7
assert resolved["sigmas"] == [3.0]
assert resolved["scales"] == ["process"]
def test_floor_basis_is_labelled_render_seed() -> None:
"""The reclassification must be machine-readable, not just prose."""
source = Path(flow.__file__).read_text(encoding="utf-8")
assert '"floor_basis": "render_seed_reproducibility"' in source
assert "NOT a sensor-noise model" in source
# --------------------------------------------------------------------------- #
# Retention and disagreement, in the sensitivity report
# --------------------------------------------------------------------------- #
def _scored_scale(gain: float = 10.0):
from orisys_sensor_design import sensitivity as sens
axis = sens.AxisScore(
axis="x",
direction_px=(1.0, 0.0),
direction_source="test",
floor_px=0.02,
floor_source="null_projection_p95",
points=(),
gain_px_per_mm=gain,
gain_mm_per_mm=None,
scale_px_per_mm=13.0,
gain_gate="no_gain",
snr=1.0,
spearman=1.0,
resolvable_step_mm=None,
saturation_mm=None,
cross_axis_leak=0.0,
score=1.0,
verdict="resolvable",
px_per_mm_axis=13.0,
)
return sens.ScaleScore(
name="render",
width=500,
height=500,
px_per_mm=13.0,
displacement_px_per_step=0.13,
cell_px=6.25,
cell_over_patch_px=0.78,
null_floor_px=0.02,
null_floor_reported_px=0.02,
axes=(axis,),
score=1.0,
verdict="resolvable",
)
def test_attach_noise_models_computes_retention_and_snr() -> None:
scale = _scored_scale(gain=10.0)
report = {
"scales": [
{
"name": "render",
"noise_models": {
"basis": "injected",
"draws": 1,
"sigmas": [4.0],
"axes": {
"x": [
{
"sigma": 4.0,
"gain_px_per_mm": 8.0,
"floor_px": 0.04,
"points": 10,
"floor_samples": 1,
}
]
},
},
}
]
}
attached = sensitivity.attach_noise_models([scale], report, max_mm=0.10)
models = attached[0].noise_models
assert models is not None
row = models["axes"]["x"][0]
assert row["response_retention"] == pytest.approx(0.8)
# snr under the same model: |gain| * max_mm / floor
assert row["snr_noise"] == pytest.approx(8.0 * 0.10 / 0.04)
def test_noise_disagreement_fires_only_past_the_threshold() -> None:
scale = _scored_scale(gain=10.0)
noisy = {
"axes": {"x": [{"sigma": 16.0, "gain_px_per_mm": 6.0, "floor_px": 0.05}]},
}
import dataclasses
loud = dataclasses.replace(scale, noise_models=noisy)
message = sensitivity.noise_disagreement(loud)
assert message is not None and "60%" in message
quiet = dataclasses.replace(
scale,
noise_models={"axes": {"x": [{"sigma": 16.0, "gain_px_per_mm": 9.5, "floor_px": 0.05}]}},
)
assert sensitivity.noise_disagreement(quiet) is None
assert sensitivity.noise_disagreement(scale) is None
def test_calibration_note_states_the_factor() -> None:
note = sensitivity.calibration_note(
{
"floor_per_period_short": 0.0089,
"floor_px_short": 0.2267,
"width": 1280,
"height": 720,
"dominant_period_px": 25.5,
"drift_ratio": 0.98,
}
)
assert "0.00890" in note
assert "white noise" in note
def test_noise_calibration_is_cached_and_skippable(tmp_path: Path) -> None:
frames = _noisy_clip(tmp_path / "footage", 3.0, count=6)
settings = SimpleNamespace(noise={"idle_footage": str(frames), "max_frames": 6})
first = sensitivity.noise_calibration(tmp_path, settings)
assert first is not None and first["source"] == "measured"
assert (tmp_path / sensitivity.IDLE_CALIBRATION_FILENAME).is_file()
second = sensitivity.noise_calibration(tmp_path, settings)
assert second is not None and second["source"] == "cached"
assert sensitivity.noise_calibration(tmp_path, settings, enabled=False) is None
unset = sensitivity.noise_calibration(tmp_path, SimpleNamespace(noise={}))
assert unset is not None and unset["source"] == "unavailable"
# --------------------------------------------------------------------------- #
# Figure selection: the duplicate-label regression
# --------------------------------------------------------------------------- #
def _twelve_runs() -> list[dict]:
runs = []
for nx in (31, 56, 80):
for radius in (0.5, 1.5):
for dis_mode in (1, 0):
runs.append(
{
"id": f"nx{nx}@r{radius}@dm{dis_mode}",
"nx": nx,
"radius_mm": radius,
"dis_mode": dis_mode,
"score": float(nx + radius + dis_mode),
}
)
return runs
def test_select_runs_is_unique_and_represents_every_dis_mode() -> None:
runs = _twelve_runs()
chosen = compare.select_runs(runs, limit=4, reference_radius=0.5)
ids = [run["id"] for run in chosen]
assert len(ids) == len(set(ids)), "a run must not appear twice"
assert len(ids) == 4
# Global best and worst are always present.
ranked = sorted(runs, key=lambda run: -run["score"])
assert ranked[0]["id"] in ids and ranked[-1]["id"] in ids
# And the shipped setting (dm1) is represented at the reference radius: the old
# best+worst-of-keys[:1] rule picked dm0 only and dropped it.
assert any(
run["id"] in ids and run["dis_mode"] == 1 and run["radius_mm"] == 0.5
for run in runs
)
def test_select_runs_never_duplicates_when_radii_differ() -> None:
"""The exact shape that produced two identical `nx=80 dm0` legend entries."""
runs = _twelve_runs()
chosen = compare.select_runs(runs, limit=6, reference_radius=0.5)
labels = [f"nx={r['nx']} r={r['radius_mm']:g} mm dm={r['dis_mode']}" for r in chosen]
assert len(labels) == len(set(labels))
+503
View File
@@ -0,0 +1,503 @@
"""Tests for the deterministic marker-pattern optimizer.
No Blender and no cv2: the per-candidate pipeline is injected, so these tests
exercise enumeration, ranking, winner selection, deterministic ordering, the
full-resolution re-confirmation and the dry-run path directly.
"""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from orisys_sensor_design import optimise as opt
def _paths(tmp_path: Path) -> SimpleNamespace:
revision = tmp_path / "rev"
revision.mkdir(exist_ok=True)
return SimpleNamespace(root=revision)
def _settings(**overrides: object) -> SimpleNamespace:
values = {
"resolution": [500, 500],
"process_resolution": [250, 250],
"step_mm": 0.01,
}
values.update(overrides)
return SimpleNamespace(**values)
class RecordingRunner:
"""Deterministic fake pipeline: score depends only on the candidate."""
def __init__(
self,
scores: dict[str, float] | None = None,
confirm_factor: float = 0.9,
scale_scores: dict[str, dict[str, float]] | None = None,
):
self.calls: list[tuple[str, tuple[int, int], str, Path]] = []
self.scores = scores or {}
self.confirm_factor = confirm_factor
self.scale_scores = scale_scores or {}
def __call__(self, run: opt.CandidateRun) -> dict:
self.calls.append((run.candidate.id, run.resolution, run.stage, run.directory))
run.directory.mkdir(parents=True, exist_ok=True)
score = self.scores.get(run.candidate.id, float(run.candidate.nx))
if run.stage == "confirm":
score = score * self.confirm_factor
scales = {
name: {
"score": value * (self.confirm_factor if run.stage == "confirm" else 1.0),
"verdict": "marginal",
}
for name, value in (self.scale_scores.get(run.candidate.id) or {}).items()
}
return {
"score": score,
"verdict": "marginal",
"cell_px": None,
"px_per_mm": 6.624,
"report": str(run.directory / "sensitivity_report.json"),
"texture_sha256": f"sha-{run.candidate.id}",
"scales": scales,
}
class ReportWritingRunner(RecordingRunner):
"""Runner that returns only a report path, like cli.py's real runner."""
def __call__(self, run: opt.CandidateRun) -> dict:
result = super().__call__(run)
result.pop("scales", None)
report_path = Path(result["report"])
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(
json.dumps(
{
"schema": "orisys-sensor-design/sensitivity-report-v1",
"scales": [
{"name": "render", "score": 30.0 + run.candidate.nx, "verdict": "resolvable"},
{"name": "process", "score": 40.0 - run.candidate.nx, "verdict": "marginal"},
],
}
),
encoding="utf-8",
)
return result
# --------------------------------------------------------------------------- #
# Enumeration
# --------------------------------------------------------------------------- #
def test_enumeration_is_sorted_and_square_by_default() -> None:
candidates = opt.enumerate_candidates([31, 15, 20], [7])
assert [candidate.id for candidate in candidates] == ["15@7", "20@7", "31@7"]
assert all(candidate.nx == candidate.ny for candidate in candidates)
def test_enumeration_is_independent_of_input_order() -> None:
forward = opt.enumerate_candidates([15, 20, 31], [7, 3])
backward = opt.enumerate_candidates([31, 15, 20], [3, 7])
assert forward == backward
assert [candidate.id for candidate in forward] == [
"15@3", "15@7", "20@3", "20@7", "31@3", "31@7"
]
def test_enumeration_deduplicates_and_honours_grid_y() -> None:
candidates = opt.enumerate_candidates([20, 20], [7, 7], grid_y=[20, 30])
assert [candidate.id for candidate in candidates] == ["20@7", "20x30@7"]
def test_enumeration_is_bounded_by_the_grid_and_seed_sets() -> None:
candidates = opt.enumerate_candidates(opt.DEFAULT_GRID, [7])
assert len(candidates) == len(opt.DEFAULT_GRID)
assert candidates[0].nx == 15
assert candidates[-1].nx == 80
# nx ~ 28-36 is inside the default grid: it brackets the DIS patch size.
assert any(28 <= candidate.nx <= 36 for candidate in candidates)
@pytest.mark.parametrize(
"grid,seeds",
[
([], [7]),
([20], []),
([0], [7]),
([-3], [7]),
([20], [-1]),
(["x"], [7]),
(None, [7]),
],
)
def test_invalid_search_bounds_raise(grid: object, seeds: object) -> None:
with pytest.raises(opt.OptimiseError):
opt.enumerate_candidates(grid, seeds) # type: ignore[arg-type]
# --------------------------------------------------------------------------- #
# Ranking
# --------------------------------------------------------------------------- #
def _entries(scores: dict[tuple[int, int, int], float | None]) -> list[dict]:
return [
{"candidate": opt.Candidate(nx, ny, seed), "score": score, "verdict": "marginal"}
for (nx, ny, seed), score in scores.items()
]
def test_ranking_is_stable_and_independent_of_input_order() -> None:
scores = {(31, 31, 7): 0.4, (20, 20, 7): 0.9, (36, 36, 7): 0.9, (48, 48, 7): 0.1}
ordered = _entries(dict(sorted(scores.items())))
shuffled = _entries(dict(reversed(list(scores.items()))))
first = [entry["candidate"].id for entry in opt.rank_entries(ordered)]
second = [entry["candidate"].id for entry in opt.rank_entries(shuffled)]
assert first == second == ["20@7", "36@7", "31@7", "48@7"]
def test_unscored_candidates_sort_last() -> None:
ranked = opt.rank_entries(
_entries({(31, 31, 7): 0.4, (20, 20, 7): None, (36, 36, 7): 0.9})
)
assert [entry["candidate"].id for entry in ranked] == ["36@7", "31@7", "20@7"]
def test_ties_break_on_candidate_order() -> None:
ranked = opt.rank_entries(_entries({(36, 36, 7): 0.5, (20, 20, 7): 0.5, (48, 48, 7): 0.5}))
assert [entry["candidate"].id for entry in ranked] == ["20@7", "36@7", "48@7"]
# --------------------------------------------------------------------------- #
# run_optimise: winner, confirmation, determinism, notes
# --------------------------------------------------------------------------- #
def _run(tmp_path: Path, runner: object, **kwargs: object) -> tuple[dict, int]:
kwargs.setdefault("top", None)
return opt.run_optimise(
_paths(tmp_path),
_settings(),
grid=[20, 31, 48],
seeds=[7],
search_resolution=(250, 250),
output_dir=tmp_path / "out",
progress=lambda message: None,
texture_vector_mm=(18.5, 16.4),
runner=runner, # type: ignore[arg-type]
**kwargs,
)
def test_winner_is_the_top_ranked_candidate_and_confirmed_at_full_resolution(
tmp_path: Path,
) -> None:
runner = RecordingRunner(scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5})
report, code = _run(tmp_path, runner)
assert code == 0
assert report["ranked"][0] == "31@7"
assert report["winner"]["nx"] == 31 and report["winner"]["seed"] == 7
assert report["winner"]["score"] == pytest.approx(0.9)
assert report["winner"]["confirmed_at_resolution"] == [500, 500]
assert report["winner"]["confirmed_score"] == pytest.approx(0.9 * 0.9)
assert report["winner"]["report"]
search_calls = [call for call in runner.calls if call[2] == "search"]
confirm_calls = [call for call in runner.calls if call[2] == "confirm"]
assert len(search_calls) == 3
assert all(call[1] == (250, 250) for call in search_calls)
assert len(confirm_calls) == 1
assert confirm_calls[0][0] == "31@7"
assert confirm_calls[0][1] == (500, 500), "re-confirmation must use probe.resolution"
def test_report_never_claims_a_global_optimum(tmp_path: Path) -> None:
runner = RecordingRunner()
report, _ = _run(tmp_path, runner)
notes = report["notes"]
assert any("not a global optimum" in note for note in notes)
for note in notes:
if "global optimum" in note:
assert "not a global optimum" in note, note
assert report["search"]["objective"] == "sensitivity_report.overall.score"
# The grid rationale must say it was chosen around the DIS patch, not nx=80.
assert any("DIS patch" in note for note in notes)
def test_optimizer_notes_record_the_roi_confound(tmp_path: Path) -> None:
report, _ = _run(tmp_path, RecordingRunner())
joined = " ".join(report["notes"])
assert "confounded with the ROI size" in joined
assert "Only nx/ny/seed vary" in joined
assert "radius_mm, the ROI and the scale are held fixed" in joined
def test_notes_no_longer_assert_aliasing_for_the_shipped_pattern(tmp_path: Path) -> None:
report, _ = _run(tmp_path, RecordingRunner())
joined = " ".join(report["notes"])
# The disproved claim must be gone.
assert "aliases a sub-period shift" not in joined
assert "wrong period" not in joined
# What was actually measured must be there instead.
assert "does NOT alias" in joined
# The rationale must now reference the effective window and the camera-space
# cell, not the old millimetre-derived cell or the nominal patch alone.
assert "effective" in joined
assert "width/nx" in joined
assert "setFinestScale" in joined
def test_notes_state_that_best_is_scale_dependent(tmp_path: Path) -> None:
report, _ = _run(tmp_path, RecordingRunner())
joined = " ".join(report["notes"])
assert "'Best' is scale-dependent" in joined
assert "unweighted mean" in joined
assert "scale_scores" in joined
def test_notes_report_the_search_to_confirm_score_gap(tmp_path: Path) -> None:
report, _ = _run(tmp_path, RecordingRunner())
joined = " ".join(report["notes"])
assert "score gap between the search and the confirmation" in joined
assert "ratio" in joined
def test_unset_search_resolution_defaults_to_settings_resolution(tmp_path: Path) -> None:
runner = RecordingRunner()
report, code = opt.run_optimise(
_paths(tmp_path),
_settings(resolution=[500, 500]),
grid=[20, 31],
seeds=[7],
top=None,
output_dir=tmp_path / "out",
progress=lambda message: None,
texture_vector_mm=(18.5, 16.4),
runner=runner,
)
assert code == 0
assert report["search"]["render_resolution"] == [500, 500]
search_resolutions = {call[1] for call in runner.calls if call[2] == "search"}
assert search_resolutions == {(500, 500)}, "search must default to settings.resolution"
# The confirmation still uses settings.resolution too.
assert report["winner"]["confirmed_at_resolution"] == [500, 500]
def test_unset_search_resolution_falls_back_when_settings_carry_none(
tmp_path: Path,
) -> None:
runner = RecordingRunner()
report, code = opt.run_optimise(
_paths(tmp_path),
SimpleNamespace(step_mm=0.01), # no resolution at all
grid=[20],
seeds=[7],
top=None,
output_dir=tmp_path / "out",
progress=lambda message: None,
runner=runner,
)
assert code == 0
assert report["search"]["render_resolution"] == list(opt.DEFAULT_SEARCH_RESOLUTION)
def test_winner_is_confirmed_at_settings_resolution_by_default(tmp_path: Path) -> None:
report, _ = _run(
tmp_path, RecordingRunner(scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5})
)
# `_run` passes search_resolution=(250,250) explicitly; the confirmation target
# still comes from settings.resolution and must stay 500x500.
assert report["winner"]["confirmed_at_resolution"] == [500, 500]
assert report["search"]["render_resolution"] == [250, 250]
def test_candidate_rows_carry_per_scale_scores_and_ranks(tmp_path: Path) -> None:
scale_scores = {
"20@7": {"render": 0.1, "process": 0.9},
"31@7": {"render": 0.3, "process": 0.5},
"48@7": {"render": 0.9, "process": 0.1},
}
runner = RecordingRunner(
scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5},
scale_scores=scale_scores,
)
report, _ = _run(tmp_path, runner)
assert report["search"]["scales"] == ["process", "render"]
# Overall winner is the mean's winner ...
assert report["ranked"][0] == "31@7"
# ... but it is NOT the render-scale winner, which is the point of the dimension.
assert report["ranked_by_scale"]["render"] == ["48@7", "31@7", "20@7"]
assert report["ranked_by_scale"]["process"] == ["20@7", "31@7", "48@7"]
by_nx = {row["nx"]: row for row in report["candidates"]}
assert by_nx[48]["scale_scores"]["render"] == pytest.approx(0.9)
assert by_nx[48]["scale_ranks"]["render"] == 1
assert by_nx[31]["scale_ranks"]["render"] == 2
assert by_nx[20]["scale_ranks"]["process"] == 1
assert by_nx[20]["scale_verdicts"]["render"] == "marginal"
assert by_nx[31]["scale_ranks"]["process"] == 2
assert report["winner"]["scale_scores"]["render"] == pytest.approx(0.3)
assert report["search"]["render_resolution"] == [250, 250]
def test_per_scale_scores_are_read_from_the_candidate_report_path(tmp_path: Path) -> None:
runner = ReportWritingRunner()
report, code = _run(tmp_path, runner)
assert code == 0
by_nx = {row["nx"]: row for row in report["candidates"]}
assert by_nx[31]["scale_scores"]["render"] == pytest.approx(61.0)
assert by_nx[31]["scale_scores"]["process"] == pytest.approx(9.0)
assert report["ranked_by_scale"]["render"][0] == "48@7"
assert report["ranked_by_scale"]["process"][0] == "20@7"
def test_candidate_table_is_sorted_and_carries_measured_fields(tmp_path: Path) -> None:
runner = RecordingRunner(scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5})
report, _ = _run(tmp_path, runner)
rows = report["candidates"]
assert [row["nx"] for row in rows] == [31, 48, 20]
assert report["ranked"] == ["31@7", "48@7", "20@7"]
for row in rows:
assert set(row) >= {"nx", "ny", "seed", "score", "verdict", "cell_px",
"report", "texture_sha256", "scale_scores",
"scale_verdicts", "scale_ranks"}
assert report["search"]["candidates"] == 3
assert report["search"]["render_resolution"] == [250, 250]
def test_cell_px_is_the_camera_space_cell(tmp_path: Path) -> None:
"""The pattern grid is defined across the camera image, so cell = width / nx.
This replaces an earlier expectation of `(18.5/31) * px_per_mm`, which derived
the cell from the *physical* page cell times the probe-local scale. That is a
different (and ~2x smaller) number: the probe-local scale is 13.2 px/mm while
the camera-grid scale is 500/18.5 = 27.0 px/mm. Using the millimetre cell made
a 16.1 px cell look like 7.9 px and hid the effective-window rule.
"""
runner = RecordingRunner()
report, _ = _run(tmp_path, runner)
row = next(item for item in report["candidates"] if item["nx"] == 31)
assert row["cell_px"] == pytest.approx(500.0 / 31.0, rel=1e-9)
# And it must NOT be the millimetre-derived value.
assert row["cell_px"] != pytest.approx((18.5 / 31) * 6.624 * 2.0, rel=1e-3)
def test_two_runs_give_byte_identical_rankings(tmp_path: Path) -> None:
first, _ = _run(tmp_path, RecordingRunner(scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5}))
second, _ = _run(tmp_path, RecordingRunner(scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5}))
first.pop("generated_at")
second.pop("generated_at")
assert first["ranked"] == second["ranked"]
assert first["candidates"] == second["candidates"]
assert json.dumps(first["candidates"], sort_keys=True) == json.dumps(
second["candidates"], sort_keys=True
)
def test_top_truncates_ranked_but_keeps_the_winner(tmp_path: Path) -> None:
runner = RecordingRunner(scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5})
report, _ = _run(tmp_path, runner, top=2)
assert report["ranked"] == ["31@7", "48@7"]
assert report["winner"]["nx"] == 31
def test_runner_failure_records_error_without_aborting(tmp_path: Path) -> None:
class Flaky(RecordingRunner):
def __call__(self, run: opt.CandidateRun) -> dict:
if run.candidate.nx == 31 and run.stage == "search":
raise RuntimeError("blender exploded")
return super().__call__(run)
runner = Flaky(scores={"20@7": 0.2, "48@7": 0.5})
report, code = _run(tmp_path, runner)
assert code == 0
assert report["winner"]["nx"] == 48, "a failed candidate must not win"
bad = next(item for item in report["candidates"] if item["nx"] == 31)
assert bad["score"] is None
assert "blender exploded" in bad["error"]
def test_all_candidates_failing_returns_nonzero(tmp_path: Path) -> None:
class Broken:
def __call__(self, run: opt.CandidateRun) -> dict:
raise RuntimeError("no pipeline")
report, code = _run(tmp_path, Broken())
assert code == 1
assert report["winner"] is None
assert all(row["score"] is None for row in report["candidates"])
def test_missing_runner_writes_a_degraded_report_and_returns_2(tmp_path: Path) -> None:
report, code = opt.run_optimise(
_paths(tmp_path),
_settings(),
grid=[20, 31],
seeds=[7],
search_resolution=(250, 250),
top=None,
output_dir=tmp_path / "out",
progress=lambda message: None,
runner=None,
)
assert code == 2
assert "runner" in report["error"]["message"]
assert (tmp_path / "out" / opt.OPTIMIZER_REPORT_FILENAME).is_file()
assert any("not a global optimum" in note for note in report["notes"])
# --------------------------------------------------------------------------- #
# Dry run
# --------------------------------------------------------------------------- #
def test_dry_run_enumerates_without_running_or_writing_candidate_dirs(
tmp_path: Path,
) -> None:
runner = RecordingRunner()
report, code = opt.run_optimise(
_paths(tmp_path),
_settings(),
grid=[15, 20, 31],
seeds=[7, 3],
search_resolution=(250, 250),
top=None,
dry_run=True,
output_dir=tmp_path / "out",
progress=lambda message: None,
runner=runner,
)
assert code == 0
assert runner.calls == [], "dry-run must not run the pipeline"
assert not (tmp_path / "out" / "candidates").exists()
assert report["search"]["dry_run"] is True
assert report["search"]["candidates"] == 6
assert [row["nx"] for row in report["candidates"]] == [15, 15, 20, 20, 31, 31]
assert report["ranked"] == []
assert report["ranked_by_scale"] == {}
assert report["winner"] is None
assert all(row["score"] is None for row in report["candidates"])
assert (tmp_path / "out" / opt.OPTIMIZER_REPORT_FILENAME).is_file()
assert any("dry-run" in note for note in report["notes"])
assert any("scale-dependent" in note for note in report["notes"])
def test_optimizer_report_schema_keys(tmp_path: Path) -> None:
report, _ = _run(tmp_path, RecordingRunner())
assert report["schema"] == opt.OPTIMIZER_REPORT_SCHEMA
assert set(report) >= {"schema", "generated_at", "search", "candidates", "ranked",
"ranked_by_scale", "winner", "notes"}
assert set(report["search"]) >= {"grid", "seeds", "render_resolution", "candidates",
"objective", "scales"}
assert set(report["winner"]) >= {"nx", "ny", "seed", "score",
"confirmed_at_resolution", "confirmed_score", "report"}
+4 -1
View File
@@ -328,7 +328,10 @@ pytestmark_gl = pytest.mark.skipif(
not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built"
)
def test_preview_draws_the_mesh_and_the_housing_toggle_changes_it() -> None:
pytest.importorskip("PyQt5")
# Guard the submodule, not the top-level package: a partially installed
# PyQt5 can expose `PyQt5` (with `PyQt5.__file__ is None`) while every
# binding submodule is missing, which would otherwise fail instead of skip.
pytest.importorskip("PyQt5.QtWidgets")
from PyQt5.QtWidgets import QApplication
from orisys_sensor_design.preview.gl_view import (
+272
View File
@@ -0,0 +1,272 @@
"""Tests for `probe_plot`: the optical-flow figures.
matplotlib is an optional extra, so the whole module is skipped when it is absent —
but nothing here needs Blender or cv2. Every figure is built from synthetic arrays and
tiny JSON fixtures, which is deliberate: the figures are a view of measurements, so
they must be testable without making a measurement.
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
pytest.importorskip("matplotlib")
from orisys_sensor_design import probe_plot # noqa: E402
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
def _flow(height: int = 60, width: int = 60, value: float = 0.4) -> np.ndarray:
flow = np.zeros((height, width, 2), dtype=np.float32)
flow[..., 0] = value
return flow
def _panel(**overrides) -> dict:
panel = {
"label": "nx=31, dis_mode=1",
"flow": _flow(),
"roi": {"u": 30.0, "v": 30.0, "radius": 6.6},
"cell_px": 16.13,
"effective_patch_px": 16.0,
"applied_px": 1.325,
"response_px": 0.886,
"direction_px": [1.0, 0.0],
"scale_note": "render 500x500 | patch 8 px, finest 1",
"note": "",
}
panel.update(overrides)
return panel
def _assert_png(path: Path) -> None:
assert path.is_file()
assert path.stat().st_size > 0
assert path.read_bytes()[:8] == PNG_MAGIC
def test_flow_comparison_writes_a_png(tmp_path: Path) -> None:
out = probe_plot.render_flow_comparison(
panels=[_panel(label="nx=31"), _panel(label="nx=80", cell_px=6.25, flow=_flow(value=0.9))],
out_path=tmp_path / "flow.png",
)
_assert_png(out)
def test_flow_comparison_annotates_a_missing_flow_array(tmp_path: Path) -> None:
"""A failed setting must appear as a named placeholder, not vanish."""
out = probe_plot.render_flow_comparison(
panels=[
_panel(label="ok"),
_panel(label="broken", flow=None, note="missing flow array 010_x.npy"),
],
out_path=tmp_path / "flow_missing.png",
)
_assert_png(out)
def test_flow_comparison_handles_empty_and_tiny_inputs(tmp_path: Path) -> None:
out = probe_plot.render_flow_comparison(panels=[], out_path=tmp_path / "empty.png")
_assert_png(out)
tiny = _flow(height=3, width=3, value=0.1)
out = probe_plot.render_flow_comparison(
panels=[_panel(flow=tiny, roi={"u": 1.0, "v": 1.0, "radius": 0.5})],
out_path=tmp_path / "tiny.png",
)
_assert_png(out)
def test_tuning_writes_both_panels(tmp_path: Path) -> None:
out = probe_plot.render_tuning(
series=[
{
"label": "render | dis_mode=1",
"points": [
{"nx": 31, "cell_px": 16.13, "effective_patch_px": 16.0, "score": 69.2},
{"nx": 80, "cell_px": 6.25, "effective_patch_px": 16.0, "score": 40.5},
],
},
{
"label": "render | dis_mode=0",
"points": [
{"nx": 31, "cell_px": 16.13, "effective_patch_px": 8.0, "score": 20.0},
{"nx": 80, "cell_px": 6.25, "effective_patch_px": 8.0, "score": 55.0},
],
},
],
out_path=tmp_path / "tuning.png",
patch_px=8.0,
)
_assert_png(out)
def test_tuning_with_no_points_still_writes_a_placeholder(tmp_path: Path) -> None:
out = probe_plot.render_tuning(series=[], out_path=tmp_path / "tuning_empty.png")
_assert_png(out)
def test_response_curves_writes_with_a_per_curve_floor(tmp_path: Path) -> None:
out = probe_plot.render_response_curves(
curves=[
{
"label": "nx=80 r=0.5 mm dm=1",
"verdict": "resolvable",
"score": 57.7,
"floor_px": 0.0155,
"points": [
{"step_mm": 0.01, "response_px": 0.08, "applied_px": 0.13},
{"step_mm": 0.10, "response_px": 0.89, "applied_px": 1.32},
],
},
{
"label": "nx=31 r=0.5 mm dm=1",
"verdict": "resolvable",
"score": 12.3,
# A different floor, so the two must get their own lines.
"floor_px": 0.0372,
"points": [
{"step_mm": 0.01, "response_px": 0.08, "applied_px": 0.13},
{"step_mm": 0.10, "response_px": 0.48, "applied_px": 1.32},
],
},
],
out_path=tmp_path / "response.png",
axis="x",
)
_assert_png(out)
def test_response_curves_adds_a_retention_panel(tmp_path: Path) -> None:
"""Retention is the panel that exposes a pattern collapsing under noise."""
out = probe_plot.render_response_curves(
curves=[
{
"label": "nx=80 r=0.5 mm dm=1",
"points": [{"step_mm": 0.10, "response_px": 0.90, "applied_px": 1.32}],
"floor_px": 0.0155,
"retention": [
{"sigma": 4.0, "response_retention": 0.99},
{"sigma": 16.0, "response_retention": 0.84},
],
},
{
"label": "nx=31 r=0.5 mm dm=1",
"points": [{"step_mm": 0.10, "response_px": 0.48, "applied_px": 1.32}],
"floor_px": 0.0372,
"retention": [
{"sigma": 4.0, "response_retention": 0.72},
{"sigma": 16.0, "response_retention": 0.59},
],
},
],
out_path=tmp_path / "retention.png",
axis="x",
)
_assert_png(out)
def test_response_curve_labels_must_be_unique_to_be_useful(tmp_path: Path) -> None:
"""Regression: the old label was `nx=.. dm..` and omitted the radius, so two runs
(nx80@r0.5@dm0 and nx80@r1.5@dm0) produced identical legend entries."""
runs = [
("nx=80 r=0.5 mm dm=0", 0.03),
("nx=80 r=1.5 mm dm=0", 0.09),
]
out = probe_plot.render_response_curves(
curves=[
{
"label": label,
"points": [{"step_mm": 0.10, "response_px": 0.9, "applied_px": 1.32}],
"floor_px": floor,
}
for label, floor in runs
],
out_path=tmp_path / "unique.png",
axis="x",
)
_assert_png(out)
assert len({label for label, _ in runs}) == 2
def test_response_curves_without_curves(tmp_path: Path) -> None:
out = probe_plot.render_response_curves(
curves=[], out_path=tmp_path / "response_empty.png"
)
_assert_png(out)
def _write_run(directory: Path, *, nx: int = 31, include_flow: bool = True) -> None:
directory.mkdir(parents=True, exist_ok=True)
frames_dir = directory / "frames"
frames_dir.mkdir(exist_ok=True)
frame_name = "000_reference"
(frames_dir / f"{frame_name}.png").write_bytes(PNG_MAGIC)
manifest = {
"width": 500,
"height": 500,
"probe_roi_px": {"u": 30.0, "v": 30.0, "radius": 6.6},
"frames": [
{"index": 0, "name": frame_name, "axis": None, "step_mm": 0.0,
"png": f"frames/{frame_name}.png", "centroid_px": [30.0, 30.0]},
{"index": 1, "name": "001_x_+0.10mm", "axis": "x", "step_mm": 0.10,
"png": "frames/001_x_+0.10mm.png", "centroid_px": [31.3, 30.0]},
],
}
(directory / "render_manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
flow_report = {
"tracker": {"patch_px": 8, "finest_scale": 1},
"scales": [
{"name": "render", "width": 500, "height": 500,
"frames": [{"name": "001_x_+0.10mm",
"roi": {"u": 30.0, "v": 30.0, "radius": 6.6, "pixels": 139}}]}
],
}
(directory / "flow_report.json").write_text(json.dumps(flow_report), encoding="utf-8")
sensitivity = {
"scales": [
{"name": "render", "cell_px": 500.0 / nx, "effective_patch_px": 16.0,
"axes": [{"axis": "x", "direction_px": [1.0, 0.0],
"points": [{"step_mm": 0.10, "response_px": 0.886,
"applied_px": 1.325}]}]}
]
}
(directory / "sensitivity_report.json").write_text(json.dumps(sensitivity), encoding="utf-8")
if include_flow:
flow_dir = directory / "flow" / "render"
flow_dir.mkdir(parents=True, exist_ok=True)
np.save(flow_dir / "001_x_+0.10mm.npy", _flow())
def test_panel_from_run_reads_the_artifacts(tmp_path: Path) -> None:
_write_run(tmp_path, nx=31)
panel = probe_plot.panel_from_run(label="nx=31", output_dir=tmp_path, axis="x", step_mm=0.10)
assert panel["label"] == "nx=31"
assert panel["flow"] is not None
assert panel["response_px"] == pytest.approx(0.886)
assert panel["applied_px"] == pytest.approx(1.325)
assert panel["cell_px"] == pytest.approx(500.0 / 31)
assert panel["effective_patch_px"] == pytest.approx(16.0)
assert "patch 8 px" in panel["scale_note"]
def test_panel_from_run_names_a_missing_flow_array(tmp_path: Path) -> None:
_write_run(tmp_path, include_flow=False)
panel = probe_plot.panel_from_run(label="broken", output_dir=tmp_path, axis="x", step_mm=0.10)
assert panel["flow"] is None
assert "missing flow array" in panel["note"]
def test_panel_from_run_survives_no_artifacts_at_all(tmp_path: Path) -> None:
empty = tmp_path / "nothing"
empty.mkdir()
panel = probe_plot.panel_from_run(label="empty", output_dir=empty, axis="x", step_mm=0.10)
assert panel["flow"] is None
assert panel["cell_px"] is None
# And the figure must still be drawable from it.
out = probe_plot.render_flow_comparison(panels=[panel], out_path=tmp_path / "none.png")
_assert_png(out)
+801
View File
@@ -0,0 +1,801 @@
"""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"]
+971
View File
@@ -0,0 +1,971 @@
"""Tests for the probe sensitivity scoring and report.
The strongest evidence is the synthetic case with a *known injected gain*: the
scorer must recover exactly the gain it was handed. The magnitudes used here are
the real measured probe-local scales (x 13.248, y 17.670, z 1.519 px/mm at
500x500; halved at the 250x250 process scale) so the gated and ungated paths are
both exercised at true magnitudes.
"""
from __future__ import annotations
import json
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from orisys_sensor_design import sensitivity as sens
H = W = 48
ROI = sens.Roi(u=24.0, v=24.0, radius=5.0)
PROCESS_H = PROCESS_W = 24
PROCESS_ROI = sens.Roi(
u=(ROI.u + 0.5) / 2.0 - 0.5,
v=(ROI.v + 0.5) / 2.0 - 0.5,
radius=ROI.radius / 2.0,
)
DIRECTION = (0.9995, -0.0310)
DIRECTION_UNIT = tuple(
float(value) / float(np.hypot(*DIRECTION)) for value in DIRECTION
)
STEPS = tuple(index * 0.01 for index in range(1, 11))
RENDER_PX_PER_MM = 13.2480
PROCESS_PX_PER_MM = 6.6240
#: Real measured per-axis probe-local scales (px per mm).
RENDER_LOCAL = {"x": 13.2480, "y": 17.6704, "z": 1.5193}
PROCESS_LOCAL = {"x": 6.6240, "y": 8.8352, "z": 0.75965}
#: Injecting gain == probe-local scale models a rigid probe: all applied
#: displacement shows up in the image, so gain_mm_per_mm ~ 1.
AXIS_GAIN = {"x": RENDER_LOCAL["x"], "y": RENDER_LOCAL["y"], "z": RENDER_LOCAL["z"]}
AXIS_SEED = {"x": 11, "y": 22, "z": 33}
# --------------------------------------------------------------------------- #
# Synthetic flow builders
# --------------------------------------------------------------------------- #
def _injected_flow(
gain_px_per_mm: float,
*,
seed: int,
floor_sigma: float = 0.0255,
perpendicular_px_per_mm: float = 0.0,
shape: tuple[int, int] = (H, W),
roi: sens.Roi = ROI,
) -> tuple[list[sens.FrameFlow], np.ndarray]:
"""Frames whose ROI-mean response is ``gain * step`` (plus noise)."""
height, width = shape
rng = np.random.default_rng(seed)
frames = [
sens.FrameFlow(
index=0,
name="000_reference",
axis=None,
step_mm=0.0,
roi=roi,
flow=np.zeros((height, width, 2), dtype=np.float64),
centroid_px=(roi.u, roi.v),
)
]
perpendicular = (-DIRECTION_UNIT[1], DIRECTION_UNIT[0])
for index, step in enumerate(STEPS, start=1):
signal = gain_px_per_mm * step
frame = np.zeros((height, width, 2), dtype=np.float64)
frame[..., 0] = signal * DIRECTION_UNIT[0]
frame[..., 1] = signal * DIRECTION_UNIT[1]
if perpendicular_px_per_mm:
leak = perpendicular_px_per_mm * step
frame[..., 0] += leak * perpendicular[0]
frame[..., 1] += leak * perpendicular[1]
frame += rng.normal(0.0, floor_sigma, (height, width, 2))
frames.append(
sens.FrameFlow(
index=index,
name=f"{index:03d}_frame",
axis="x",
step_mm=step,
roi=roi,
flow=frame,
centroid_px=(
roi.u + signal * DIRECTION_UNIT[0],
roi.v + signal * DIRECTION_UNIT[1],
),
)
)
null = rng.normal(0.0, floor_sigma, (height, width, 2))
return frames, null
def _axis_frames(
axis: str,
gain_px_per_mm: float,
*,
seed: int,
shape: tuple[int, int] = (H, W),
roi: sens.Roi = ROI,
perpendicular_px_per_mm: float = 0.0,
) -> tuple[list[sens.FrameFlow], np.ndarray]:
frames, null = _injected_flow(
gain_px_per_mm,
seed=seed,
shape=shape,
roi=roi,
perpendicular_px_per_mm=perpendicular_px_per_mm,
)
renamed: list[sens.FrameFlow] = []
for frame in frames:
if frame.step_mm == 0.0:
renamed.append(replace(frame, name="000_reference", axis=None))
else:
renamed.append(replace(frame, name=f"{axis}_{frame.step_mm:.2f}", axis=axis))
return renamed, null
def _score(
gain_px_per_mm: float = RENDER_LOCAL["x"],
*,
axis: str = "x",
seed: int = 0,
scale_px_per_mm: float | None = RENDER_LOCAL["x"],
null_flow: np.ndarray | None = None,
manifest_scale_factor: float = 1.0,
perpendicular_px_per_mm: float = 0.0,
**kwargs: object,
) -> sens.AxisScore:
frames, null = _axis_frames(
axis, gain_px_per_mm, seed=seed, perpendicular_px_per_mm=perpendicular_px_per_mm
)
return sens.score_axis(
frames,
axis,
px_per_mm=RENDER_PX_PER_MM,
null_flow=null if null_flow is None else null_flow,
scale_px_per_mm=scale_px_per_mm,
manifest_scale_factor=manifest_scale_factor,
**kwargs,
)
# --------------------------------------------------------------------------- #
# Synthetic known-gain recovery (primary correctness evidence)
# --------------------------------------------------------------------------- #
def test_known_gain_recovered_in_px_per_mm() -> None:
injected = RENDER_LOCAL["x"]
score = _score(injected)
assert score.gain_px_per_mm is not None
recovered = score.gain_px_per_mm
tolerance = 0.20 # 1.5 % of 13.248
assert abs(recovered - injected) < tolerance, (
f"injected gain {injected} px/mm, recovered {recovered:.6f} px/mm; "
f"tolerance {tolerance}"
)
assert 0.030 < score.floor_px < 0.070, score.floor_px
assert score.floor_source == "null_projection_p95"
assert score.direction_source == "centroid_regression"
assert score.direction_px[0] == pytest.approx(DIRECTION_UNIT[0], abs=5e-3)
assert score.direction_px[1] == pytest.approx(DIRECTION_UNIT[1], abs=5e-3)
expected_snr = abs(recovered) * 0.10 / score.floor_px
assert score.snr == pytest.approx(expected_snr, rel=1e-9)
assert score.gain_mm_per_mm == pytest.approx(recovered / RENDER_LOCAL["x"], rel=1e-9)
assert score.gain_gate == "derived"
assert score.verdict == "resolvable"
print(
f"\n[synthetic] injected={injected} px/mm recovered={recovered:.6f} px/mm "
f"tol={tolerance} floor={score.floor_px:.6f} px snr={score.snr:.4f} "
f"spearman={score.spearman:.4f} leak={score.cross_axis_leak:.4f} "
f"score={score.score:.4f}"
)
def test_known_gain_recovery_is_scale_agnostic() -> None:
frames, null = _injected_flow(4.0, seed=3, floor_sigma=0.01)
score = sens.score_axis(
frames, "x", px_per_mm=RENDER_PX_PER_MM, null_flow=null, scale_px_per_mm=4.0
)
assert score.gain_px_per_mm == pytest.approx(4.0, abs=0.05)
def test_gain_requires_three_points_above_floor_else_null() -> None:
score = _score(0.02)
assert score.gain_px_per_mm is None
assert score.gain_mm_per_mm is None
assert score.gain_gate == "no_gain"
assert score.resolvable_step_mm is None
assert score.verdict == "unresolvable"
# --------------------------------------------------------------------------- #
# The mm/mm gate
# --------------------------------------------------------------------------- #
def test_local_scale_gate_boundary_matches_the_measured_axes() -> None:
derived = _score(RENDER_LOCAL["z"], axis="z", scale_px_per_mm=RENDER_LOCAL["z"])
gated = _score(
PROCESS_LOCAL["z"], axis="z", scale_px_per_mm=PROCESS_LOCAL["z"]
)
# Render z = 1.519 px/mm clears the 1.0 threshold and keeps its ratio.
assert derived.gain_gate == "derived"
assert derived.gain_mm_per_mm is not None
# Process z = 0.760 px/mm is gated to null with no score inflation.
assert gated.gain_mm_per_mm is None, "sub-threshold local scale must not be divided"
assert gated.gain_gate == "below_min_local_scale"
assert gated.score < derived.score or gated.gain_px_per_mm is not None
def test_gate_does_not_inflate_the_score() -> None:
injected = RENDER_LOCAL["x"]
derived = _score(injected, scale_px_per_mm=RENDER_LOCAL["x"])
gated = _score(injected, scale_px_per_mm=0.4)
assert gated.gain_mm_per_mm is None
assert gated.gain_gate == "below_min_local_scale"
assert gated.gain_px_per_mm == pytest.approx(injected, abs=0.2)
assert gated.score == pytest.approx(derived.score, rel=1e-9)
assert gated.score < injected / 0.4
unknown = _score(injected, scale_px_per_mm=None)
assert unknown.gain_mm_per_mm is None
assert unknown.gain_gate == "scale_unknown"
def test_min_local_scale_constant_is_one() -> None:
assert sens.min_local_scale_px_per_mm == 1.0
assert sens.MIN_LOCAL_SCALE_PX_PER_MM == 1.0
# --------------------------------------------------------------------------- #
# applied_px must be per-axis, and per-scale
# --------------------------------------------------------------------------- #
def test_applied_px_uses_the_per_axis_scale() -> None:
frames, null = _axis_frames("z", AXIS_GAIN["z"], seed=AXIS_SEED["z"])
score = sens.score_axis(
frames,
"z",
px_per_mm=RENDER_PX_PER_MM,
null_flow=null,
scale_px_per_mm=RENDER_LOCAL["z"],
)
last = score.points[-1]
assert score.px_per_mm_axis is not None
assert last["applied_px"] == pytest.approx(0.10 * score.px_per_mm_axis, rel=1e-6)
# The isotropic scalar 13.248 would claim 1.3248 px; the true z motion is 0.152 px.
assert last["applied_px"] < 0.2
assert last["applied_px"] == pytest.approx(0.10 * RENDER_LOCAL["z"], abs=5e-3)
def test_px_per_mm_axis_and_applied_px_are_rescaled_to_the_scale() -> None:
frames, null = _axis_frames("z", AXIS_GAIN["z"], seed=AXIS_SEED["z"])
render = sens.score_axis(
frames,
"z",
px_per_mm=RENDER_PX_PER_MM,
null_flow=null,
scale_px_per_mm=RENDER_LOCAL["z"],
manifest_scale_factor=1.0,
)
process = sens.score_axis(
frames,
"z",
px_per_mm=PROCESS_PX_PER_MM,
null_flow=null,
scale_px_per_mm=PROCESS_LOCAL["z"],
manifest_scale_factor=0.5,
)
assert process.px_per_mm_axis == pytest.approx(render.px_per_mm_axis * 0.5, rel=1e-9)
assert process.points[-1]["applied_px"] == pytest.approx(
render.points[-1]["applied_px"] * 0.5, rel=1e-9
)
assert process.px_per_mm_axis == pytest.approx(PROCESS_LOCAL["z"], abs=5e-3)
# --------------------------------------------------------------------------- #
# Ambiguous (periodic-pattern aliasing) verdict
# --------------------------------------------------------------------------- #
def _find_axis(report: dict, axis: str, scale_name: str = "render") -> dict:
scale = next(item for item in report["scales"] if item["name"] == scale_name)
for entry in scale["axes"]:
if entry["axis"] == axis:
return entry
raise AssertionError(f"axis {axis!r} not in scale {scale_name!r}")
def _find_scale(report: dict, scale_name: str) -> dict:
return next(item for item in report["scales"] if item["name"] == scale_name)
def test_high_score_with_failed_reference_texture_known_answer_is_ambiguous(
tmp_path: Path,
) -> None:
report = _run_end_to_end(
tmp_path, pattern={"nx": 80, "ny": 80, "seed": 7}, known_answer_passed=False
)
x_axis = _find_axis(report, "x")
assert x_axis["summary"]["score"] > 0, "the score stays a measured number"
assert report["overall"]["score"] > 0
assert x_axis["summary"]["verdict"] == "ambiguous"
assert report["overall"]["verdict"] == "ambiguous"
assert report["overall"]["verdict"] != "resolvable"
aliasing = [w for w in report["warnings"] if "reference_texture_known_answer FAILED" in w]
assert aliasing, report["warnings"]
assert "measured" in aliasing[0] and "expected" in aliasing[0]
assert "8.17" in aliasing[0]
def test_passing_known_answer_does_not_force_ambiguous(tmp_path: Path) -> None:
report = _run_end_to_end(
tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7}, known_answer_passed=True
)
assert _find_axis(report, "x")["summary"]["verdict"] == "resolvable"
assert report["overall"]["verdict"] != "ambiguous"
def test_ambiguous_outranks_unresolvable_and_marginal() -> None:
assert sens.VERDICT_SEVERITY["ambiguous"] < sens.VERDICT_SEVERITY["unresolvable"]
assert sens.VERDICT_SEVERITY["unresolvable"] < sens.VERDICT_SEVERITY["marginal"]
assert sens.VERDICT_SEVERITY["marginal"] < sens.VERDICT_SEVERITY["resolvable"]
# --------------------------------------------------------------------------- #
# Resolvable-step selection, leak, saturation, spearman
# --------------------------------------------------------------------------- #
def test_resolvable_step_is_first_step_above_floor() -> None:
score = _score()
above = [point for point in score.points if point["above_floor"]]
assert above, score.points
assert score.resolvable_step_mm == pytest.approx(above[0]["step_mm"])
assert score.resolvable_step_mm is not None and score.resolvable_step_mm > 0.0
def test_resolvable_step_none_when_nothing_clears_floor() -> None:
frames, _ = _axis_frames("x", 0.0, seed=0)
null = np.full((H, W, 2), 0.2)
score = sens.score_axis(
frames,
"x",
px_per_mm=RENDER_PX_PER_MM,
null_flow=null,
scale_px_per_mm=6.2,
direction_px=(1.0, 0.0),
)
assert score.floor_px == pytest.approx(0.2)
assert score.resolvable_step_mm is None
assert score.gain_px_per_mm is None
assert score.verdict == "unresolvable"
assert score.score == 0.0
def test_resolvable_step_skips_a_below_floor_first_step() -> None:
frames, null = _axis_frames("x", AXIS_GAIN["x"], seed=0)
first = frames[1]
frames[1] = replace(first, flow=np.zeros((H, W, 2)))
score = sens.score_axis(
frames,
"x",
px_per_mm=RENDER_PX_PER_MM,
null_flow=null,
scale_px_per_mm=RENDER_LOCAL["x"],
)
assert score.resolvable_step_mm == pytest.approx(0.02)
def test_leak_penalty_reduces_score() -> None:
clean = _score(seed=1, perpendicular_px_per_mm=0.0)
leaky = _score(seed=1, perpendicular_px_per_mm=RENDER_LOCAL["x"])
assert leaky.cross_axis_leak > clean.cross_axis_leak
assert leaky.gain_px_per_mm == pytest.approx(clean.gain_px_per_mm, abs=0.05)
assert leaky.score < clean.score
expected = leaky.snr * leaky.spearman * (1.0 - min(leaky.cross_axis_leak, 0.5))
assert leaky.score == pytest.approx(expected, rel=1e-9)
def test_leak_penalty_saturates_at_half() -> None:
frames, _ = _axis_frames("x", 0.0, seed=0)
perpendicular = (-DIRECTION_UNIT[1], DIRECTION_UNIT[0])
for index, step in enumerate(STEPS, start=1):
flow = np.zeros((H, W, 2))
flow[..., 0] = perpendicular[0] * step * 10.0
flow[..., 1] = perpendicular[1] * step * 10.0
frames[index] = replace(frames[index], flow=flow)
null = np.full((H, W, 2), 0.001)
score = sens.score_axis(
frames,
"x",
px_per_mm=RENDER_PX_PER_MM,
null_flow=null,
scale_px_per_mm=6.2,
direction_px=DIRECTION_UNIT,
)
assert score.cross_axis_leak == pytest.approx(1.0)
assert score.score == 0.0
def test_saturation_detected_at_first_decrease_after_a_rise() -> None:
frames, _ = _axis_frames("x", 0.0, seed=0)
peaks = (1, 2, 3, 4, 5, 4, 3, 2, 1, 1)
for index, (step, peak) in enumerate(zip(STEPS, peaks), start=1):
flow = np.zeros((H, W, 2))
flow[..., 0] = 0.20 * peak * DIRECTION_UNIT[0]
flow[..., 1] = 0.20 * peak * DIRECTION_UNIT[1]
frames[index] = replace(frames[index], flow=flow)
null = np.full((H, W, 2), 0.01)
score = sens.score_axis(
frames, "x", px_per_mm=RENDER_PX_PER_MM, null_flow=null, scale_px_per_mm=6.2
)
assert score.saturation_mm == pytest.approx(0.06)
def test_no_saturation_when_response_only_rises() -> None:
assert _score().saturation_mm is None
def test_spearman_is_clamped_to_zero_for_decreasing_response() -> None:
frames, _ = _axis_frames("x", 0.0, seed=0)
for index, step in enumerate(STEPS, start=1):
signal = 0.30 - (index - 1) * 0.02
flow = np.zeros((H, W, 2))
flow[..., 0] = signal * DIRECTION_UNIT[0]
flow[..., 1] = signal * DIRECTION_UNIT[1]
frames[index] = replace(frames[index], flow=flow)
null = np.full((H, W, 2), 0.01)
score = sens.score_axis(
frames, "x", px_per_mm=RENDER_PX_PER_MM, null_flow=null, scale_px_per_mm=6.2
)
assert score.spearman == 0.0
assert score.score == 0.0
def test_null_floor_is_the_p95_directional_projection() -> None:
frames, null = _axis_frames("x", AXIS_GAIN["x"], seed=5)
score = sens.score_axis(
frames,
"x",
px_per_mm=RENDER_PX_PER_MM,
null_flow=null,
scale_px_per_mm=RENDER_LOCAL["x"],
)
mask = ROI.mask(H, W)
direction = score.direction_px
projection = np.abs(null[..., 0] * direction[0] + null[..., 1] * direction[1])
assert score.floor_px == pytest.approx(float(np.percentile(projection[mask], 95.0)))
def test_cell_px_uses_the_physical_cell_not_the_camera() -> None:
frames, null = _axis_frames("x", AXIS_GAIN["x"], seed=0)
scale = sens.ScaleInput(
name="render",
width=W,
height=H,
px_per_mm=RENDER_PX_PER_MM,
frames=tuple(frames),
null_flow=null,
step_mm=0.01,
local_scale_px_per_mm={"x": RENDER_LOCAL["x"]},
local_scale_source="per_axis",
mesh_px_per_mm=13.4302,
)
scored = sens.score_scale(scale, cell_mm=[18.5 / 31, 16.4 / 31])
assert scored.cell_px == pytest.approx((18.5 / 31) * RENDER_PX_PER_MM)
assert scored.cell_over_patch_px == pytest.approx((18.5 / 31) * RENDER_PX_PER_MM / 8.0)
assert scored.displacement_px_per_step == pytest.approx(0.01 * RENDER_PX_PER_MM)
# --------------------------------------------------------------------------- #
# End-to-end report: schema, precedence, determinism, exit codes
# --------------------------------------------------------------------------- #
def _write_flow_scale(
flow_dir: Path, name: str, frames: list[sens.FrameFlow], null: np.ndarray
) -> None:
directory = flow_dir / name
directory.mkdir(parents=True, exist_ok=True)
for frame in frames:
if frame.step_mm == 0.0:
continue
np.save(directory / f"{frame.name}.npy", frame.flow.astype(np.float32))
np.save(directory / "reference_null.npy", null.astype(np.float32))
def _run_end_to_end(
tmp_path: Path,
*,
pattern: dict | None,
known_answer_passed: bool = True,
write_texture_job: bool = False,
settings_pattern: dict | None = None,
output_name: str = "out",
) -> dict:
output_dir = tmp_path / output_name
output_dir.mkdir(parents=True, exist_ok=True)
if pattern is not None and "cell_mm" not in pattern:
pattern = dict(pattern)
pattern["cell_mm"] = [18.5 / pattern["nx"], 16.4 / pattern["ny"]]
render_frames: dict[str, list[sens.FrameFlow]] = {}
process_frames: dict[str, list[sens.FrameFlow]] = {}
nulls: dict[str, tuple[np.ndarray, np.ndarray]] = {}
name_map: dict[tuple[str, float], str] = {}
manifest_frames: list[dict] = [
{
"index": 0,
"name": "000_reference",
"axis": None,
"step_mm": 0.0,
"centroid_px": [ROI.u, ROI.v],
}
]
index = 0
for axis in ("x", "y", "z"):
frames, null = _axis_frames(axis, AXIS_GAIN[axis], seed=AXIS_SEED[axis])
half_gain = AXIS_GAIN[axis] * (PROCESS_PX_PER_MM / RENDER_PX_PER_MM)
proc, proc_null = _axis_frames(
axis,
half_gain,
seed=AXIS_SEED[axis],
shape=(PROCESS_H, PROCESS_W),
roi=PROCESS_ROI,
)
render_frames[axis] = frames
process_frames[axis] = proc
nulls[axis] = (null, proc_null)
for frame in frames:
if frame.step_mm == 0.0:
continue
index += 1
name = f"{index:03d}_{axis}_+{frame.step_mm:.2f}mm"
name_map[(axis, frame.step_mm)] = name
manifest_frames.append(
{
"index": index,
"name": name,
"axis": axis,
"step_mm": frame.step_mm,
"centroid_px": list(frame.centroid_px),
}
)
def rename(frames: list[sens.FrameFlow], axis: str) -> list[sens.FrameFlow]:
renamed: list[sens.FrameFlow] = []
for frame in frames:
if frame.step_mm == 0.0:
continue
renamed.append(
replace(frame, name=name_map[(axis, frame.step_mm)], axis=axis)
)
return renamed
scale_specs = (
("render", W, H, RENDER_PX_PER_MM, ROI, render_frames, 0),
("process", PROCESS_W, PROCESS_H, PROCESS_PX_PER_MM, PROCESS_ROI, process_frames, 1),
)
report_frames_by_scale: dict[str, list[dict]] = {}
for name, width, height, px_per_mm, roi, frame_map, null_index in scale_specs:
report_frames: list[dict] = []
combined: list[sens.FrameFlow] = []
# Build the reference first so the flow directory has a consistent shape.
reference = sens.FrameFlow(
index=0,
name="000_reference",
axis=None,
step_mm=0.0,
roi=roi,
flow=np.zeros((height, width, 2)),
centroid_px=(ROI.u, ROI.v),
)
_write_flow_scale(output_dir / "flow", name, [reference], nulls["x"][null_index])
for axis in ("x", "y", "z"):
for frame in rename(frame_map[axis], axis):
combined.append(frame)
report_frames.append(
{
"index": frame.index,
"name": frame.name,
"axis": axis,
"step_mm": frame.step_mm,
"roi": {"u": roi.u, "v": roi.v, "radius": roi.radius},
"flow": {"mean_mag": 0.0},
"response_proj_px": 0.0,
"response_mm": 0.0,
}
)
# Per-axis nulls must share the scale's shape; write each axis's own null
# under a per-axis name and point every frame at the module's canonical
# reference_null by combining the three axes' nulls is not meaningful here.
# Instead: the null is scale-wide and identical for every axis, so use the x
# null (all axes share floor_sigma and seed-independent noise is fine).
_write_flow_scale(
output_dir / "flow",
name,
combined,
nulls["x"][null_index],
)
report_frames_by_scale[name] = report_frames
known_answer = {
"passed": known_answer_passed,
"gating": False,
"cases": [
{
"dx": 2,
"dy": 0,
"expected_u": 2.0,
"expected_v": 0.0,
"measured_u": 2.0 if known_answer_passed else 8.17,
"measured_v": 0.0,
}
],
}
flow_report = {
"schema": sens.FLOW_REPORT_SCHEMA,
"tracker": {"algorithm": "dis", "dis_preset": "medium", "dis_mode": 1},
"known_answer": {"passed": True},
"reference_texture_known_answer": known_answer,
"scales": [
{
"name": name,
"width": width,
"height": height,
"px_per_mm": px_per_mm,
"mm_per_px": 1.0 / px_per_mm,
"frames": report_frames_by_scale[name],
"null": {"floor_px": 0.05, "floor_source": "reference_null"},
}
for name, width, height, px_per_mm, _roi, _fm, _ni in scale_specs
],
}
(output_dir / "flow_report.json").write_text(json.dumps(flow_report), encoding="utf-8")
manifest = {
"schema": sens.RENDER_MANIFEST_SCHEMA,
"texture": {"path": str(output_dir / "marker.png"), "sha256": "abc123"},
"width": W,
"height": H,
"mesh_px_per_mm": {"min": 10.4994, "p05": 10.9457, "p50": 13.4302,
"p95": 22.8857, "max": 38.5943},
"probe_px_per_mm": {"p50": 13.2480, "x": 13.2480, "y": 17.6704, "z": 1.5193},
"probe_roi_px": {"u": ROI.u, "v": ROI.v, "radius": ROI.radius},
"reference_index": 0,
"reference_deterministic": True,
"frames": manifest_frames,
"null": {"name": "reference_null", "seed": 1},
}
(output_dir / "render_manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
if write_texture_job:
(output_dir / "_texture_job.json").write_text(
json.dumps(
{
"schema": "orisys-sensor-design/texture-job-v3",
"settings": {
"nx": 20,
"ny": 20,
"seed": 7,
"vector": {"width_mm": 18.5, "height_mm": 16.4},
},
}
),
encoding="utf-8",
)
settings = SimpleNamespace(
step_mm=0.01,
max_mm=0.10,
axes=["x", "y", "z"],
resolution=[500, 500],
process_resolution=[250, 250],
radius_mm=0.5,
falloff="smooth",
group="probe",
mesh="sensing_surface",
tracker={"dis_preset": "medium", "dis_mode": 1, "patch_px": 8},
pattern=settings_pattern,
)
report, code = sens.run_sensitivity(
settings, output_dir, pattern=pattern, progress=lambda message: None
)
assert code == 0, report.get("warnings")
assert (output_dir / sens.SENSITIVITY_REPORT_FILENAME).is_file()
return report
def test_report_schema_keys(tmp_path: Path) -> None:
report = _run_end_to_end(tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7})
assert report["schema"] == sens.SENSITIVITY_REPORT_SCHEMA
for key in ("generated_at", "revision", "texture", "probe", "tracker", "scales",
"overall", "warnings", "notes"):
assert key in report, key
assert set(report["overall"]) >= {"score", "verdict", "best_scale", "best_axis"}
assert report["texture"]["grid"] == [31, 31]
assert report["texture"]["cell_mm"] == pytest.approx([18.5 / 31, 16.4 / 31])
assert [scale["name"] for scale in report["scales"]] == ["render", "process"]
for scale in report["scales"]:
for key in ("name", "px_per_mm", "displacement_px_per_step", "cell_px",
"cell_over_patch_px", "null_floor_px", "axes", "score", "verdict"):
assert key in scale, key
axis = _find_axis(report, "x")
assert set(axis) >= {"axis", "direction_px", "direction_source", "points", "summary"}
assert axis["direction_source"] in {
"centroid_regression",
"centroid_finite_difference",
"roi_mean_flow_at_max_step",
"provided",
}
assert set(axis["points"][0]) == {"step_mm", "applied_px", "response_px", "above_floor"}
for key in ("gain_px_per_mm", "gain_mm_per_mm", "scale_px_per_mm", "snr",
"spearman", "resolvable_step_mm", "saturation_mm",
"cross_axis_leak", "floor_px"):
assert key in axis["summary"], key
def test_report_is_deterministic_given_same_inputs(tmp_path: Path) -> None:
first = _run_end_to_end(tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7})
second = _run_end_to_end(tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7})
first.pop("generated_at")
second.pop("generated_at")
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
def test_pattern_precedence_explicit_beats_texture_job(tmp_path: Path) -> None:
report = _run_end_to_end(
tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7}, write_texture_job=True
)
assert report["texture"]["grid"] == [31, 31]
assert "pattern argument" in " ".join(report["notes"])
def test_pattern_falls_back_to_texture_job(tmp_path: Path) -> None:
report = _run_end_to_end(tmp_path, pattern=None, write_texture_job=True)
assert report["texture"]["grid"] == [20, 20]
assert report["texture"]["cell_mm"] == pytest.approx([18.5 / 20, 16.4 / 20])
assert "_texture_job.json" in " ".join(report["notes"])
def test_pattern_unknown_is_null_not_a_guessed_80x80(tmp_path: Path) -> None:
report = _run_end_to_end(tmp_path, pattern=None, write_texture_job=False)
assert report["texture"]["grid"] is None
assert report["texture"]["cell_mm"] is None
assert report["scales"][0]["cell_px"] is None
assert any("unknown" in note for note in report["notes"])
def test_settings_pattern_is_used_when_no_argument(tmp_path: Path) -> None:
report = _run_end_to_end(
tmp_path,
pattern=None,
settings_pattern={"nx": 40, "ny": 40, "seed": 7, "cell_mm": [0.4625, 0.41]},
)
assert report["texture"]["grid"] == [40, 40]
assert report["texture"]["cell_mm"] == pytest.approx([0.4625, 0.41])
assert "settings.pattern" in " ".join(report["notes"])
def test_missing_artifact_returns_3_and_still_writes_a_report(tmp_path: Path) -> None:
output_dir = tmp_path / "empty"
output_dir.mkdir()
settings = SimpleNamespace(step_mm=0.01, radius_mm=0.5, falloff="smooth")
report, code = sens.run_sensitivity(settings, output_dir, progress=lambda m: None)
assert code == 3
path = output_dir / sens.SENSITIVITY_REPORT_FILENAME
assert path.is_file()
written = json.loads(path.read_text(encoding="utf-8"))
assert written["schema"] == sens.SENSITIVITY_REPORT_SCHEMA
assert written["warnings"]
assert written["overall"]["verdict"] == "unresolvable"
def test_degraded_report_on_bad_flow_report(tmp_path: Path) -> None:
output_dir = tmp_path / "bad"
output_dir.mkdir()
(output_dir / "flow_report.json").write_text("{not json", encoding="utf-8")
(output_dir / "render_manifest.json").write_text("{}", encoding="utf-8")
report, code = sens.run_sensitivity(SimpleNamespace(step_mm=0.01), output_dir, progress=lambda m: None)
assert code == 1
assert (output_dir / sens.SENSITIVITY_REPORT_FILENAME).is_file()
assert report["error"]["kind"] == "data"
def test_subpixel_and_cell_warnings_are_emitted(tmp_path: Path) -> None:
report = _run_end_to_end(tmp_path, pattern={"nx": 80, "ny": 80, "seed": 7})
joined = " ".join(report["warnings"])
assert "below one pixel" in joined
# The cell warning is now about representability, not a "DIS patch Nyquist limit"
# statement derived from the old millimetre-based cell.
assert "cannot be represented at all" in joined
# nx=80 at 500 px is a 6.25 px cell against a 16 px effective window (ratio 0.39),
# so the effective-window guidance must fire and name the ratio.
assert "effective DIS window" in joined
def test_cell_px_is_the_camera_space_cell_and_effective_window_is_reported(
tmp_path: Path,
) -> None:
"""The governing numbers must be camera-space, and consistent with each other."""
report = _run_end_to_end(tmp_path, pattern={"nx": 80, "ny": 80, "seed": 7})
scale = next(item for item in report["scales"] if item["name"] == "render")
assert scale["reference_width_px"] == scale["width"]
assert scale["cell_px"] == pytest.approx(scale["width"] / 80.0, rel=1e-9)
assert scale["cell_px_source"].startswith("render_width/nx")
# patch 8 px x 2**finest_scale; the fixture tracker config uses dis_mode=1.
assert scale["effective_patch_px"] == pytest.approx(16.0, rel=1e-9)
assert scale["cell_over_effective_patch_px"] == pytest.approx(
scale["cell_px"] / scale["effective_patch_px"], rel=1e-9
)
assert scale["cell_over_patch_px"] == pytest.approx(scale["cell_px"] / 8.0, rel=1e-9)
def test_nx31_is_near_the_effective_window_on_a_500px_render() -> None:
"""The regression that pins the corrected interpretation.
nx=31 on a 500 px render is a 16.13 px cell, which is 1.008x the 16 px effective
window of an 8 px patch at finest_scale 1 -- the measured render-scale optimum.
"""
win = sens.cell_metrics(width=500, nx=31, cell_mm=(0.5968, 0.529), px_per_mm=13.248,
finest_scale=1)
assert win["cell_px"] == pytest.approx(16.129, rel=1e-3)
assert win["cell_px_source"].startswith("render_width/nx")
assert win["effective_patch_px"] == pytest.approx(16.0, rel=1e-9)
assert win["cell_over_effective_patch_px"] == pytest.approx(1.008, rel=1e-2)
# The old, wrong definition is still recorded for comparison, and differs ~2x.
assert win["nominal_cell_px"] == pytest.approx(7.906, rel=1e-3)
finer = sens.cell_metrics(width=500, nx=80, cell_mm=(0.23125, 0.23125), px_per_mm=13.248,
finest_scale=1)
assert finer["cell_px"] == pytest.approx(6.25, rel=1e-9)
assert finer["cell_over_effective_patch_px"] == pytest.approx(0.3906, rel=1e-3)
# dis_mode=0 halves the effective window, so the same pattern sits at 2x the ratio.
coarse = sens.cell_metrics(width=500, nx=31, cell_mm=(0.5968, 0.529), px_per_mm=13.248,
finest_scale=0)
assert coarse["effective_patch_px"] == pytest.approx(8.0, rel=1e-9)
assert coarse["cell_over_effective_patch_px"] == pytest.approx(2.016, rel=1e-2)
# Without a grid the metric falls back and says so, rather than guessing.
fallback = sens.cell_metrics(width=500, nx=None, cell_mm=(0.5968, 0.529), px_per_mm=13.248)
assert fallback["cell_px_source"].startswith("cell_mm x probe-local")
assert fallback["cell_px"] == pytest.approx(7.906, rel=1e-3)
def test_step_size_and_estimator_floor_are_reported_without_conflation(
tmp_path: Path,
) -> None:
report = _run_end_to_end(tmp_path, pattern={"nx": 80, "ny": 80, "seed": 7})
axis = _find_axis(report, "x", "render")
# The step is sub-pixel but still clears the measured floor: 0.1325 px > ~0.046 px.
assert axis["summary"]["resolvable_step_mm"] == pytest.approx(0.01)
assert axis["summary"]["floor_px"] < 0.01 * RENDER_PX_PER_MM
joined = " ".join(report["warnings"])
assert "below one pixel" in joined
# "Below one pixel" must never be asserted as "below the estimator floor".
assert "below the estimator floor" not in joined
assert "estimator floor" in joined, "the floor must still be stated"
assert "per-pixel precision limit" in joined
# The old false claim must be gone: the ROI mean does resolve single steps here.
notes = " ".join(report["notes"])
assert "Only the ROI-aggregated response over the whole sweep is resolvable" not in notes
assert "smallest step clearing the measured floor" in notes
def test_roi_confound_and_attribution_notes_are_present(tmp_path: Path) -> None:
report = _run_end_to_end(tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7})
joined = " ".join(report["notes"])
assert "confounded with the ROI size" in joined
assert "never the radius" in joined
assert "17%" in joined and "95%" in joined
assert "not noise, aliasing" in joined
assert "2.0000 px" in joined
def test_null_floor_is_the_projection_p95_on_that_scale_roi(tmp_path: Path) -> None:
"""Regression guard for the 15x floor error.
A projected p95 (``|dot(flow, d_hat)|`` over the ROI) can never exceed the p95
of ``|flow|`` over the *same* field and region, so that invariant bounds the
reported ``null_floor_px``. The exact-equality check pins the field *and* the
region: using the render-scale manifest ROI on the process-scale arrays (the
bug that inflated the process floor to 0.262662 px against a true 0.017082 px)
fails here on both scales.
"""
report = _run_end_to_end(tmp_path, pattern={"nx": 80, "ny": 80, "seed": 7})
output_dir = tmp_path / "out"
flow_report = json.loads((output_dir / "flow_report.json").read_text(encoding="utf-8"))
for scale in report["scales"]:
name = scale["name"]
entry = next(item for item in flow_report["scales"] if item["name"] == name)
raw = entry["frames"][0]["roi"]
roi = sens.Roi(u=float(raw["u"]), v=float(raw["v"]), radius=float(raw["radius"]))
null = np.load(output_dir / "flow" / name / "reference_null.npy").astype(np.float64)
mask = roi.mask(null.shape[0], null.shape[1])
assert mask.any(), name
if name != "render":
# The render-scale manifest ROI is a *different* region on this scale's
# grid; confirm the guard can tell them apart.
manifest_roi = sens.Roi(u=ROI.u, v=ROI.v, radius=ROI.radius)
assert not np.array_equal(
mask, manifest_roi.mask(null.shape[0], null.shape[1])
), name
magnitude = np.sqrt(null[..., 0] ** 2 + null[..., 1] ** 2)
magnitude_p95 = float(np.percentile(magnitude[mask], 95.0))
assert scale["null_floor_px"] <= magnitude_p95 + 1e-9, name
for axis in scale["axes"]:
direction = axis["direction_px"]
projection = np.abs(
null[..., 0] * direction[0] + null[..., 1] * direction[1]
)
expected = float(np.percentile(projection[mask], 95.0))
assert axis["summary"]["floor_px"] == pytest.approx(
expected, rel=1e-9, abs=1e-6
), (name, axis["axis"])
assert axis["summary"]["floor_px"] <= magnitude_p95 + 1e-9, (name, axis["axis"])
def test_address_scale_gate_and_rescale_end_to_end(tmp_path: Path) -> None:
report = _run_end_to_end(tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7})
render_scale = _find_scale(report, "render")
process_scale = _find_scale(report, "process")
assert render_scale["mesh_px_per_mm"] == pytest.approx(13.4302)
assert render_scale["px_per_mm"] == pytest.approx(RENDER_PX_PER_MM)
assert process_scale["px_per_mm"] == pytest.approx(PROCESS_PX_PER_MM)
for axis in ("x", "y", "z"):
render_axis = _find_axis(report, axis, "render")
process_axis = _find_axis(report, axis, "process")
# applied_px is per-axis and per-scale, not the isotropic scalar.
assert render_axis["points"][-1]["applied_px"] == pytest.approx(
0.10 * render_axis["summary"]["px_per_mm_axis"], rel=1e-3
)
assert process_axis["points"][-1]["applied_px"] == pytest.approx(
0.10 * process_axis["summary"]["px_per_mm_axis"], rel=1e-3
)
# px_per_mm_axis is at the scale it appears in.
assert render_axis["summary"]["px_per_mm_axis"] == pytest.approx(
RENDER_LOCAL[axis], rel=0.05
)
assert process_axis["summary"]["px_per_mm_axis"] == pytest.approx(
PROCESS_LOCAL[axis], rel=0.05
)
# z is 0.152 px over the whole sweep, not 1.325 px.
assert _find_axis(report, "z", "render")["points"][-1]["applied_px"] == pytest.approx(
0.152, abs=0.01
)
# Render z (1.519) keeps its derived ratio; process z (0.760) is gated to null.
assert _find_axis(report, "z", "render")["summary"]["gain_gate"] == "derived"
assert _find_axis(report, "z", "render")["summary"]["gain_mm_per_mm"] is not None
assert _find_axis(report, "z", "process")["summary"]["gain_gate"] == "below_min_local_scale"
assert _find_axis(report, "z", "process")["summary"]["gain_mm_per_mm"] is None
assert any(
"min_local_scale_px_per_mm" in warning for warning in report["warnings"]
)