migrated texture generation script to cli
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""orisys-design CLI — validate, export, manifest."""
|
||||
"""orisys-design CLI — validate, export, texture, manifest."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -24,6 +24,14 @@ from orisys_sensor_design.manifest import (
|
||||
write_export_job,
|
||||
write_manifest,
|
||||
)
|
||||
from orisys_sensor_design.texture import (
|
||||
TextureConfigError,
|
||||
build_texture_job,
|
||||
resolve_texture_output_dir,
|
||||
resolve_texture_settings,
|
||||
texture_overrides_from_args,
|
||||
write_texture_job,
|
||||
)
|
||||
|
||||
|
||||
def _blender_from_lock(root: Path) -> Path | None:
|
||||
@@ -215,6 +223,53 @@ def cmd_export(revision_dir: Path, profile: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
"""Generate the colored patch texture headlessly for one revision."""
|
||||
root = repo_root(revision_dir)
|
||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||
errors = validate_revision_metadata(paths)
|
||||
if errors:
|
||||
for message in errors:
|
||||
print(f"ERROR: {message}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
overrides = texture_overrides_from_args(args)
|
||||
try:
|
||||
settings = resolve_texture_settings(paths, overrides)
|
||||
output_dir = resolve_texture_output_dir(root, paths, args.output_dir)
|
||||
except TextureConfigError as exc:
|
||||
for message in str(exc).splitlines():
|
||||
print(f"ERROR: {message}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
output_path = output_dir / settings.filename
|
||||
job = build_texture_job(paths, settings, output_path)
|
||||
job_path = write_texture_job(job, output_dir)
|
||||
print(f"Wrote texture job: {job_path}")
|
||||
|
||||
code = _run_blender_script(
|
||||
"random_patch_dd02c.py",
|
||||
paths.source_blend,
|
||||
["--job", str(job_path)],
|
||||
)
|
||||
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,
|
||||
)
|
||||
return 3
|
||||
|
||||
print(f"OK: colored patch texture written to {output_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}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_manifest(revision_dir: Path, out: Path | None, profile: str) -> int:
|
||||
root = repo_root(revision_dir)
|
||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||
@@ -253,6 +308,74 @@ def main(argv: list[str] | None = None) -> None:
|
||||
export_p.add_argument("revision_dir", type=Path)
|
||||
export_p.add_argument("--profile", default="compute", choices=["compute"])
|
||||
|
||||
texture_p = sub.add_parser(
|
||||
"texture",
|
||||
help="Generate the colored patch texture headlessly (UV unwrap stays in-app)",
|
||||
)
|
||||
texture_p.add_argument("revision_dir", type=Path)
|
||||
texture_p.add_argument(
|
||||
"--output-dir",
|
||||
type=Path,
|
||||
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")
|
||||
texture_p.add_argument("--height", type=int, default=None, help="Texture height in pixels")
|
||||
texture_p.add_argument("--nx", type=int, default=None, help="Patch columns across the frame")
|
||||
texture_p.add_argument("--ny", type=int, default=None, help="Patch rows across the frame")
|
||||
texture_p.add_argument(
|
||||
"--color-r",
|
||||
dest="color_r",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Colour separation between neighbouring patches",
|
||||
)
|
||||
texture_p.add_argument(
|
||||
"--edge-sample-grid",
|
||||
dest="edge_sample_grid",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Edge sampling grid for partially covered patches",
|
||||
)
|
||||
texture_p.add_argument("--seed", type=int, default=None, help="Seed for reproducible colours")
|
||||
texture_p.add_argument(
|
||||
"--no-cross",
|
||||
dest="no_cross",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Do not draw the red reference cross at the UV centre",
|
||||
)
|
||||
texture_p.add_argument(
|
||||
"--cross-length",
|
||||
dest="cross_length",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Reference cross arm length in pixels",
|
||||
)
|
||||
texture_p.add_argument(
|
||||
"--cross-thickness",
|
||||
dest="cross_thickness",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Reference cross line thickness in pixels",
|
||||
)
|
||||
texture_p.add_argument(
|
||||
"--cross-color",
|
||||
dest="cross_color",
|
||||
type=float,
|
||||
nargs=4,
|
||||
metavar=("R", "G", "B", "A"),
|
||||
default=None,
|
||||
help="Reference cross RGBA colour in 0-1",
|
||||
)
|
||||
|
||||
manifest_p = sub.add_parser("manifest", help="Write release manifest with file hashes")
|
||||
manifest_p.add_argument("revision_dir", type=Path)
|
||||
manifest_p.add_argument(
|
||||
@@ -268,6 +391,8 @@ def main(argv: list[str] | None = None) -> None:
|
||||
code = cmd_validate(args.revision_dir, args.blender)
|
||||
elif args.command == "export":
|
||||
code = cmd_export(args.revision_dir, args.profile)
|
||||
elif args.command == "texture":
|
||||
code = cmd_texture(args.revision_dir, args)
|
||||
elif args.command == "manifest":
|
||||
code = cmd_manifest(args.revision_dir, args.out, args.profile)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Headless colored-patch texture settings, output paths, and job I/O.
|
||||
|
||||
The host CLI (``orisys-design texture``) reads the ``texture_patch`` mapping in
|
||||
``design.yaml``, merges explicit CLI overrides, writes a stdlib-JSON job file,
|
||||
and invokes ``blender/scripts/random_patch_dd02c.py`` headlessly. Every path
|
||||
handed to Blender is absolute, so no manual copying between directories is
|
||||
required.
|
||||
|
||||
UV unwrapping is deliberately outside this module: the headless run consumes the
|
||||
UV map already authored in the Blender GUI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from orisys_sensor_design.manifest import (
|
||||
RevisionPaths,
|
||||
export_object_names,
|
||||
load_yaml,
|
||||
)
|
||||
|
||||
TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v1"
|
||||
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.
|
||||
DEFAULT_TEXTURE_PATCH: dict[str, Any] = {
|
||||
"filename": "patch_texture.png",
|
||||
"width": 2048,
|
||||
"height": 2048,
|
||||
"nx": 80,
|
||||
"ny": 45,
|
||||
"color_r": 0.05,
|
||||
"edge_sample_grid": 5,
|
||||
"seed": None,
|
||||
"cross": {
|
||||
"enabled": True,
|
||||
"length_px": 40,
|
||||
"thickness_px": 3,
|
||||
"color": [1.0, 0.0, 0.0, 1.0],
|
||||
},
|
||||
}
|
||||
|
||||
_TEXTURE_PATCH_KEYS = frozenset(
|
||||
{
|
||||
"mesh",
|
||||
"camera",
|
||||
"filename",
|
||||
"width",
|
||||
"height",
|
||||
"nx",
|
||||
"ny",
|
||||
"color_r",
|
||||
"edge_sample_grid",
|
||||
"seed",
|
||||
"cross",
|
||||
}
|
||||
)
|
||||
_CROSS_KEYS = frozenset({"enabled", "length_px", "thickness_px", "color"})
|
||||
|
||||
_MAX_TEXTURE_EDGE = 32768
|
||||
|
||||
|
||||
class TextureConfigError(ValueError):
|
||||
"""Raised when texture_patch metadata or CLI overrides are invalid.
|
||||
|
||||
The message may span several lines; each line is one problem.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TexturePatchSettings:
|
||||
"""Resolved colored-patch settings for one headless run."""
|
||||
|
||||
mesh: str
|
||||
camera: str
|
||||
filename: str
|
||||
width: int
|
||||
height: int
|
||||
nx: int
|
||||
ny: int
|
||||
color_r: float
|
||||
edge_sample_grid: int
|
||||
seed: int | None
|
||||
cross_enabled: bool
|
||||
cross_length_px: int
|
||||
cross_thickness_px: int
|
||||
cross_color: tuple[float, float, float, float]
|
||||
|
||||
def to_job_settings(self) -> dict[str, Any]:
|
||||
"""Return the JSON-serialisable settings block consumed by Blender."""
|
||||
return {
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"nx": self.nx,
|
||||
"ny": self.ny,
|
||||
"color_r": self.color_r,
|
||||
"edge_sample_grid": self.edge_sample_grid,
|
||||
"seed": self.seed,
|
||||
"cross": {
|
||||
"enabled": self.cross_enabled,
|
||||
"length_px": self.cross_length_px,
|
||||
"thickness_px": self.cross_thickness_px,
|
||||
"color": list(self.cross_color),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _is_int(value: Any) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _is_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def load_texture_patch_block(paths: RevisionPaths) -> dict[str, Any]:
|
||||
"""Return the raw ``texture_patch`` mapping from design.yaml ({} when absent)."""
|
||||
design = load_yaml(paths.design_yaml)
|
||||
raw = design.get("texture_patch")
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise TextureConfigError(
|
||||
f"{paths.design_yaml}: 'texture_patch' must be a mapping, got {type(raw).__name__}"
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def _check_known_keys(block: dict[str, Any], where: str, errors: list[str]) -> None:
|
||||
unknown = sorted(set(block) - _TEXTURE_PATCH_KEYS)
|
||||
if unknown:
|
||||
allowed = ", ".join(sorted(_TEXTURE_PATCH_KEYS))
|
||||
errors.append(f"{where}: unknown key(s) {', '.join(unknown)} (allowed: {allowed})")
|
||||
|
||||
|
||||
def merge_texture_patch(
|
||||
paths: RevisionPaths,
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge built-in defaults ← design.yaml ``texture_patch`` ← CLI overrides.
|
||||
|
||||
Overrides with a value of ``None`` are ignored so an unset CLI flag never
|
||||
erases a configured value.
|
||||
"""
|
||||
merged: dict[str, Any] = {
|
||||
key: (dict(value) if isinstance(value, dict) else value)
|
||||
for key, value in DEFAULT_TEXTURE_PATCH.items()
|
||||
}
|
||||
|
||||
errors: list[str] = []
|
||||
for block, where in (
|
||||
(load_texture_patch_block(paths), f"{paths.design_yaml} texture_patch"),
|
||||
(
|
||||
{key: value for key, value in (overrides or {}).items() if value is not None},
|
||||
"CLI overrides",
|
||||
),
|
||||
):
|
||||
if not block:
|
||||
continue
|
||||
_check_known_keys(block, where, errors)
|
||||
for key, value in block.items():
|
||||
if key not in _TEXTURE_PATCH_KEYS:
|
||||
continue
|
||||
if key == "cross":
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"{where}.cross: must be a mapping")
|
||||
continue
|
||||
unknown_cross = sorted(set(value) - _CROSS_KEYS)
|
||||
if unknown_cross:
|
||||
errors.append(
|
||||
f"{where}.cross: unknown key(s) {', '.join(unknown_cross)} "
|
||||
f"(allowed: {', '.join(sorted(_CROSS_KEYS))})"
|
||||
)
|
||||
for cross_key, cross_value in value.items():
|
||||
if cross_key in _CROSS_KEYS:
|
||||
merged["cross"][cross_key] = cross_value
|
||||
continue
|
||||
merged[key] = value
|
||||
|
||||
if errors:
|
||||
raise TextureConfigError("\n".join(errors))
|
||||
return merged
|
||||
|
||||
|
||||
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] = []
|
||||
|
||||
mesh = block.get("mesh")
|
||||
if mesh is None:
|
||||
block["mesh"] = objects["sensing_surface"]
|
||||
elif not isinstance(mesh, str) or not mesh.strip():
|
||||
errors.append("mesh: must be a non-empty object name")
|
||||
|
||||
camera = block.get("camera")
|
||||
if camera is None:
|
||||
block["camera"] = objects["cam_sensor"]
|
||||
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})")
|
||||
|
||||
for key in ("width", "height"):
|
||||
value = block.get(key)
|
||||
if not _is_int(value) or value <= 0:
|
||||
errors.append(f"{key}: must be a positive integer (got {value!r})")
|
||||
elif value > _MAX_TEXTURE_EDGE:
|
||||
errors.append(f"{key}: must be <= {_MAX_TEXTURE_EDGE} (got {value})")
|
||||
|
||||
for key in ("nx", "ny"):
|
||||
value = block.get(key)
|
||||
if not _is_int(value) or value < 1:
|
||||
errors.append(f"{key}: must be an integer >= 1 (got {value!r})")
|
||||
|
||||
color_r = block.get("color_r")
|
||||
if not _is_number(color_r) or not 0.0 <= float(color_r) <= 1.0:
|
||||
errors.append(f"color_r: must be a number in [0, 1] (got {color_r!r})")
|
||||
|
||||
edge_sample_grid = block.get("edge_sample_grid")
|
||||
if not _is_int(edge_sample_grid) or edge_sample_grid < 2:
|
||||
errors.append(f"edge_sample_grid: must be an integer >= 2 (got {edge_sample_grid!r})")
|
||||
|
||||
seed = block.get("seed")
|
||||
if seed is not None and not _is_int(seed):
|
||||
errors.append(f"seed: must be an integer or null (got {seed!r})")
|
||||
|
||||
cross = block.get("cross")
|
||||
if not isinstance(cross, dict):
|
||||
errors.append("cross: must be a mapping")
|
||||
return errors
|
||||
|
||||
if not isinstance(cross.get("enabled"), bool):
|
||||
errors.append(f"cross.enabled: must be true or false (got {cross.get('enabled')!r})")
|
||||
for key in ("length_px", "thickness_px"):
|
||||
value = cross.get(key)
|
||||
if not _is_int(value) or value < 1:
|
||||
errors.append(f"cross.{key}: must be an integer >= 1 (got {value!r})")
|
||||
|
||||
color = cross.get("color")
|
||||
if not isinstance(color, (list, tuple)) or len(color) not in (3, 4):
|
||||
errors.append("cross.color: must be a list of 3 or 4 numbers in [0, 1]")
|
||||
elif any(not _is_number(channel) or not 0.0 <= float(channel) <= 1.0 for channel in color):
|
||||
errors.append(f"cross.color: every channel must be a number in [0, 1] (got {color!r})")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def resolve_texture_settings(
|
||||
paths: RevisionPaths,
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> TexturePatchSettings:
|
||||
"""Merge and validate settings for a headless colored-patch run."""
|
||||
block = merge_texture_patch(paths, overrides)
|
||||
objects = export_object_names(paths)
|
||||
errors = validate_texture_patch(block, objects)
|
||||
if errors:
|
||||
raise TextureConfigError("\n".join(errors))
|
||||
|
||||
cross = block["cross"]
|
||||
color = [float(channel) for channel in cross["color"]]
|
||||
if len(color) == 3:
|
||||
color.append(1.0)
|
||||
|
||||
return TexturePatchSettings(
|
||||
mesh=str(block["mesh"]),
|
||||
camera=str(block["camera"]),
|
||||
filename=str(block["filename"]),
|
||||
width=int(block["width"]),
|
||||
height=int(block["height"]),
|
||||
nx=int(block["nx"]),
|
||||
ny=int(block["ny"]),
|
||||
color_r=float(block["color_r"]),
|
||||
edge_sample_grid=int(block["edge_sample_grid"]),
|
||||
seed=None if block["seed"] is None else int(block["seed"]),
|
||||
cross_enabled=bool(cross["enabled"]),
|
||||
cross_length_px=int(cross["length_px"]),
|
||||
cross_thickness_px=int(cross["thickness_px"]),
|
||||
cross_color=(color[0], color[1], color[2], color[3]),
|
||||
)
|
||||
|
||||
|
||||
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``).
|
||||
"""
|
||||
sensors_root = (root / "sensors").resolve()
|
||||
try:
|
||||
rel = paths.root.relative_to(sensors_root)
|
||||
except ValueError:
|
||||
design = load_yaml(paths.design_yaml)
|
||||
sensor = str(design.get("sensor") or paths.root.parent.name)
|
||||
revision = str(design.get("revision") or paths.root.name)
|
||||
return Path(sensor) / revision
|
||||
return rel
|
||||
|
||||
|
||||
def texture_output_dir(root: Path, paths: RevisionPaths) -> Path:
|
||||
"""Default output directory: build/textures/<sensor>/<revision>."""
|
||||
return (root / TEXTURE_RELATIVE_ROOT / texture_revision_rel(root, paths)).resolve()
|
||||
|
||||
|
||||
def resolve_texture_output_dir(
|
||||
root: Path,
|
||||
paths: RevisionPaths,
|
||||
output_dir: Path | None = None,
|
||||
) -> Path:
|
||||
"""Resolve the texture output directory (explicit --output-dir wins)."""
|
||||
if output_dir is None:
|
||||
return texture_output_dir(root, paths)
|
||||
return (Path(output_dir).expanduser()).resolve()
|
||||
|
||||
|
||||
def texture_job_path(output_dir: Path) -> Path:
|
||||
"""Return the job file path written beside the generated PNG."""
|
||||
return Path(output_dir) / TEXTURE_JOB_FILENAME
|
||||
|
||||
|
||||
def build_texture_job(
|
||||
paths: RevisionPaths,
|
||||
settings: TexturePatchSettings,
|
||||
output_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the JSON job dict that random_patch_dd02c.py consumes (stdlib JSON)."""
|
||||
return {
|
||||
"schema": TEXTURE_JOB_SCHEMA,
|
||||
"mode": TEXTURE_MODE,
|
||||
"revision_dir": str(paths.root.resolve()),
|
||||
"source_blend": str(paths.source_blend.resolve()),
|
||||
"output": str(Path(output_path).resolve()),
|
||||
"objects": {
|
||||
"mesh": settings.mesh,
|
||||
"camera": settings.camera,
|
||||
},
|
||||
"settings": settings.to_job_settings(),
|
||||
}
|
||||
|
||||
|
||||
def write_texture_job(job: dict[str, Any], output_dir: Path) -> Path:
|
||||
"""Write _texture_job.json into the output directory; return its path."""
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
job_path = texture_job_path(output_dir)
|
||||
job_path.write_text(json.dumps(job, indent=2) + "\n", encoding="utf-8")
|
||||
return job_path
|
||||
|
||||
|
||||
def texture_overrides_from_args(args: Any) -> dict[str, Any]:
|
||||
"""Collect CLI override values from an argparse namespace (None = unset)."""
|
||||
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),
|
||||
"ny": getattr(args, "ny", None),
|
||||
"color_r": getattr(args, "color_r", None),
|
||||
"edge_sample_grid": getattr(args, "edge_sample_grid", None),
|
||||
"seed": getattr(args, "seed", None),
|
||||
}
|
||||
|
||||
cross: dict[str, Any] = {}
|
||||
length = getattr(args, "cross_length", None)
|
||||
thickness = getattr(args, "cross_thickness", None)
|
||||
color = getattr(args, "cross_color", None)
|
||||
if length is not None:
|
||||
cross["length_px"] = length
|
||||
if thickness is not None:
|
||||
cross["thickness_px"] = thickness
|
||||
if color is not None:
|
||||
cross["color"] = list(color)
|
||||
no_cross = getattr(args, "no_cross", None)
|
||||
if no_cross is not None:
|
||||
cross["enabled"] = not bool(no_cross)
|
||||
if cross:
|
||||
overrides["cross"] = cross
|
||||
|
||||
return overrides
|
||||
Reference in New Issue
Block a user