1222 lines
46 KiB
Python
1222 lines
46 KiB
Python
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)
|
|
USER_HOME = Path.home()
|
|
|
|
BASE = Path(__file__).resolve().parents[2]
|
|
|
|
# Kombiniert den Benutzerpfad mit dem spezifischen Ordnerpfad und konvertiert direkt zu str
|
|
ROBOT_JSON_FILE = str(BASE / "data" / "robot" / "robot.json")
|
|
OUTPUT_FILE = 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)
|