migrated texture generation script to cli

This commit is contained in:
ykcheungab
2026-09-16 21:34:59 +08:00
parent a29ca6846c
commit 026f495f77
12 changed files with 1292 additions and 124 deletions
+126 -1
View File
@@ -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:
+399
View File
@@ -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