| |
| """ |
| Blender ERP全景图渲染脚本 |
| |
| 此脚本在Blender内部执行,用于渲染ERP(等距圆柱投影)全景图。 |
| |
| 使用方法: |
| blender --background --python render_erp_blender.py -- \ |
| --mesh "path/to/mesh.glb" \ |
| --output "output/panorama_0000.png" \ |
| --camera-pos "0.0,0.5,0.0" \ |
| --camera-rot "0.0,0.0,0.0" \ |
| --resolution "1024,512" |
| """ |
|
|
| import bpy |
| import sys |
| import os |
| import json |
| import math |
| import argparse |
| from mathutils import Vector, Euler, Quaternion |
|
|
|
|
| def parse_args(): |
| """解析命令行参数(Blender的--之后的参数)""" |
| |
| argv = sys.argv |
| if "--" in argv: |
| argv = argv[argv.index("--") + 1:] |
| else: |
| argv = [] |
| |
| parser = argparse.ArgumentParser(description="Blender ERP渲染脚本") |
| parser.add_argument("--mesh", type=str, required=True, |
| help="输入mesh文件路径(GLB/GLTF/OBJ)") |
| parser.add_argument("--output", type=str, required=True, |
| help="输出图像路径") |
| parser.add_argument("--pose-output", type=str, default=None, |
| help="输出位姿JSON路径(默认与图像同名)") |
| parser.add_argument("--camera-pos", type=str, default="0.0,0.0,0.0", |
| help="相机位置 x,y,z") |
| parser.add_argument("--camera-rot", type=str, default="0.0,0.0,0.0", |
| help="相机旋转 roll,pitch,yaw(弧度)") |
| parser.add_argument("--camera-rot-quat", type=str, default=None, |
| help="(已废弃)相机旋转四元数 w,x,y,z。现在主路径使用 --camera-rot Euler角") |
| parser.add_argument("--resolution", type=str, default="1024,512", |
| help="渲染分辨率 width,height") |
| parser.add_argument("--samples", type=int, default=16, |
| help="渲染采样数(默认16,Emission材质不需要高采样,可大幅提升渲染速度)") |
| parser.add_argument("--engine", type=str, default="CYCLES", |
| choices=["BLENDER_EEVEE", "CYCLES"], |
| help="渲染引擎(全景图必须使用CYCLES)") |
| parser.add_argument("--frame-id", type=int, default=0, |
| help="帧序号") |
| parser.add_argument("--ref-position", type=str, default=None, |
| help="参考帧位置 x,y,z(Y-up坐标系),None表示第一帧") |
| parser.add_argument("--ref-quaternion", type=str, default=None, |
| help="参考帧四元数 w,x,y,z,None表示第一帧") |
| parser.add_argument("--render-depth", action="store_true", |
| help="是否渲染深度图(保存为.npy格式)") |
| parser.add_argument("--depth-output", type=str, default=None, |
| help="深度图输出路径(默认与图像同目录,后缀_depth.npy)") |
| |
| return parser.parse_args(argv) |
|
|
|
|
| def clear_scene(): |
| """清空当前场景""" |
| |
| bpy.ops.object.select_all(action='SELECT') |
| |
| bpy.ops.object.delete(use_global=False) |
| |
| |
| for block in bpy.data.meshes: |
| if block.users == 0: |
| bpy.data.meshes.remove(block) |
| for block in bpy.data.materials: |
| if block.users == 0: |
| bpy.data.materials.remove(block) |
| for block in bpy.data.textures: |
| if block.users == 0: |
| bpy.data.textures.remove(block) |
| for block in bpy.data.images: |
| if block.users == 0: |
| bpy.data.images.remove(block) |
|
|
|
|
| def import_mesh(mesh_path): |
| """导入mesh文件""" |
| ext = os.path.splitext(mesh_path)[1].lower() |
| |
| if ext in ['.glb', '.gltf']: |
| bpy.ops.import_scene.gltf(filepath=mesh_path) |
| elif ext == '.obj': |
| bpy.ops.wm.obj_import(filepath=mesh_path) |
| elif ext == '.fbx': |
| bpy.ops.import_scene.fbx(filepath=mesh_path) |
| elif ext == '.ply': |
| bpy.ops.wm.ply_import(filepath=mesh_path) |
| else: |
| raise ValueError(f"不支持的文件格式: {ext}") |
| |
| print(f"[INFO] 导入mesh: {mesh_path}") |
| |
| |
| imported_objects = [obj for obj in bpy.context.selected_objects] |
| print(f"[INFO] 导入了 {len(imported_objects)} 个对象") |
| |
| |
| apply_procedural_textures(imported_objects) |
| |
| return imported_objects |
|
|
|
|
| def is_room_structure(obj_name): |
| """ |
| 判断对象是否是房间结构(墙面、地板、天花板) |
| |
| 房间结构的常见命名模式: |
| 1. None.obj - 标准3D-Front房间结构 |
| 2. geometry_N - 无纹理的通用几何体 |
| 3. 纯数字.obj (如 12670.obj) - 数字ID命名的结构 |
| """ |
| name_lower = obj_name.lower() |
| |
| |
| if 'none' in name_lower: |
| return True |
| |
| |
| if name_lower.startswith('geometry_') or name_lower.startswith('geometry.'): |
| return True |
| |
| |
| base_name = obj_name.replace('.obj', '').replace('.OBJ', '') |
| if base_name.isdigit(): |
| return True |
| |
| return False |
|
|
|
|
| def apply_procedural_textures(objects): |
| """为所有对象添加Emission材质(材质预览模式:显示原始颜色,不受光照影响)""" |
| applied_count = 0 |
| for obj in objects: |
| if obj.type != 'MESH': |
| continue |
| |
| |
| has_material = obj.data.materials and len(obj.data.materials) > 0 and obj.data.materials[0] is not None |
| |
| |
| |
| if not has_material or is_room_structure(obj.name): |
| |
| print(f"[INFO] 为对象添加Emission材质: {obj.name}") |
| apply_room_material(obj) |
| applied_count += 1 |
| else: |
| |
| print(f"[INFO] 将对象材质转换为Emission: {obj.name}") |
| convert_to_emission_material(obj) |
| applied_count += 1 |
| |
| if applied_count == 0: |
| print("[WARN] 未找到需要添加材质的对象") |
| else: |
| print(f"[INFO] 共为 {applied_count} 个对象添加了Emission材质(材质预览模式)") |
|
|
|
|
| def apply_room_material(obj): |
| """为房间结构应用程序化材质(墙面、地板、天花板)""" |
| |
| mat = bpy.data.materials.new(name="RoomProceduralMaterial") |
| mat.use_nodes = True |
| |
| nodes = mat.node_tree.nodes |
| links = mat.node_tree.links |
| |
| |
| nodes.clear() |
| |
| |
| output = nodes.new('ShaderNodeOutputMaterial') |
| output.location = (800, 0) |
| |
| |
| geometry = nodes.new('ShaderNodeNewGeometry') |
| geometry.location = (-600, 0) |
| |
| |
| separate_xyz = nodes.new('ShaderNodeSeparateXYZ') |
| separate_xyz.location = (-400, 0) |
| links.new(geometry.outputs['Normal'], separate_xyz.inputs['Vector']) |
| |
| |
| ceiling_check = nodes.new('ShaderNodeMath') |
| ceiling_check.operation = 'LESS_THAN' |
| ceiling_check.inputs[1].default_value = -0.5 |
| ceiling_check.location = (-200, 100) |
| links.new(separate_xyz.outputs['Z'], ceiling_check.inputs[0]) |
| |
| |
| floor_check = nodes.new('ShaderNodeMath') |
| floor_check.operation = 'GREATER_THAN' |
| floor_check.inputs[1].default_value = 0.5 |
| floor_check.location = (-200, -100) |
| links.new(separate_xyz.outputs['Z'], floor_check.inputs[0]) |
| |
| |
| floor_shader = create_wood_floor_material(nodes, links) |
| floor_shader.location = (0, 300) |
| |
| wall_shader = create_brick_wall_material(nodes, links) |
| wall_shader.location = (0, 0) |
| |
| ceiling_shader = create_grid_ceiling_material(nodes, links) |
| ceiling_shader.location = (0, -300) |
| |
| |
| mix_floor_wall = nodes.new('ShaderNodeMixShader') |
| mix_floor_wall.location = (300, 100) |
| links.new(floor_check.outputs['Value'], mix_floor_wall.inputs['Fac']) |
| links.new(wall_shader.outputs['Emission'], mix_floor_wall.inputs[1]) |
| links.new(floor_shader.outputs['Emission'], mix_floor_wall.inputs[2]) |
| |
| |
| mix_final = nodes.new('ShaderNodeMixShader') |
| mix_final.location = (500, 0) |
| links.new(ceiling_check.outputs['Value'], mix_final.inputs['Fac']) |
| links.new(mix_floor_wall.outputs['Shader'], mix_final.inputs[1]) |
| links.new(ceiling_shader.outputs['Emission'], mix_final.inputs[2]) |
| |
| |
| links.new(mix_final.outputs['Shader'], output.inputs['Surface']) |
| |
| |
| if obj.data.materials: |
| obj.data.materials[0] = mat |
| else: |
| obj.data.materials.append(mat) |
| |
| print(f"[INFO] Emission材质已应用(地板+砖墙+网格天花板,材质预览模式)") |
|
|
|
|
| def convert_to_emission_material(obj): |
| """将现有材质转换为Emission材质(材质预览模式)""" |
| if not obj.data.materials or len(obj.data.materials) == 0: |
| |
| apply_room_material(obj) |
| return |
| |
| |
| existing_mat = obj.data.materials[0] |
| if existing_mat is None: |
| apply_room_material(obj) |
| return |
| |
| |
| if existing_mat.use_nodes: |
| nodes = existing_mat.node_tree.nodes |
| links = existing_mat.node_tree.links |
| |
| |
| bsdf_node = None |
| for node in nodes: |
| if node.type == 'BSDF_PRINCIPLED': |
| bsdf_node = node |
| break |
| |
| if bsdf_node and 'Base Color' in bsdf_node.inputs: |
| |
| base_color_input = bsdf_node.inputs['Base Color'] |
| |
| |
| emission = nodes.new('ShaderNodeEmission') |
| emission.name = "Emission" |
| emission.location = bsdf_node.location |
| |
| |
| if base_color_input.is_linked: |
| |
| color_source = base_color_input.links[0].from_node |
| color_output = base_color_input.links[0].from_socket |
| links.new(color_output, emission.inputs['Color']) |
| else: |
| |
| emission.inputs['Color'].default_value = base_color_input.default_value |
| |
| emission.inputs['Strength'].default_value = 1.0 |
| |
| |
| output_node = None |
| for node in nodes: |
| if node.type == 'OUTPUT_MATERIAL': |
| output_node = node |
| break |
| |
| if output_node: |
| |
| if output_node.inputs['Surface'].is_linked: |
| for link in output_node.inputs['Surface'].links: |
| existing_mat.node_tree.links.remove(link) |
| |
| links.new(emission.outputs['Emission'], output_node.inputs['Surface']) |
| print(f"[INFO] 已将材质转换为Emission: {obj.name}") |
| return |
| |
| |
| apply_room_material(obj) |
|
|
|
|
| def create_wood_floor_material(nodes, links): |
| """创建木地板程序化材质(Emission模式,显示原始颜色)""" |
| |
| emission = nodes.new('ShaderNodeEmission') |
| emission.name = "FloorEmission" |
| |
| |
| noise = nodes.new('ShaderNodeTexNoise') |
| noise.inputs['Scale'].default_value = 20.0 |
| noise.inputs['Detail'].default_value = 8.0 |
| noise.inputs['Roughness'].default_value = 0.6 |
| noise.location = (-600, 200) |
| |
| |
| wave = nodes.new('ShaderNodeTexWave') |
| wave.wave_type = 'BANDS' |
| wave.bands_direction = 'X' |
| wave.inputs['Scale'].default_value = 3.0 |
| wave.inputs['Distortion'].default_value = 5.0 |
| wave.inputs['Detail'].default_value = 3.0 |
| wave.location = (-600, 0) |
| |
| |
| color_ramp = nodes.new('ShaderNodeValToRGB') |
| color_ramp.color_ramp.elements[0].color = (0.15, 0.08, 0.04, 1.0) |
| color_ramp.color_ramp.elements[1].color = (0.35, 0.20, 0.10, 1.0) |
| color_ramp.location = (-400, 100) |
| |
| |
| mix_rgb = nodes.new('ShaderNodeMix') |
| mix_rgb.data_type = 'RGBA' |
| mix_rgb.inputs['Factor'].default_value = 0.5 |
| mix_rgb.location = (-400, 0) |
| |
| links.new(noise.outputs['Fac'], mix_rgb.inputs['A']) |
| links.new(wave.outputs['Fac'], mix_rgb.inputs['B']) |
| links.new(mix_rgb.outputs['Result'], color_ramp.inputs['Fac']) |
| |
| |
| links.new(color_ramp.outputs['Color'], emission.inputs['Color']) |
| emission.inputs['Strength'].default_value = 1.0 |
| |
| return emission |
|
|
|
|
| def create_brick_wall_material(nodes, links): |
| """创建砖墙程序化材质(Emission模式,显示原始颜色)""" |
| |
| emission = nodes.new('ShaderNodeEmission') |
| emission.name = "BrickWallEmission" |
| |
| |
| tex_coord = nodes.new('ShaderNodeTexCoord') |
| tex_coord.location = (-800, 0) |
| |
| |
| mapping = nodes.new('ShaderNodeMapping') |
| mapping.inputs['Scale'].default_value = (4.0, 8.0, 1.0) |
| mapping.location = (-600, 0) |
| links.new(tex_coord.outputs['Generated'], mapping.inputs['Vector']) |
| |
| |
| brick = nodes.new('ShaderNodeTexBrick') |
| brick.inputs['Color1'].default_value = (0.6, 0.3, 0.2, 1.0) |
| brick.inputs['Color2'].default_value = (0.5, 0.25, 0.15, 1.0) |
| brick.inputs['Mortar'].default_value = (0.85, 0.85, 0.8, 1.0) |
| brick.inputs['Scale'].default_value = 3.0 |
| brick.inputs['Mortar Size'].default_value = 0.02 |
| brick.inputs['Mortar Smooth'].default_value = 0.1 |
| brick.inputs['Bias'].default_value = 0.0 |
| brick.inputs['Brick Width'].default_value = 0.5 |
| brick.inputs['Row Height'].default_value = 0.25 |
| brick.location = (-400, 0) |
| links.new(mapping.outputs['Vector'], brick.inputs['Vector']) |
| |
| |
| noise = nodes.new('ShaderNodeTexNoise') |
| noise.inputs['Scale'].default_value = 50.0 |
| noise.inputs['Detail'].default_value = 3.0 |
| noise.location = (-400, -200) |
| links.new(mapping.outputs['Vector'], noise.inputs['Vector']) |
| |
| |
| mix_color = nodes.new('ShaderNodeMix') |
| mix_color.data_type = 'RGBA' |
| mix_color.inputs['Factor'].default_value = 0.1 |
| mix_color.location = (-200, 0) |
| links.new(brick.outputs['Color'], mix_color.inputs['A']) |
| links.new(noise.outputs['Color'], mix_color.inputs['B']) |
| |
| |
| links.new(mix_color.outputs['Result'], emission.inputs['Color']) |
| emission.inputs['Strength'].default_value = 2.0 |
| |
| return emission |
|
|
|
|
| def create_grid_ceiling_material(nodes, links): |
| """创建网格天花板程序化材质(Emission模式,显示原始颜色)""" |
| |
| emission = nodes.new('ShaderNodeEmission') |
| emission.name = "GridCeilingEmission" |
| |
| |
| tex_coord = nodes.new('ShaderNodeTexCoord') |
| tex_coord.location = (-800, -400) |
| |
| |
| mapping = nodes.new('ShaderNodeMapping') |
| mapping.inputs['Scale'].default_value = (5.0, 5.0, 1.0) |
| mapping.location = (-600, -400) |
| links.new(tex_coord.outputs['Generated'], mapping.inputs['Vector']) |
| |
| |
| separate = nodes.new('ShaderNodeSeparateXYZ') |
| separate.location = (-400, -400) |
| links.new(mapping.outputs['Vector'], separate.inputs['Vector']) |
| |
| |
| math_sin_x = nodes.new('ShaderNodeMath') |
| math_sin_x.operation = 'SINE' |
| math_sin_x.location = (-200, -350) |
| |
| math_mul_x = nodes.new('ShaderNodeMath') |
| math_mul_x.operation = 'MULTIPLY' |
| math_mul_x.inputs[1].default_value = 6.28 |
| math_mul_x.location = (-300, -350) |
| links.new(separate.outputs['X'], math_mul_x.inputs[0]) |
| links.new(math_mul_x.outputs['Value'], math_sin_x.inputs[0]) |
| |
| |
| math_sin_y = nodes.new('ShaderNodeMath') |
| math_sin_y.operation = 'SINE' |
| math_sin_y.location = (-200, -500) |
| |
| math_mul_y = nodes.new('ShaderNodeMath') |
| math_mul_y.operation = 'MULTIPLY' |
| math_mul_y.inputs[1].default_value = 6.28 |
| math_mul_y.location = (-300, -500) |
| links.new(separate.outputs['Y'], math_mul_y.inputs[0]) |
| links.new(math_mul_y.outputs['Value'], math_sin_y.inputs[0]) |
| |
| |
| abs_x = nodes.new('ShaderNodeMath') |
| abs_x.operation = 'ABSOLUTE' |
| abs_x.location = (-100, -350) |
| links.new(math_sin_x.outputs['Value'], abs_x.inputs[0]) |
| |
| abs_y = nodes.new('ShaderNodeMath') |
| abs_y.operation = 'ABSOLUTE' |
| abs_y.location = (-100, -500) |
| links.new(math_sin_y.outputs['Value'], abs_y.inputs[0]) |
| |
| |
| math_min = nodes.new('ShaderNodeMath') |
| math_min.operation = 'MINIMUM' |
| math_min.location = (0, -425) |
| links.new(abs_x.outputs['Value'], math_min.inputs[0]) |
| links.new(abs_y.outputs['Value'], math_min.inputs[1]) |
| |
| |
| color_ramp = nodes.new('ShaderNodeValToRGB') |
| color_ramp.color_ramp.elements[0].color = (0.3, 0.3, 0.35, 1.0) |
| color_ramp.color_ramp.elements[0].position = 0.0 |
| color_ramp.color_ramp.elements[1].color = (0.95, 0.95, 0.95, 1.0) |
| color_ramp.color_ramp.elements[1].position = 0.15 |
| color_ramp.location = (150, -425) |
| links.new(math_min.outputs['Value'], color_ramp.inputs['Fac']) |
| |
| |
| links.new(color_ramp.outputs['Color'], emission.inputs['Color']) |
| emission.inputs['Strength'].default_value = 2.0 |
| |
| return emission |
|
|
|
|
| def get_scene_bounds(): |
| """获取场景中所有物体的边界框""" |
| min_coords = [float('inf'), float('inf'), float('inf')] |
| max_coords = [float('-inf'), float('-inf'), float('-inf')] |
| |
| for obj in bpy.context.scene.objects: |
| if obj.type == 'MESH': |
| |
| for corner in obj.bound_box: |
| world_corner = obj.matrix_world @ Vector(corner) |
| for i in range(3): |
| min_coords[i] = min(min_coords[i], world_corner[i]) |
| max_coords[i] = max(max_coords[i], world_corner[i]) |
| |
| |
| if min_coords[0] == float('inf'): |
| return ([-5, -5, 0], [5, 5, 3]) |
| |
| return (min_coords, max_coords) |
|
|
|
|
| def create_erp_camera(name="ERP_Camera"): |
| """创建ERP全景相机""" |
| |
| camera_data = bpy.data.cameras.new(name=name) |
| |
| |
| camera_data.type = 'PANO' |
| |
| |
| |
| if hasattr(camera_data, 'panorama_type'): |
| camera_data.panorama_type = 'EQUIRECTANGULAR' |
| |
| if hasattr(camera_data, 'cycles'): |
| camera_data.cycles.panorama_type = 'EQUIRECTANGULAR' |
| |
| |
| camera_object = bpy.data.objects.new(name, camera_data) |
| |
| |
| bpy.context.scene.collection.objects.link(camera_object) |
| |
| print(f"[INFO] 创建ERP相机: {name}") |
| |
| return camera_object |
|
|
|
|
| def setup_camera(camera_object, position, rotation_euler=None, rotation_quat=None): |
| """设置相机位置和旋转(Euler 或 Quaternion)""" |
| |
| camera_object.location = Vector(position) |
| |
| |
| if rotation_quat is not None: |
| |
| camera_object.rotation_mode = 'QUATERNION' |
| camera_object.rotation_quaternion = Quaternion(rotation_quat) |
| print(f"[INFO] 相机位置: {position}") |
| print(f"[INFO] 相机旋转(Quaternion wxyz): {list(rotation_quat)}") |
| else: |
| |
| camera_object.rotation_mode = 'XYZ' |
| camera_object.rotation_euler = Euler(rotation_euler, 'XYZ') |
| print(f"[INFO] 相机位置: {position}") |
| print(f"[INFO] 相机旋转(Euler XYZ, rad): {rotation_euler}") |
|
|
|
|
| def setup_render_settings(resolution, engine, samples): |
| """设置渲染参数""" |
| scene = bpy.context.scene |
| |
| |
| scene.render.engine = engine |
| print(f"[INFO] 渲染引擎: {engine}") |
| |
| |
| scene.render.resolution_x = resolution[0] |
| scene.render.resolution_y = resolution[1] |
| scene.render.resolution_percentage = 100 |
| print(f"[INFO] 分辨率: {resolution[0]}x{resolution[1]}") |
| |
| |
| scene.render.image_settings.file_format = 'PNG' |
| scene.render.image_settings.color_mode = 'RGB' |
| scene.render.image_settings.color_depth = '8' |
| |
| |
| if engine == 'BLENDER_EEVEE': |
| |
| if hasattr(scene, 'eevee'): |
| |
| if hasattr(scene.eevee, 'taa_render_samples'): |
| scene.eevee.taa_render_samples = samples |
| |
| if hasattr(scene.eevee, 'use_soft_shadows'): |
| scene.eevee.use_soft_shadows = True |
| elif engine == 'CYCLES': |
| |
| scene.cycles.samples = samples |
| scene.cycles.use_denoising = True |
| |
| |
| |
| scene.cycles.max_bounces = 4 |
| scene.cycles.diffuse_bounces = 2 |
| scene.cycles.glossy_bounces = 2 |
| scene.cycles.transmission_bounces = 2 |
| |
| |
| try: |
| bpy.context.preferences.addons['cycles'].preferences.compute_device_type = 'CUDA' |
| bpy.context.scene.cycles.device = 'GPU' |
| print("[INFO] 使用GPU渲染") |
| except: |
| print("[INFO] 使用CPU渲染") |
|
|
|
|
| def setup_lighting(camera_position=None, scene_bounds=None): |
| """ |
| 设置照明(材质预览模式:仅使用强环境光,无其他光源,显示材质原始颜色和亮度) |
| |
| Args: |
| camera_position: 相机位置 (x, y, z)(未使用) |
| scene_bounds: 场景边界 (min, max)(未使用) |
| """ |
| scene = bpy.context.scene |
| |
| |
| world = bpy.data.worlds.new("World") |
| scene.world = world |
| world.use_nodes = True |
| |
| |
| nodes = world.node_tree.nodes |
| links = world.node_tree.links |
| |
| |
| nodes.clear() |
| |
| |
| |
| background = nodes.new('ShaderNodeBackground') |
| background.inputs['Color'].default_value = (1.0, 1.0, 1.0, 1.0) |
| background.inputs['Strength'].default_value = 1.0 |
| |
| |
| output = nodes.new('ShaderNodeOutputWorld') |
| |
| |
| links.new(background.outputs['Background'], output.inputs['Surface']) |
| |
| |
| |
| |
| |
| print("[INFO] 设置照明完成(材质预览模式:仅强环境光,无其他光源,显示材质原始颜色和亮度)") |
|
|
|
|
| def setup_depth_pass(): |
| """ |
| 设置深度渲染 pass(Blender 5.0+ API) |
| |
| 在 Blender 中启用 Z pass,用于获取深度信息。 |
| 使用 Blender 5.0 新的 compositing_node_group API。 |
| """ |
| scene = bpy.context.scene |
| |
| |
| view_layer = bpy.context.view_layer |
| view_layer.use_pass_z = True |
| |
| |
| |
| tree = bpy.data.node_groups.new("DepthCompositor", "CompositorNodeTree") |
| scene.compositing_node_group = tree |
| nodes = tree.nodes |
| links = tree.links |
| |
| |
| render_layers = nodes.new('CompositorNodeRLayers') |
| render_layers.location = (0, 300) |
| |
| |
| output = nodes.new('NodeGroupOutput') |
| output.location = (400, 300) |
| tree.interface.new_socket(name="Image", in_out="OUTPUT", socket_type="NodeSocketColor") |
| |
| |
| links.new(render_layers.outputs['Image'], output.inputs['Image']) |
| |
| |
| file_output = nodes.new('CompositorNodeOutputFile') |
| file_output.location = (400, 0) |
| file_output.directory = "" |
| file_output.format.media_type = 'IMAGE' |
| file_output.format.file_format = 'OPEN_EXR' |
| file_output.format.color_depth = '32' |
| file_output.format.exr_codec = 'ZIP' |
| |
| |
| file_output.file_output_items.clear() |
| file_output.file_output_items.new('FLOAT', "depth") |
| |
| |
| links.new(render_layers.outputs['Depth'], file_output.inputs['depth']) |
| |
| print("[INFO] 深度 pass 已启用(Blender 5.0 API)") |
| |
| return file_output |
|
|
|
|
| def _convert_depth_exr_via_blender_api(exr_path, npy_path): |
| """使用 Blender 图像 API 将 EXR 转为 NPY(备用路径,不依赖 OpenEXR)。""" |
| import numpy as np |
| img = bpy.data.images.load(exr_path) |
| width = img.size[0] |
| height = img.size[1] |
| pixels = np.array(img.pixels[:]) |
| pixels = pixels.reshape(height, width, -1) |
| depth = pixels[:, :, 0] |
| depth = np.flipud(depth) |
| unit_scale = bpy.context.scene.unit_settings.scale_length |
| depth_meters = depth * unit_scale |
| max_valid_depth = 1000.0 |
| depth_meters[depth_meters > max_valid_depth] = np.nan |
| depth_meters[depth_meters <= 0] = np.nan |
| np.save(npy_path, depth_meters.astype(np.float32)) |
| bpy.data.images.remove(img) |
| os.remove(exr_path) |
| print(f"[OK] 深度图保存(备用方法): {npy_path}") |
|
|
|
|
| def convert_depth_exr_to_npy(exr_path, npy_path): |
| """ |
| 将 Blender 渲染的深度 EXR 转换为 NPY 格式 |
| |
| Blender ERP 相机的深度是 range depth(射线距离),单位为 Blender 单位(通常是米) |
| |
| Args: |
| exr_path: EXR 文件路径 |
| npy_path: NPY 输出路径 |
| """ |
| import numpy as np |
| try: |
| import OpenEXR |
| import Imath |
| |
| |
| exr_file = OpenEXR.InputFile(exr_path) |
| |
| |
| header = exr_file.header() |
| dw = header['dataWindow'] |
| width = dw.max.x - dw.min.x + 1 |
| height = dw.max.y - dw.min.y + 1 |
| |
| |
| |
| pt = Imath.PixelType(Imath.PixelType.FLOAT) |
| |
| |
| channel_names = ['depth.R', 'R', 'V', 'Z', 'depth.V'] |
| depth_str = None |
| for ch in channel_names: |
| if ch in header['channels']: |
| depth_str = exr_file.channel(ch, pt) |
| print(f"[INFO] 使用深度通道: {ch}") |
| break |
| |
| if depth_str is None: |
| |
| available_channels = list(header['channels'].keys()) |
| print(f"[WARN] 可用通道: {available_channels}") |
| |
| if available_channels: |
| depth_str = exr_file.channel(available_channels[0], pt) |
| print(f"[INFO] 使用通道: {available_channels[0]}") |
| else: |
| raise ValueError("无法找到深度通道") |
| |
| |
| depth = np.frombuffer(depth_str, dtype=np.float32) |
| depth = depth.reshape(height, width) |
| |
| |
| unit_scale = bpy.context.scene.unit_settings.scale_length |
| |
| |
| |
| depth_meters = depth * unit_scale |
| |
| |
| |
| max_valid_depth = 1000.0 |
| depth_meters[depth_meters > max_valid_depth] = np.nan |
| depth_meters[depth_meters <= 0] = np.nan |
| |
| |
| np.save(npy_path, depth_meters.astype(np.float32)) |
| |
| |
| os.remove(exr_path) |
| |
| |
| valid_mask = np.isfinite(depth_meters) |
| if np.any(valid_mask): |
| print(f"[OK] 深度图保存: {npy_path}") |
| print(f" 形状: {depth_meters.shape}") |
| print(f" 深度范围: {np.nanmin(depth_meters):.3f} - {np.nanmax(depth_meters):.3f} 米") |
| print(f" 有效像素: {np.sum(valid_mask)} / {depth_meters.size} ({100*np.sum(valid_mask)/depth_meters.size:.1f}%)") |
| else: |
| print(f"[WARN] 深度图全部无效!") |
| |
| except ImportError: |
| print("[ERROR] 需要安装 OpenEXR 库: pip install OpenEXR") |
| print("[INFO] 尝试使用 Blender 内置方法...") |
| try: |
| _convert_depth_exr_via_blender_api(exr_path, npy_path) |
| except Exception as e: |
| print(f"[ERROR] 备用方法也失败: {e}") |
| print(f"[INFO] EXR 文件保留在: {exr_path}") |
| except Exception as e: |
| print(f"[WARN] OpenEXR 读取失败,尝试 Blender 内置方法: {e}") |
| try: |
| _convert_depth_exr_via_blender_api(exr_path, npy_path) |
| except Exception as e2: |
| print(f"[ERROR] 备用方法也失败: {e2}") |
| print(f"[INFO] EXR 文件保留在: {exr_path}") |
|
|
|
|
| def render_and_save(output_path, render_depth=False, depth_output=None): |
| """ |
| 执行渲染并保存 |
| |
| Args: |
| output_path: RGB 图像输出路径 |
| render_depth: 是否渲染深度 |
| depth_output: 深度图输出路径(.npy 格式) |
| """ |
| |
| output_dir = os.path.dirname(output_path) |
| if output_dir and not os.path.exists(output_dir): |
| os.makedirs(output_dir) |
| |
| |
| bpy.context.scene.render.filepath = output_path |
| |
| |
| print(f"[INFO] 开始渲染...") |
| bpy.ops.render.render(write_still=True) |
|
|
| |
| if (not os.path.exists(output_path)) or os.path.getsize(output_path) <= 0: |
| raise RuntimeError(f"渲染完成但输出图像不存在或为空: {output_path}") |
|
|
| print(f"[OK] 渲染完成: {output_path}") |
|
|
|
|
| def save_pose(camera_object, output_path, frame_id=0, ref_position=None, ref_quaternion=None): |
| """ |
| 保存相机位姿(绝对位姿,兼容 ERPT 格式) |
| |
| 输出格式: |
| - position: 相机中心在世界坐标系的绝对位置(米),[X右, Y上, Z前] |
| - rotation_quaternion: [w, x, y, z],camera->world 旋转 (R_cw) |
| |
| 核心公式:R_cw_erpt = T @ R_blender_obj @ M |
| - T: Blender世界(Y前Z上) -> 统一世界(Y上Z前) 坐标轴交换 |
| - R_blender_obj: Blender相机的旋转矩阵(object local -> world) |
| - M: Blender相机本地(-Z前) -> ERPT相机(+Z前) Z轴翻转 |
| |
| Args: |
| camera_object: Blender相机对象 |
| output_path: 输出路径 |
| frame_id: 帧序号 |
| ref_position: (保留参数,当前未使用) |
| ref_quaternion: (保留参数,当前未使用) |
| """ |
| from mathutils import Matrix |
| |
| |
| abs_position_blender = list(camera_object.location) |
| abs_quat_blender = camera_object.rotation_euler.to_quaternion() |
| |
| |
| |
| abs_position_unified = [ |
| abs_position_blender[0], |
| abs_position_blender[2], |
| abs_position_blender[1] |
| ] |
| |
| |
| |
| R_obj_blender = abs_quat_blender.to_matrix() |
| |
| |
| T_blender_to_unified = Matrix([ |
| [1, 0, 0], |
| [0, 0, 1], |
| [0, 1, 0] |
| ]) |
| |
| |
| |
| |
| M_cam = Matrix([ |
| [1, 0, 0], |
| [0, 1, 0], |
| [0, 0, -1] |
| ]) |
| |
| |
| |
| R_cw_erpt = T_blender_to_unified @ R_obj_blender @ M_cam |
| |
| |
| quat_cw = R_cw_erpt.to_quaternion() |
| abs_quaternion_cw = [quat_cw.w, quat_cw.x, quat_cw.y, quat_cw.z] |
| |
| |
| |
| pose_data = { |
| "frame_id": frame_id, |
| "position": abs_position_unified, |
| "rotation_quaternion": abs_quaternion_cw, |
| "camera_type": "erp_ray", |
| "coordinate_system": "right-handed, Y-up, Z-forward (cam_to_world)", |
| "render_method": "blender_cycles" |
| } |
| |
| |
| with open(output_path, 'w') as f: |
| json.dump(pose_data, f, indent=2) |
|
|
| if (not os.path.exists(output_path)) or os.path.getsize(output_path) <= 0: |
| raise RuntimeError(f"位姿文件写入失败或为空: {output_path}") |
| |
| print(f"[OK] 位姿保存: {output_path}") |
| print(f" Position (absolute, meters): {abs_position_unified}") |
| print(f" Rotation (cam_to_world): {abs_quaternion_cw}") |
| |
| |
| return abs_position_unified, abs_quaternion_cw |
|
|
|
|
| def main(): |
| |
| args = parse_args() |
| |
| |
| camera_pos = [float(x) for x in args.camera_pos.split(',')] |
| camera_rot = [float(x) for x in args.camera_rot.split(',')] |
| camera_rot_quat = None |
| if args.camera_rot_quat: |
| camera_rot_quat = [float(x) for x in args.camera_rot_quat.split(',')] |
| resolution = [int(x) for x in args.resolution.split(',')] |
| |
| |
| ref_position = None |
| ref_quaternion = None |
| if args.ref_position: |
| ref_position = [float(x) for x in args.ref_position.split(',')] |
| if args.ref_quaternion: |
| ref_quaternion = [float(x) for x in args.ref_quaternion.split(',')] |
| |
| |
| if args.pose_output: |
| pose_output = args.pose_output |
| else: |
| pose_output = os.path.splitext(args.output)[0] + '_pose.json' |
| |
| print("=" * 60) |
| print("Blender ERP渲染") |
| print("=" * 60) |
| |
| |
| print("\n[1/6] 清空场景...") |
| clear_scene() |
| |
| |
| print("\n[2/6] 导入mesh...") |
| import_mesh(args.mesh) |
| |
| |
| print("\n[3/6] 创建ERP相机...") |
| camera = create_erp_camera() |
| setup_camera(camera, camera_pos, rotation_euler=camera_rot, rotation_quat=camera_rot_quat) |
| bpy.context.scene.camera = camera |
| |
| |
| scene_bounds = get_scene_bounds() |
| print(f"[INFO] 场景边界: min={scene_bounds[0]}, max={scene_bounds[1]}") |
| |
| |
| print("\n[4/6] 设置渲染参数...") |
| setup_render_settings(resolution, args.engine, args.samples) |
| setup_lighting(camera_position=camera_pos, scene_bounds=scene_bounds) |
| |
| |
| print("\n[5/6] 渲染中...") |
| render_and_save(args.output) |
| |
| |
| print("\n[6/6] 保存位姿...") |
| abs_pos, abs_quat = save_pose( |
| camera, |
| pose_output, |
| frame_id=args.frame_id, |
| ref_position=ref_position, |
| ref_quaternion=ref_quaternion |
| ) |
| |
| |
| print(f"[ABS_POSE] {abs_pos[0]},{abs_pos[1]},{abs_pos[2]}|{abs_quat[0]},{abs_quat[1]},{abs_quat[2]},{abs_quat[3]}") |
| |
| print("\n" + "=" * 60) |
| print("渲染完成!") |
| print("=" * 60) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|
|
|