Files
orisys-sensor-design/blender/scripts/export.py
T
2026-09-23 10:01:08 +08:00

541 lines
18 KiB
Python

"""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 math
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, list[str] | None, dict | None]:
"""Load output path, objects map, housing collections and the optics stack."""
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")
housing = data.get("housing_collections")
if housing is not None:
if not isinstance(housing, list) or not all(
isinstance(name, str) for name in housing
):
raise ValueError(
f"Export job 'housing_collections' must be a list of names: {path}"
)
optics = data.get("optics")
if optics is not None:
if not isinstance(optics, dict) or not isinstance(optics.get("layers"), list):
raise ValueError(
f"Export job 'optics' must be a mapping with 'layers': {path}"
)
return (str(output) if output else None), objects, housing, optics
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 _housing_objects(collection_names: list[str]) -> list[bpy.types.Object]:
"""Unique mesh objects in the configured collections, in a stable order."""
found: dict[str, bpy.types.Object] = {}
for name in collection_names:
collection = bpy.data.collections.get(name)
if collection is None:
print(
f"WARNING: housing collection not found: {name!r}",
file=sys.stderr,
)
continue
for obj in collection.all_objects:
if obj.type == "MESH" and obj.name not in found:
found[obj.name] = obj
return list(found.values())
def export_housing_in_sensor_frame(
objects: list[bpy.types.Object],
t_sensor_from_world: Matrix,
filepath: Path,
) -> tuple[int, int, int]:
"""Export the housing meshes in the sensor frame, keeping part identity.
Parts are concatenated into one buffer -- the same schema family as
``mesh.npz`` -- with ``part_index`` linking every face to a ``part_names``
entry. Housing never goes into ``mesh.npz``: the SDK unroll reads that file's
vertices as the sensing surface.
"""
vertex_blocks: list[np.ndarray] = []
face_blocks: list[np.ndarray] = []
part_index: list[int] = []
part_names: list[str] = []
offset = 0
for obj in objects:
mesh, world, cleanup = _triangulated_mesh(obj)
try:
transform = t_sensor_from_world @ world
faces_list = [tuple(poly.vertices) for poly in mesh.polygons]
if not faces_list:
print(f"WARNING: housing object {obj.name!r} has no faces; skipped")
continue
if any(len(face) != 3 for face in faces_list):
raise RuntimeError(
f"Housing object {obj.name!r} is not fully triangulated after export"
)
vertex_blocks.append(
np.array([transform @ v.co for v in mesh.vertices], dtype=np.float32)
)
faces = np.asarray(faces_list, dtype=np.int32) + offset
face_blocks.append(faces)
part_index.extend([len(part_names)] * len(faces))
part_names.append(obj.name)
offset += len(mesh.vertices)
finally:
cleanup()
if vertex_blocks:
vertices = np.concatenate(vertex_blocks)
faces = np.concatenate(face_blocks)
else:
vertices = np.zeros((0, 3), dtype=np.float32)
faces = np.zeros((0, 3), dtype=np.int32)
names = (
np.array(part_names, dtype=np.str_)
if part_names
else np.array([""], dtype=np.str_)[:0]
)
np.savez_compressed(
filepath,
vertices=vertices,
faces=faces,
part_index=np.asarray(part_index, dtype=np.int32),
part_names=names,
)
print(
f"Housing exported to {filepath} "
f"({len(part_names)} part(s), {len(vertices)} verts, {len(faces)} tris)"
)
if not part_names:
print(
"WARNING: no housing meshes found; housing.npz is empty",
file=sys.stderr,
)
return len(part_names), int(len(vertices)), int(len(faces))
def _mesh_world(obj: bpy.types.Object):
"""Triangulated mesh plus its world matrix, with a cleanup callback."""
return _triangulated_mesh(obj)
def _measure_layer_thickness(
outer_obj: bpy.types.Object, inner_obj: bpy.types.Object
) -> dict[str, float | int | None]:
"""Thickness of one layer, by casting from the outer surface onto the inner.
Each outer vertex is sent inward along its negated vertex normal and the
distance to the inner surface is recorded, which measures the real layer
rather than the nearest-neighbour gap between two point clouds (that
overestimates wherever the surfaces are oblique to each other).
"""
from mathutils.bvhtree import BVHTree
inner_mesh, inner_world, inner_cleanup = _mesh_world(inner_obj)
try:
inner_vertices = [inner_world @ vertex.co for vertex in inner_mesh.vertices]
inner_faces = [tuple(poly.vertices) for poly in inner_mesh.polygons]
if not inner_faces:
return {"count": 0, "p05": None, "p50": None, "p95": None}
tree = BVHTree.FromPolygons(inner_vertices, inner_faces, all_triangles=True)
finally:
inner_cleanup()
outer_mesh, outer_world, outer_cleanup = _mesh_world(outer_obj)
try:
rotation = outer_world.to_3x3()
distances: list[float] = []
for vertex in outer_mesh.vertices:
origin = outer_world @ vertex.co
normal = rotation @ vertex.normal
if normal.length <= 0.0:
continue
normal.normalize()
hit = tree.ray_cast(origin - normal * 1e-4, -normal)
location = hit[0]
if location is None:
continue
distances.append(float((location - origin).length))
finally:
outer_cleanup()
if not distances:
return {"count": 0, "p05": None, "p50": None, "p95": None}
distances.sort()
def pick(fraction: float) -> float:
position = (len(distances) - 1) * fraction
low = int(math.floor(position))
high = int(math.ceil(position))
if low == high:
return distances[low]
return distances[low] + (distances[high] - distances[low]) * (position - low)
return {
"count": len(distances),
"p05": pick(0.05),
"p50": pick(0.50),
"p95": pick(0.95),
}
def export_optics_in_sensor_frame(
layers: list[dict],
t_sensor_from_world: Matrix,
filepath: Path,
) -> int:
"""Export the refracting interfaces, in the sensor frame, one pair per layer."""
payload: dict = {"layer_names": np.array([layer["name"] for layer in layers], dtype=np.str_)}
thickness_p50: list[float] = []
ior_values: list[float] = []
for index, layer in enumerate(layers):
if layer.get("solid"):
targets = [("solid", _require_object(str(layer["solid"]), "MESH"))]
else:
targets = [
("outer", _require_object(str(layer["outer"]), "MESH")),
("inner", _require_object(str(layer["inner"]), "MESH")),
]
for prefix, obj in targets:
mesh, world, cleanup = _mesh_world(obj)
try:
transform = t_sensor_from_world @ world
payload[f"layer{index}_{prefix}_vertices"] = np.array(
[transform @ vertex.co for vertex in mesh.vertices], dtype=np.float32
)
payload[f"layer{index}_{prefix}_faces"] = np.asarray(
[tuple(poly.vertices) for poly in mesh.polygons], dtype=np.int32
)
finally:
cleanup()
if layer.get("solid"):
# Thickness of a solid: enter one wall and leave by the opposite one.
measured = _measure_layer_thickness(targets[0][1], targets[0][1])
else:
measured = _measure_layer_thickness(targets[0][1], targets[1][1])
declared = layer.get("thickness_mm")
thickness = float(declared) if declared is not None else (measured["p50"] or 0.0)
thickness_p50.append(thickness)
ior_values.append(float(layer["ior"]))
print(
f" optics layer {layer['name']!r}: ior={float(layer['ior']):g} "
f"thickness={thickness:.4f} mm "
f"({'declared' if declared is not None else 'measured p50'}), "
f"measured p05={measured['p05']} p95={measured['p95']}"
)
payload["ior"] = np.asarray(ior_values, dtype=np.float64)
payload["thickness_mm"] = np.asarray(thickness_p50, dtype=np.float64)
np.savez_compressed(filepath, **payload)
print(f"Optics exported to {filepath} ({len(layers)} layer(s))")
return len(layers)
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)",
)
parser.add_argument(
"--housing-collections",
dest="housing_collections",
default=None,
help="Comma-separated collections exported as housing (overrides job)",
)
return parser.parse_args(argv)
def _resolve_config(
args: argparse.Namespace,
) -> tuple[Path, str, str, str, list[str], dict | None]:
"""Merge --job JSON with explicit flag overrides. Explicit flags win."""
job_output: str | None = None
job_objects: dict = {}
job_housing: list[str] | None = None
job_optics: dict | None = None
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, job_housing, job_optics = _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"]
)
housing: list[str] = []
if args.housing_collections:
housing = [
name.strip()
for name in str(args.housing_collections).split(",")
if name.strip()
]
elif job_housing is not None:
housing = list(job_housing)
return (
Path(output_raw).resolve(),
str(mesh),
str(cam),
str(origin),
housing,
job_optics,
)
def main() -> int:
args = parse_args(_argv_after_double_dash())
try:
output_dir, mesh_name, cam_name, origin_name, housing_collections, optics = (
_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"
housing_path = output_dir / "housing.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)
export_housing_in_sensor_frame(
_housing_objects(housing_collections), t_sensor_from_world, housing_path
)
if optics and optics.get("layers"):
export_optics_in_sensor_frame(
list(optics["layers"]), t_sensor_from_world, output_dir / "optics.npz"
)
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" {housing_path.name}")
print(f" {intrinsics_path.name}")
print(f" {pose_path.name}")
return 0
if __name__ == "__main__":
raise SystemExit(main())