Files
orisys-sensor-design/blender/scripts/random_patch_multiview.py
T
2026-09-28 10:47:00 +08:00

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