adde produce + compute texture export

This commit is contained in:
ykcheungab
2026-09-18 11:25:09 +08:00
parent 026f495f77
commit b1815b15ea
9 changed files with 503 additions and 161 deletions
+210 -77
View File
@@ -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