initial commit

This commit is contained in:
ykcheungab
2026-09-16 21:04:28 +08:00
commit a29ca6846c
31 changed files with 2537 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
"""Deprecated interactive export helpers.
Use the headless script instead:
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/export.py -- \\
--output <abs/output_dir> --mesh sensing_surface --cam cam_sensor --origin origin_sensor
Or: orisys-design export sensors/finger/r01 --profile compute
"""
raise SystemExit(
"blender_export.py is deprecated. Use blender/scripts/export.py (headless) "
"or: orisys-design export sensors/finger/r01 --profile compute"
)
+281
View File
@@ -0,0 +1,281 @@
"""Blender headless nominal compute export (YAML-free).
The host CLI (orisys-design export) 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/export.py -- \\
--job <abs/build/export/finger/r01/_export_job.json>
Manual overrides (optional, win over job values):
--output DIR --mesh sensing_surface --cam cam_sensor --origin origin_sensor
Writes only the compute payload (mesh.npz, camera_intrinsics.npz, world_to_cam.npy)
under the job's output directory. Metadata such as _export_job.json and
manifest.json live one level above the payload directory.
"""
# pyright: reportMissingImports=false
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import bmesh
import bpy
import numpy as np
from mathutils import Matrix
_DEFAULT_OBJECTS = {
"sensing_surface": "sensing_surface",
"cam_sensor": "cam_sensor",
"origin_sensor": "origin_sensor",
}
def _argv_after_double_dash() -> list[str]:
if "--" not in sys.argv:
return []
return sys.argv[sys.argv.index("--") + 1 :]
def _require_object(name: str, expected_type: str | None = None) -> bpy.types.Object:
obj = bpy.data.objects.get(name)
if obj is None:
raise RuntimeError(f"Object not found: {name!r}")
if expected_type is not None and obj.type != expected_type:
raise RuntimeError(
f"Object {name!r} has type {obj.type!r}, expected {expected_type!r}"
)
return obj
def _load_job(path: Path) -> tuple[str | None, dict]:
"""Load output path and objects map from a JSON export job."""
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"Export job must be a JSON object: {path}")
objects = data.get("objects") or {}
if not isinstance(objects, dict):
raise ValueError(f"Export job 'objects' must be a mapping: {path}")
output = data.get("output")
return (str(output) if output else None), objects
def export_camera_intrinsics(camera_obj: bpy.types.Object, scene: bpy.types.Scene):
"""Return OpenCV-style K (3x3) and render width/height from Blender camera."""
cam = camera_obj.data
render = scene.render
res_x = int(render.resolution_x)
res_y = int(render.resolution_y)
if cam.sensor_fit == "HORIZONTAL":
sensor_width = float(cam.sensor_width)
sensor_height = sensor_width / (res_x / res_y)
else:
sensor_height = float(cam.sensor_height)
sensor_width = sensor_height * (res_x / res_y)
focal = float(cam.lens)
pixel_width = sensor_width / res_x
pixel_height = sensor_height / res_y
fx = focal / pixel_width
fy = focal / pixel_height
cx = res_x / 2.0
cy = res_y / 2.0
K = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]], dtype=np.float64)
return K, res_x, res_y
def _triangulated_mesh(
obj: bpy.types.Object,
) -> tuple[bpy.types.Mesh, Matrix, object]:
"""Return evaluated triangulated mesh, world matrix, and cleanup callback."""
depsgraph = bpy.context.evaluated_depsgraph_get()
eval_obj = obj.evaluated_get(depsgraph)
mesh = eval_obj.to_mesh()
world = eval_obj.matrix_world.copy()
bm = bmesh.new()
try:
bm.from_mesh(mesh)
bmesh.ops.triangulate(bm, faces=bm.faces[:])
bm.to_mesh(mesh)
finally:
bm.free()
mesh.update()
def _cleanup() -> None:
eval_obj.to_mesh_clear()
return mesh, world, _cleanup
def export_mesh_in_sensor_frame(
obj: bpy.types.Object,
t_sensor_from_world: Matrix,
filepath: Path,
) -> tuple[int, int]:
"""Export triangulated mesh vertices in the sensor frame."""
mesh, world, cleanup = _triangulated_mesh(obj)
try:
if not mesh.uv_layers.active:
raise RuntimeError(f"Mesh {obj.name!r} has no active UV layer")
t_sensor_from_object = t_sensor_from_world @ world
vertices = np.array(
[t_sensor_from_object @ v.co for v in mesh.vertices],
dtype=np.float32,
)
faces_list = [tuple(poly.vertices) for poly in mesh.polygons]
if not faces_list:
raise RuntimeError(f"Mesh {obj.name!r} has no faces")
if any(len(face) != 3 for face in faces_list):
raise RuntimeError(f"Mesh {obj.name!r} is not fully triangulated after export")
faces = np.asarray(faces_list, dtype=np.int32)
uv_layer = mesh.uv_layers.active.data
uv_data = np.array([uv_layer[i].uv[:] for i in range(len(uv_layer))], dtype=np.float32)
loop_to_vertex = np.array(
[mesh.loops[i].vertex_index for i in range(len(mesh.loops))],
dtype=np.int32,
)
np.savez_compressed(
filepath,
vertices=vertices,
faces=faces,
uv_data=uv_data,
loop_to_vertex=loop_to_vertex,
)
print(f"Mesh exported to {filepath} ({len(vertices)} verts, {len(faces)} tris)")
return int(len(vertices)), int(len(faces))
finally:
cleanup()
def export_camera_pose_sensor_frame(
camera_obj: bpy.types.Object,
t_sensor_from_world: Matrix,
filepath: Path,
) -> None:
"""Save world_to_cam.npy where 'world' means the sensor frame (origin_sensor)."""
t_sensor_from_cam = t_sensor_from_world @ camera_obj.matrix_world
cam_from_sensor = np.linalg.inv(np.array(t_sensor_from_cam, dtype=np.float64))
np.save(filepath, cam_from_sensor.astype(np.float32))
print(f"Camera pose exported to {filepath}")
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="export.py")
parser.add_argument(
"--job",
type=Path,
default=None,
help="JSON job written by orisys-design (output + object names)",
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="Absolute output directory (overrides job)",
)
parser.add_argument(
"--mesh",
default=None,
help="Sensing surface mesh object name (overrides job)",
)
parser.add_argument(
"--cam",
default=None,
help="Sensor camera object name (overrides job)",
)
parser.add_argument(
"--origin",
default=None,
help="Sensor-frame empty object name (overrides job)",
)
return parser.parse_args(argv)
def _resolve_config(args: argparse.Namespace) -> tuple[Path, str, str, str]:
"""Merge --job JSON with explicit flag overrides. Explicit flags win."""
job_output: str | None = None
job_objects: dict = {}
if args.job is not None:
job_path = args.job.resolve()
if not job_path.is_file():
raise RuntimeError(f"Export job not found: {job_path}")
job_output, job_objects = _load_job(job_path)
output_raw = args.output or job_output
if not output_raw:
raise RuntimeError("Provide --job <path.json> and/or --output <dir>")
mesh = (
args.mesh
or job_objects.get("sensing_surface")
or _DEFAULT_OBJECTS["sensing_surface"]
)
cam = args.cam or job_objects.get("cam_sensor") or _DEFAULT_OBJECTS["cam_sensor"]
origin = (
args.origin
or job_objects.get("origin_sensor")
or _DEFAULT_OBJECTS["origin_sensor"]
)
return Path(output_raw).resolve(), str(mesh), str(cam), str(origin)
def main() -> int:
args = parse_args(_argv_after_double_dash())
try:
output_dir, mesh_name, cam_name, origin_name = _resolve_config(args)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
output_dir.mkdir(parents=True, exist_ok=True)
try:
mesh_obj = _require_object(mesh_name, "MESH")
cam_obj = _require_object(cam_name, "CAMERA")
origin_obj = _require_object(origin_name)
except RuntimeError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
t_sensor_from_world = origin_obj.matrix_world.inverted()
scene = bpy.context.scene
mesh_path = output_dir / "mesh.npz"
intrinsics_path = output_dir / "camera_intrinsics.npz"
pose_path = output_dir / "world_to_cam.npy"
try:
export_mesh_in_sensor_frame(mesh_obj, t_sensor_from_world, mesh_path)
K, width, height = export_camera_intrinsics(cam_obj, scene)
np.savez(
intrinsics_path,
K=K,
width=np.int32(width),
height=np.int32(height),
)
print(f"Intrinsics exported to {intrinsics_path} ({width}x{height})")
export_camera_pose_sensor_frame(cam_obj, t_sensor_from_world, pose_path)
except Exception as exc:
print(f"ERROR: export failed: {exc}", file=sys.stderr)
return 1
print(f"Export complete: {output_dir}")
print(f" {mesh_path.name}")
print(f" {intrinsics_path.name}")
print(f" {pose_path.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+917
View File
@@ -0,0 +1,917 @@
"""
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 bpy
import numpy as np
import os
import time
from mathutils import Vector
# ----------------------------------------------------------------------
# Common Utilities
# ----------------------------------------------------------------------
def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
"""
Draw a cross (plus sign) in pixel space.
cx_uv, cy_uv: centre of cross in UV coordinates (0‑1), origin at bottom‑left.
length_px: total length of each arm (pixels).
thickness_px: line thickness (odd number gives symmetric result).
"""
# Convert UV centre to pixel coordinates (y from bottom)
cx = int(round(cx_uv * (width - 1)))
cy = int(round(cy_uv * (height - 1)))
half_len = length_px // 2
half_thick = thickness_px // 2
# Horizontal arm
x0 = max(0, cx - half_len)
x1 = min(width - 1, cx + half_len)
y0 = max(0, cy - half_thick)
y1 = min(height - 1, cy + half_thick)
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
idx = (y * width + x) * 4
pixels[idx:idx+4] = color
# Vertical arm
x0 = max(0, cx - half_thick)
x1 = min(width - 1, cx + half_thick)
y0 = max(0, cy - half_len)
y1 = min(height - 1, cy + half_len)
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
idx = (y * width + x) * 4
pixels[idx:idx+4] = color
def fill_background(pixels, width, height, color):
"""Fill the entire image with a solid RGBA color (0-1 floats)."""
r, g, b, a = color
for y in range(height):
for x in range(width):
idx = (y * width + x) * 4
pixels[idx] = r
pixels[idx+1] = g
pixels[idx+2] = b
pixels[idx+3] = a
def fill_polygon(image, pixels, width, height, polygon, color):
"""Fill a polygon in the pixel array (flattened, RGBA)."""
poly = [(int(round(x)), int(round(y))) for x, y in polygon]
if len(poly) < 3:
return
min_x = max(0, min(p[0] for p in poly))
max_x = min(width-1, max(p[0] for p in poly))
min_y = max(0, min(p[1] for p in poly))
max_y = min(height-1, max(p[1] for p in poly))
for y in range(min_y, max_y + 1):
intersections = []
for i in range(len(poly)):
x1, y1 = poly[i]
x2, y2 = poly[(i+1) % len(poly)]
if y1 == y2:
continue
if (y1 <= y < y2) or (y2 <= y < y1):
t = (y - y1) / (y2 - y1)
x = x1 + t * (x2 - x1)
intersections.append(x)
intersections.sort()
for k in range(0, len(intersections), 2):
if k+1 >= len(intersections):
break
x_start = int(round(intersections[k]))
x_end = int(round(intersections[k+1]))
x_start = max(0, min(width-1, x_start))
x_end = max(0, min(width-1, x_end))
for x in range(x_start, x_end + 1):
idx = (y * width + x) * 4
pixels[idx:idx+4] = color
def fill_polygon_value(grid, width, height, polygon, value):
"""Fill a polygon into a 2D scalar grid."""
poly = [(int(round(x)), int(round(y))) for x, y in polygon]
if len(poly) < 3:
return
min_x = max(0, min(p[0] for p in poly))
max_x = min(width - 1, max(p[0] for p in poly))
min_y = max(0, min(p[1] for p in poly))
max_y = min(height - 1, max(p[1] for p in poly))
for y in range(min_y, max_y + 1):
intersections = []
for i in range(len(poly)):
x1, y1 = poly[i]
x2, y2 = poly[(i + 1) % len(poly)]
if y1 == y2:
continue
if (y1 <= y < y2) or (y2 <= y < y1):
t = (y - y1) / (y2 - y1)
x = x1 + t * (x2 - x1)
intersections.append(x)
intersections.sort()
for k in range(0, len(intersections), 2):
if k + 1 >= len(intersections):
break
x_start = int(round(intersections[k]))
x_end = int(round(intersections[k + 1]))
x_start = max(0, min(width - 1, x_start))
x_end = max(0, min(width - 1, x_end))
grid[y, x_start:x_end + 1] = value
def unwrap_periodic_values(values):
"""
Unwrap periodic UV coordinates so points straddling the 0/1 seam stay local.
"""
arr = np.asarray(values, dtype=np.float64).copy()
if arr.size == 0:
return arr
if arr.max() - arr.min() > 0.5:
arr[arr < 0.5] += 1.0
return arr
def wrap_uv_polygon_for_raster(uv_poly):
"""
Return a seam-aware version of a UV polygon.
UV islands may cross the 0/1 seam. If we rasterize those raw coordinates as a
single polygon, Blender's repeated UVs turn into a long diagonal triangle
across the texture. We unwrap seam-crossing polygons first, then draw wrapped
copies in the texture tile(s) they overlap.
"""
if len(uv_poly) < 3:
return []
uv_arr = np.array([(float(u), float(v)) for u, v in uv_poly], dtype=np.float64)
uv_arr[:, 0] = unwrap_periodic_values(uv_arr[:, 0])
uv_arr[:, 1] = unwrap_periodic_values(uv_arr[:, 1])
return [tuple(pt) for pt in uv_arr]
def uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
"""
Convert one UV polygon into one or more pixel polygons, duplicating seam-
crossing polygons into adjacent tiles so rasterization stays local.
"""
unwrapped = wrap_uv_polygon_for_raster(uv_poly)
if len(unwrapped) < 3:
return []
u_vals = [u for u, _ in unwrapped]
v_vals = [v for _, v in unwrapped]
u_shift_start = int(np.floor(min(u_vals)))
u_shift_end = int(np.floor(max(u_vals)))
v_shift_start = int(np.floor(min(v_vals)))
v_shift_end = int(np.floor(max(v_vals)))
pixel_polys = []
for u_shift in range(u_shift_start, u_shift_end + 1):
for v_shift in range(v_shift_start, v_shift_end + 1):
shifted = [(u - u_shift, v - v_shift) for u, v in unwrapped]
shifted_u = [u for u, _ in shifted]
shifted_v = [v for _, v in shifted]
if max(shifted_u) < 0.0 or min(shifted_u) > 1.0:
continue
if max(shifted_v) < 0.0 or min(shifted_v) > 1.0:
continue
pixel_polys.append([
(u * (tex_width - 1), v * (tex_height - 1))
for u, v in shifted
])
return pixel_polys
def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color):
"""Filled ellipse with semi‑axes a,b (pixels), rotation angle (rad)."""
cos_a = np.cos(angle)
sin_a = np.sin(angle)
rx = abs(a * cos_a) + abs(b * sin_a)
ry = abs(a * sin_a) + abs(b * cos_a)
x_min = int(max(0, cx - rx))
x_max = int(min(width-1, cx + rx))
y_min = int(max(0, cy - ry))
y_max = int(min(height-1, cy + ry))
for y in range(y_min, y_max + 1):
for x in range(x_min, x_max + 1):
dx = x - cx
dy = y - cy
u = dx * cos_a + dy * sin_a
v = -dx * sin_a + dy * cos_a
if (u/a)**2 + (v/b)**2 <= 1.0:
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."""
me = obj.data
local_loc = obj.matrix_world.inverted() @ location
poly = me.polygons[face_index]
vert_indices = poly.vertices
loop_indices = poly.loop_indices
if len(vert_indices) < 3:
return None
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
def tri_area(a, b, c):
return 0.5 * ((b - a).cross(c - a)).length
total_area = tri_area(v0, v1, v2)
if total_area == 0:
return None
w0 = tri_area(local_loc, v1, v2) / total_area
w1 = tri_area(local_loc, v2, v0) / total_area
w2 = 1.0 - w0 - w1
uv = w0 * uv0 + w1 * uv1 + w2 * uv2
return Vector((uv.x, uv.y))
def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
"""Camera raycast through normalised sensor coordinates (0‑1)."""
sx = (u_ndc - 0.5) * frame_width
sy = (v_ndc - 0.5) * frame_height
direction_cam = Vector((sx, sy, -1.0))
direction_world = cam.matrix_world.to_quaternion() @ direction_cam
direction_world.normalize()
origin = cam.matrix_world.translation
hit, location, normal, face_index, hit_obj, matrix = scene.ray_cast(
depsgraph, origin, direction_world)
if hit and hit_obj == obj:
return location, face_index
return None, None
def raycast_uv_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
"""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)
def convex_hull_uv(points):
"""Return UV points ordered around their 2D convex hull."""
unwrapped = wrap_uv_polygon_for_raster([(p.x, p.y) for p in points])
unique = sorted(set(unwrapped))
if len(unique) < 3:
return None
def cross(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
lower = []
for p in unique:
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
lower.pop()
lower.append(p)
upper = []
for p in reversed(unique):
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
upper.pop()
upper.append(p)
hull = lower[:-1] + upper[:-1]
if len(hull) < 3:
return None
return [Vector((u, v)) for u, v in hull]
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
):
"""
Build a UV polygon for one camera-space patch.
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.
"""
corners_ndc = [
(u_start, v_start),
(u_end, v_start),
(u_end, v_end),
(u_start, v_end),
]
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
)
if uv is not None:
corner_uvs.append(uv)
if len(corner_uvs) >= 3:
return corner_uvs
sampled_uvs = list(corner_uvs)
for sy in range(edge_sample_grid):
v_ndc = v_start + (v_end - v_start) * sy / (edge_sample_grid - 1)
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
)
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)):
"""
Fill all triangles of the active UV map with 'color' (default white).
This marks every part of the mesh, visible or not, as white.
"""
me = obj.data
uv_layer = me.uv_layers.active
if not uv_layer:
print("Warning: No active UV layer – mesh rasterization skipped.")
return
uv_data = uv_layer.data
for poly in me.polygons:
# Get loop indices for this polygon
loops = poly.loop_indices
# Build UV polygon
uv_poly = []
for loop_idx in loops:
uv = uv_data[loop_idx].uv
uv_poly.append((uv.x, uv.y))
# Convert UV (0‑1) to pixel coordinates
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
def rasterize_mesh_mask(obj, tex_width, tex_height):
"""Rasterize the active UV map to a boolean mesh mask."""
mask = np.zeros((tex_height, tex_width), dtype=bool)
me = obj.data
uv_layer = me.uv_layers.active
if not uv_layer:
return mask
uv_data = uv_layer.data
for poly in me.polygons:
uv_poly = []
for loop_idx in poly.loop_indices:
uv = uv_data[loop_idx].uv
uv_poly.append((uv.x, uv.y))
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
fill_polygon_value(mask, tex_width, tex_height, pixel_poly, True)
return mask
def paint_mask_color(pixels, width, height, mask, color):
"""Paint all True pixels of a boolean mask with an RGBA color."""
r, g, b, a = color
ys, xs = np.nonzero(mask)
for y, x in zip(ys, xs):
idx = (y * width + x) * 4
pixels[idx:idx+4] = (r, g, b, a)
def complete_checker_gaps(pixels, width, height, mesh_mask, patch_state, max_gap_px=6):
"""
Fill small white slivers inside the mesh by expanding nearby checker colors.
`patch_state`: -1 = unassigned, 0 = black patch, 1 = white patch.
"""
for _ in range(max_gap_px):
candidate = mesh_mask & (patch_state < 0)
if not np.any(candidate):
break
black_counts = np.zeros_like(patch_state, dtype=np.uint8)
white_counts = np.zeros_like(patch_state, dtype=np.uint8)
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
if dx == 0 and dy == 0:
continue
src_y0 = max(0, -dy)
src_y1 = height - max(0, dy)
src_x0 = max(0, -dx)
src_x1 = width - max(0, dx)
dst_y0 = max(0, dy)
dst_y1 = height - max(0, -dy)
dst_x0 = max(0, dx)
dst_x1 = width - max(0, -dx)
neighbor_vals = patch_state[src_y0:src_y1, src_x0:src_x1]
black_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 0)
white_counts[dst_y0:dst_y1, dst_x0:dst_x1] += (neighbor_vals == 1)
fillable = candidate & ((black_counts + white_counts) > 0)
if not np.any(fillable):
break
patch_state[fillable] = np.where(
black_counts[fillable] > white_counts[fillable], 0, 1
).astype(np.int8)
paint_mask_color(pixels, width, height, patch_state == 0, (0.0, 0.0, 0.0, 1.0))
paint_mask_color(pixels, width, height, patch_state == 1, (1.0, 1.0, 1.0, 1.0))
# ----------------------------------------------------------------------
# Color generation (for patch mode)
# ----------------------------------------------------------------------
def merge_intervals(intervals):
if not intervals: return []
intervals.sort(key=lambda x: x[0])
merged = [intervals[0]]
for current in intervals[1:]:
last = merged[-1]
if current[0] <= last[1]:
merged[-1] = (last[0], max(last[1], current[1]))
else:
merged.append(current)
return merged
def get_allowed_intervals(neighbor_values, delta):
forbidden = []
for c in neighbor_values:
low = max(0, c - delta)
high = min(1, c + delta)
forbidden.append((low, high))
merged = merge_intervals(forbidden)
allowed = []
if merged:
if merged[0][0] > 0:
allowed.append((0, merged[0][0]))
for i in range(1, len(merged)):
allowed.append((merged[i-1][1], merged[i][0]))
if merged[-1][1] < 1:
allowed.append((merged[-1][1], 1))
else:
allowed.append((0, 1))
return allowed
def sample_from_allowed(allowed):
lengths = [end - start for start, end in allowed]
total_length = sum(lengths)
if total_length <= 0: return None
point = np.random.rand() * total_length
cumulative = 0
for (start, end), length in zip(allowed, lengths):
cumulative += length
if point <= cumulative:
return np.random.uniform(start, end)
return allowed[-1][0]
def sample_channel(neighbor_values, delta):
allowed = get_allowed_intervals(neighbor_values, delta)
if not allowed: return None
return sample_from_allowed(allowed)
def generate_valid_color(neighbors, r):
delta = np.sqrt(r)
color = np.zeros(3)
for channel in range(3):
neighbor_channel = [n[channel] for n in neighbors]
sampled = sample_channel(neighbor_channel, delta)
if sampled is None:
sampled = np.random.rand()
color[channel] = sampled
return color
def checker_patch_color(i, j):
"""Return a black/white checker color for patch index (i, j)."""
if (i + j) % 2 == 0:
return np.array([1.0, 1.0, 1.0], dtype=np.float32)
return np.array([0.0, 0.0, 0.0], dtype=np.float32)
# ----------------------------------------------------------------------
# Mode 1: Colored Patches
# ----------------------------------------------------------------------
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,
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5
):
cam_data = cam.data
render = scene.render
res_x = render.resolution_x
res_y = render.resolution_y
if cam_data.sensor_fit == 'HORIZONTAL':
sensor_width = cam_data.sensor_width
sensor_height = sensor_width / (res_x / res_y)
else:
sensor_height = cam_data.sensor_height
sensor_width = sensor_height * (res_x / res_y)
focal = cam_data.lens
frame_width = sensor_width / focal
frame_height = sensor_height / focal
edge_sample_grid = max(2, int(edge_sample_grid))
patch_uv_polygons = []
patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
skipped_patches = 0
print(f"Raycasting {nx*ny} patches...")
for j in range(ny):
v_start = j / ny
v_end = (j+1) / ny
for i in range(nx):
u_start = i / nx
u_end = (i+1) / nx
uv_poly = sample_patch_uv_polygon(
scene, depsgraph, cam, obj,
u_start, u_end, v_start, v_end,
frame_width, frame_height,
edge_sample_grid=edge_sample_grid
)
if uv_poly is None:
patch_uv_polygons.append(None)
skipped_patches += 1
continue
patch_uv_polygons.append(uv_poly)
neighbors = []
if j > 0: neighbors.append(patch_colors[j-1, i])
if i > 0: neighbors.append(patch_colors[j, i-1])
color = np.random.rand(3) if len(neighbors)==0 else generate_valid_color(neighbors, r)
patch_colors[j, i] = color
print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.")
# Create image – start with black background
img_name = "PatchTexture"
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)
pixels = list(image.pixels)
# Fill entire image with black (no mesh areas)
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))
# Now draw the visible patches on top
idx = 0
for j in range(ny):
for i in range(nx):
uv_poly = patch_uv_polygons[idx]; idx += 1
if uv_poly is None: continue
color = patch_colors[j,i]
rgba = (color[0], color[1], color[2], 1.0)
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
# (after drawing all patches)
if uv_centre is not None:
draw_cross(pixels, texture_width, texture_height,
uv_centre[0], uv_centre[1],
cross_length, cross_thickness, cross_color)
image.pixels[:] = pixels
image.update()
abs_path = bpy.path.abspath(output_path)
image.filepath_raw = abs_path
image.file_format = 'PNG'
image.save()
print(f"Patch texture saved to {abs_path}")
return image
# ----------------------------------------------------------------------
# Mode 2: Black/White Checker Patches
# ----------------------------------------------------------------------
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,
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
gap_fill_px=6
):
cam_data = cam.data
render = scene.render
res_x = render.resolution_x
res_y = render.resolution_y
if cam_data.sensor_fit == 'HORIZONTAL':
sensor_width = cam_data.sensor_width
sensor_height = sensor_width / (res_x / res_y)
else:
sensor_height = cam_data.sensor_height
sensor_width = sensor_height * (res_x / res_y)
focal = cam_data.lens
frame_width = sensor_width / focal
frame_height = sensor_height / focal
edge_sample_grid = max(2, int(edge_sample_grid))
patch_uv_polygons = []
patch_colors = np.zeros((ny, nx, 3), dtype=np.float32)
skipped_patches = 0
print(f"Raycasting {nx*ny} checker patches...")
for j in range(ny):
v_start = j / ny
v_end = (j+1) / ny
for i in range(nx):
u_start = i / nx
u_end = (i+1) / nx
uv_poly = sample_patch_uv_polygon(
scene, depsgraph, cam, obj,
u_start, u_end, v_start, v_end,
frame_width, frame_height,
edge_sample_grid=edge_sample_grid
)
if uv_poly is None:
patch_uv_polygons.append(None)
skipped_patches += 1
continue
patch_uv_polygons.append(uv_poly)
patch_colors[j, i] = checker_patch_color(i, j)
print(f"Skipped {skipped_patches} checker patches with fewer than 3 mesh samples.")
img_name = "CheckerTexture"
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)
pixels = list(image.pixels)
mesh_mask = rasterize_mesh_mask(obj, texture_width, texture_height)
patch_state = np.full((texture_height, texture_width), -1, dtype=np.int8)
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
paint_mask_color(pixels, texture_width, texture_height, mesh_mask, (1,1,1,1))
idx = 0
for j in range(ny):
for i in range(nx):
uv_poly = patch_uv_polygons[idx]; idx += 1
if uv_poly is None:
continue
color = patch_colors[j,i]
rgba = (color[0], color[1], color[2], 1.0)
patch_value = 1 if np.mean(color) >= 0.5 else 0
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
fill_polygon_value(
patch_state,
texture_width,
texture_height,
pixel_poly,
patch_value,
)
complete_checker_gaps(
pixels, texture_width, texture_height, mesh_mask, patch_state, max_gap_px=gap_fill_px
)
if uv_centre is not None:
draw_cross(pixels, texture_width, texture_height,
uv_centre[0], uv_centre[1],
cross_length, cross_thickness, cross_color)
image.pixels[:] = pixels
image.update()
abs_path = bpy.path.abspath(output_path)
image.filepath_raw = abs_path
image.file_format = 'PNG'
image.save()
print(f"Checker texture saved to {abs_path}")
return image
# ----------------------------------------------------------------------
# Mode 3: Circular Dot Markers
# ----------------------------------------------------------------------
def generate_dot_texture(
obj, cam, scene, depsgraph,
dot_diameter_pixels=12,
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)
):
render = scene.render
res_x = render.resolution_x
res_y = render.resolution_y
cam_data = cam.data
if cam_data.sensor_fit == 'HORIZONTAL':
sensor_width = cam_data.sensor_width
sensor_height = sensor_width / (res_x / res_y)
else:
sensor_height = cam_data.sensor_height
sensor_width = sensor_height * (res_x / res_y)
focal = cam_data.lens
frame_width = sensor_width / focal
frame_height = sensor_height / focal
margin = spacing_pixels // 2
x_centers = np.arange(margin, res_x, spacing_pixels)
y_centers = np.arange(margin, res_y, spacing_pixels)
offset_pix = max(2, dot_diameter_pixels // 4)
du_ndc = offset_pix / res_x
dv_ndc = offset_pix / res_y
dots = [] # (cx_uv, cy_uv, a_uv, b_uv, angle_uv)
print(f"Raycasting {len(x_centers)*len(y_centers)} dot centers...")
for y_pix in y_centers:
v_ndc = y_pix / res_y
for x_pix in x_centers:
u_ndc = x_pix / res_x
center_loc, face_idx = raycast_from_camera(scene, depsgraph, cam, obj,
u_ndc, v_ndc, frame_width, frame_height)
if center_loc is None: continue
center_uv = get_uv_from_face(obj, face_idx, center_loc)
if center_uv is None: continue
offsets = [
( u_ndc + du_ndc, v_ndc),
( u_ndc - du_ndc, v_ndc),
( u_ndc, v_ndc + dv_ndc),
( u_ndc, v_ndc - dv_ndc)
]
uv_offsets = []
for (u_off, v_off) in offsets:
uv_off = raycast_uv_from_camera(
scene, depsgraph, cam, obj,
u_off, v_off, frame_width, frame_height
)
uv_offsets.append(uv_off)
scale_u = 1.0 / res_x
scale_v = 1.0 / res_y
r_pix = dot_diameter_pixels / 2.0
right_uv, left_uv, up_uv, down_uv = uv_offsets
if right_uv is not None and left_uv is not None:
du_dx = (right_uv - left_uv) / (2 * offset_pix * scale_u)
a_uv = r_pix * du_dx.length * scale_u
elif right_uv is not None:
du_dx = (right_uv - center_uv) / (offset_pix * scale_u)
a_uv = r_pix * du_dx.length * scale_u
elif left_uv is not None:
du_dx = (center_uv - left_uv) / (offset_pix * scale_u)
a_uv = r_pix * du_dx.length * scale_u
else:
du_dx = Vector((1.0, 0.0))
a_uv = r_pix / texture_width
if up_uv is not None and down_uv is not None:
dv_dx = (up_uv - down_uv) / (2 * offset_pix * scale_v)
b_uv = r_pix * dv_dx.length * scale_v
elif up_uv is not None:
dv_dx = (up_uv - center_uv) / (offset_pix * scale_v)
b_uv = r_pix * dv_dx.length * scale_v
elif down_uv is not None:
dv_dx = (center_uv - down_uv) / (offset_pix * scale_v)
b_uv = r_pix * dv_dx.length * scale_v
else:
b_uv = r_pix / texture_height
a_uv = max(a_uv, 1.0 / texture_width)
b_uv = max(b_uv, 1.0 / texture_height)
angle = np.arctan2(du_dx.y, du_dx.x) if du_dx.length > 0 else 0.0
dots.append((center_uv.x, center_uv.y, a_uv, b_uv, angle))
print(f"Found {len(dots)} valid dots.")
# Create image
img_name = "DotTexture"
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)
pixels = list(image.pixels)
# Black background (no mesh)
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
# White for all mesh areas
rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
# Draw dots
for (cx_uv, cy_uv, a_uv, b_uv, angle_uv) in dots:
cx_px = cx_uv * (texture_width - 1)
cy_px = cy_uv * (texture_height - 1)
a_px = a_uv * texture_width
b_px = b_uv * texture_height
draw_ellipse(pixels, texture_width, texture_height,
cx_px, cy_px, a_px, b_px, angle_uv, dot_color)
if uv_centre is not None:
draw_cross(pixels, texture_width, texture_height,
uv_centre[0], uv_centre[1],
cross_length, cross_thickness, cross_color)
image.pixels[:] = pixels
image.update()
abs_path = bpy.path.abspath(output_path)
image.filepath_raw = abs_path
image.file_format = 'PNG'
image.save()
print(f"Dot texture saved to {abs_path}")
return image
# ----------------------------------------------------------------------
# Main
# ----------------------------------------------------------------------
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",
}
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()
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.")
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 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":
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
)
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'.")
print(f"Completed in {time.time()-start_time:.2f} seconds.")
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
"""Blender headless scene validation (collections and naming).
Usage (from repo root):
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/validate.py -- sensors/finger/r01
Note: this script loads design.yaml with PyYAML. Prefer installing PyYAML into
Blender's Python, or run metadata-only checks via: orisys-design validate <revision>
"""
from __future__ import annotations
import sys
from pathlib import Path
import bpy
try:
import yaml
except ImportError:
yaml = None
def _argv_after_double_dash() -> list[str]:
if "--" not in sys.argv:
return []
return sys.argv[sys.argv.index("--") + 1 :]
def _load_design(revision_dir: Path) -> dict:
if yaml is None:
raise RuntimeError(
"PyYAML is not installed in Blender's Python. "
"Run: orisys-design validate <revision> without --blender, "
"or pip install PyYAML into Blender's Python."
)
with (revision_dir / "design.yaml").open(encoding="utf-8") as handle:
data = yaml.safe_load(handle)
if not isinstance(data, dict):
raise ValueError("design.yaml must be a mapping")
return data
def main() -> int:
args = _argv_after_double_dash()
if not args:
print("validate.py: pass revision directory after --", file=sys.stderr)
return 2
revision_dir = Path(args[0]).resolve()
try:
design = _load_design(revision_dir)
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
collections = design.get("collections", {})
objects = design.get("objects", {})
errors: list[str] = []
present = {col.name for col in bpy.data.collections}
for key, expected_name in collections.items():
if expected_name not in present:
errors.append(f"Missing collection {expected_name!r} (design key: {key})")
for key, expected_name in objects.items():
obj = bpy.data.objects.get(expected_name)
if obj is None:
errors.append(f"Missing object {expected_name!r} (design key: {key})")
if errors:
for message in errors:
print(f"ERROR: {message}", file=sys.stderr)
print(f"Present collections: {sorted(present)}", file=sys.stderr)
return 1
print(f"OK: Blender scene matches conventions for {revision_dir.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())