adde produce + compute texture export
This commit is contained in:
@@ -6,15 +6,16 @@ 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):
|
||||
Manual single-target mode (cannot be combined with --job):
|
||||
|
||||
--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
|
||||
--uv-map produce --output FILE.png --mesh sensing_surface --cam cam_sensor \
|
||||
--width 3280 --height 3200 --nx 80 --ny 45 --color-r 0.05 --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.
|
||||
already carry the requested UV maps: UV unwrapping is still an in-app Blender
|
||||
step and is never performed here. A scene saved in Edit mode is handled by
|
||||
switching to Object mode in memory only — the .blend is never written.
|
||||
|
||||
- Mesh UVs are filled white so that non-visible parts become white.
|
||||
- Texture background (no-mesh areas) stays black.
|
||||
@@ -220,9 +221,16 @@ def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color):
|
||||
idx = (y * width + x) * 4
|
||||
pixels[idx:idx+4] = color
|
||||
|
||||
def get_uv_from_face(obj, face_index, location):
|
||||
"""World‑space location → UV coordinate on the mesh face."""
|
||||
def get_uv_from_face(obj, face_index, location, uv_layer=None):
|
||||
"""World‑space location → UV coordinate on the mesh face.
|
||||
|
||||
``uv_layer`` selects the mesh UV map explicitly. ``None`` keeps the previous
|
||||
active-layer behaviour, which the non-headless checker/dot helpers rely on.
|
||||
"""
|
||||
me = obj.data
|
||||
layer = uv_layer if uv_layer is not None else me.uv_layers.active
|
||||
if layer is None:
|
||||
return None
|
||||
local_loc = obj.matrix_world.inverted() @ location
|
||||
poly = me.polygons[face_index]
|
||||
vert_indices = poly.vertices
|
||||
@@ -232,10 +240,10 @@ def get_uv_from_face(obj, face_index, location):
|
||||
v0 = me.vertices[vert_indices[0]].co
|
||||
v1 = me.vertices[vert_indices[1]].co
|
||||
v2 = me.vertices[vert_indices[2]].co
|
||||
uv_layer = me.uv_layers.active.data
|
||||
uv0 = uv_layer[loop_indices[0]].uv
|
||||
uv1 = uv_layer[loop_indices[1]].uv
|
||||
uv2 = uv_layer[loop_indices[2]].uv
|
||||
uv_data = layer.data
|
||||
uv0 = uv_data[loop_indices[0]].uv
|
||||
uv1 = uv_data[loop_indices[1]].uv
|
||||
uv2 = uv_data[loop_indices[2]].uv
|
||||
def tri_area(a, b, c):
|
||||
return 0.5 * ((b - a).cross(c - a)).length
|
||||
total_area = tri_area(v0, v1, v2)
|
||||
@@ -261,14 +269,16 @@ def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, f
|
||||
return location, face_index
|
||||
return None, None
|
||||
|
||||
def raycast_uv_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
|
||||
def raycast_uv_from_camera(
|
||||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer=None
|
||||
):
|
||||
"""Camera raycast through normalized sensor coordinates and return UV."""
|
||||
loc, face_idx = raycast_from_camera(
|
||||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
|
||||
)
|
||||
if loc is None:
|
||||
return None
|
||||
return get_uv_from_face(obj, face_idx, loc)
|
||||
return get_uv_from_face(obj, face_idx, loc, uv_layer)
|
||||
|
||||
def convex_hull_uv(points):
|
||||
"""Return UV points ordered around their 2D convex hull."""
|
||||
@@ -301,7 +311,7 @@ def sample_patch_uv_polygon(
|
||||
scene, depsgraph, cam, obj,
|
||||
u_start, u_end, v_start, v_end,
|
||||
frame_width, frame_height,
|
||||
edge_sample_grid=5
|
||||
edge_sample_grid=5, uv_layer=None
|
||||
):
|
||||
"""
|
||||
Build a UV polygon for one camera-space patch.
|
||||
@@ -309,6 +319,8 @@ def sample_patch_uv_polygon(
|
||||
Full interior cells use the four corners. Edge cells that only partially hit
|
||||
the mesh are sampled more densely and converted to a convex hull so they are
|
||||
not dropped just because some corners miss the silhouette.
|
||||
|
||||
``uv_layer`` selects which mesh UV map the raycasts are resolved against.
|
||||
"""
|
||||
corners_ndc = [
|
||||
(u_start, v_start),
|
||||
@@ -320,7 +332,7 @@ def sample_patch_uv_polygon(
|
||||
corner_uvs = []
|
||||
for u_ndc, v_ndc in corners_ndc:
|
||||
uv = raycast_uv_from_camera(
|
||||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
|
||||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer
|
||||
)
|
||||
if uv is not None:
|
||||
corner_uvs.append(uv)
|
||||
@@ -334,24 +346,27 @@ def sample_patch_uv_polygon(
|
||||
for sx in range(edge_sample_grid):
|
||||
u_ndc = u_start + (u_end - u_start) * sx / (edge_sample_grid - 1)
|
||||
uv = raycast_uv_from_camera(
|
||||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
|
||||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer
|
||||
)
|
||||
if uv is not None:
|
||||
sampled_uvs.append(uv)
|
||||
|
||||
return convex_hull_uv(sampled_uvs)
|
||||
|
||||
def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)):
|
||||
def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1), uv_layer=None):
|
||||
"""
|
||||
Fill all triangles of the active UV map with 'color' (default white).
|
||||
Fill all triangles of one UV map with 'color' (default white).
|
||||
This marks every part of the mesh, visible or not, as white.
|
||||
|
||||
``uv_layer`` selects the mesh UV map; ``None`` keeps the active-layer
|
||||
behaviour used by the checker/dot helpers.
|
||||
"""
|
||||
me = obj.data
|
||||
uv_layer = me.uv_layers.active
|
||||
if not uv_layer:
|
||||
print("Warning: No active UV layer – mesh rasterization skipped.")
|
||||
layer = uv_layer if uv_layer is not None else me.uv_layers.active
|
||||
if not layer:
|
||||
print("Warning: no UV layer selected – mesh rasterization skipped.")
|
||||
return
|
||||
uv_data = uv_layer.data
|
||||
uv_data = layer.data
|
||||
for poly in me.polygons:
|
||||
# Get loop indices for this polygon
|
||||
loops = poly.loop_indices
|
||||
@@ -508,7 +523,8 @@ def generate_patch_texture(
|
||||
nx=40, ny=30, r=0.05,
|
||||
texture_width=2048, texture_height=2048,
|
||||
output_path=None, uv_centre=None, cross_length=40,
|
||||
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5
|
||||
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
|
||||
uv_layer=None, image_name="PatchTexture"
|
||||
):
|
||||
if not output_path:
|
||||
raise ValueError("output_path is required (absolute PNG path)")
|
||||
@@ -542,7 +558,7 @@ def generate_patch_texture(
|
||||
scene, depsgraph, cam, obj,
|
||||
u_start, u_end, v_start, v_end,
|
||||
frame_width, frame_height,
|
||||
edge_sample_grid=edge_sample_grid
|
||||
edge_sample_grid=edge_sample_grid, uv_layer=uv_layer
|
||||
)
|
||||
if uv_poly is None:
|
||||
patch_uv_polygons.append(None)
|
||||
@@ -558,7 +574,7 @@ def generate_patch_texture(
|
||||
print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.")
|
||||
|
||||
# Create image – start with black background
|
||||
img_name = "PatchTexture"
|
||||
img_name = image_name
|
||||
if img_name in bpy.data.images:
|
||||
bpy.data.images.remove(bpy.data.images[img_name])
|
||||
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
|
||||
@@ -568,7 +584,9 @@ def generate_patch_texture(
|
||||
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
||||
|
||||
# Rasterize all mesh UVs in white (visible + non‑visible parts)
|
||||
rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
|
||||
rasterize_mesh_uvs(
|
||||
obj, pixels, texture_width, texture_height, color=(1,1,1,1), uv_layer=uv_layer
|
||||
)
|
||||
|
||||
# Now draw the visible patches on top
|
||||
idx = 0
|
||||
@@ -858,7 +876,14 @@ def parse_args(argv):
|
||||
"--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(
|
||||
"--output", type=Path, default=None,
|
||||
help="Absolute output PNG for a manual single-target run (needs --uv-map)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--uv-map", dest="uv_map", default=None,
|
||||
help="Mesh UV map name for a manual single-target run (with --output)",
|
||||
)
|
||||
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)")
|
||||
@@ -913,20 +938,68 @@ def _as_color(value, label):
|
||||
return tuple(rgba)
|
||||
|
||||
|
||||
def _as_output_path(raw, label="output"):
|
||||
if raw is None or raw == "":
|
||||
raise ConfigError(f"{label}: a PNG path is required")
|
||||
if not isinstance(raw, (str, Path)):
|
||||
raise ConfigError(f"{label}: must be a path (got {raw!r})")
|
||||
path = Path(raw).expanduser()
|
||||
if not path.is_absolute():
|
||||
raise ConfigError(f"{label} must be an absolute path (got {raw!r})")
|
||||
if path.suffix.lower() != ".png":
|
||||
raise ConfigError(f"{label} must be a .png file (got {raw!r})")
|
||||
return path
|
||||
|
||||
|
||||
def _resolve_targets(args, job):
|
||||
"""Return the ordered ``[(uv_map, output_path)]`` targets from the job or flags."""
|
||||
if args.job is not None:
|
||||
if args.output is not None or args.uv_map is not None:
|
||||
raise ConfigError("--output/--uv-map cannot be combined with --job")
|
||||
entries = job.get("outputs")
|
||||
if not isinstance(entries, list) or not entries:
|
||||
raise ConfigError("job 'outputs' must be a non-empty list of {uv_map, output}")
|
||||
targets = []
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, dict):
|
||||
raise ConfigError(f"job outputs[{index}] must be a mapping")
|
||||
uv_map = entry.get("uv_map")
|
||||
if not isinstance(uv_map, str) or not uv_map.strip():
|
||||
raise ConfigError(
|
||||
f"job outputs[{index}]: 'uv_map' must be a non-empty string (got {uv_map!r})"
|
||||
)
|
||||
targets.append(
|
||||
(uv_map, _as_output_path(entry.get("output"), f"job outputs[{index}].output"))
|
||||
)
|
||||
else:
|
||||
if args.uv_map is None or args.output is None:
|
||||
raise ConfigError(
|
||||
"Provide --job <path.json>, or both --uv-map <name> and --output <file.png>"
|
||||
)
|
||||
targets = [(args.uv_map, _as_output_path(args.output))]
|
||||
|
||||
seen_names = set()
|
||||
seen_paths = {}
|
||||
for uv_map, path in targets:
|
||||
if uv_map in seen_names:
|
||||
raise ConfigError(f"duplicate UV map {uv_map!r} in outputs")
|
||||
seen_names.add(uv_map)
|
||||
key = str(path).lower()
|
||||
if key in seen_paths:
|
||||
raise ConfigError(
|
||||
f"duplicate output path for {seen_paths[key]!r} and {uv_map!r}: {path}"
|
||||
)
|
||||
seen_paths[key] = uv_map
|
||||
return targets
|
||||
|
||||
|
||||
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})")
|
||||
targets = _resolve_targets(args, job)
|
||||
|
||||
objects = job.get("objects") or {}
|
||||
if not isinstance(objects, dict):
|
||||
@@ -984,7 +1057,7 @@ def _resolve_config(args):
|
||||
)
|
||||
|
||||
return {
|
||||
"output": output,
|
||||
"outputs": targets,
|
||||
"mesh": str(mesh),
|
||||
"camera": str(camera),
|
||||
"width": width,
|
||||
@@ -1010,21 +1083,55 @@ def _require_object(name, expected_type, label):
|
||||
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."
|
||||
)
|
||||
def _prepare_mesh_object(name):
|
||||
"""Return the named mesh object in Object mode so its UV data is readable.
|
||||
|
||||
source_blend may be saved with the object in Edit mode, where UV layer data
|
||||
reads as empty. The switch is in memory only — this script never saves the
|
||||
.blend file.
|
||||
"""
|
||||
obj = _require_object(name, "MESH", "Mesh")
|
||||
if obj.mode != "OBJECT":
|
||||
print(f"Switching {obj.name!r} from {obj.mode} to OBJECT (in memory only)")
|
||||
try:
|
||||
for other in bpy.context.view_layer.objects:
|
||||
other.select_set(False)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
except RuntimeError as exc:
|
||||
raise SceneError(f"Could not leave Edit mode for {obj.name!r}: {exc}") from exc
|
||||
return obj
|
||||
|
||||
|
||||
def _require_uv_layer(obj, name):
|
||||
"""Return the named UV layer, or fail listing the available maps."""
|
||||
mesh = obj.data
|
||||
for layer in mesh.uv_layers:
|
||||
if layer.name == name:
|
||||
if len(layer.data) == 0:
|
||||
raise SceneError(
|
||||
f"UV map {name!r} on mesh {obj.name!r} has no loop data. UV unwrapping "
|
||||
"is still an in-app Blender step: unwrap the mesh in the GUI, save the "
|
||||
".blend, then re-run orisys-design texture."
|
||||
)
|
||||
return layer
|
||||
available = ", ".join(sorted(layer.name for layer in mesh.uv_layers)) or "none"
|
||||
raise SceneError(
|
||||
f"Mesh {obj.name!r} has no UV map named {name!r} (available: {available}). UV "
|
||||
"unwrapping is still an in-app Blender step: unwrap the mesh in the GUI, save the "
|
||||
".blend, then re-run orisys-design texture."
|
||||
)
|
||||
|
||||
|
||||
def _uv_centre(uv_layer):
|
||||
"""UV bounding-box centre for one UV map."""
|
||||
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})")
|
||||
print(f"UV centre [{uv_layer.name}]: ({centre[0]:.4f}, {centre[1]:.4f})")
|
||||
return centre
|
||||
|
||||
|
||||
@@ -1036,50 +1143,76 @@ def main():
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
output = config["output"]
|
||||
# Validate every target before rendering anything so a partial pair is never
|
||||
# produced: the mesh mode, both UV maps and both output paths must be usable.
|
||||
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()
|
||||
|
||||
try:
|
||||
obj = _require_object(config["mesh"], "MESH", "Mesh")
|
||||
obj = _prepare_mesh_object(config["mesh"])
|
||||
cam = _require_object(config["camera"], "CAMERA", "Camera")
|
||||
uv_centre = _uv_centre(obj)
|
||||
targets = [
|
||||
(uv_map, output, _require_uv_layer(obj, uv_map))
|
||||
for uv_map, output in config["outputs"]
|
||||
]
|
||||
except SceneError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
for uv_map, output, _layer in targets:
|
||||
try:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"ERROR: cannot create output directory for UV map {uv_map!r} "
|
||||
f"({output.parent}): {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
scene = bpy.context.scene
|
||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||
|
||||
if config["seed"] is not None:
|
||||
np.random.seed(config["seed"])
|
||||
print(f"Using colour seed {config['seed']}")
|
||||
# One RNG snapshot is replayed for every target, so the same physical patch
|
||||
# keeps the same colour in each UV layout (and reruns reproduce when seeded).
|
||||
rng_state = np.random.get_state()
|
||||
|
||||
start_time = time.time()
|
||||
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=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"],
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"ERROR: texture generation failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
for uv_map, output, uv_layer in targets:
|
||||
np.random.set_state(rng_state)
|
||||
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
||||
print(f"--- UV map {uv_map!r} -> {output}")
|
||||
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=str(output),
|
||||
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_{uv_map}",
|
||||
)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"ERROR: texture generation failed for UV map {uv_map!r}: {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
|
||||
if not output.is_file() or output.stat().st_size == 0:
|
||||
print(
|
||||
f"ERROR: texture was not written for UV map {uv_map!r}: {output}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"Texture complete: {output}")
|
||||
print(f"Texture complete: {len(targets)} file(s)")
|
||||
for uv_map, output, _layer in targets:
|
||||
print(f" [{uv_map}] {output} ({output.stat().st_size} bytes)")
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user