migrated texture generation script to cli
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
"""Tests for headless colored-patch texture settings, job I/O, and CLI wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_sensor_design import cli
|
||||
from orisys_sensor_design.manifest import RevisionPaths, repo_root
|
||||
from orisys_sensor_design.texture import (
|
||||
DEFAULT_TEXTURE_PATCH,
|
||||
TEXTURE_JOB_FILENAME,
|
||||
TEXTURE_JOB_SCHEMA,
|
||||
TextureConfigError,
|
||||
build_texture_job,
|
||||
load_texture_patch_block,
|
||||
merge_texture_patch,
|
||||
resolve_texture_output_dir,
|
||||
resolve_texture_settings,
|
||||
texture_job_path,
|
||||
texture_output_dir,
|
||||
texture_revision_rel,
|
||||
write_texture_job,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def revision_dir() -> Path:
|
||||
return repo_root() / "sensors" / "finger" / "r01"
|
||||
|
||||
|
||||
def _texture_namespace(
|
||||
revision_dir: Path, output_dir: Path | None = None, **overrides: object
|
||||
) -> argparse.Namespace:
|
||||
namespace = argparse.Namespace(
|
||||
revision_dir=revision_dir,
|
||||
output_dir=output_dir,
|
||||
filename=None,
|
||||
mesh=None,
|
||||
cam=None,
|
||||
width=None,
|
||||
height=None,
|
||||
nx=None,
|
||||
ny=None,
|
||||
color_r=None,
|
||||
edge_sample_grid=None,
|
||||
seed=None,
|
||||
no_cross=None,
|
||||
cross_length=None,
|
||||
cross_thickness=None,
|
||||
cross_color=None,
|
||||
)
|
||||
for key, value in overrides.items():
|
||||
setattr(namespace, key, value)
|
||||
return namespace
|
||||
|
||||
|
||||
def _write_revision(
|
||||
tmp_path: Path,
|
||||
texture_patch_yaml: str = "",
|
||||
*,
|
||||
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 / "profiles").mkdir(parents=True)
|
||||
(revision / "source").mkdir(parents=True)
|
||||
(revision / "source" / "assembly.blend").write_bytes(b"")
|
||||
design = (
|
||||
"sensor: finger\n"
|
||||
f"revision: {revision_name}\n"
|
||||
"meters_per_source_unit: 0.001\n"
|
||||
"source_blend: source/assembly.blend\n"
|
||||
)
|
||||
design += texture_patch_yaml
|
||||
(revision / "design.yaml").write_text(design, encoding="utf-8")
|
||||
for name in ("compute.yaml", "studio.yaml"):
|
||||
(revision / "profiles" / name).write_text(
|
||||
f"profile: {name.split('.')[0]}\n", encoding="utf-8"
|
||||
)
|
||||
return revision
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- paths
|
||||
|
||||
|
||||
def test_output_dir_is_revision_scoped(revision_dir: Path) -> None:
|
||||
root = repo_root()
|
||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||
assert texture_revision_rel(root, paths).as_posix() == "finger/r01"
|
||||
assert texture_output_dir(root, paths) == (
|
||||
root / "build" / "textures" / "finger" / "r01"
|
||||
).resolve()
|
||||
|
||||
|
||||
def test_output_dir_explicit_override_wins(revision_dir: Path, tmp_path: Path) -> None:
|
||||
root = repo_root()
|
||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||
assert resolve_texture_output_dir(root, paths, tmp_path / "elsewhere") == (
|
||||
tmp_path / "elsewhere"
|
||||
).resolve()
|
||||
|
||||
|
||||
def test_output_dir_uses_directory_names_not_design_sensor_field(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""finger-xynova declares `sensor: finger`; the folder name must win."""
|
||||
revision = _write_revision(tmp_path, sensor_dir="finger-xynova", revision_name="r01")
|
||||
paths = RevisionPaths.from_revision_dir(revision)
|
||||
assert texture_revision_rel(tmp_path, paths) == Path("finger-xynova") / "r01"
|
||||
|
||||
|
||||
def test_job_path_sits_beside_texture(tmp_path: Path) -> None:
|
||||
assert texture_job_path(tmp_path / "out") == tmp_path / "out" / TEXTURE_JOB_FILENAME
|
||||
|
||||
|
||||
# ------------------------------------------------------- settings merge
|
||||
|
||||
|
||||
def test_defaults_come_from_design_yaml(revision_dir: Path) -> None:
|
||||
settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision_dir))
|
||||
assert settings.mesh == "sensing_surface"
|
||||
assert settings.camera == "cam_sensor"
|
||||
assert settings.filename == "patch_texture.png"
|
||||
assert (settings.width, settings.height) == (3280, 3200)
|
||||
assert (settings.nx, settings.ny) == (80, 45)
|
||||
assert settings.color_r == pytest.approx(0.05)
|
||||
assert settings.edge_sample_grid == 5
|
||||
assert settings.seed is None
|
||||
assert settings.cross_enabled is True
|
||||
assert settings.cross_length_px == 40
|
||||
assert settings.cross_thickness_px == 3
|
||||
assert settings.cross_color == (1.0, 0.0, 0.0, 1.0)
|
||||
|
||||
|
||||
def test_builtin_defaults_when_design_block_absent(tmp_path: Path) -> None:
|
||||
revision = _write_revision(tmp_path)
|
||||
paths = RevisionPaths.from_revision_dir(revision)
|
||||
assert load_texture_patch_block(paths) == {}
|
||||
settings = resolve_texture_settings(paths)
|
||||
assert settings.width == DEFAULT_TEXTURE_PATCH["width"]
|
||||
assert settings.height == DEFAULT_TEXTURE_PATCH["height"]
|
||||
# Object names fall back to design.yaml `objects` defaults.
|
||||
assert settings.mesh == "sensing_surface"
|
||||
assert settings.camera == "cam_sensor"
|
||||
|
||||
|
||||
def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None:
|
||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||
settings = resolve_texture_settings(
|
||||
paths,
|
||||
{
|
||||
"width": 640,
|
||||
"height": 512,
|
||||
"nx": 4,
|
||||
"ny": 3,
|
||||
"mesh": "other_mesh",
|
||||
"camera": "other_cam",
|
||||
"color_r": 0.25,
|
||||
"edge_sample_grid": 3,
|
||||
"seed": 11,
|
||||
"cross": {"enabled": False, "length_px": 8, "color": [0.0, 1.0, 0.0]},
|
||||
},
|
||||
)
|
||||
assert (settings.width, settings.height) == (640, 512)
|
||||
assert (settings.nx, settings.ny) == (4, 3)
|
||||
assert settings.mesh == "other_mesh"
|
||||
assert settings.camera == "other_cam"
|
||||
assert settings.color_r == pytest.approx(0.25)
|
||||
assert settings.edge_sample_grid == 3
|
||||
assert settings.seed == 11
|
||||
assert settings.cross_enabled is False
|
||||
assert settings.cross_length_px == 8
|
||||
# A 3-channel cross colour is widened to RGBA.
|
||||
assert settings.cross_color == (0.0, 1.0, 0.0, 1.0)
|
||||
|
||||
|
||||
def test_unset_overrides_do_not_erase_configured_values(revision_dir: Path) -> None:
|
||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||
merged = merge_texture_patch(paths, {"width": None, "mesh": None, "cross": None})
|
||||
assert merged["width"] == 3280
|
||||
assert merged["mesh"] == "sensing_surface"
|
||||
assert merged["cross"]["length_px"] == 40
|
||||
|
||||
|
||||
def test_unknown_design_key_is_rejected(tmp_path: Path) -> None:
|
||||
revision = _write_revision(tmp_path, "texture_patch:\n widht: 100\n")
|
||||
with pytest.raises(TextureConfigError) as excinfo:
|
||||
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
|
||||
assert "widht" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_unknown_cross_key_is_rejected(tmp_path: Path) -> None:
|
||||
revision = _write_revision(
|
||||
tmp_path, "texture_patch:\n cross:\n thickness: 3\n"
|
||||
)
|
||||
with pytest.raises(TextureConfigError) as excinfo:
|
||||
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
|
||||
assert "thickness" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_texture_patch_must_be_a_mapping(tmp_path: Path) -> None:
|
||||
revision = _write_revision(tmp_path, "texture_patch: patches\n")
|
||||
with pytest.raises(TextureConfigError) as excinfo:
|
||||
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
|
||||
assert "must be a mapping" in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("overrides", "expected"),
|
||||
[
|
||||
({"width": 0}, "width"),
|
||||
({"height": -4}, "height"),
|
||||
({"nx": 0}, "nx"),
|
||||
({"ny": "many"}, "ny"),
|
||||
({"color_r": 2.0}, "color_r"),
|
||||
({"edge_sample_grid": 1}, "edge_sample_grid"),
|
||||
({"seed": "abc"}, "seed"),
|
||||
({"filename": "tex.jpg"}, "filename"),
|
||||
({"filename": "nested/tex.png"}, "filename"),
|
||||
({"filename": "..\\tex.png"}, "filename"),
|
||||
({"cross": {"enabled": "yes"}}, "cross.enabled"),
|
||||
({"cross": {"length_px": 0}}, "cross.length_px"),
|
||||
({"cross": {"color": [2.0, 0.0, 0.0, 1.0]}}, "cross.color"),
|
||||
({"cross": {"color": [0.0, 0.0]}}, "cross.color"),
|
||||
],
|
||||
)
|
||||
def test_invalid_overrides_are_rejected(
|
||||
revision_dir: Path, overrides: dict, expected: str
|
||||
) -> None:
|
||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||
with pytest.raises(TextureConfigError) as excinfo:
|
||||
resolve_texture_settings(paths, overrides)
|
||||
assert expected in str(excinfo.value)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- job I/O
|
||||
|
||||
|
||||
def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path) -> None:
|
||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||
settings = resolve_texture_settings(paths, {"width": 256, "height": 256, "seed": 3})
|
||||
output_dir = tmp_path / "textures"
|
||||
output_path = output_dir / settings.filename
|
||||
|
||||
job = build_texture_job(paths, settings, output_path)
|
||||
assert job["schema"] == TEXTURE_JOB_SCHEMA
|
||||
assert job["mode"] == "patches"
|
||||
assert set(job) == {
|
||||
"schema",
|
||||
"mode",
|
||||
"revision_dir",
|
||||
"source_blend",
|
||||
"output",
|
||||
"objects",
|
||||
"settings",
|
||||
}
|
||||
assert set(job["objects"]) == {"mesh", "camera"}
|
||||
assert set(job["settings"]) == {
|
||||
"width",
|
||||
"height",
|
||||
"nx",
|
||||
"ny",
|
||||
"color_r",
|
||||
"edge_sample_grid",
|
||||
"seed",
|
||||
"cross",
|
||||
}
|
||||
assert set(job["settings"]["cross"]) == {
|
||||
"enabled",
|
||||
"length_px",
|
||||
"thickness_px",
|
||||
"color",
|
||||
}
|
||||
|
||||
job_path = write_texture_job(job, output_dir)
|
||||
assert job_path == output_dir / TEXTURE_JOB_FILENAME
|
||||
loaded = json.loads(job_path.read_text(encoding="utf-8"))
|
||||
assert loaded["output"] == str(output_path.resolve())
|
||||
assert loaded["source_blend"] == str(paths.source_blend.resolve())
|
||||
assert Path(loaded["output"]).is_absolute()
|
||||
assert loaded["objects"] == {"mesh": "sensing_surface", "camera": "cam_sensor"}
|
||||
assert loaded["settings"]["seed"] == 3
|
||||
assert loaded["settings"]["cross"]["color"] == [1.0, 0.0, 0.0, 1.0]
|
||||
|
||||
|
||||
def test_write_job_creates_output_directory(revision_dir: Path, tmp_path: Path) -> None:
|
||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||
settings = resolve_texture_settings(paths)
|
||||
nested = tmp_path / "a" / "b"
|
||||
write_texture_job(build_texture_job(paths, settings, nested / settings.filename), nested)
|
||||
assert (nested / TEXTURE_JOB_FILENAME).is_file()
|
||||
|
||||
|
||||
# ----------------------------------------------------------- CLI wiring
|
||||
|
||||
|
||||
def _fake_blender(monkeypatch: pytest.MonkeyPatch, exit_code: int, write_png: bool):
|
||||
calls: list[tuple[str, Path, list[str]]] = []
|
||||
|
||||
def fake_run(script: str, blend: Path, extra_args: list[str]):
|
||||
calls.append((script, blend, list(extra_args)))
|
||||
if write_png:
|
||||
job_path = Path(extra_args[extra_args.index("--job") + 1])
|
||||
job = json.loads(job_path.read_text(encoding="utf-8"))
|
||||
Path(job["output"]).write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
return exit_code
|
||||
|
||||
monkeypatch.setattr(cli, "_run_blender_script", fake_run)
|
||||
return calls
|
||||
|
||||
|
||||
def test_cmd_texture_forwards_job_and_verifies_artifact(
|
||||
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
||||
) -> None:
|
||||
calls = _fake_blender(monkeypatch, 0, write_png=True)
|
||||
out_dir = tmp_path / "out"
|
||||
assert cli.cmd_texture(revision_dir, _texture_namespace(revision_dir, out_dir)) == 0
|
||||
|
||||
script, blend, extra = calls[0]
|
||||
assert script == "random_patch_dd02c.py"
|
||||
assert blend == revision_dir.resolve() / "source" / "geometry.blend"
|
||||
assert extra[0] == "--job"
|
||||
job_path = Path(extra[1])
|
||||
assert job_path == out_dir.resolve() / TEXTURE_JOB_FILENAME
|
||||
assert (out_dir / "patch_texture.png").is_file()
|
||||
|
||||
|
||||
def test_cmd_texture_propagates_blender_failure(
|
||||
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
||||
) -> None:
|
||||
_fake_blender(monkeypatch, 9, write_png=False)
|
||||
namespace = _texture_namespace(revision_dir, tmp_path / "out")
|
||||
assert cli.cmd_texture(revision_dir, namespace) == 9
|
||||
|
||||
|
||||
def test_cmd_texture_detects_missing_texture(
|
||||
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
||||
) -> None:
|
||||
_fake_blender(monkeypatch, 0, write_png=False)
|
||||
namespace = _texture_namespace(revision_dir, tmp_path / "out")
|
||||
assert cli.cmd_texture(revision_dir, namespace) == 3
|
||||
|
||||
|
||||
def test_main_texture_parses_flags_into_job(
|
||||
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
||||
) -> None:
|
||||
calls = _fake_blender(monkeypatch, 0, write_png=True)
|
||||
out_dir = tmp_path / "flagged"
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cli.main(
|
||||
[
|
||||
"texture",
|
||||
str(revision_dir),
|
||||
"--output-dir",
|
||||
str(out_dir),
|
||||
"--filename",
|
||||
"custom.png",
|
||||
"--mesh",
|
||||
"sensing_surface",
|
||||
"--width",
|
||||
"256",
|
||||
"--height",
|
||||
"256",
|
||||
"--nx",
|
||||
"4",
|
||||
"--ny",
|
||||
"3",
|
||||
"--seed",
|
||||
"7",
|
||||
"--no-cross",
|
||||
]
|
||||
)
|
||||
assert excinfo.value.code == 0
|
||||
|
||||
job = json.loads(
|
||||
Path(calls[0][2][1]).read_text(encoding="utf-8")
|
||||
)
|
||||
assert job["settings"]["width"] == 256
|
||||
assert job["settings"]["nx"] == 4
|
||||
assert job["settings"]["ny"] == 3
|
||||
assert job["settings"]["seed"] == 7
|
||||
assert job["settings"]["cross"]["enabled"] is False
|
||||
assert (out_dir / "custom.png").is_file()
|
||||
|
||||
|
||||
def test_main_texture_rejects_invalid_flag_with_exit_2(
|
||||
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
||||
) -> None:
|
||||
_fake_blender(monkeypatch, 0, write_png=True)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
cli.main(["texture", str(revision_dir), "--width", "0"])
|
||||
assert excinfo.value.code == 2
|
||||
Reference in New Issue
Block a user