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:
@@ -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 == {}
|
||||
@@ -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"],
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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"}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
)
|
||||
Reference in New Issue
Block a user