Blender模拟结构光3D Scanner(三)获取相机观测点云的真值
模拟结构光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.scenedepsgraph = 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_worldcam_data = camera_obj.data# 计算相机参数aspect_ratio = res_x / res_ysensor_width = cam_data.sensor_widthsensor_height = sensor_width / aspect_ratiofocal_length = cam_data.lens# 进度更新wm = bpy.context.window_managerwm.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):breakfor 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] = Truefinally:wm.progress_end()return world_coords, hit_maskdef 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.0ndc_y = 1.0 - (pixel_y / res_y) * 2.0 # Y轴翻转# 考虑相机传感器和焦距aspect_ratio = res_x / res_ysensor_width = camera_obj.data.sensor_widthsensor_height = sensor_width / aspect_ratiofocal_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_scaledirection = Vector((ndc_x * scale / 2,ndc_y * scale / 2 / aspect_ratio,-1.0))# 转换到世界空间direction_world = camera_obj.matrix_world.to_3x3() @ directiondirection_world.normalize()return direction_worlddef 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_sizescene.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 osfrom 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) 写入 .asctry: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")# 逐行写 xyznp.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 osfrom 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.namereturn 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.layoutscene = 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 = Noneif 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_databox = 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_dataif __name__ == "__main__":register()