模拟结构光3D Scanner时,常见的一个问题是如何获取重建点云的真值?在Blender中,可以使用光线求交的方法,从相机光心沿各像素发出射线,与场景物体求交,并将交点导出为点云文件。
本文介绍了一个Blender插件,用于通过光线追踪技术将相机视角下的3D场景转换为点云数据。该插件从相机光心向每个像素发射射线,与场景物体求交后将交点坐标导出为ASC或NPZ格式的点云文件。核心功能包括:1)支持设置分辨率、采样步长等参数;2)可选择场景中的任意相机;3)提供点云数据可视化和导出功能。代码实现了射线投射计算、数据存储和用户界面交互,适用于3D扫描、计算机视觉等需要将3D场景转换为点云数据的应用场景。插件安装后可在Blender的3D视图侧边栏中调用。
以下是Blender插件代码,在Preference->Add-ons中从文件导入,导入方法参见
Blender模拟结构光3D Scanner(二)投影仪内参数匹配-CSDN博客
word_coordinate_mapper.py
bl_info = { "name": "世界坐标映射工具", "author": "Your Name", "version": (1, 1), "blender": (2, 80, 0), "location": "View3D > Sidebar > 工具", "description": "获取指定相机渲染图像每个像素对应的世界坐标", "category": "3D View", } import bpy import numpy as np from mathutils import Vector # 属性组用于存储场景数据 class WorldCoordData(bpy.types.PropertyGroup): resolution: bpy.props.IntVectorProperty( name="分辨率", size=2, default=(640, 480) ) hit_count: bpy.props.IntProperty( name="命中点数", default=0 ) step_size: bpy.props.IntProperty( name="采样步长", default=1 ) selected_camera: bpy.props.StringProperty( name="选择相机", default="" ) class WORLD_COORD_OT_calculate(bpy.types.Operator): """计算指定相机视图的世界坐标映射""" bl_idname = "world_coord.calculate" bl_label = "计算世界坐标" bl_options = {'REGISTER', 'UNDO'} resolution_x: bpy.props.IntProperty( name="X分辨率", description="输出图像的X分辨率", default=640, min=64, max=4096 ) resolution_y: bpy.props.IntProperty( name="Y分辨率", description="输出图像的Y分辨率", default=480, min=64, max=4096 ) step_size: bpy.props.IntProperty( name="采样步长", description="像素采样步长(1=每个像素,2=每2个像素,以此类推)", default=2, min=1, max=10 ) save_to_file: bpy.props.BoolProperty( name="保存到文件", description="将结果保存到NPZ文件", default=True ) camera_name: bpy.props.StringProperty( name="相机", description="选择要使用的相机", default="" ) def execute(self, context): """执行操作的主要函数""" try: # 检查是否选择了相机 if not self.camera_name: self.report({'ERROR'}, "请选择一个相机!") return {'CANCELLED'} # 获取选择的相机对象 camera_obj = bpy.data.objects.get(self.camera_name) if not camera_obj or camera_obj.type != 'CAMERA': self.report({'ERROR'}, "选择的相机无效或不存在!") return {'CANCELLED'} # 执行射线投射计算 world_coords, hit_mask = self.raycast_world_coordinates( camera_obj, self.resolution_x, self.resolution_y, self.step_size ) # 保存结果到场景属性 self.save_results_to_scene(context, camera_obj, world_coords, hit_mask) # 可选:保存到文件 if self.save_to_file: self.save_to_asc(camera_obj, world_coords, hit_mask) self.report({'INFO'}, f"计算完成!相机 '{camera_obj.name}' 找到 {np.sum(hit_mask)} 个命中点") return {'FINISHED'} except Exception as e: self.report({'ERROR'}, f"计算失败: {str(e)}") return {'CANCELLED'} def raycast_world_coordinates(self, camera_obj, res_x, res_y, step_size=1): """通过射线投射获取世界坐标""" scene = bpy.context.scene depsgraph = bpy.context.evaluated_depsgraph_get() # 初始化结果数组 world_coords = np.full((res_y, res_x, 3), np.nan, dtype=np.float32) hit_mask = np.zeros((res_y, res_x), dtype=bool) # 获取相机矩阵 cam_matrix = camera_obj.matrix_world cam_data = camera_obj.data # 计算相机参数 aspect_ratio = res_x / res_y sensor_width = cam_data.sensor_width sensor_height = sensor_width / aspect_ratio focal_length = cam_data.lens # 进度更新 wm = bpy.context.window_manager wm.progress_begin(0, res_y) try: # 遍历每个像素(带步长) for y in range(0, res_y, step_size): if y % 10 == 0: # 每10行更新一次进度 wm.progress_update(y) # 新代码: if getattr(wm, 'is_modal', False) or getattr(wm, 'progress_abort', False): break for x in range(0, res_x, step_size): # 计算射线方向 ray_direction = self.get_camera_ray_direction( camera_obj, x, y, res_x, res_y ) # 射线原点(相机位置) ray_origin = cam_matrix @ Vector((0, 0, 0)) # 执行射线投射 hit, location, normal, index, obj, matrix = scene.ray_cast( depsgraph, ray_origin, ray_direction ) if hit: world_coords[y, x] = np.array(location) hit_mask[y, x] = True finally: wm.progress_end() return world_coords, hit_mask def get_camera_ray_direction(self, camera_obj, pixel_x, pixel_y, res_x, res_y): """计算相机射线方向""" # 转换为标准化设备坐标 (-1 到 1) ndc_x = (pixel_x / res_x) * 2.0 - 1.0 ndc_y = 1.0 - (pixel_y / res_y) * 2.0 # Y轴翻转 # 考虑相机传感器和焦距 aspect_ratio = res_x / res_y sensor_width = camera_obj.data.sensor_width sensor_height = sensor_width / aspect_ratio focal_length = camera_obj.data.lens # 计算相机空间中的方向 if camera_obj.data.type == 'PERSP': # 透视相机 direction = Vector(( ndc_x * (sensor_width / 2) / focal_length, ndc_y * (sensor_height / 2) / focal_length, -1.0 # 相机看向-Z方向 )) else: # 正交相机 scale = camera_obj.data.ortho_scale direction = Vector(( ndc_x * scale / 2, ndc_y * scale / 2 / aspect_ratio, -1.0 )) # 转换到世界空间 direction_world = camera_obj.matrix_world.to_3x3() @ direction direction_world.normalize() return direction_world def save_results_to_scene(self, context, camera_obj, world_coords, hit_mask): """保存结果到场景属性""" scene = context.scene # 更新场景属性 scene.world_coord_data.resolution = (world_coords.shape[1], world_coords.shape[0]) scene.world_coord_data.hit_count = int(np.sum(hit_mask)) scene.world_coord_data.step_size = self.step_size scene.world_coord_data.selected_camera = camera_obj.name # 保存原始数据(可选,如果需要后续访问) if not hasattr(scene, 'world_coord_raw_data'): scene['world_coord_raw_data'] = {} scene['world_coord_raw_data'] = { 'camera_name': camera_obj.name, 'timestamp': bpy.context.scene.frame_current, 'resolution': (world_coords.shape[1], world_coords.shape[0]) } def save_to_asc(self, camera_obj, world_coords, hit_mask): """将命中点的世界坐标保存为 .asc 文件(ASCII 点云)""" import os from datetime import datetime # 1) 输出目录 output_dir = os.path.join(bpy.path.abspath("//"), "world_coords") os.makedirs(output_dir, exist_ok=True) # 2) 文件名 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"world_coords_{camera_obj.name}_{timestamp}.asc" filepath = os.path.join(output_dir, filename) # 3) 提取命中点坐标 y_idx, x_idx = np.where(hit_mask) # 命中像素坐标 points = world_coords[y_idx, x_idx] # Nx3 # 4) 写入 .asc try: with open(filepath, 'w') as f: # 可按需写表头(CloudCompare 识别) f.write(f"# .asc point cloud generated by Blender addon\n") f.write(f"# camera: {camera_obj.name}\n") # 逐行写 xyz np.savetxt(f, points, fmt="%.6f") self.report({'INFO'}, f"已保存 asc: {filepath}") except Exception as e: self.report({'ERROR'}, f"保存 asc 失败: {str(e)}") def save_to_npz(self, camera_obj, world_coords, hit_mask): """保存结果到NPZ文件""" import os from datetime import datetime # 创建输出目录 output_dir = os.path.join(bpy.path.abspath("//"), "world_coords") os.makedirs(output_dir, exist_ok=True) # 生成文件名 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"world_coords_{camera_obj.name}_{timestamp}.npz" filepath = os.path.join(output_dir, filename) # 保存数据 np.savez_compressed( filepath, world_coords=world_coords, hit_mask=hit_mask, camera_name=camera_obj.name, camera_matrix=np.array(camera_obj.matrix_world), resolution=np.array([world_coords.shape[1], world_coords.shape[0]]), step_size=self.step_size, timestamp=timestamp ) self.report({'INFO'}, f"数据已保存到: {filepath}") def invoke(self, context, event): """调用操作时显示属性对话框""" # 设置默认相机(如果场景有相机) if context.scene.camera and not self.camera_name: self.camera_name = context.scene.camera.name return context.window_manager.invoke_props_dialog(self) def draw(self, context): """绘制操作属性对话框""" layout = self.layout # 相机选择下拉框 row = layout.row() row.label(text="选择相机:") row = layout.row() row.prop(self, "camera_name", text="") # 分辨率设置 row = layout.row() row.prop(self, "resolution_x") row = layout.row() row.prop(self, "resolution_y") # 其他设置 row = layout.row() row.prop(self, "step_size") row = layout.row() row.prop(self, "save_to_file") # 显示当前选择的相机信息 camera_obj = bpy.data.objects.get(self.camera_name) if camera_obj and camera_obj.type == 'CAMERA': box = layout.box() box.label(text="相机信息:", icon='CAMERA_DATA') box.label(text=f"名称: {camera_obj.name}") box.label(text=f"类型: {camera_obj.data.type}") box.label(text=f"焦距: {camera_obj.data.lens}mm") class WORLD_COORD_PT_panel(bpy.types.Panel): """创建UI面板""" bl_label = "世界坐标映射" bl_idname = "WORLD_COORD_PT_panel" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = "工具" def draw(self, context): layout = self.layout scene = context.scene # 相机选择 row = layout.row() row.label(text="选择相机:") row = layout.row() row.prop_search(scene.world_coord_data, "selected_camera", scene, "objects", text="", icon='CAMERA_DATA') # 检查选择的相机是否有效 camera_obj = None if scene.world_coord_data.selected_camera: camera_obj = bpy.data.objects.get(scene.world_coord_data.selected_camera) if camera_obj and camera_obj.type == 'CAMERA': # 显示相机信息 box = layout.box() box.label(text="相机信息:", icon='INFO') box.label(text=f"名称: {camera_obj.name}") box.label(text=f"类型: {camera_obj.data.type}") box.label(text=f"焦距: {camera_obj.data.lens}mm") # 计算按钮 row = layout.row() row.operator("world_coord.calculate", text="计算世界坐标", icon='CAMERA_DATA') else: # 警告信息 box = layout.box() box.label(text="请选择一个有效的相机", icon='ERROR') if scene.camera: box.label(text=f"场景相机: {scene.camera.name}") # 仍然显示计算按钮,但会弹出设置对话框 row = layout.row() op = row.operator("world_coord.calculate", text="计算世界坐标", icon='CAMERA_DATA') # 显示上次计算结果 if hasattr(scene, 'world_coord_data') and scene.world_coord_data.hit_count > 0: data = scene.world_coord_data box = layout.box() box.label(text="上次计算结果:", icon='TEXT') box.label(text=f"相机: {data.selected_camera}") box.label(text=f"分辨率: {data.resolution[0]} x {data.resolution[1]}") box.label(text=f"命中点数: {data.hit_count}") box.label(text=f"采样步长: {data.step_size}") # 可视化按钮 row = layout.row() row.operator("world_coord.visualize", text="可视化结果", icon='HIDE_OFF') class WORLD_COORD_OT_visualize(bpy.types.Operator): """可视化世界坐标结果""" bl_idname = "world_coord.visualize" bl_label = "可视化结果" bl_description = "在3D视图中显示世界坐标点" def execute(self, context): scene = context.scene # 检查是否有计算结果 if not hasattr(scene, 'world_coord_raw_data'): self.report({'WARNING'}, "没有找到计算结果数据") return {'CANCELLED'} # 这里可以添加可视化代码 # 例如:创建空物体表示坐标点,或者绘制点云 self.report({'INFO'}, "开始可视化世界坐标点") # 简单的可视化示例:在命中点位置创建空物体 try: # 清除之前的可视化对象 self.clear_visualization_objects(scene) # 这里可以添加具体的可视化代码 # 由于原始数据可能很大,建议使用采样或简化表示 self.report({'INFO'}, "可视化完成") except Exception as e: self.report({'ERROR'}, f"可视化失败: {str(e)}") return {'FINISHED'} def clear_visualization_objects(self, scene): """清除之前创建的可视化对象""" # 删除名称以"vis_"开头的空物体 objects_to_remove = [obj for obj in scene.objects if obj.name.startswith("vis_") and obj.type == 'EMPTY'] for obj in objects_to_remove: bpy.data.objects.remove(obj, do_unlink=True) # 场景中所有相机的列表属性(用于UI) def get_camera_objects(self, context): """获取场景中所有相机的列表""" items = [] cameras = [obj for obj in context.scene.objects if obj.type == 'CAMERA'] for i, camera in enumerate(cameras): items.append((camera.name, camera.name, f"相机: {camera.name}", 'CAMERA_DATA', i)) if not items: items.append(('NONE', "无相机", "场景中没有相机", 'ERROR', 0)) return items # 注册和取消注册函数 def register(): bpy.utils.register_class(WorldCoordData) bpy.utils.register_class(WORLD_COORD_OT_calculate) bpy.utils.register_class(WORLD_COORD_PT_panel) bpy.utils.register_class(WORLD_COORD_OT_visualize) # 添加场景属性 bpy.types.Scene.world_coord_data = bpy.props.PointerProperty(type=WorldCoordData) def unregister(): bpy.utils.unregister_class(WorldCoordData) bpy.utils.unregister_class(WORLD_COORD_OT_calculate) bpy.utils.unregister_class(WORLD_COORD_PT_panel) bpy.utils.unregister_class(WORLD_COORD_OT_visualize) # 清理场景属性 if hasattr(bpy.types.Scene, 'world_coord_data'): del bpy.types.Scene.world_coord_data if __name__ == "__main__": register()