"""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())