migrated texture generation script to cli
This commit is contained in:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user