from unicodedata import name import bpy import math import random 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) import os USER_HOME = Path.home() BASE = Path(__file__).resolve().parents[2] # Pfade: env-Variablen (Container) mit Fallback auf repo-relative Pfade (lokal unverändert). ROBOT_JSON_FILE = os.environ.get("ROBOT_JSON", str(BASE / "data" / "robot" / "robot.json")) OUTPUT_FILE = os.environ.get("RENDER_OUTPUT", str(BASE / "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.5)) # ── Realismus-Störungen ──────────────────────────────────────────── # Marker leicht "falsch aufgeklebt": tangentiale Verschiebung (mm). Deterministisch # pro Marker-ID, damit der Marker in ALLEN Kameraansichten konsistent versetzt ist. marker_offset_max_mm = float(rendering_info.get("markerOffsetMaxMm", 0.0)) marker_offset_seed = int(rendering_info.get("markerOffsetSeed", 0)) marker_rot_max_deg = float(rendering_info.get("markerRotationMaxDeg", 0.0)) # Leichtes Verwackeln: pro Aufnahme zufällig gerichteter, kleiner Blur (Pixel). motion_blur = as_bool(rendering_info.get("motionBlur", False)) motion_blur_max_px = float(rendering_info.get("motionBlurMaxPx", 1.5)) # ── Linsen-/Kamerafehler ─────────────────────────────────────────── # (A) Kalibrier-Restfehler: die npz-Intrinsik leicht von der Wahrheit abweichen lassen # (deterministisch pro Kamera -> über alle Posen konsistent). Das Bild bleibt ideal, # nur die *angenommene* Kalibrierung ist falsch — wie bei realer Webcam-Kalibrierung. intr_focal_err_pct = float(rendering_info.get("focalErrorPct", 0.0)) # ±% auf fx, fy intr_principal_px = float(rendering_info.get("principalErrorPx", 0.0)) # ±px auf cx, cy intr_residual_dist = rendering_info.get("residualDistortion", None) # [k1, k2] in dist_coeffs # (B-D) photometrische Effekte (Compositor) vignette = as_bool(rendering_info.get("vignette", False)) # C vignette_strength = float(rendering_info.get("vignetteStrength", 0.25)) localized_blur = as_bool(rendering_info.get("localizedBlur", False)) # C localized_blur_strength = float(rendering_info.get("localizedBlurStrength", 0.15)) sensor_noise = as_bool(rendering_info.get("sensorNoise", False)) # D sensor_noise_strength = float(rendering_info.get("sensorNoiseStrength", 0.02)) lens_distortion = as_bool(rendering_info.get("lensDistortion", False)) # D (chromat. Aberration) lens_distortion_strength = float(rendering_info.get("lensDistortionStrength", 0.01)) 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 scale = render.resolution_percentage / 100.0 width_px = render.resolution_x * scale height_px = render.resolution_y * scale focal_mm = cam.lens sensor_width_mm = cam.sensor_width sensor_height_mm = cam.sensor_height # Pixel aspect ratio (1.0 for synthetic cameras with square pixels) pixel_aspect_ratio = render.pixel_aspect_y / render.pixel_aspect_x # Blender's 'sensor_fit' decides which sensor axis defines the focal length. # With AUTO it is the longer (pixel-aspect-weighted) image axis, and pixels are # square (fy == fx). The previous code used sensor_height for fy, which only # matched at a 3:2 aspect ratio and was ~15% off at 16:9 (e.g. 1280x720). sensor_fit = cam.sensor_fit if sensor_fit == 'AUTO': sensor_fit = 'HORIZONTAL' if width_px >= height_px * pixel_aspect_ratio else 'VERTICAL' if sensor_fit == 'HORIZONTAL': sensor_size_mm = sensor_width_mm view_fac_px = width_px else: # VERTICAL sensor_size_mm = sensor_height_mm view_fac_px = pixel_aspect_ratio * height_px f_px = focal_mm * view_fac_px / sensor_size_mm fx = f_px fy = f_px / pixel_aspect_ratio # square pixels => fy == fx 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) # ── (A) Kalibrier-Restfehler: Intrinsik gezielt verfälschen ── # Seed deterministisch aus der Kameraposition (NICHT hash() -> PYTHONHASHSEED), damit # dieselbe Kamera über alle Posen denselben Kalibrierfehler trägt. if intr_focal_err_pct > 0.0 or intr_principal_px > 0.0 or intr_residual_dist: _cp = rendering_info.get("cameraPosition", [0, 0, 0]) _seed = abs(int(round(float(_cp[0]) * 1000 + float(_cp[1]) * 100 + float(_cp[2]) * 10))) + 17 _rng = random.Random(_seed) if intr_focal_err_pct > 0.0: fx *= 1.0 + _rng.uniform(-1.0, 1.0) * intr_focal_err_pct / 100.0 fy *= 1.0 + _rng.uniform(-1.0, 1.0) * intr_focal_err_pct / 100.0 if intr_principal_px > 0.0: cx += _rng.uniform(-1.0, 1.0) * intr_principal_px cy += _rng.uniform(-1.0, 1.0) * intr_principal_px camera_matrix = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtype=np.float32) if intr_residual_dist: _k = [float(v) for v in intr_residual_dist] dist_coeffs = np.array([[_k[0] if len(_k) > 0 else 0.0], [_k[1] if len(_k) > 1 else 0.0], [0.0], [0.0], [0.0]], dtype=np.float32) print(f"[render] (A) intrinsics jitter -> fx={fx:.1f} fy={fy:.1f} cx={cx:.1f} cy={cy:.1f} " f"dist={dist_coeffs.ravel()[:2]}") 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: # Wenige feine Staubkörner: hoch aufgelöstes Noise, hohe Schwelle in der # Ramp (nur Spitzen -> wenige kleine Punkte), Stärke als echter Faktor. noise = nodes.new("ShaderNodeTexNoise") ramp = nodes.new("ShaderNodeValToRGB") mult = nodes.new("ShaderNodeMath") mix = nodes.new("ShaderNodeMixRGB") noise.location = (-700, -220) ramp.location = (-480, -220) mult.location = (-280, -220) mix.location = (-120, -120) noise.inputs["Scale"].default_value = 220.0 # feine Körnung noise.inputs["Detail"].default_value = 2.0 # nur die obersten ~5-8 % der Noise-Werte werden zu Staub ramp.color_ramp.elements[0].position = 0.82 ramp.color_ramp.elements[1].position = 0.90 mult.operation = "MULTIPLY" mult.inputs[1].default_value = max(0.0, min(1.0, aruco_dust_strength)) mix.blend_type = "MIX" mix.inputs["Color2"].default_value = (0.78, 0.76, 0.72, 1.0) # heller, matter Staub links.new(tex.outputs["Color"], mix.inputs["Color1"]) links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) links.new(ramp.outputs["Color"], mult.inputs[0]) links.new(mult.outputs["Value"], 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() # Marker "falsch aufgeklebt": deterministische tangentiale Verschiebung # (gleicher Versatz in allen Kameraansichten -> konsistente Geometrie). place_offset = mathutils.Vector((0.0, 0.0, 0.0)) if marker_offset_max_mm > 0.0: rng = random.Random(marker_id + 1000 * marker_offset_seed) ref = mathutils.Vector((1.0, 0.0, 0.0)) if abs(normal_local.dot(ref)) > 0.9: ref = mathutils.Vector((0.0, 1.0, 0.0)) u = normal_local.cross(ref).normalized() v = normal_local.cross(u).normalized() r_m = mm_to_m(marker_offset_max_mm * math.sqrt(rng.random())) # gleichvert. in Kreisfläche ang = rng.uniform(0.0, 2.0 * math.pi) place_offset = u * (r_m * math.cos(ang)) + v * (r_m * math.sin(ang)) if marker_rot_max_deg > 0.0: d_ang = math.radians(rng.uniform(-marker_rot_max_deg, marker_rot_max_deg)) marker_obj.rotation_quaternion = ( mathutils.Quaternion(normal_local, d_ang) @ marker_obj.rotation_quaternion ) # minimal vorziehen gegen Z-Fighting (lokaler Versatz) marker_obj.location = ( mathutils.Vector(marker_pos) + place_offset + 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 # ============================================================ # ── Post-Processing: Verwackeln + Linsen-/Sensor-Effekte (B-D) ── # Eine Compositor-Kette; jeder Effekt einzeln gekapselt, damit ein API-Problem den # Render nicht abbricht (der betroffene Effekt wird dann nur übersprungen + gewarnt). _post_active = ((motion_blur and motion_blur_max_px > 0.0) or lens_distortion or localized_blur or vignette or lens_dirt or sensor_noise) if _post_active: scene.use_nodes = True tree = scene.node_tree for _n in list(tree.nodes): tree.nodes.remove(_n) rl = tree.nodes.new("CompositorNodeRLayers") cur = rl.outputs["Image"] # (D) chromatische Aberration (Dispersion) — KEINE geometrische Verzeichnung (-> A/npz) if lens_distortion and lens_distortion_strength > 0.0: try: ld = tree.nodes.new("CompositorNodeLensdist") ld.use_projector = False ld.inputs["Distort"].default_value = 0.0 ld.inputs["Dispersion"].default_value = min(1.0, lens_distortion_strength) tree.links.new(cur, ld.inputs["Image"]); cur = ld.outputs["Image"] print("[render] (D) chromatic aberration") except Exception as _e: print("[render][WARN] lens_distortion skipped:", _e) # Verwackeln (Motion Blur), pro Aufnahme zufällig gerichtet if motion_blur and motion_blur_max_px > 0.0: try: mb = tree.nodes.new("CompositorNodeBlur") mb.filter_type = "GAUSS"; mb.use_relative = False _ang = random.uniform(0.0, math.pi) _amp = random.uniform(0.35, 1.0) * motion_blur_max_px mb.size_x = max(0, int(round(abs(_amp * math.cos(_ang))))) mb.size_y = max(0, int(round(abs(_amp * math.sin(_ang))))) tree.links.new(cur, mb.inputs["Image"]); cur = mb.outputs["Image"] print(f"[render] motion blur size=({mb.size_x},{mb.size_y})") except Exception as _e: print("[render][WARN] motion_blur skipped:", _e) # (C) Rand-Unschärfe (Feldkrümmung): scharf in der Mitte, unscharf am Rand if localized_blur and localized_blur_strength > 0.0: try: eb = tree.nodes.new("CompositorNodeBlur") eb.filter_type = "GAUSS"; eb.use_relative = True eb.factor_x = min(0.5, localized_blur_strength) eb.factor_y = min(0.5, localized_blur_strength) em = tree.nodes.new("CompositorNodeEllipseMask") em.width = 0.7; em.height = 0.7 ex = tree.nodes.new("CompositorNodeMixRGB") tree.links.new(cur, eb.inputs["Image"]) tree.links.new(em.outputs["Mask"], ex.inputs["Fac"]) tree.links.new(eb.outputs["Image"], ex.inputs[1]) # Fac=0 (Rand) -> Blur tree.links.new(cur, ex.inputs[2]) # Fac=1 (Mitte) -> scharf cur = ex.outputs["Image"] print("[render] (C) edge blur") except Exception as _e: print("[render][WARN] localized_blur skipped:", _e) # (C) Vignette: Randabdunklung über weiche Ellipse-Maske if vignette and vignette_strength > 0.0: try: vm = tree.nodes.new("CompositorNodeEllipseMask") vm.width = 1.0; vm.height = 1.0 vs = tree.nodes.new("CompositorNodeBlur") vs.filter_type = "GAUSS"; vs.use_relative = True vs.factor_x = 0.3; vs.factor_y = 0.3 vr = tree.nodes.new("CompositorNodeMapRange") vr.inputs["From Min"].default_value = 0.0 vr.inputs["From Max"].default_value = 1.0 vr.inputs["To Min"].default_value = 1.0 - min(0.9, vignette_strength) vr.inputs["To Max"].default_value = 1.0 vmul = tree.nodes.new("CompositorNodeMixRGB") vmul.blend_type = "MULTIPLY"; vmul.inputs["Fac"].default_value = 1.0 tree.links.new(vm.outputs["Mask"], vs.inputs["Image"]) tree.links.new(vs.outputs["Image"], vr.inputs["Value"]) tree.links.new(cur, vmul.inputs[1]) tree.links.new(vr.outputs["Value"], vmul.inputs[2]) cur = vmul.outputs["Image"] print("[render] (C) vignette") except Exception as _e: print("[render][WARN] vignette skipped:", _e) # (B) Staub auf der Linse: wenige dunkle Flecken über dem ganzen Bild if lens_dirt and lens_dirt_strength > 0.0: try: dtex = bpy.data.textures.new("lensDirtTex", type="VORONOI") dt = tree.nodes.new("CompositorNodeTexture") dt.texture = dtex dramp = tree.nodes.new("CompositorNodeValToRGB") dramp.color_ramp.elements[0].position = 0.0 dramp.color_ramp.elements[1].position = 0.12 # nur wenige Flecken dramp.color_ramp.elements[0].color = (1, 1, 1, 1) dramp.color_ramp.elements[1].color = (1.0 - min(0.9, lens_dirt_strength),) * 3 + (1.0,) dmul = tree.nodes.new("CompositorNodeMixRGB") dmul.blend_type = "MULTIPLY"; dmul.inputs["Fac"].default_value = 1.0 tree.links.new(dt.outputs["Value"], dramp.inputs["Fac"]) tree.links.new(cur, dmul.inputs[1]) tree.links.new(dramp.outputs["Image"], dmul.inputs[2]) cur = dmul.outputs["Image"] print("[render] (B) lens dirt") except Exception as _e: print("[render][WARN] lens_dirt skipped:", _e) # (D) Sensor-Rauschen: feines additives Rauschen if sensor_noise and sensor_noise_strength > 0.0: try: ntex = bpy.data.textures.new("sensorNoiseTex", type="NOISE") nt = tree.nodes.new("CompositorNodeTexture") nt.texture = ntex nmix = tree.nodes.new("CompositorNodeMixRGB") nmix.blend_type = "ADD" nmix.inputs["Fac"].default_value = min(0.3, sensor_noise_strength) tree.links.new(cur, nmix.inputs[1]) tree.links.new(nt.outputs["Image"], nmix.inputs[2]) cur = nmix.outputs["Image"] print("[render] (D) sensor noise") except Exception as _e: print("[render][WARN] sensor_noise skipped:", _e) comp = tree.nodes.new("CompositorNodeComposite") tree.links.new(cur, comp.inputs["Image"]) 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)