344 lines
14 KiB
Python
344 lines
14 KiB
Python
"""Multi-view production texture generator — step 1: one independent texture per view.
|
|
|
|
blender -b sensors/finger/r01/source/geometry.blend \\
|
|
-P blender/scripts/random_patch_multiview.py -- --job <_texture_views_job.json>
|
|
|
|
Each *pair* in the job is ``(camera, uv_map, artifacts)`` and is generated exactly like a
|
|
single-view run: that camera's cells are clipped against the mesh, its own per-cell colour
|
|
table is seeded, and the result is written into that UV layout. There is no ownership
|
|
partition, no seam handling and no merge — two pairs may cover the same surface region and
|
|
the overlap is deliberately left unresolved (see ``texture_views.py`` for why the merge has
|
|
to happen in surface space, and why it is a later step).
|
|
|
|
This file is additive: ``random_patch_dd02c.py`` and every existing single-view artifact are
|
|
untouched, and the helpers below are imported from it rather than forked, so the raster,
|
|
SVG, PDF, clipping and colour code paths stay identical between the two entry points.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import bpy
|
|
import numpy as np
|
|
|
|
# Blender -P does not consistently include the script directory on sys.path.
|
|
_SCRIPT_DIR = str(Path(__file__).resolve().parent)
|
|
if _SCRIPT_DIR not in sys.path:
|
|
sys.path.insert(0, _SCRIPT_DIR)
|
|
|
|
from random_patch_dd02c import ( # noqa: E402
|
|
ConfigError,
|
|
SceneError,
|
|
_prepare_mesh_object,
|
|
_require_object,
|
|
_require_uv_layer,
|
|
_uv_centre,
|
|
build_patch_geometry,
|
|
generate_patch_texture,
|
|
precompute_cell_colors,
|
|
)
|
|
|
|
SCHEMA = "orisys-sensor-design/texture-job-multiview-v1"
|
|
DEFAULT_MESH = "sensing_surface"
|
|
ARTIFACT_FORMATS = ("png", "svg", "pdf")
|
|
_DEFAULT_COLOR_R = 0.05
|
|
|
|
|
|
def _argv_after_double_dash() -> list[str]:
|
|
argv = sys.argv
|
|
if "--" in argv:
|
|
return argv[argv.index("--") + 1 :]
|
|
return []
|
|
|
|
|
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Multi-view colored-patch texture generator (job JSON only)"
|
|
)
|
|
parser.add_argument("--job", required=True, type=Path, help="path to the job JSON")
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def _load_job(path: Path) -> dict[str, Any]:
|
|
try:
|
|
with path.open(encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
except OSError as exc:
|
|
raise ConfigError(f"cannot read job {path}: {exc}") from exc
|
|
except json.JSONDecodeError as exc:
|
|
raise ConfigError(f"job {path} is not valid JSON: {exc}") from exc
|
|
if not isinstance(data, dict):
|
|
raise ConfigError(f"job {path} must be a JSON object")
|
|
return data
|
|
|
|
|
|
def _as_int(value: Any, label: str, minimum: int = 1) -> int:
|
|
if not isinstance(value, int) or isinstance(value, bool):
|
|
raise ConfigError(f"{label}: must be an integer (got {value!r})")
|
|
if value < minimum:
|
|
raise ConfigError(f"{label}: must be >= {minimum} (got {value!r})")
|
|
return int(value)
|
|
|
|
|
|
def _as_positive_number(value: Any, label: str) -> float:
|
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
raise ConfigError(f"{label}: must be a number (got {value!r})")
|
|
if float(value) <= 0.0:
|
|
raise ConfigError(f"{label}: must be > 0 (got {value!r})")
|
|
return float(value)
|
|
|
|
|
|
def _as_color(value: Any, label: str) -> tuple[float, float, float, float]:
|
|
if not isinstance(value, (list, tuple)) or len(value) not in (3, 4):
|
|
raise ConfigError(f"{label}: must be 3 or 4 numbers (got {value!r})")
|
|
channels = [float(v) for v in value]
|
|
if any(v < 0.0 or v > 1.0 for v in channels):
|
|
raise ConfigError(f"{label}: channels must be in [0, 1] (got {value!r})")
|
|
if len(channels) == 3:
|
|
channels.append(1.0)
|
|
return (channels[0], channels[1], channels[2], channels[3])
|
|
|
|
|
|
def _resolve_pairs(job: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Validate every pair up front so a partial run is never produced."""
|
|
raw = job.get("pairs")
|
|
if not isinstance(raw, list) or not raw:
|
|
raise ConfigError("job 'pairs' must be a non-empty list")
|
|
pairs: list[dict[str, Any]] = []
|
|
seen_ids: set[str] = set()
|
|
seen_paths: dict[str, str] = {}
|
|
for index, entry in enumerate(raw):
|
|
where = f"pairs[{index}]"
|
|
if not isinstance(entry, dict):
|
|
raise ConfigError(f"{where}: must be a mapping")
|
|
pair_id = entry.get("id")
|
|
if not isinstance(pair_id, str) or not pair_id.strip():
|
|
raise ConfigError(f"{where}.id: must be a non-empty string")
|
|
if pair_id in seen_ids:
|
|
raise ConfigError(f"{where}.id: duplicate pair id {pair_id!r}")
|
|
seen_ids.add(pair_id)
|
|
camera = entry.get("camera")
|
|
if not isinstance(camera, str) or not camera.strip():
|
|
raise ConfigError(f"{where}.camera: must be a non-empty string")
|
|
uv_map = entry.get("uv_map")
|
|
if not isinstance(uv_map, str) or not uv_map.strip():
|
|
raise ConfigError(f"{where}.uv_map: must be a non-empty string")
|
|
artifacts = entry.get("artifacts")
|
|
if not isinstance(artifacts, dict):
|
|
raise ConfigError(f"{where}.artifacts: must be a mapping")
|
|
paths: dict[str, str] = {}
|
|
for format_name in ARTIFACT_FORMATS:
|
|
value = artifacts.get(format_name)
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ConfigError(
|
|
f"{where}.artifacts.{format_name}: must be a non-empty path"
|
|
)
|
|
key = str(Path(value)).lower()
|
|
if key in seen_paths:
|
|
raise ConfigError(
|
|
f"{where}.artifacts.{format_name}: {value!r} is also written by "
|
|
f"{seen_paths[key]}"
|
|
)
|
|
seen_paths[key] = f"{pair_id}/{format_name}"
|
|
paths[format_name] = value
|
|
seed = entry.get("seed")
|
|
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
|
|
raise ConfigError(f"{where}.seed: must be an integer or null")
|
|
pairs.append(
|
|
{
|
|
"id": pair_id,
|
|
"camera": camera,
|
|
"uv_map": uv_map,
|
|
"seed": None if seed is None else int(seed),
|
|
"artifacts": paths,
|
|
}
|
|
)
|
|
return pairs
|
|
|
|
|
|
def _resolve_config(job: dict[str, Any]) -> dict[str, Any]:
|
|
if job.get("schema") != SCHEMA:
|
|
raise ConfigError(
|
|
f"job schema must be {SCHEMA!r} (got {job.get('schema')!r})"
|
|
)
|
|
objects = job.get("objects") or {}
|
|
if not isinstance(objects, dict):
|
|
raise ConfigError("job 'objects' must be a mapping")
|
|
settings = job.get("settings") or {}
|
|
if not isinstance(settings, dict):
|
|
raise ConfigError("job 'settings' must be a mapping")
|
|
vector = settings.get("vector") or {}
|
|
if not isinstance(vector, dict):
|
|
raise ConfigError("job 'settings.vector' must be a mapping")
|
|
cross = settings.get("cross") or {}
|
|
if not isinstance(cross, dict):
|
|
raise ConfigError("job 'settings.cross' must be a mapping")
|
|
|
|
seed = settings.get("seed")
|
|
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
|
|
raise ConfigError(f"settings.seed: must be an integer or null (got {seed!r})")
|
|
|
|
return {
|
|
"mesh": str(objects.get("mesh") or DEFAULT_MESH),
|
|
"width": _as_int(settings.get("width", 2048), "settings.width"),
|
|
"height": _as_int(settings.get("height", 2048), "settings.height"),
|
|
"vector_width_mm": _as_positive_number(
|
|
vector.get("width_mm", 164.0), "settings.vector.width_mm"
|
|
),
|
|
"vector_height_mm": _as_positive_number(
|
|
vector.get("height_mm", 160.0), "settings.vector.height_mm"
|
|
),
|
|
"nx": _as_int(settings.get("nx", 80), "settings.nx"),
|
|
"ny": _as_int(settings.get("ny", 45), "settings.ny"),
|
|
"edge_sample_grid": _as_int(
|
|
settings.get("edge_sample_grid", 5), "settings.edge_sample_grid", minimum=2
|
|
),
|
|
"color_r": float(settings.get("color_r", _DEFAULT_COLOR_R)),
|
|
"seed": None if seed is None else int(seed),
|
|
"cross_enabled": bool(cross.get("enabled", True)),
|
|
"cross_length": _as_int(cross.get("length_px", 40), "settings.cross.length_px"),
|
|
"cross_thickness": _as_int(
|
|
cross.get("thickness_px", 3), "settings.cross.thickness_px"
|
|
),
|
|
"cross_color": _as_color(cross.get("color", [1.0, 0.0, 0.0, 1.0]), "settings.cross.color"),
|
|
"pairs": _resolve_pairs(job),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args(_argv_after_double_dash())
|
|
try:
|
|
config = _resolve_config(_load_job(args.job.resolve()))
|
|
except (ConfigError, OSError) as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
# Resolve every object and UV layer before rendering anything, so a missing camera or
|
|
# UV map cannot leave a half-written set of artifacts behind.
|
|
try:
|
|
obj = _prepare_mesh_object(config["mesh"])
|
|
resolved = [
|
|
(
|
|
pair,
|
|
_require_object(pair["camera"], "CAMERA", f"view {pair['id']!r} camera"),
|
|
_require_uv_layer(obj, pair["uv_map"]),
|
|
)
|
|
for pair in config["pairs"]
|
|
]
|
|
except SceneError as exc:
|
|
print(f"ERROR: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
for pair, _cam, _layer in resolved:
|
|
for format_name, output in pair["artifacts"].items():
|
|
try:
|
|
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
|
except OSError as exc:
|
|
print(
|
|
f"ERROR: cannot create {format_name.upper()} directory for view "
|
|
f"{pair['id']!r} ({Path(output).parent}): {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
scene = bpy.context.scene
|
|
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
start_time = time.time()
|
|
|
|
print(
|
|
f"Multi-view: {len(resolved)} pair(s), {config['nx']}x{config['ny']} grid, "
|
|
f"{config['width']}x{config['height']} raster, "
|
|
f"vector {config['vector_width_mm']:g}x{config['vector_height_mm']:g} mm"
|
|
)
|
|
|
|
for pair, cam, uv_layer in resolved:
|
|
pair_id = pair["id"]
|
|
print(f"--- view {pair_id!r}: camera={pair['camera']!r} uv_map={pair['uv_map']!r}")
|
|
# A colour table per pair: cell (i, j) indexes a different place on the surface in
|
|
# each camera's grid, so sharing one table would place the same colour on unrelated
|
|
# surface while looking like consistency.
|
|
if pair["seed"] is not None:
|
|
np.random.seed(pair["seed"])
|
|
print(f" colour seed {pair['seed']}")
|
|
patch_colors = precompute_cell_colors(config["nx"], config["ny"], config["color_r"])
|
|
try:
|
|
geometry = build_patch_geometry(
|
|
obj,
|
|
cam,
|
|
scene,
|
|
depsgraph,
|
|
[pair["uv_map"]],
|
|
config["nx"],
|
|
config["ny"],
|
|
config["edge_sample_grid"],
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - surface the real cause to stderr
|
|
print(
|
|
f"ERROR: patch geometry failed for view {pair_id!r}: {exc}", file=sys.stderr
|
|
)
|
|
return 1
|
|
|
|
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
|
try:
|
|
generate_patch_texture(
|
|
obj,
|
|
cam,
|
|
scene,
|
|
depsgraph,
|
|
nx=config["nx"],
|
|
ny=config["ny"],
|
|
r=config["color_r"],
|
|
texture_width=config["width"],
|
|
texture_height=config["height"],
|
|
output_path=pair["artifacts"]["png"],
|
|
svg_path=pair["artifacts"]["svg"],
|
|
pdf_path=pair["artifacts"]["pdf"],
|
|
vector_width_mm=config["vector_width_mm"],
|
|
vector_height_mm=config["vector_height_mm"],
|
|
uv_centre=uv_centre,
|
|
cross_length=config["cross_length"],
|
|
cross_thickness=config["cross_thickness"],
|
|
cross_color=config["cross_color"],
|
|
edge_sample_grid=config["edge_sample_grid"],
|
|
uv_layer=uv_layer,
|
|
image_name=f"PatchTexture_{pair_id}",
|
|
patch_colors=patch_colors,
|
|
geometry=geometry[pair["uv_map"]],
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
print(
|
|
f"ERROR: texture generation failed for view {pair_id!r}: {exc}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
for format_name, output in pair["artifacts"].items():
|
|
path = Path(output)
|
|
if not path.is_file() or path.stat().st_size == 0:
|
|
print(
|
|
f"ERROR: {format_name.upper()} artifact was not written for view "
|
|
f"{pair_id!r}: {path}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
artifact_count = sum(len(pair["artifacts"]) for pair, _c, _l in resolved)
|
|
print(f"Multi-view complete: {artifact_count} file(s)")
|
|
for pair, _cam, _layer in resolved:
|
|
print(f" [{pair['id']}] camera={pair['camera']!r} uv_map={pair['uv_map']!r}")
|
|
for format_name, output in pair["artifacts"].items():
|
|
path = Path(output)
|
|
print(f" {format_name.upper()}: {path} ({path.stat().st_size} bytes)")
|
|
print(f"Completed in {time.time() - start_time:.2f} seconds.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|