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
+287 -115
View File
@@ -1,15 +1,33 @@
"""Blender headless colored-patch texture generator (YAML-free).
The host CLI (orisys-design texture) reads design.yaml, writes a JSON job file,
and invokes this script. Blender only needs stdlib json — not PyYAML:
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- \
--job <abs/build/textures/finger/r01/_texture_job.json>
Manual overrides (optional, win over job values):
--output FILE.png --mesh sensing_surface --cam cam_sensor --width 3280 --height 3200 \
--nx 80 --ny 45 --color-r 0.05 --edge-sample-grid 5 --seed 7 --no-cross
Scope: the colored "patches" mode only. checker/dots helpers remain available to
callers but are not reachable from the headless entry point. The mesh must
already have an active UV map: UV unwrapping is still an in-app Blender step and
is never performed here.
- Mesh UVs are filled white so that non-visible parts become white.
- Texture background (no-mesh areas) stays black.
"""
Texture Generator for Arbitrary 3D Surfaces (Blender Script)
- Mode: "patches" (colored patches), "checker" (black/white patches),
or "dots" (circular markers)
- Mesh UVs are filled white so that non‑visible parts become white.
- Texture background (no‑mesh areas) stays black.
"""
import argparse
import json
import sys
import time
from pathlib import Path
import bpy
import numpy as np
import os
import time
from mathutils import Vector
# ----------------------------------------------------------------------
@@ -489,9 +507,11 @@ def generate_patch_texture(
obj, cam, scene, depsgraph,
nx=40, ny=30, r=0.05,
texture_width=2048, texture_height=2048,
output_path="//patch_texture.png", uv_centre=None, cross_length=40,
output_path=None, uv_centre=None, cross_length=40,
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5
):
if not output_path:
raise ValueError("output_path is required (absolute PNG path)")
cam_data = cam.data
render = scene.render
res_x = render.resolution_x
@@ -569,11 +589,12 @@ def generate_patch_texture(
image.pixels[:] = pixels
image.update()
abs_path = bpy.path.abspath(output_path)
image.filepath_raw = abs_path
out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
image.filepath_raw = str(out_path)
image.file_format = 'PNG'
image.save()
print(f"Patch texture saved to {abs_path}")
print(f"Patch texture saved to {out_path}")
return image
# ----------------------------------------------------------------------
@@ -583,10 +604,12 @@ def generate_checker_texture(
obj, cam, scene, depsgraph,
nx=40, ny=40,
texture_width=2048, texture_height=2048,
output_path="//checker_texture.png", uv_centre=None, cross_length=40,
output_path=None, uv_centre=None, cross_length=40,
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
gap_fill_px=6
):
if not output_path:
raise ValueError("output_path is required (absolute PNG path)")
cam_data = cam.data
render = scene.render
res_x = render.resolution_x
@@ -668,11 +691,12 @@ def generate_checker_texture(
image.pixels[:] = pixels
image.update()
abs_path = bpy.path.abspath(output_path)
image.filepath_raw = abs_path
out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
image.filepath_raw = str(out_path)
image.file_format = 'PNG'
image.save()
print(f"Checker texture saved to {abs_path}")
print(f"Checker texture saved to {out_path}")
return image
# ----------------------------------------------------------------------
@@ -684,8 +708,10 @@ def generate_dot_texture(
spacing_pixels=40,
dot_color=(0,0,0,1), # RGBA 0-1
texture_width=2048, texture_height=2048,
output_path="//dot_texture.png",uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
output_path=None,uv_centre=None, cross_length=40, cross_thickness=3, cross_color=(1,0,0,1)
):
if not output_path:
raise ValueError("output_path is required (absolute PNG path)")
render = scene.render
res_x = render.resolution_x
res_y = render.resolution_y
@@ -797,121 +823,267 @@ def generate_dot_texture(
image.pixels[:] = pixels
image.update()
abs_path = bpy.path.abspath(output_path)
image.filepath_raw = abs_path
out_path = Path(output_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
image.filepath_raw = str(out_path)
image.file_format = 'PNG'
image.save()
print(f"Dot texture saved to {abs_path}")
print(f"Dot texture saved to {out_path}")
return image
# ----------------------------------------------------------------------
# Main
# Headless configuration (job file from `orisys-design texture`)
# ----------------------------------------------------------------------
def main():
# ==================== CONFIGURATION ====================
MODE = "patches" # "patches", "checker", or "dots"
OBJ_NAME = "FingerMesh0525.001"
CAM_NAME = "Camera0607"
TEXTURE_WIDTH = 1640*2
TEXTURE_HEIGHT = 1600*2
OUTPUT_NAMES = {
"patches": "patch_texture.png",
"checker": "checker_texture.png",
"dots": "dot_texture.png",
_DEFAULT_MESH = "sensing_surface"
_DEFAULT_CAMERA = "cam_sensor"
class ConfigError(ValueError):
"""Invalid job/flags (exit code 2)."""
class SceneError(RuntimeError):
"""Missing or unusable scene data (exit code 1)."""
def _argv_after_double_dash():
if "--" not in sys.argv:
return []
return sys.argv[sys.argv.index("--") + 1:]
def parse_args(argv):
parser = argparse.ArgumentParser(prog="random_patch_dd02c.py")
parser.add_argument(
"--job", type=Path, default=None,
help="JSON texture job written by orisys-design (output + objects + settings)",
)
parser.add_argument("--output", type=Path, default=None, help="Absolute output PNG (overrides job)")
parser.add_argument("--mesh", default=None, help="Mesh object name (overrides job)")
parser.add_argument("--cam", default=None, help="Camera object name (overrides job)")
parser.add_argument("--width", type=int, default=None, help="Texture width (overrides job)")
parser.add_argument("--height", type=int, default=None, help="Texture height (overrides job)")
parser.add_argument("--nx", type=int, default=None, help="Patch columns (overrides job)")
parser.add_argument("--ny", type=int, default=None, help="Patch rows (overrides job)")
parser.add_argument("--color-r", dest="color_r", type=float, default=None, help="Colour separation")
parser.add_argument(
"--edge-sample-grid", dest="edge_sample_grid", type=int, default=None,
help="Edge sampling grid for partially covered patches",
)
parser.add_argument("--seed", type=int, default=None, help="Seed for reproducible colours")
parser.add_argument(
"--no-cross", dest="no_cross", action="store_true", default=None,
help="Do not draw the reference cross",
)
parser.add_argument("--cross-length", dest="cross_length", type=int, default=None)
parser.add_argument("--cross-thickness", dest="cross_thickness", type=int, default=None)
parser.add_argument(
"--cross-color", dest="cross_color", type=float, nargs=4,
metavar=("R", "G", "B", "A"), default=None,
)
return parser.parse_args(argv)
def _load_job(path):
if not path.is_file():
raise ConfigError(f"Texture job not found: {path}")
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ConfigError(f"Texture job must be a JSON object: {path}")
return data
def _as_int(value, label, minimum=1):
if not isinstance(value, int) or isinstance(value, bool) or value < minimum:
raise ConfigError(f"{label}: must be an integer >= {minimum} (got {value!r})")
return int(value)
def _as_color(value, label):
if not isinstance(value, (list, tuple)) or len(value) not in (3, 4):
raise ConfigError(f"{label}: must be a list of 3 or 4 numbers in [0, 1] (got {value!r})")
for channel in value:
if not isinstance(channel, (int, float)) or isinstance(channel, bool):
raise ConfigError(f"{label}: every channel must be a number (got {value!r})")
if not 0.0 <= float(channel) <= 1.0:
raise ConfigError(f"{label}: every channel must be in [0, 1] (got {value!r})")
rgba = [float(channel) for channel in value]
if len(rgba) == 3:
rgba.append(1.0)
return tuple(rgba)
def _resolve_config(args):
"""Merge --job JSON with explicit flags. Explicit flags win over job values."""
job = {}
if args.job is not None:
job = _load_job(args.job.resolve())
output_raw = args.output or job.get("output")
if not output_raw:
raise ConfigError("Provide --job <path.json> and/or --output <file.png>")
output = Path(output_raw).expanduser()
if not output.is_absolute():
raise ConfigError(f"output must be an absolute path (got {str(output_raw)!r})")
if output.suffix.lower() != ".png":
raise ConfigError(f"output must be a .png file (got {str(output_raw)!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")
job_cross = settings.get("cross") or {}
if not isinstance(job_cross, dict):
raise ConfigError("job 'settings.cross' must be a mapping")
def pick(flag, key, default):
if flag is not None:
return flag
value = settings.get(key, default)
return default if value is None else value
def pick_cross(flag, key, default):
if flag is not None:
return flag
value = job_cross.get(key, default)
return default if value is None else value
mesh = args.mesh or objects.get("mesh") or _DEFAULT_MESH
camera = args.cam or objects.get("camera") or _DEFAULT_CAMERA
width = _as_int(pick(args.width, "width", 2048), "width")
height = _as_int(pick(args.height, "height", 2048), "height")
nx = _as_int(pick(args.nx, "nx", 80), "nx")
ny = _as_int(pick(args.ny, "ny", 45), "ny")
edge_sample_grid = _as_int(
pick(args.edge_sample_grid, "edge_sample_grid", 5), "edge_sample_grid", minimum=2
)
color_r = pick(args.color_r, "color_r", 0.05)
if not isinstance(color_r, (int, float)) or isinstance(color_r, bool):
raise ConfigError(f"color_r: must be a number in [0, 1] (got {color_r!r})")
if not 0.0 <= float(color_r) <= 1.0:
raise ConfigError(f"color_r: must be a number in [0, 1] (got {color_r!r})")
seed = pick(args.seed, "seed", None)
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
raise ConfigError(f"seed: must be an integer or null (got {seed!r})")
cross_enabled = bool(pick_cross(args.no_cross, "enabled", True))
if args.no_cross is not None:
cross_enabled = not args.no_cross
cross_length = _as_int(pick_cross(args.cross_length, "length_px", 40), "cross.length_px")
cross_thickness = _as_int(pick_cross(args.cross_thickness, "thickness_px", 3), "cross.thickness_px")
cross_color = _as_color(
args.cross_color if args.cross_color is not None else job_cross.get("color", [1.0, 0.0, 0.0, 1.0]),
"cross.color",
)
return {
"output": output,
"mesh": str(mesh),
"camera": str(camera),
"width": width,
"height": height,
"nx": nx,
"ny": ny,
"color_r": float(color_r),
"edge_sample_grid": edge_sample_grid,
"seed": None if seed is None else int(seed),
"cross_enabled": cross_enabled,
"cross_length": cross_length,
"cross_thickness": cross_thickness,
"cross_color": cross_color,
}
OUTPUT_PATH = "//" + OUTPUT_NAMES.get(MODE, "texture.png")
# Patch mode
PATCH_NX = 80; PATCH_NY = 45; COLOR_R = 0.05
EDGE_SAMPLE_GRID = 5
# Checker mode
CHECKER_NX = 40; CHECKER_NY = 40; CHECKER_GAP_FILL_PX = 6
# Dot mode
DOT_DIAMETER_PIX = 12; DOT_SPACING_PIX = 40; DOT_COLOR = (0,0,0,1)
# Cross
CROSS_LENGTH_PX = 40
CROSS_THICKNESS_PX = 3
CROSS_COLOR = (1, 0, 0, 1) # red cross
# ======================================================
start_time = time.time()
def _require_object(name, expected_type, label):
obj = bpy.data.objects.get(name)
if obj is None:
raise SceneError(f"{label} object not found: {name!r}")
if obj.type != expected_type:
raise SceneError(f"{label} {name!r} has type {obj.type!r}, expected {expected_type!r}")
return obj
def _uv_centre(obj):
"""UV bounding-box centre; fails if the mesh was never unwrapped."""
uv_layer = obj.data.uv_layers.active
if uv_layer is None or len(uv_layer.data) == 0:
raise SceneError(
f"Mesh {obj.name!r} has no active UV map with loops. UV unwrapping is still an "
"in-app Blender step: unwrap the mesh in the GUI, save the .blend, then re-run "
"orisys-design texture."
)
uv = np.array(
[uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))],
dtype=np.float64,
)
centre = (uv.min(axis=0) + uv.max(axis=0)) / 2.0
print(f"UV centre: ({centre[0]:.4f}, {centre[1]:.4f})")
return centre
def main():
args = parse_args(_argv_after_double_dash())
try:
config = _resolve_config(args)
except (ConfigError, json.JSONDecodeError, OSError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
output = config["output"]
try:
output.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
print(f"ERROR: cannot create output directory {output.parent}: {exc}", file=sys.stderr)
return 2
scene = bpy.context.scene
depsgraph = bpy.context.evaluated_depsgraph_get()
if OBJ_NAME:
obj = bpy.data.objects.get(OBJ_NAME)
else:
obj = bpy.context.active_object
if obj is None or obj.type != 'MESH':
raise ValueError("No valid mesh object found.")
try:
obj = _require_object(config["mesh"], "MESH", "Mesh")
cam = _require_object(config["camera"], "CAMERA", "Camera")
uv_centre = _uv_centre(obj)
except SceneError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
cam = bpy.data.objects.get(CAM_NAME) if CAM_NAME else scene.camera
if cam is None or cam.type != 'CAMERA':
raise ValueError("No camera found.")
if config["seed"] is not None:
np.random.seed(config["seed"])
print(f"Using colour seed {config['seed']}")
if not obj.data.uv_layers.active:
obj.data.uv_layers.new(name="UVMap")
# Compute UV centre
uv_layer = obj.data.uv_layers.active
uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
uv_min = uv_coords.min(axis=0)
uv_max = uv_coords.max(axis=0)
uv_centre = (uv_min + uv_max) / 2.0
print(f"UV centre: ({uv_centre[0]:.4f}, {uv_centre[1]:.4f})")
# Compute UV bounding box centre
me = obj.data
uv_layer = me.uv_layers.active
uv_coords = np.array([uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))])
uv_min = uv_coords.min(axis=0)
uv_max = uv_coords.max(axis=0)
uv_centre = (uv_min + uv_max) / 2.0
print(f"UV centre: {uv_centre[0]:.4f}, {uv_centre[1]:.4f}")
if MODE == "patches":
start_time = time.time()
try:
generate_patch_texture(
obj, cam, scene, depsgraph,
nx=PATCH_NX, ny=PATCH_NY, r=COLOR_R,
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
output_path=OUTPUT_PATH,
uv_centre=uv_centre,
cross_length=CROSS_LENGTH_PX,
cross_thickness=CROSS_THICKNESS_PX,
cross_color=CROSS_COLOR,
edge_sample_grid=EDGE_SAMPLE_GRID
nx=config["nx"], ny=config["ny"], r=config["color_r"],
texture_width=config["width"], texture_height=config["height"],
output_path=str(output),
uv_centre=uv_centre if config["cross_enabled"] else None,
cross_length=config["cross_length"],
cross_thickness=config["cross_thickness"],
cross_color=config["cross_color"],
edge_sample_grid=config["edge_sample_grid"],
)
elif MODE == "checker":
generate_checker_texture(
obj, cam, scene, depsgraph,
nx=CHECKER_NX, ny=CHECKER_NY,
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
output_path=OUTPUT_PATH,
uv_centre=uv_centre,
cross_length=CROSS_LENGTH_PX,
cross_thickness=CROSS_THICKNESS_PX,
cross_color=CROSS_COLOR,
edge_sample_grid=EDGE_SAMPLE_GRID,
gap_fill_px=CHECKER_GAP_FILL_PX
)
elif MODE == "dots":
generate_dot_texture(
obj, cam, scene, depsgraph,
dot_diameter_pixels=DOT_DIAMETER_PIX,
spacing_pixels=DOT_SPACING_PIX,
dot_color=DOT_COLOR,
texture_width=TEXTURE_WIDTH, texture_height=TEXTURE_HEIGHT,
output_path=OUTPUT_PATH,
uv_centre=uv_centre,
cross_length=CROSS_LENGTH_PX,
cross_thickness=CROSS_THICKNESS_PX,
cross_color=CROSS_COLOR
)
else:
raise ValueError(f"Unknown MODE: {MODE!r}. Use 'patches', 'checker', or 'dots'.")
except Exception as exc:
print(f"ERROR: texture generation failed: {exc}", file=sys.stderr)
return 1
if not output.is_file() or output.stat().st_size == 0:
print(f"ERROR: texture was not written: {output}", file=sys.stderr)
return 1
print(f"Texture complete: {output}")
print(f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']}")
print(f"Completed in {time.time() - start_time:.2f} seconds.")
return 0
print(f"Completed in {time.time()-start_time:.2f} seconds.")
if __name__ == "__main__":
main()
raise SystemExit(main())