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
+20 -15
View File
@@ -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")
+101 -24
View File
@@ -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),