adde produce + compute texture export
This commit is contained in:
@@ -29,6 +29,7 @@ from orisys_sensor_design.texture import (
|
||||
build_texture_job,
|
||||
resolve_texture_output_dir,
|
||||
resolve_texture_settings,
|
||||
texture_output_paths,
|
||||
texture_overrides_from_args,
|
||||
write_texture_job,
|
||||
)
|
||||
@@ -224,7 +225,7 @@ def cmd_export(revision_dir: Path, profile: str) -> int:
|
||||
|
||||
|
||||
def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
"""Generate the colored patch texture headlessly for one revision."""
|
||||
"""Generate one colored patch texture per configured UV map, headlessly."""
|
||||
root = repo_root(revision_dir)
|
||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||
errors = validate_revision_metadata(paths)
|
||||
@@ -242,8 +243,8 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
print(f"ERROR: {message}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
output_path = output_dir / settings.filename
|
||||
job = build_texture_job(paths, settings, output_path)
|
||||
targets = texture_output_paths(output_dir, settings)
|
||||
job = build_texture_job(paths, settings, output_dir)
|
||||
job_path = write_texture_job(job, output_dir)
|
||||
print(f"Wrote texture job: {job_path}")
|
||||
|
||||
@@ -255,14 +256,23 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
if code != 0:
|
||||
return code
|
||||
|
||||
if not output_path.is_file() or output_path.stat().st_size == 0:
|
||||
print(
|
||||
f"ERROR: Blender finished but the texture is missing or empty: {output_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
missing = [
|
||||
(target, path)
|
||||
for target, path in targets
|
||||
if not path.is_file() or path.stat().st_size == 0
|
||||
]
|
||||
if missing:
|
||||
for target, path in missing:
|
||||
print(
|
||||
f"ERROR: Blender finished but the texture for UV map "
|
||||
f"{target.uv_map!r} is missing or empty: {path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
print(f"OK: colored patch texture written to {output_path}")
|
||||
print(f"OK: wrote {len(targets)} colored patch texture(s) to {output_dir}")
|
||||
for target, path in targets:
|
||||
print(f" [{target.uv_map}] {path}")
|
||||
print(
|
||||
f" mode=patches {settings.width}x{settings.height} "
|
||||
f"grid={settings.nx}x{settings.ny} mesh={settings.mesh!r} camera={settings.camera!r}"
|
||||
@@ -310,7 +320,7 @@ def main(argv: list[str] | None = None) -> None:
|
||||
|
||||
texture_p = sub.add_parser(
|
||||
"texture",
|
||||
help="Generate the colored patch texture headlessly (UV unwrap stays in-app)",
|
||||
help="Generate one colored patch texture per UV map (UV unwrap stays in-app)",
|
||||
)
|
||||
texture_p.add_argument("revision_dir", type=Path)
|
||||
texture_p.add_argument(
|
||||
@@ -319,11 +329,6 @@ def main(argv: list[str] | None = None) -> None:
|
||||
default=None,
|
||||
help="Output directory (default: build/textures/<sensor>/<revision>)",
|
||||
)
|
||||
texture_p.add_argument(
|
||||
"--filename",
|
||||
default=None,
|
||||
help="PNG file name (default: design.yaml texture_patch.filename)",
|
||||
)
|
||||
texture_p.add_argument("--mesh", default=None, help="Sensing surface mesh object name")
|
||||
texture_p.add_argument("--cam", default=None, help="Sensor camera object name")
|
||||
texture_p.add_argument("--width", type=int, default=None, help="Texture width in pixels")
|
||||
|
||||
@@ -6,8 +6,13 @@ and invokes ``blender/scripts/random_patch_dd02c.py`` headlessly. Every path
|
||||
handed to Blender is absolute, so no manual copying between directories is
|
||||
required.
|
||||
|
||||
A revision may carry several UV maps on the sensing surface (for example
|
||||
``produce`` and ``compute``). ``texture_patch.uv_outputs`` names one output PNG
|
||||
per UV map; a single run renders all of them with the same camera-space patch
|
||||
colours, so the files differ only in how the unwrap arranges the patches.
|
||||
|
||||
UV unwrapping is deliberately outside this module: the headless run consumes the
|
||||
UV map already authored in the Blender GUI.
|
||||
UV maps already authored in the Blender GUI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,15 +28,22 @@ from orisys_sensor_design.manifest import (
|
||||
load_yaml,
|
||||
)
|
||||
|
||||
TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v1"
|
||||
TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v2"
|
||||
TEXTURE_JOB_FILENAME = "_texture_job.json"
|
||||
TEXTURE_RELATIVE_ROOT = Path("build") / "textures"
|
||||
TEXTURE_MODE = "patches"
|
||||
|
||||
# Built-in fallbacks. sensors/<sensor>/<revision>/design.yaml is the source of
|
||||
# truth for a revision; these only apply when a key is absent there.
|
||||
#
|
||||
# `uv_outputs` maps a UV map name on the mesh to the PNG written for it. Each run
|
||||
# renders every entry with the same camera-space patch colours, so the maps
|
||||
# differ only by how the unwrap arranges the patches.
|
||||
DEFAULT_TEXTURE_PATCH: dict[str, Any] = {
|
||||
"filename": "patch_texture.png",
|
||||
"uv_outputs": {
|
||||
"produce": "patch_texture_produce.png",
|
||||
"compute": "patch_texture_compute.png",
|
||||
},
|
||||
"width": 2048,
|
||||
"height": 2048,
|
||||
"nx": 80,
|
||||
@@ -51,7 +63,7 @@ _TEXTURE_PATCH_KEYS = frozenset(
|
||||
{
|
||||
"mesh",
|
||||
"camera",
|
||||
"filename",
|
||||
"uv_outputs",
|
||||
"width",
|
||||
"height",
|
||||
"nx",
|
||||
@@ -74,13 +86,35 @@ class TextureConfigError(ValueError):
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextureOutputTarget:
|
||||
"""One generated PNG: the mesh UV map it reads and the file it writes."""
|
||||
|
||||
uv_map: str
|
||||
filename: str
|
||||
|
||||
def output_path(self, output_dir: Path) -> Path:
|
||||
return Path(output_dir) / self.filename
|
||||
|
||||
def to_job_entry(self, output_dir: Path) -> dict[str, Any]:
|
||||
"""Return the job entry Blender consumes (absolute path, ordered)."""
|
||||
return {
|
||||
"uv_map": self.uv_map,
|
||||
"output": str(self.output_path(output_dir).resolve()),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TexturePatchSettings:
|
||||
"""Resolved colored-patch settings for one headless run."""
|
||||
"""Resolved colored-patch settings for one headless run.
|
||||
|
||||
All rendering settings are shared by every target in ``outputs``; the targets
|
||||
differ only in which UV map they read and the file they write.
|
||||
"""
|
||||
|
||||
mesh: str
|
||||
camera: str
|
||||
filename: str
|
||||
outputs: tuple[TextureOutputTarget, ...]
|
||||
width: int
|
||||
height: int
|
||||
nx: int
|
||||
@@ -189,6 +223,22 @@ def merge_texture_patch(
|
||||
return merged
|
||||
|
||||
|
||||
def _bare_png_name_problems(value: Any, label: str) -> list[str]:
|
||||
"""Return messages for a value that must be a bare ``.png`` file name."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return [f"{label}: must be a non-empty file name"]
|
||||
if (
|
||||
"/" in value
|
||||
or "\\" in value
|
||||
or Path(value).name != value
|
||||
or value in {".", ".."}
|
||||
):
|
||||
return [f"{label}: must be a bare file name without directories ({value!r})"]
|
||||
if Path(value).suffix.lower() != ".png":
|
||||
return [f"{label}: must end with .png ({value!r})"]
|
||||
return []
|
||||
|
||||
|
||||
def validate_texture_patch(block: dict[str, Any], objects: dict[str, str]) -> list[str]:
|
||||
"""Return one message per invalid value in a merged texture_patch mapping."""
|
||||
errors: list[str] = []
|
||||
@@ -205,18 +255,30 @@ def validate_texture_patch(block: dict[str, Any], objects: dict[str, str]) -> li
|
||||
elif not isinstance(camera, str) or not camera.strip():
|
||||
errors.append("camera: must be a non-empty object name")
|
||||
|
||||
filename = block.get("filename")
|
||||
if not isinstance(filename, str) or not filename.strip():
|
||||
errors.append("filename: must be a non-empty file name")
|
||||
elif (
|
||||
"/" in filename
|
||||
or "\\" in filename
|
||||
or Path(filename).name != filename
|
||||
or filename in {".", ".."}
|
||||
):
|
||||
errors.append(f"filename: must be a bare file name without directories ({filename!r})")
|
||||
elif Path(filename).suffix.lower() != ".png":
|
||||
errors.append(f"filename: must end with .png ({filename!r})")
|
||||
uv_outputs = block.get("uv_outputs")
|
||||
if not isinstance(uv_outputs, dict) or not uv_outputs:
|
||||
errors.append(
|
||||
"uv_outputs: must be a non-empty mapping of UV map name to PNG file name"
|
||||
)
|
||||
else:
|
||||
seen_outputs: dict[str, str] = {}
|
||||
for uv_name, out_name in uv_outputs.items():
|
||||
if not isinstance(uv_name, str) or not uv_name.strip():
|
||||
errors.append(
|
||||
f"uv_outputs: UV map names must be non-empty strings (got {uv_name!r})"
|
||||
)
|
||||
continue
|
||||
problems = _bare_png_name_problems(out_name, f"uv_outputs.{uv_name}")
|
||||
if problems:
|
||||
errors.extend(problems)
|
||||
continue
|
||||
if out_name in seen_outputs:
|
||||
errors.append(
|
||||
f"uv_outputs: duplicate output file name {out_name!r} "
|
||||
f"for {seen_outputs[out_name]!r} and {uv_name!r}"
|
||||
)
|
||||
else:
|
||||
seen_outputs[out_name] = uv_name
|
||||
|
||||
for key in ("width", "height"):
|
||||
value = block.get(key)
|
||||
@@ -279,10 +341,15 @@ def resolve_texture_settings(
|
||||
if len(color) == 3:
|
||||
color.append(1.0)
|
||||
|
||||
outputs = tuple(
|
||||
TextureOutputTarget(uv_map=str(uv_name), filename=str(out_name))
|
||||
for uv_name, out_name in block["uv_outputs"].items()
|
||||
)
|
||||
|
||||
return TexturePatchSettings(
|
||||
mesh=str(block["mesh"]),
|
||||
camera=str(block["camera"]),
|
||||
filename=str(block["filename"]),
|
||||
outputs=outputs,
|
||||
width=int(block["width"]),
|
||||
height=int(block["height"]),
|
||||
nx=int(block["nx"]),
|
||||
@@ -301,8 +368,8 @@ def texture_revision_rel(root: Path, paths: RevisionPaths) -> Path:
|
||||
"""Return ``<sensor>/<revision>`` for the revision below the repo's sensors/.
|
||||
|
||||
Directory names are used rather than design.yaml's ``sensor``/``revision``
|
||||
fields because those are not reliable across sensor folders (finger-xynova
|
||||
still declares ``sensor: finger``).
|
||||
fields so the output layout follows the on-disk revision tree regardless of
|
||||
what those metadata fields say.
|
||||
"""
|
||||
sensors_root = (root / "sensors").resolve()
|
||||
try:
|
||||
@@ -336,10 +403,19 @@ def texture_job_path(output_dir: Path) -> Path:
|
||||
return Path(output_dir) / TEXTURE_JOB_FILENAME
|
||||
|
||||
|
||||
def texture_output_paths(
|
||||
output_dir: Path,
|
||||
settings: TexturePatchSettings,
|
||||
) -> list[tuple[TextureOutputTarget, Path]]:
|
||||
"""Pair every configured target with its absolute output path, in order."""
|
||||
resolved_dir = Path(output_dir).resolve()
|
||||
return [(target, target.output_path(resolved_dir)) for target in settings.outputs]
|
||||
|
||||
|
||||
def build_texture_job(
|
||||
paths: RevisionPaths,
|
||||
settings: TexturePatchSettings,
|
||||
output_path: Path,
|
||||
output_dir: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the JSON job dict that random_patch_dd02c.py consumes (stdlib JSON)."""
|
||||
return {
|
||||
@@ -347,7 +423,9 @@ def build_texture_job(
|
||||
"mode": TEXTURE_MODE,
|
||||
"revision_dir": str(paths.root.resolve()),
|
||||
"source_blend": str(paths.source_blend.resolve()),
|
||||
"output": str(Path(output_path).resolve()),
|
||||
"outputs": [
|
||||
target.to_job_entry(Path(output_dir)) for target in settings.outputs
|
||||
],
|
||||
"objects": {
|
||||
"mesh": settings.mesh,
|
||||
"camera": settings.camera,
|
||||
@@ -370,7 +448,6 @@ def texture_overrides_from_args(args: Any) -> dict[str, Any]:
|
||||
overrides: dict[str, Any] = {
|
||||
"mesh": getattr(args, "mesh", None),
|
||||
"camera": getattr(args, "cam", None),
|
||||
"filename": getattr(args, "filename", None),
|
||||
"width": getattr(args, "width", None),
|
||||
"height": getattr(args, "height", None),
|
||||
"nx": getattr(args, "nx", None),
|
||||
|
||||
Reference in New Issue
Block a user