adde produce + compute texture export

This commit is contained in:
ykcheungab
2026-09-18 11:25:09 +08:00
parent 026f495f77
commit b1815b15ea
9 changed files with 503 additions and 161 deletions
+130 -30
View File
@@ -15,6 +15,7 @@ from orisys_sensor_design.texture import (
TEXTURE_JOB_FILENAME,
TEXTURE_JOB_SCHEMA,
TextureConfigError,
TextureOutputTarget,
build_texture_job,
load_texture_patch_block,
merge_texture_patch,
@@ -22,10 +23,16 @@ from orisys_sensor_design.texture import (
resolve_texture_settings,
texture_job_path,
texture_output_dir,
texture_output_paths,
texture_revision_rel,
write_texture_job,
)
DEFAULT_OUTPUTS = [
("produce", "patch_texture_produce.png"),
("compute", "patch_texture_compute.png"),
]
@pytest.fixture()
def revision_dir() -> Path:
@@ -38,7 +45,6 @@ def _texture_namespace(
namespace = argparse.Namespace(
revision_dir=revision_dir,
output_dir=output_dir,
filename=None,
mesh=None,
cam=None,
width=None,
@@ -108,16 +114,34 @@ def test_output_dir_explicit_override_wins(revision_dir: Path, tmp_path: Path) -
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."""
"""The on-disk sensor folder must win over design.yaml's sensor field."""
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_output_paths_pair_each_target(revision_dir: Path, tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(revision_dir)
settings = resolve_texture_settings(paths)
targets = texture_output_paths(tmp_path, settings)
assert [(target.uv_map, path.name) for target, path in targets] == DEFAULT_OUTPUTS
assert all(path.parent == tmp_path.resolve() for _, path in targets)
def test_job_path_sits_beside_texture(tmp_path: Path) -> None:
assert texture_job_path(tmp_path / "out") == tmp_path / "out" / TEXTURE_JOB_FILENAME
def test_output_target_entry_is_absolute(revision_dir: Path, tmp_path: Path) -> None:
target = TextureOutputTarget(uv_map="produce", filename="a.png")
entry = target.to_job_entry(tmp_path)
assert entry["uv_map"] == "produce"
assert Path(entry["output"]).is_absolute()
assert Path(entry["output"]).name == "a.png"
# ------------------------------------------------------- settings merge
@@ -125,7 +149,7 @@ 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 [(target.uv_map, target.filename) for target in settings.outputs] == DEFAULT_OUTPUTS
assert (settings.width, settings.height) == (3280, 3200)
assert (settings.nx, settings.ny) == (80, 45)
assert settings.color_r == pytest.approx(0.05)
@@ -144,11 +168,23 @@ def test_builtin_defaults_when_design_block_absent(tmp_path: Path) -> None:
settings = resolve_texture_settings(paths)
assert settings.width == DEFAULT_TEXTURE_PATCH["width"]
assert settings.height == DEFAULT_TEXTURE_PATCH["height"]
assert [target.uv_map for target in settings.outputs] == ["produce", "compute"]
# Object names fall back to design.yaml `objects` defaults.
assert settings.mesh == "sensing_surface"
assert settings.camera == "cam_sensor"
def test_design_yaml_uv_outputs_replace_builtin_defaults(tmp_path: Path) -> None:
revision = _write_revision(
tmp_path,
"texture_patch:\n uv_outputs:\n custom: my_custom_layout.png\n",
)
settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
assert [(target.uv_map, target.filename) for target in settings.outputs] == [
("custom", "my_custom_layout.png")
]
def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None:
paths = RevisionPaths.from_revision_dir(revision_dir)
settings = resolve_texture_settings(
@@ -177,6 +213,8 @@ def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None:
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)
# Shared rendering settings leave both targets intact.
assert [target.uv_map for target in settings.outputs] == ["produce", "compute"]
def test_unset_overrides_do_not_erase_configured_values(revision_dir: Path) -> None:
@@ -194,6 +232,14 @@ def test_unknown_design_key_is_rejected(tmp_path: Path) -> None:
assert "widht" in str(excinfo.value)
def test_legacy_filename_key_is_rejected(tmp_path: Path) -> None:
"""The singular filename key was replaced by uv_outputs."""
revision = _write_revision(tmp_path, "texture_patch:\n filename: patch_texture.png\n")
with pytest.raises(TextureConfigError) as excinfo:
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
assert "filename" 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"
@@ -220,9 +266,6 @@ def test_texture_patch_must_be_a_mapping(tmp_path: Path) -> None:
({"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"),
@@ -238,6 +281,31 @@ def test_invalid_overrides_are_rejected(
assert expected in str(excinfo.value)
@pytest.mark.parametrize(
("overrides", "expected"),
[
({"uv_outputs": {}}, "uv_outputs"),
({"uv_outputs": "produce.png"}, "uv_outputs"),
({"uv_outputs": {"": "produce.png"}}, "uv_outputs"),
({"uv_outputs": {"produce": ""}}, "uv_outputs.produce"),
({"uv_outputs": {"produce": "texture.jpg"}}, "uv_outputs.produce"),
({"uv_outputs": {"produce": "nested/a.png"}}, "uv_outputs.produce"),
({"uv_outputs": {"produce": "..\\a.png"}}, "uv_outputs.produce"),
(
{"uv_outputs": {"produce": "same.png", "compute": "same.png"}},
"duplicate output file name",
),
],
)
def test_invalid_uv_outputs_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
@@ -245,9 +313,8 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
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)
job = build_texture_job(paths, settings, output_dir)
assert job["schema"] == TEXTURE_JOB_SCHEMA
assert job["mode"] == "patches"
assert set(job) == {
@@ -255,10 +322,16 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
"mode",
"revision_dir",
"source_blend",
"output",
"outputs",
"objects",
"settings",
}
assert "output" not in job
assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
assert [Path(entry["output"]).name for entry in job["outputs"]] == [
filename for _, filename in DEFAULT_OUTPUTS
]
assert all(Path(entry["output"]).is_absolute() for entry in job["outputs"])
assert set(job["objects"]) == {"mesh", "camera"}
assert set(job["settings"]) == {
"width",
@@ -280,9 +353,8 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
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["outputs"] == job["outputs"]
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]
@@ -292,56 +364,76 @@ def test_write_job_creates_output_directory(revision_dir: Path, tmp_path: Path)
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)
write_texture_job(build_texture_job(paths, settings, nested), nested)
assert (nested / TEXTURE_JOB_FILENAME).is_file()
# ----------------------------------------------------------- CLI wiring
def _fake_blender(monkeypatch: pytest.MonkeyPatch, exit_code: int, write_png: bool):
def _fake_blender(
monkeypatch: pytest.MonkeyPatch,
exit_code: int,
write_pngs: bool = True,
skip_uv: str | None = None,
):
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:
if write_pngs:
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")
for entry in job["outputs"]:
if skip_uv is not None and entry["uv_map"] == skip_uv:
continue
Path(entry["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(
def test_cmd_texture_forwards_job_and_verifies_both_artifacts(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
calls = _fake_blender(monkeypatch, 0, write_png=True)
calls = _fake_blender(monkeypatch, 0)
out_dir = tmp_path / "out"
assert cli.cmd_texture(revision_dir, _texture_namespace(revision_dir, out_dir)) == 0
assert len(calls) == 1, "one Blender launch must render every target"
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()
for _, filename in DEFAULT_OUTPUTS:
assert (out_dir / filename).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)
_fake_blender(monkeypatch, 9, write_pngs=False)
namespace = _texture_namespace(revision_dir, tmp_path / "out")
assert cli.cmd_texture(revision_dir, namespace) == 9
def test_cmd_texture_detects_missing_texture(
def test_cmd_texture_detects_both_textures_missing(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
_fake_blender(monkeypatch, 0, write_png=False)
_fake_blender(monkeypatch, 0, write_pngs=False)
namespace = _texture_namespace(revision_dir, tmp_path / "out")
assert cli.cmd_texture(revision_dir, namespace) == 3
def test_cmd_texture_detects_partial_pair(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
"""One UV map written, the other missing, must not look like success."""
_fake_blender(monkeypatch, 0, skip_uv="compute")
namespace = _texture_namespace(revision_dir, tmp_path / "out")
assert cli.cmd_texture(revision_dir, namespace) == 3
@@ -349,7 +441,7 @@ def test_cmd_texture_detects_missing_texture(
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)
calls = _fake_blender(monkeypatch, 0)
out_dir = tmp_path / "flagged"
with pytest.raises(SystemExit) as excinfo:
cli.main(
@@ -358,8 +450,6 @@ def test_main_texture_parses_flags_into_job(
str(revision_dir),
"--output-dir",
str(out_dir),
"--filename",
"custom.png",
"--mesh",
"sensing_surface",
"--width",
@@ -377,21 +467,31 @@ def test_main_texture_parses_flags_into_job(
)
assert excinfo.value.code == 0
job = json.loads(
Path(calls[0][2][1]).read_text(encoding="utf-8")
)
job = json.loads(Path(calls[0][2][1]).read_text(encoding="utf-8"))
assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
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()
for _, filename in DEFAULT_OUTPUTS:
assert (out_dir / filename).is_file()
def test_main_texture_rejects_filename_flag(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path
) -> None:
"""The singular --filename flag is gone; per-UV names come from design.yaml."""
_fake_blender(monkeypatch, 0)
with pytest.raises(SystemExit) as excinfo:
cli.main(["texture", str(revision_dir), "--filename", "custom.png"])
assert excinfo.value.code == 2
def test_main_texture_rejects_invalid_flag_with_exit_2(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
monkeypatch: pytest.MonkeyPatch, revision_dir: Path
) -> None:
_fake_blender(monkeypatch, 0, write_png=True)
_fake_blender(monkeypatch, 0)
with pytest.raises(SystemExit) as excinfo:
cli.main(["texture", str(revision_dir), "--width", "0"])
assert excinfo.value.code == 2