Files
appRobotRender/setup/generateSets/render_robot.py
2026-05-31 10:22:19 +02:00

1009 lines
35 KiB
Python

from unicodedata import name
import bpy
import math
import mathutils
import json
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
from mathutils import Matrix
# ============================================================
# PATHS
# ============================================================
# Holt dynamisch den Pfad zum aktuellen Benutzerverzeichnis (z.B. C:\Users\Name)
USER_HOME = Path.home()
# Kombiniert den Benutzerpfad mit dem spezifischen Ordnerpfad und konvertiert direkt zu str
ROBOT_JSON_FILE = str(USER_HOME / "SynologyDrive" / "2026-AppServer-AppRobot" / "appRobotRendering" / "data" / "robot" / "robot.json")
OUTPUT_FILE = str(USER_HOME / "SynologyDrive" / "2026-AppServer-AppRobot" / "appRobotRendering" / "data" / "simulation" / "debug" / "render.png")
print("Using robot JSON file:", ROBOT_JSON_FILE)
print("Using output file:", OUTPUT_FILE)
# ============================================================
# DEFAULT MATERIALS
# ============================================================
DEFAULT_MATERIALS = {
"wood": {"baseColor": (0.72, 0.52, 0.33, 1.0), "roughness": 0.85, "metallic": 0.0},
"plaWhite": {"baseColor": (0.95, 0.95, 0.95, 1.0), "roughness": 0.45, "metallic": 0.0},
"steel": {"baseColor": (0.72, 0.72, 0.75, 1.0), "roughness": 0.25, "metallic": 1.0},
"powderCoatBlue": {"baseColor": (0.15, 0.25, 0.70, 1.0), "roughness": 0.55, "metallic": 0.0},
"defaultPlastic": {"baseColor": (0.95, 0.95, 0.95, 1.0), "roughness": 0.40, "metallic": 0.0},
"skeletonRed": {"baseColor": (0.85, 0.20, 0.20, 1.0), "roughness": 0.35, "metallic": 0.0},
"markerBlack": {"baseColor": (0.04, 0.04, 0.04, 1.0), "roughness": 0.80, "metallic": 0.0},
}
STATE_KEYS = ["x", "y", "z", "a", "b", "c", "e"]
marker_export = []
# ============================================================
# JSON LOADING
# ============================================================
with open(ROBOT_JSON_FILE, "r", encoding="utf-8") as f:
robot: Dict[str, Any] = json.load(f)
rendering_info = robot.get("renderingInfo", {})
metric = rendering_info.get("metric", "mm")
scale_factor = 0.001 if metric == "mm" else 1.0
RENDER_WIDTH = int(rendering_info.get("width", 1200))
RENDER_HEIGHT = int(rendering_info.get("height", 800))
def as_bool(value: Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
return bool(value)
show_skeleton = as_bool(rendering_info.get("showSkeleton", False))
show_markers = as_bool(rendering_info.get("showMarkers", False))
lens_dirt = as_bool(rendering_info.get("lensDirt", False))
lens_dirt_strength = float(rendering_info.get("lensDirtStrength", 0.08))
dof_enabled = as_bool(rendering_info.get("dofEnabled", True))
dof_fstop = float(rendering_info.get("dofFStop", 7.5))
aruco_dust = as_bool(rendering_info.get("arucoDust", False))
aruco_dust_strength = float(rendering_info.get("arucoDustStrength", 0.0005))
state: Dict[str, float] = {k: 0.0 for k in STATE_KEYS}
for source_name in ("defaultPosition", "recognized", "movements"):
source = robot.get(source_name, {}) or {}
for k in STATE_KEYS:
v = source.get(k, None)
if v is not None:
try:
state[k] = float(v)
except Exception:
pass
links_def = robot.get("links", {})
if not isinstance(links_def, dict):
raise ValueError("robot.json must contain a top-level 'links' object")
# ============================================================
# HELPERS
# ============================================================
def mm_to_m(value: float) -> float:
return value * scale_factor
def resolve_scalar(value: Any, state_map: Dict[str, float]) -> float:
if value is None:
return 0.0
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
key = value.strip().lower()
if key in state_map:
return float(state_map[key])
try:
return float(key)
except ValueError:
return 0.0
return 0.0
def resolve_vector(value: Any, state_map: Dict[str, float], default_len: int = 3) -> Tuple[float, ...]:
if value is None:
return tuple(0.0 for _ in range(default_len))
if isinstance(value, (int, float, str)):
return (resolve_scalar(value, state_map),)
if isinstance(value, (list, tuple)):
resolved = [resolve_scalar(v, state_map) for v in value]
if len(resolved) < default_len:
resolved.extend([0.0] * (default_len - len(resolved)))
return tuple(resolved[:default_len])
return tuple(0.0 for _ in range(default_len))
def resolve_vec3_m(value: Any, state_map: Dict[str, float]) -> Tuple[float, float, float]:
vec = list(resolve_vector(value, state_map, default_len=3))
while len(vec) < 3:
vec.append(0.0)
x, y, z = vec[:3]
return mm_to_m(x), mm_to_m(y), mm_to_m(z)
def normalize_axis(axis: Iterable[Any]) -> mathutils.Vector:
ax = mathutils.Vector((float(axis[0]), float(axis[1]), float(axis[2])))
return ax.normalized() if ax.length > 0 else mathutils.Vector((1.0, 0.0, 0.0))
def euler_deg_xyz(values: Any) -> Tuple[float, float, float]:
vec = list(resolve_vector(values, state, default_len=3))
while len(vec) < 3:
vec.append(0.0)
return math.radians(vec[0]), math.radians(vec[1]), math.radians(vec[2])
def create_or_get_material(name: str, fallback: str = "defaultPlastic") -> bpy.types.Material:
info = rendering_info.get("materials", {}) or {}
spec = None
if isinstance(info, dict):
spec = info.get(name)
if isinstance(spec, dict):
base = DEFAULT_MATERIALS.get(name, DEFAULT_MATERIALS[fallback]).copy()
if "baseColor" in spec:
color = tuple(spec["baseColor"])
base["baseColor"] = (*color[:3], 1.0) if len(color) == 3 else tuple(color[:4])
if "roughness" in spec:
base["roughness"] = float(spec["roughness"])
if "metallic" in spec:
base["metallic"] = float(spec["metallic"])
spec = base
else:
spec = DEFAULT_MATERIALS.get(name, DEFAULT_MATERIALS[fallback])
if name in bpy.data.materials:
mat = bpy.data.materials[name]
else:
mat = bpy.data.materials.new(name=name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf is not None:
bsdf.inputs["Base Color"].default_value = spec["baseColor"]
bsdf.inputs["Roughness"].default_value = spec["roughness"]
bsdf.inputs["Metallic"].default_value = spec["metallic"]
if name == "wood" and mat.node_tree.nodes.get("WoodGrainMix") is None:
nodes = mat.node_tree.nodes
links = mat.node_tree.links
texcoord = nodes.new("ShaderNodeTexCoord")
mapping = nodes.new("ShaderNodeMapping")
noise = nodes.new("ShaderNodeTexNoise")
wave = nodes.new("ShaderNodeTexWave")
ramp = nodes.new("ShaderNodeValToRGB")
mix = nodes.new("ShaderNodeMixRGB")
texcoord.name = "WoodTexCoord"
mapping.name = "WoodMapping"
noise.name = "WoodNoise"
wave.name = "WoodWave"
ramp.name = "WoodRamp"
mix.name = "WoodGrainMix"
texcoord.location = (-900, 0)
mapping.location = (-700, 0)
noise.location = (-700, -220)
wave.location = (-500, 0)
ramp.location = (-260, 0)
mix.location = (-60, 0)
# statt extrem hoch:
mapping.inputs["Scale"].default_value = (1000.0, 1000.0, 1000.0)
wave.inputs["Scale"].default_value = 1.0
noise.inputs["Scale"].default_value = 6.0
wave.inputs["Distortion"].default_value = 3.0
# und wichtig: Wave Fac statt Color verwenden
links.new(wave.outputs["Fac"], ramp.inputs["Fac"])
bump = nodes.new("ShaderNodeBump")
bump.location = (120, -160)
bump.inputs["Strength"].default_value = 0.4
links.new(ramp.outputs["Color"], bump.inputs["Height"])
links.new(bump.outputs["Normal"], bsdf.inputs["Normal"])
ramp.color_ramp.elements[0].position = 0.53
ramp.color_ramp.elements[1].position = 0.58
mix.blend_type = "MIX"
mix.inputs["Fac"].default_value = 0.08
mix.inputs["Color1"].default_value = spec["baseColor"]
mix.inputs["Color2"].default_value = (0.74, 0.58, 0.50, 1.0)
links.new(texcoord.outputs["Object"], mapping.inputs["Vector"])
links.new(mapping.outputs["Vector"], noise.inputs["Vector"])
links.new(noise.outputs["Fac"], wave.inputs["Distortion"])
links.new(mapping.outputs["Vector"], wave.inputs["Vector"])
links.new(wave.outputs["Color"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], mix.inputs["Fac"])
links.new(mix.outputs["Color"], bsdf.inputs["Base Color"])
return mat
def import_stl(filepath: str) -> List[bpy.types.Object]:
path = Path(filepath).resolve()
if not path.exists():
raise FileNotFoundError(f"STL file not found:\n{path}")
before = set(bpy.data.objects)
bpy.ops.wm.stl_import(filepath=str(path))
after = [obj for obj in bpy.data.objects if obj not in before]
return after
def create_empty(name: str) -> bpy.types.Object:
empty = bpy.data.objects.new(name, None)
bpy.context.collection.objects.link(empty)
return empty
def safe_parent(child: bpy.types.Object, parent: Optional[bpy.types.Object], keep_world: bool = False):
if parent is None:
return
world_matrix = child.matrix_world.copy()
child.parent = parent
if keep_world:
child.matrix_parent_inverse = parent.matrix_world.inverted()
child.matrix_world = world_matrix
else:
child.matrix_parent_inverse = Matrix.Identity(4)
def create_material_segment(name: str, color: Tuple[float, float, float], roughness: float = 0.35) -> bpy.types.Material:
if name in bpy.data.materials:
mat = bpy.data.materials[name]
else:
mat = bpy.data.materials.new(name=name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf is not None:
bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0)
bsdf.inputs["Roughness"].default_value = roughness
bsdf.inputs["Metallic"].default_value = 0.0
return mat
def create_cylinder_between(
name: str,
p1_local: Tuple[float, float, float],
p2_local: Tuple[float, float, float],
radius_m: float,
parent: bpy.types.Object,
material: bpy.types.Material
) -> bpy.types.Object:
v1 = mathutils.Vector(p1_local)
v2 = mathutils.Vector(p2_local)
delta = v2 - v1
length = delta.length
if length <= 1e-9:
length = 1e-6
delta = mathutils.Vector((0.0, 0.0, 1e-6))
bpy.ops.mesh.primitive_cylinder_add(radius=radius_m, depth=length)
obj = bpy.context.active_object
obj.name = name
safe_parent(obj, parent, keep_world=False)
obj.location = (v1 + v2) * 0.5
obj.rotation_mode = "QUATERNION"
obj.rotation_quaternion = mathutils.Vector((0, 0, 1)).rotation_difference(delta.normalized())
if len(obj.data.materials) == 0:
obj.data.materials.append(material)
else:
obj.data.materials[0] = material
return obj
def derive_default_skeleton_from_size(size_mm: List[float]) -> Dict[str, Any]:
sx, sy, sz = (float(size_mm[0]), float(size_mm[1]), float(size_mm[2]))
ax = max((abs(sx), 0), (abs(sy), 1), (abs(sz), 2), key=lambda x: x[0])[1]
if ax == 0:
return {"from": [0, sy * 0.5, sz * 0.5], "to": [sx, sy * 0.5, sz * 0.5]}
if ax == 1:
return {"from": [sx * 0.5, 0, sz * 0.5], "to": [sx * 0.5, sy, sz * 0.5]}
return {"from": [sx * 0.5, sy * 0.5, 0], "to": [sx * 0.5, sy * 0.5, sz]}
def resolve_stl_path(stl_file: str) -> Path:
base_dir = Path(ROBOT_JSON_FILE).parent
candidates = [
base_dir / stl_file,
base_dir / "surfaces" / stl_file,
Path(stl_file),
]
for c in candidates:
p = c.resolve()
if p.exists():
return p
raise FileNotFoundError(
"STL file not found in any expected location:\n" +
"\n".join(str(c.resolve()) for c in candidates)
)
# ============================================================
# SCENE RESET
# ============================================================
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
scene = bpy.context.scene
scene.unit_settings.system = "METRIC"
scene.unit_settings.length_unit = "MILLIMETERS"
scene.unit_settings.scale_length = scale_factor
# ============================================================
# WORLD / RENDER SETTINGS
# ============================================================
world = scene.world or bpy.data.worlds.new("World")
scene.world = world
world.use_nodes = True
bg = world.node_tree.nodes["Background"]
bg.inputs[0].default_value = tuple(rendering_info.get("backgroundColor", [0.70, 0.85, 1.0])) + (1.0,)
bg.inputs[1].default_value = float(rendering_info.get("backgroundStrength", 0.20))
scene.render.engine = "CYCLES"
scene.view_settings.exposure = float(rendering_info.get("exposure", -1.5))
scene.cycles.samples = 32
scene.cycles.preview_samples = 32
scene.cycles.use_adaptive_sampling = True
scene.cycles.adaptive_threshold = 0.02
scene.cycles.use_denoising = True
scene.render.resolution_x = RENDER_WIDTH
scene.render.resolution_y = RENDER_HEIGHT
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.filepath = OUTPUT_FILE
scene.render.film_transparent = False
# ============================================================
# FLOOR
# ============================================================
bpy.ops.mesh.primitive_plane_add(size=2.0, location=(0, 0, mm_to_m(-28.0)))
floor = bpy.context.active_object
floor_tex_path = Path(ROBOT_JSON_FILE).parent / "surfaces" / "boden.jpg"
if floor_tex_path.exists():
floor_mat = bpy.data.materials.new(name="FloorPhoto")
floor_mat.use_nodes = True
nodes = floor_mat.node_tree.nodes
links = floor_mat.node_tree.links
nodes.clear()
output_node = nodes.new(type="ShaderNodeOutputMaterial")
bsdf_node = nodes.new(type="ShaderNodeBsdfPrincipled")
texcoord_node = nodes.new(type="ShaderNodeTexCoord")
mapping_node = nodes.new(type="ShaderNodeMapping")
image_node = nodes.new(type="ShaderNodeTexImage")
image_node.image = bpy.data.images.load(str(floor_tex_path), check_existing=True)
image_node.interpolation = "Cubic"
links.new(texcoord_node.outputs["UV"], mapping_node.inputs["Vector"])
links.new(mapping_node.outputs["Vector"], image_node.inputs["Vector"])
links.new(image_node.outputs["Color"], bsdf_node.inputs["Base Color"])
links.new(bsdf_node.outputs["BSDF"], output_node.inputs["Surface"])
floor.data.materials.append(floor_mat)
else:
# Fallback: dein bisheriges Checkerboard behalten
checker_mat = bpy.data.materials.new(name="Checkerboard")
checker_mat.use_nodes = True
nodes = checker_mat.node_tree.nodes
links = checker_mat.node_tree.links
nodes.clear()
output_node = nodes.new(type="ShaderNodeOutputMaterial")
bsdf_node = nodes.new(type="ShaderNodeBsdfPrincipled")
checker_node = nodes.new(type="ShaderNodeTexChecker")
mapping_node = nodes.new(type="ShaderNodeMapping")
texcoord_node = nodes.new(type="ShaderNodeTexCoord")
checker_node.inputs["Color1"].default_value = (0.82, 0.82, 0.82, 1.0)
checker_node.inputs["Color2"].default_value = (0.18, 0.18, 0.18, 1.0)
mapping_node.inputs["Scale"].default_value = (20.0, 20.0, 20.0)
links.new(texcoord_node.outputs["UV"], mapping_node.inputs["Vector"])
links.new(mapping_node.outputs["Vector"], checker_node.inputs["Vector"])
links.new(checker_node.outputs["Color"], bsdf_node.inputs["Base Color"])
links.new(bsdf_node.outputs["BSDF"], output_node.inputs["Surface"])
floor.data.materials.append(checker_mat)
# ============================================================
# CAMERA
# ============================================================
cam_data = bpy.data.cameras.new("Camera")
cam_obj = bpy.data.objects.new("Camera", cam_data)
bpy.context.collection.objects.link(cam_obj)
cam_pos = resolve_vec3_m(rendering_info.get("cameraPosition", [-400, -700, 300]), state)
cam_target = resolve_vec3_m(rendering_info.get("cameraTarget", [0, 0, 0]), state)
cam_obj.location = cam_pos
cam_data.lens = 50
if dof_enabled:
cam_data.dof.use_dof = True
cam_data.dof.focus_distance = (mathutils.Vector(cam_target) - mathutils.Vector(cam_pos)).length
cam_data.dof.aperture_fstop = dof_fstop
else:
cam_data.dof.use_dof = False
cam_vec = mathutils.Vector(cam_target) - mathutils.Vector(cam_pos)
if cam_vec.length == 0:
cam_vec = mathutils.Vector((1, 0, 0))
cam_obj.rotation_euler = cam_vec.to_track_quat("-Z", "Y").to_euler()
scene.camera = cam_obj
# ============================================================
# EXPORT CAMERA CALIBRATION (.npz)
# ============================================================
import numpy as np
CALIBRATION_OUTPUT = str(
Path(OUTPUT_FILE).with_suffix(".npz")
)
render = scene.render
cam = cam_obj.data
width_px = render.resolution_x
height_px = render.resolution_y
scale = render.resolution_percentage / 100.0
width_px *= scale
height_px *= scale
sensor_width_mm = cam.sensor_width
sensor_height_mm = cam.sensor_height
focal_mm = cam.lens
# focal length in pixels
fx = (width_px * focal_mm) / sensor_width_mm
fy = (height_px * focal_mm) / sensor_height_mm
cx = width_px / 2.0
cy = height_px / 2.0
camera_matrix = np.array([
[fx, 0, cx],
[0, fy, cy],
[0, 0, 1]
], dtype=np.float32)
# ideal synthetic camera
dist_coeffs = np.zeros((5, 1), dtype=np.float32)
np.savez(
CALIBRATION_OUTPUT,
# common names
camera_matrix=camera_matrix,
dist_coeffs=dist_coeffs,
# compatibility aliases
K=camera_matrix,
mtx=camera_matrix,
dist=dist_coeffs
)
print("Saved camera calibration:", CALIBRATION_OUTPUT)
print(camera_matrix)
# ============================================================
# LIGHTS
# ============================================================
sun_data = bpy.data.lights.new(name="Sun", type="SUN")
sun_obj = bpy.data.objects.new(name="Sun", object_data=sun_data)
bpy.context.collection.objects.link(sun_obj)
sun_pos = resolve_vec3_m(rendering_info.get("lightPosition", [-500, -500, 500]), state)
light_target = resolve_vec3_m(rendering_info.get("lightTarget", [0, 0, 0]), state)
sun_obj.location = sun_pos
light_vec = mathutils.Vector(light_target) - mathutils.Vector(sun_pos)
if light_vec.length == 0:
light_vec = mathutils.Vector((1, 0, -1))
sun_obj.rotation_euler = light_vec.to_track_quat("-Z", "Y").to_euler()
sun_data.energy = float(rendering_info.get("sunEnergy", 0.35))
area_data = bpy.data.lights.new(name="AreaLight", type="AREA")
area_obj = bpy.data.objects.new(name="AreaLight", object_data=area_data)
bpy.context.collection.objects.link(area_obj)
area_obj.location = (mm_to_m(-800), mm_to_m(-1200), mm_to_m(1500))
area_obj.rotation_euler = (math.radians(60), 0.0, math.radians(-20))
area_data.energy = float(rendering_info.get("areaEnergy", 120))
area_data.size = 2.0
# ============================================================
# ROBOT HIERARCHY
# ============================================================
link_frames: Dict[str, bpy.types.Object] = {}
for link_name in links_def.keys():
link_frames[link_name] = create_empty(f"{link_name}_frame")
for link_name, link_info in links_def.items():
parent_name = link_info.get("parent")
parent_frame = link_frames.get(parent_name) if parent_name else None
size_mm = link_info.get("size", [100, 100, 100])
# mount: static position/rotation in parent coordinates
mount = create_empty(f"{link_name}_mount")
safe_parent(mount, parent_frame, keep_world=False)
mount.location = resolve_vec3_m(link_info.get("mountPosition", [0, 0, 0]), state)
mount.rotation_euler = euler_deg_xyz(link_info.get("mountRotation", [0, 0, 0]))
# joint: sits inside the mount, defines pivot/orientation
joint_info = link_info.get("jointToParent", {}) or {}
joint = create_empty(f"{link_name}_joint")
safe_parent(joint, mount, keep_world=False)
joint.location = resolve_vec3_m(joint_info.get("origin", [0, 0, 0]), state)
joint.rotation_euler = euler_deg_xyz(joint_info.get("rotation", [0, 0, 0]))
# motion: only this node gets the commanded position/angle
motion = create_empty(f"{link_name}_motion")
safe_parent(motion, joint, keep_world=False)
joint_type = str(joint_info.get("type", "fixed")).lower()
control_var = str(joint_info.get("variable", joint_info.get("control", ""))).lower()
axis = joint_info.get("axis", [1, 0, 0])
if joint_type == "linear":
value_mm = state.get(control_var, 0.0) if control_var else 0.0
motion.location = normalize_axis(axis) * mm_to_m(value_mm)
elif joint_type == "revolute":
value_deg = state.get(control_var, 0.0) if control_var else 0.0
motion.rotation_mode = "QUATERNION"
motion.rotation_quaternion = mathutils.Quaternion(normalize_axis(axis), math.radians(value_deg))
# link frame: everything belonging to this link follows motion
link_frame = link_frames[link_name]
safe_parent(link_frame, motion, keep_world=False)
# --------------------------------------------------------
# VISUAL MESHES
# --------------------------------------------------------
visual_root = create_empty(f"{link_name}_visual")
safe_parent(visual_root, link_frame, keep_world=False)
model_list = link_info.get("model", [])
if not isinstance(model_list, list):
model_list = []
for idx, model_def in enumerate(model_list):
stl_file = model_def.get("stlFile")
if not stl_file:
continue
stl_path = resolve_stl_path(stl_file)
imported = import_stl(str(stl_path))
model_node = create_empty(f"{link_name}_model_{idx}")
safe_parent(model_node, visual_root, keep_world=False)
model_node.location = resolve_vec3_m(model_def.get("originOfModel", [0, 0, 0]), state)
model_node.rotation_euler = euler_deg_xyz(model_def.get("rotationOfModelDegree", [0, 0, 0]))
material_name = model_def.get("material", "defaultPlastic")
material = create_or_get_material(material_name)
for obj in imported:
if obj.type != "MESH":
continue
safe_parent(obj, model_node, keep_world=True)
obj.scale = (scale_factor, scale_factor, scale_factor)
if len(obj.data.materials) == 0:
obj.data.materials.append(material)
else:
obj.data.materials[0] = material
# --------------------------------------------------------
# SKELETON DEBUG
# --------------------------------------------------------
if show_skeleton:
skeleton_spec = link_info.get("skeleton")
if not isinstance(skeleton_spec, dict):
skeleton_spec = derive_default_skeleton_from_size(size_mm)
p1_mm = skeleton_spec.get("from", [0, 0, 0])
p2_mm = skeleton_spec.get("to", [0, 0, 0])
p1 = resolve_vec3_m(p1_mm, state)
p2 = resolve_vec3_m(p2_mm, state)
sk_radius_mm = float(
skeleton_spec.get(
"radius",
rendering_info.get("skeletonDefaults", {}).get("radius", 4)
)
)
sk_color = skeleton_spec.get(
"color",
rendering_info.get("skeletonDefaults", {}).get("color", [0.85, 0.20, 0.20])
)
sk_mat = create_material_segment(f"{link_name}_skeletonMat", tuple(sk_color[:3]))
create_cylinder_between(
f"{link_name}_skeleton",
p1,
p2,
mm_to_m(sk_radius_mm),
link_frame,
sk_mat
)
# --------------------------------------------------------
# MARKERS
# --------------------------------------------------------
import cv2
import numpy as np
import tempfile
def create_aruco_material(marker_id: int, marker_name: str):
dictionary = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_250)
img = np.zeros((256, 256), dtype=np.uint8)
cv2.aruco.generateImageMarker(dictionary, marker_id, 256, img, 1)
tmpfile = Path(tempfile.gettempdir()) / f"aruco_{marker_id}.png"
cv2.imwrite(str(tmpfile), img)
image = bpy.data.images.load(str(tmpfile))
mat = bpy.data.materials.new(name=f"{marker_name}_mat")
mat.use_nodes = True
nodes = mat.node_tree.nodes
links = mat.node_tree.links
nodes.clear()
out = nodes.new(type="ShaderNodeOutputMaterial")
bsdf = nodes.new(type="ShaderNodeBsdfPrincipled")
tex = nodes.new(type="ShaderNodeTexImage")
tex.image = image
if aruco_dust:
noise = nodes.new("ShaderNodeTexNoise")
ramp = nodes.new("ShaderNodeValToRGB")
mix = nodes.new("ShaderNodeMixRGB")
noise.location = (-600, -220)
ramp.location = (-360, -220)
mix.location = (-120, -120)
noise.inputs["Scale"].default_value = 80.0
noise.inputs["Detail"].default_value = 1.0
ramp.color_ramp.elements[0].position = 0.49
ramp.color_ramp.elements[1].position = 0.51
mix.blend_type = "MIX"
mix.inputs["Fac"].default_value = aruco_dust_strength
mix.inputs["Color2"].default_value = (0.97, 0.97, 0.97, 1.0)
if aruco_dust:
links.new(tex.outputs["Color"], mix.inputs["Color1"])
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], mix.inputs["Fac"])
links.new(mix.outputs["Color"], bsdf.inputs["Base Color"])
else:
links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
return mat
def normal_to_quaternion(normal_vec):
normal = mathutils.Vector(normal_vec).normalized()
default_normal = mathutils.Vector((0, 0, 1))
return default_normal.rotation_difference(normal)
if show_markers:
marker_defaults = rendering_info.get("markerDefaults", {}) or {}
for m in link_info.get("markers", []):
if not isinstance(m, dict):
continue
marker_id = int(m.get("id", 0))
marker_name = m.get(
"name",
f"{link_name}_marker_{marker_id}"
)
marker_size_mm = float(
m.get(
"size",
marker_defaults.get("size", 25)
)
)
marker_pos = resolve_vec3_m(
m.get("position", [0, 0, 0]),
state
)
normal = m.get("normal", [0, 0, 1])
marker_spin_deg = float(m.get("spin", 0.0))
bpy.ops.mesh.primitive_plane_add(
size=mm_to_m(marker_size_mm)
)
marker_obj = bpy.context.active_object
marker_obj.name = marker_name
safe_parent(marker_obj, link_frame, keep_world=False)
marker_obj.rotation_mode = "QUATERNION"
#alt: base_quat = normal_to_quaternion(normal)
normal = mathutils.Vector(m.get("normal", [0, 0, 1]))
if normal.length == 0:
normal = mathutils.Vector((0, 0, 1))
normal.normalize()
base_quat = normal_to_quaternion(normal)
spin_quat = mathutils.Quaternion(
mathutils.Vector((0, 0, 1)),
math.radians(marker_spin_deg)
)
marker_obj.rotation_quaternion = (
base_quat @ spin_quat
)
# Marker-Normale im lokalen Link-Raum (aus Marker-Rotation)
normal_local = (
marker_obj.rotation_quaternion
@ mathutils.Vector((0, 0, 1))
)
normal_local.normalize()
# minimal vorziehen gegen Z-Fighting (lokaler Versatz)
marker_obj.location = (
mathutils.Vector(marker_pos)
+ normal_local * mm_to_m(0.5)
)
marker_mat = create_aruco_material(
marker_id,
marker_name
)
if len(marker_obj.data.materials) == 0:
marker_obj.data.materials.append(marker_mat)
else:
marker_obj.data.materials[0] = marker_mat
# --------------------------------------------------------
# --------------------------------------------------------
# BACKING PLATE (white PLA behind marker)
# --------------------------------------------------------
plate_side_mm = 26.5
plate_thickness_mm = 1.0
gap_mm = 0.2 # kleiner Abstand gegen Z-Fighting
bpy.ops.mesh.primitive_cube_add(size=1.0)
plate_obj = bpy.context.active_object
plate_obj.name = marker_name + "_plate"
safe_parent(plate_obj, link_frame, keep_world=False)
# gleiche Orientierung wie der Marker
plate_obj.rotation_mode = "QUATERNION"
plate_obj.rotation_quaternion = marker_obj.rotation_quaternion.copy()
# Normale des Markers im lokalen Link-Raum (aus Marker-Rotation)
normal_local = marker_obj.rotation_quaternion @ mathutils.Vector((0, 0, 1))
normal_local.normalize()
# Platte liegt "hinter" dem Marker (lokaler Versatz)
plate_obj.location = (
marker_obj.location
- normal_local * mm_to_m((plate_thickness_mm * 0.5) + gap_mm)
)
# exakte Abmessungen: 26 x 26 x 1 mm
plate_obj.dimensions = (
mm_to_m(plate_side_mm),
mm_to_m(plate_side_mm),
mm_to_m(plate_thickness_mm)
)
pla_mat = create_or_get_material("plaWhite")
if len(plate_obj.data.materials) == 0:
plate_obj.data.materials.append(pla_mat)
else:
plate_obj.data.materials[0] = pla_mat
# Weltmatrix holen (inkl. ALLER Transformationen!)
mw = marker_obj.matrix_world
world_pos = mw.translation
world_rot = mw.to_quaternion()
# Optional: Normal in Weltkoordinaten
world_normal = (world_rot @ mathutils.Vector((0, 0, 1))).normalized()
marker_export.append({
"name": marker_name,
"id": marker_id,
"link": link_name,
"position_m": [world_pos.x, world_pos.y, world_pos.z],
# oft nützlich für Computer Vision:
"position_mm": [
world_pos.x / scale_factor,
world_pos.y / scale_factor,
world_pos.z / scale_factor
],
"rotation_quaternion": [
world_rot.w,
world_rot.x,
world_rot.y,
world_rot.z
],
"normal": [
world_normal.x,
world_normal.y,
world_normal.z
]
})
# ============================================================
# DEBUG WORLD AXES
# ============================================================
def create_axis_arrow(
name,
direction,
color,
length_mm=200,
radius_mm=2,
cone_radius_mm=5,
cone_length_mm=20
):
length = mm_to_m(length_mm)
radius = mm_to_m(radius_mm)
cone_radius = mm_to_m(cone_radius_mm)
cone_length = mm_to_m(cone_length_mm)
dir_vec = mathutils.Vector(direction).normalized()
bpy.ops.mesh.primitive_cylinder_add(
radius=radius,
depth=length - cone_length
)
cyl = bpy.context.active_object
cyl.name = f"{name}_shaft"
cyl.rotation_mode = 'QUATERNION'
cyl.rotation_quaternion = mathutils.Vector((0, 0, 1)).rotation_difference(dir_vec)
cyl.location = dir_vec * ((length - cone_length) * 0.5)
bpy.ops.mesh.primitive_cone_add(
radius1=cone_radius,
depth=cone_length
)
cone = bpy.context.active_object
cone.name = f"{name}_tip"
cone.rotation_mode = 'QUATERNION'
cone.rotation_quaternion = mathutils.Vector((0, 0, 1)).rotation_difference(dir_vec)
cone.location = dir_vec * (length - cone_length * 0.5)
mat = bpy.data.materials.new(name=f"{name}_material")
mat.use_nodes = True
bsdf = mat.node_tree.nodes["Principled BSDF"]
bsdf.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0)
bsdf.inputs["Roughness"].default_value = 0.3
bsdf.inputs["Metallic"].default_value = 0.0
cyl.data.materials.append(mat)
cone.data.materials.append(mat)
create_axis_arrow("AxisX", (1, 0, 0), (1, 0, 0))
create_axis_arrow("AxisY", (0, 1, 0), (0, 1, 0))
create_axis_arrow("AxisZ", (0, 0, 1), (0, 0, 1))
# ============================================================
# AUTO GPU DETECTION (robust, plattformübergreifend)
# ============================================================
def enable_best_device(scene):
prefs = bpy.context.preferences
cycles_prefs = prefs.addons['cycles'].preferences
# Prioritäten (schnell → weniger schnell)
backends = ['OPTIX', 'CUDA', 'HIP', 'METAL', 'ONEAPI']
selected_backend = None
for backend in backends:
try:
cycles_prefs.compute_device_type = backend
cycles_prefs.get_devices()
devices = cycles_prefs.devices
# Prüfen, ob eine GPU dabei ist
gpu_devices = [d for d in devices if d.type != 'CPU']
if gpu_devices:
selected_backend = backend
# Aktiviere alle Geräte (GPU + optional CPU fallback)
for d in devices:
d.use = True
break
except Exception:
continue
if selected_backend:
print(f"[Render] Using GPU via {selected_backend}")
scene.cycles.device = 'GPU'
else:
print("[Render] No GPU found, falling back to CPU")
scene.cycles.device = 'CPU'
enable_best_device(scene)
# ============================================================
# RENDER
# ============================================================
bpy.ops.render.render(write_still=True)
print("Finished rendering:", OUTPUT_FILE)
MARKER_OUTPUT = str(Path(OUTPUT_FILE).with_name("markers.json"))
with open(MARKER_OUTPUT, "w", encoding="utf-8") as f:
json.dump(marker_export, f, indent=2)
print("Saved marker positions:", MARKER_OUTPUT)