added uv unwarp export
This commit is contained in:
@@ -118,12 +118,18 @@ def test_manifest_writes_without_export(revision_dir: Path, tmp_path: Path) -> N
|
||||
assert loaded["sensor"] == "finger"
|
||||
assert loaded["revision"] == "r01"
|
||||
assert loaded["profile"] == "compute"
|
||||
assert loaded["expected_artifacts"] == [
|
||||
assert loaded["expected_artifacts"][:4] == [
|
||||
"mesh.npz",
|
||||
"housing.npz",
|
||||
"camera_intrinsics.npz",
|
||||
"world_to_cam.npy",
|
||||
]
|
||||
# The unwarp/valid-mask map is built by the export itself (SDK precompute); listing it
|
||||
# makes a missing mask fail the export instead of shipping a bundle that renders grey.
|
||||
# Its name embeds the profile's source_scale (`scaled_map_cache_path`), so match the
|
||||
# family rather than one pinned value.
|
||||
map_name = loaded["expected_artifacts"][4]
|
||||
assert map_name.startswith("unroll_map_uv_s") and map_name.endswith(".npz")
|
||||
assert loaded["source_blend"].endswith("geometry.blend")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
"""Tests for the unwarp/valid-mask ("UV domain") map the export builds.
|
||||
|
||||
Two layers:
|
||||
|
||||
* the module itself (``orisys_sensor_design.uv_map``) — needs only numpy, so it runs with
|
||||
the interpreter that builds the SDK-side artifacts;
|
||||
* the export flow in ``cli.cmd_export`` — needs PyYAML for the profile loader, so those
|
||||
tests skip when it is absent rather than failing for the wrong reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from orisys_sensor_design import uv_map as uv
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
REVISION = REPO_ROOT / "sensors" / "finger" / "r01"
|
||||
COMPUTE_PROFILE = REVISION / "profiles" / "compute.yaml"
|
||||
|
||||
#: A 1280x720 export like sensors/finger/r01/.../compute/camera_intrinsics.npz.
|
||||
NATIVE_K = np.array(
|
||||
[[415.62082888, 0.0, 640.0], [0.0, 415.62082888, 360.0], [0.0, 0.0, 1.0]]
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Derived source scale
|
||||
# ----------------------------------------------------------------------
|
||||
def test_derive_source_scale_follows_the_output_requirement() -> None:
|
||||
# max(280/1280, 300/720) = 0.4166... -> 0.417; the floor that avoids upsampling.
|
||||
assert uv.derive_source_scale(1280, 720, 280, 300) == 0.417
|
||||
|
||||
|
||||
def test_derive_source_scale_is_camera_independent_for_one_output_grid() -> None:
|
||||
# Both cameras land on the same 533x300 source grid because the rule follows the
|
||||
# output requirement, not the native frame.
|
||||
assert uv.derive_source_scale(1920, 1080, 280, 300) == 0.278
|
||||
scale = uv.derive_source_scale(1920, 1080, 280, 300)
|
||||
assert int(1920 * scale) >= 280 and int(1080 * scale) >= 300
|
||||
|
||||
|
||||
def test_derive_source_scale_covers_the_output_one_to_one() -> None:
|
||||
assert uv.derive_source_scale(1280, 720, 640, 360) == 0.5
|
||||
# A downscale to half the frame still needs half the scale, not 1.0.
|
||||
assert uv.derive_source_scale(1280, 720, 280, 300) < 0.5
|
||||
|
||||
|
||||
def test_derive_source_scale_never_exceeds_the_native_frame() -> None:
|
||||
# An output grid larger than the camera cannot be covered without upsampling, so the
|
||||
# scale is clamped rather than raised above 1.0.
|
||||
assert uv.derive_source_scale(320, 240, 640, 480) == 1.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"native_width, native_height",
|
||||
[(0, 720), (1280, 0), (-1, 720)],
|
||||
)
|
||||
def test_derive_source_scale_rejects_a_non_positive_camera(
|
||||
native_width: int, native_height: int
|
||||
) -> None:
|
||||
with pytest.raises(uv.UvMapError, match="non-positive"):
|
||||
uv.derive_source_scale(native_width, native_height, 280, 300)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Parameter resolution: never guess
|
||||
# ----------------------------------------------------------------------
|
||||
GOOD_BLOCK = {
|
||||
"out_width": 280,
|
||||
"out_height": 300,
|
||||
"flip_v": True,
|
||||
"source_scale": 0.4,
|
||||
"edge_band": 0.12,
|
||||
"edge_expand": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def test_load_uv_map_params_reads_and_types_the_block() -> None:
|
||||
block = dict(GOOD_BLOCK, out_width="280", source_scale="0.4")
|
||||
params = uv.load_uv_map_params({"uv_map": block})
|
||||
assert params == {
|
||||
"out_width": 280,
|
||||
"out_height": 300,
|
||||
"flip_v": True,
|
||||
"source_scale": 0.4,
|
||||
"edge_band": 0.12,
|
||||
"edge_expand": 1.0,
|
||||
}
|
||||
assert isinstance(params["out_width"], int)
|
||||
assert isinstance(params["flip_v"], bool)
|
||||
|
||||
|
||||
def test_load_uv_map_params_errors_without_a_uv_map_block() -> None:
|
||||
# The whole point: a missing block must fail, not fall back to a default domain.
|
||||
with pytest.raises(uv.UvMapError, match="no uv_map: block"):
|
||||
uv.load_uv_map_params({"profile": "compute", "artifacts": ["mesh.npz"]})
|
||||
|
||||
|
||||
def test_load_uv_map_params_errors_on_an_empty_block() -> None:
|
||||
with pytest.raises(uv.UvMapError, match="no uv_map: block"):
|
||||
uv.load_uv_map_params({"uv_map": {}})
|
||||
|
||||
|
||||
def test_load_uv_map_params_errors_on_a_missing_key() -> None:
|
||||
block = dict(GOOD_BLOCK)
|
||||
del block["source_scale"]
|
||||
with pytest.raises(uv.UvMapError, match="missing \\['source_scale'\\]"):
|
||||
uv.load_uv_map_params({"uv_map": block})
|
||||
|
||||
|
||||
def test_load_uv_map_params_errors_on_an_unknown_key() -> None:
|
||||
block = dict(GOOD_BLOCK, out_depth=4)
|
||||
with pytest.raises(uv.UvMapError, match="does not understand"):
|
||||
uv.load_uv_map_params({"uv_map": block})
|
||||
|
||||
|
||||
def test_quoted_false_does_not_read_as_true() -> None:
|
||||
params = uv.load_uv_map_params({"uv_map": dict(GOOD_BLOCK, flip_v="false")})
|
||||
assert params["flip_v"] is False
|
||||
|
||||
|
||||
def test_compute_profile_pins_the_scale_the_map_is_built_at() -> None:
|
||||
pytest.importorskip("yaml", reason="reading the profile needs PyYAML")
|
||||
from orisys.unroll.geometry import scaled_map_cache_path
|
||||
|
||||
from orisys_sensor_design.manifest import load_yaml
|
||||
|
||||
profile = load_yaml(COMPUTE_PROFILE)
|
||||
params = uv.load_uv_map_params(profile)
|
||||
# Whatever the profile pins is what the build uses (the value is a deliberate choice,
|
||||
# so this asserts the wiring, not one particular number).
|
||||
assert params["source_scale"] == float(profile["uv_map"]["source_scale"])
|
||||
assert (params["out_width"], params["out_height"]) == (280, 300)
|
||||
# ... and the artifact the export must produce is named for that same scale, so a
|
||||
# changed scale cannot leave a stale, differently-named map looking complete.
|
||||
expected = Path(scaled_map_cache_path("unroll_map_uv.npz", params["source_scale"])).name
|
||||
assert expected in profile["artifacts"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# build_uv_map: the SDK call, with a stubbed SDK
|
||||
# ----------------------------------------------------------------------
|
||||
@pytest.fixture()
|
||||
def export_dir(tmp_path: Path) -> Path:
|
||||
"""An export payload directory with only the calibration the map step reads."""
|
||||
directory = tmp_path / "compute"
|
||||
directory.mkdir()
|
||||
np.savez(
|
||||
directory / "camera_intrinsics.npz",
|
||||
K=NATIVE_K,
|
||||
width=np.array(1280),
|
||||
height=np.array(720),
|
||||
)
|
||||
(directory / "mesh.npz").write_bytes(b"mesh")
|
||||
(directory / "world_to_cam.npy").write_bytes(b"pose")
|
||||
return directory
|
||||
|
||||
|
||||
def _stub_sdk(monkeypatch: pytest.MonkeyPatch, calls: dict, mask_mode: str = "valid"):
|
||||
"""Replace the two SDK entry points the map step calls, recording the arguments."""
|
||||
import orisys.unroll.geometry as geometry
|
||||
import orisys.unroll.precompute as precompute
|
||||
|
||||
def fake_scale_camera_geometry(K, width, height, scale):
|
||||
calls["scale_args"] = (width, height, scale)
|
||||
return np.asarray(K, dtype=np.float64) * scale, int(round(width * scale)), int(
|
||||
round(height * scale)
|
||||
)
|
||||
|
||||
def fake_build_or_load_domain(mesh, intrinsics, pose, map_path, **kwargs):
|
||||
calls["build_kwargs"] = kwargs
|
||||
calls["target"] = map_path
|
||||
calls["target_existed"] = Path(map_path).is_file()
|
||||
width = int(kwargs["out_width"])
|
||||
height = int(kwargs["out_height"])
|
||||
arrays = {
|
||||
"map_x": np.zeros((height, width), np.float32),
|
||||
"map_y": np.zeros((height, width), np.float32),
|
||||
}
|
||||
if mask_mode == "valid":
|
||||
mask = np.zeros((height, width), bool)
|
||||
mask[height // 2 :, :] = True
|
||||
arrays["valid_mask"] = mask
|
||||
arrays["world_xyz"] = np.zeros((height, width, 3), np.float32)
|
||||
elif mask_mode == "empty":
|
||||
arrays["valid_mask"] = np.zeros((height, width), bool)
|
||||
arrays["world_xyz"] = np.zeros((height, width, 3), np.float32)
|
||||
elif mask_mode == "no_surface":
|
||||
arrays["valid_mask"] = np.ones((height, width), bool)
|
||||
elif mask_mode == "wrong_shape":
|
||||
arrays["valid_mask"] = np.ones((4, 4), bool)
|
||||
arrays["world_xyz"] = np.zeros((4, 4, 3), np.float32)
|
||||
np.savez(map_path, **arrays)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(geometry, "scale_camera_geometry", fake_scale_camera_geometry)
|
||||
monkeypatch.setattr(precompute, "build_or_load_domain", fake_build_or_load_domain)
|
||||
|
||||
|
||||
def test_build_uv_map_scales_source_K_to_the_profile_scale(
|
||||
export_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
calls: dict = {}
|
||||
_stub_sdk(monkeypatch, calls)
|
||||
params = uv.load_uv_map_params({"uv_map": GOOD_BLOCK})
|
||||
|
||||
target = uv.build_uv_map(export_dir, params, export_dir)
|
||||
|
||||
assert target == export_dir / "unroll_map_uv_s0.4.npz"
|
||||
assert target.is_file()
|
||||
kwargs = calls["build_kwargs"]
|
||||
# 1280 x 0.4 — the scaled K, not the native one (that measured 3.5 % instead of 40.8 %).
|
||||
assert kwargs["source_width"] == 512
|
||||
assert kwargs["source_height"] == 288
|
||||
assert kwargs["source_scale"] == 0.4
|
||||
assert kwargs["source_K"][0, 0] == pytest.approx(NATIVE_K[0, 0] * 0.4)
|
||||
assert (kwargs["out_width"], kwargs["out_height"]) == (280, 300)
|
||||
assert kwargs["include_surface"] is True
|
||||
|
||||
|
||||
def test_build_uv_map_takes_the_source_frame_from_this_export(
|
||||
export_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
calls: dict = {}
|
||||
_stub_sdk(monkeypatch, calls)
|
||||
params = uv.load_uv_map_params({"uv_map": GOOD_BLOCK})
|
||||
|
||||
uv.build_uv_map(export_dir, params, export_dir)
|
||||
|
||||
# The source frame is derived from *this* export's camera_intrinsics.npz, never
|
||||
# inherited from another camera's map (that measured 0.8 %).
|
||||
assert calls["scale_args"] == (1280, 720, 0.4)
|
||||
|
||||
|
||||
def test_build_uv_map_derives_the_scale_when_none_is_pinned(
|
||||
export_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
calls: dict = {}
|
||||
_stub_sdk(monkeypatch, calls)
|
||||
params = dict(GOOD_BLOCK)
|
||||
del params["source_scale"]
|
||||
|
||||
target = uv.build_uv_map(export_dir, params, export_dir)
|
||||
|
||||
assert target.name == "unroll_map_uv_s0.417.npz"
|
||||
assert calls["build_kwargs"]["source_scale"] == 0.417
|
||||
|
||||
|
||||
def test_force_deletes_the_target_so_the_sdk_must_rebuild(
|
||||
export_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
calls: dict = {}
|
||||
_stub_sdk(monkeypatch, calls)
|
||||
params = uv.load_uv_map_params({"uv_map": GOOD_BLOCK})
|
||||
stale = export_dir / "unroll_map_uv_s0.4.npz"
|
||||
stale.write_bytes(b"stale map")
|
||||
|
||||
uv.build_uv_map(export_dir, params, export_dir)
|
||||
assert calls["target_existed"] is True # without --force the SDK may load it
|
||||
|
||||
uv.build_uv_map(export_dir, params, export_dir, force=True)
|
||||
assert calls["target_existed"] is False
|
||||
|
||||
|
||||
def test_build_uv_map_refuses_a_payload_the_export_did_not_write(tmp_path: Path) -> None:
|
||||
empty = tmp_path / "empty"
|
||||
empty.mkdir()
|
||||
with pytest.raises(uv.UvMapError, match="missing"):
|
||||
uv.build_uv_map(empty, dict(GOOD_BLOCK), empty)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mask_mode, message",
|
||||
[
|
||||
("no_surface", "world_xyz"),
|
||||
("empty", "no set pixel"),
|
||||
("wrong_shape", "does not match"),
|
||||
],
|
||||
)
|
||||
def test_build_uv_map_rejects_an_incomplete_map(
|
||||
export_dir: Path, monkeypatch: pytest.MonkeyPatch, mask_mode: str, message: str
|
||||
) -> None:
|
||||
calls: dict = {}
|
||||
_stub_sdk(monkeypatch, calls, mask_mode=mask_mode)
|
||||
params = uv.load_uv_map_params({"uv_map": GOOD_BLOCK})
|
||||
|
||||
with pytest.raises(uv.UvMapError, match=message):
|
||||
uv.build_uv_map(export_dir, params, export_dir)
|
||||
|
||||
|
||||
def test_a_map_without_a_valid_mask_is_fatal(
|
||||
export_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# mask_mode="valid" always writes one; drop it by hand to model an old/incomplete cache.
|
||||
calls: dict = {}
|
||||
_stub_sdk(monkeypatch, calls)
|
||||
params = uv.load_uv_map_params({"uv_map": GOOD_BLOCK})
|
||||
|
||||
import orisys.unroll.precompute as precompute
|
||||
|
||||
def build_without_mask(mesh, intrinsics, pose, map_path, **kwargs):
|
||||
calls["build_kwargs"] = kwargs
|
||||
height, width = int(kwargs["out_height"]), int(kwargs["out_width"])
|
||||
np.savez(map_path, map_x=np.zeros((height, width), np.float32))
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(precompute, "build_or_load_domain", build_without_mask)
|
||||
with pytest.raises(uv.UvMapError, match="valid_mask"):
|
||||
uv.build_uv_map(export_dir, params, export_dir)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Export flow: cmd_export builds the map between Blender and the artifact check
|
||||
# ----------------------------------------------------------------------
|
||||
def _cli():
|
||||
pytest.importorskip("yaml", reason="the CLI's profile loader needs PyYAML")
|
||||
from orisys_sensor_design import cli
|
||||
|
||||
return cli
|
||||
|
||||
|
||||
def _prepare_export(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, profile: dict | None = None):
|
||||
"""Point cmd_export at a temp output dir with the Blender artifacts already written."""
|
||||
cli = _cli()
|
||||
output_dir = tmp_path / "compute"
|
||||
output_dir.mkdir()
|
||||
for name in ("mesh.npz", "housing.npz", "camera_intrinsics.npz", "world_to_cam.npy"):
|
||||
(output_dir / name).write_bytes(b"artifact")
|
||||
monkeypatch.setattr(cli, "export_output_dir", lambda *a, **k: output_dir)
|
||||
monkeypatch.setattr(cli, "export_revision_dir", lambda *a, **k: tmp_path / "finger" / "r01")
|
||||
blender_calls: list = []
|
||||
monkeypatch.setattr(cli, "_run_blender_script", lambda *a, **k: blender_calls.append(a) or 0)
|
||||
if profile is not None:
|
||||
monkeypatch.setattr(cli, "load_profile", lambda paths, name: profile)
|
||||
return cli, output_dir, blender_calls
|
||||
|
||||
|
||||
def _record_builder(monkeypatch: pytest.MonkeyPatch, recorded: dict, *, fail: bool = False):
|
||||
from orisys.unroll.geometry import scaled_map_cache_path
|
||||
|
||||
def fake_build(export_dir, params, output_dir, *, force=False):
|
||||
if fail:
|
||||
raise uv.UvMapError("the island did not land in the output domain")
|
||||
recorded.update(
|
||||
export_dir=Path(export_dir),
|
||||
params=params,
|
||||
output_dir=Path(output_dir),
|
||||
force=force,
|
||||
)
|
||||
# Name it the way the real builder does, so the artifact check sees what the
|
||||
# profile promises regardless of the scale the profile pins.
|
||||
target = Path(
|
||||
scaled_map_cache_path(
|
||||
str(Path(output_dir) / "unroll_map_uv.npz"), float(params["source_scale"])
|
||||
)
|
||||
)
|
||||
target.write_bytes(b"map")
|
||||
return target
|
||||
|
||||
monkeypatch.setattr("orisys_sensor_design.uv_map.build_uv_map", fake_build)
|
||||
|
||||
|
||||
def test_export_builds_the_map_in_the_same_run(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
cli, output_dir, blender_calls = _prepare_export(tmp_path, monkeypatch)
|
||||
recorded: dict = {}
|
||||
_record_builder(monkeypatch, recorded)
|
||||
from orisys_sensor_design.manifest import load_yaml
|
||||
|
||||
expected = uv.load_uv_map_params(load_yaml(COMPUTE_PROFILE))
|
||||
|
||||
code = cli.cmd_export(REVISION, "compute")
|
||||
|
||||
assert code == 0
|
||||
assert blender_calls, "the Blender export must still run first"
|
||||
assert recorded["export_dir"] == output_dir
|
||||
assert recorded["output_dir"] == output_dir
|
||||
# Exactly the profile's uv_map: parameters - including the pinned source_scale.
|
||||
assert recorded["params"] == expected
|
||||
out = capsys.readouterr().out
|
||||
assert "OK: all profile artifacts present" in out
|
||||
|
||||
|
||||
def test_export_returns_a_distinct_code_when_the_map_fails(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
cli, _output_dir, _blender = _prepare_export(tmp_path, monkeypatch)
|
||||
_record_builder(monkeypatch, {}, fail=True)
|
||||
|
||||
code = cli.cmd_export(REVISION, "compute")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert code == cli.UV_MAP_EXIT_CODE == 4
|
||||
assert "unwarp/valid-mask" in captured.err
|
||||
# The bundle must not be reported as complete: the map is what stops it rendering grey.
|
||||
assert "OK: all profile artifacts present" not in captured.out
|
||||
|
||||
|
||||
def test_export_reports_a_profile_that_requires_a_map_it_cannot_build(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
profile = {"profile": "compute", "artifacts": ["mesh.npz", "unroll_map_uv_s0.4.npz"]}
|
||||
cli, _output_dir, _blender = _prepare_export(tmp_path, monkeypatch, profile=profile)
|
||||
recorded: dict = {}
|
||||
_record_builder(monkeypatch, recorded)
|
||||
|
||||
code = cli.cmd_export(REVISION, "compute")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert code == cli.UV_MAP_EXIT_CODE
|
||||
assert "no uv_map: block" in captured.err
|
||||
assert recorded == {}, "the builder must not be called without parameters"
|
||||
|
||||
|
||||
def test_export_skips_the_map_step_for_a_profile_that_declares_none(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
# Profiles whose runtime bundle is still SDK-owned keep their old behaviour.
|
||||
profile = {"profile": "compute", "artifacts": ["mesh.npz"]}
|
||||
cli, _output_dir, _blender = _prepare_export(tmp_path, monkeypatch, profile=profile)
|
||||
recorded: dict = {}
|
||||
_record_builder(monkeypatch, recorded)
|
||||
|
||||
code = cli.cmd_export(REVISION, "compute")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert code == 0
|
||||
assert recorded == {}
|
||||
assert "skipping the unwarp/valid-mask build" in captured.out
|
||||
assert "OK: all profile artifacts present" in captured.out
|
||||
Reference in New Issue
Block a user