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.pybl_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分辨率, size2, default(640, 480) ) hit_count: bpy.props.IntProperty( name命中点数, default0 ) step_size: bpy.props.IntProperty( name采样步长, default1 ) 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( nameX分辨率, description输出图像的X分辨率, default640, min64, max4096 ) resolution_y: bpy.props.IntProperty( nameY分辨率, description输出图像的Y分辨率, default480, min64, max4096 ) step_size: bpy.props.IntProperty( name采样步长, description像素采样步长1每个像素2每2个像素以此类推, default2, min1, max10 ) save_to_file: bpy.props.BoolProperty( name保存到文件, description将结果保存到NPZ文件, defaultTrue ) 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_size1): 通过射线投射获取世界坐标 scene bpy.context.scene depsgraph bpy.context.evaluated_depsgraph_get() # 初始化结果数组 world_coords np.full((res_y, res_x, 3), np.nan, dtypenp.float32) hit_mask np.zeros((res_y, res_x), dtypebool) # 获取相机矩阵 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_okTrue) # 2) 文件名 timestamp datetime.now().strftime(%Y%m%d_%H%M%S) filename fworld_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_okTrue) # 生成文件名 timestamp datetime.now().strftime(%Y%m%d_%H%M%S) filename fworld_coords_{camera_obj.name}_{timestamp}.npz filepath os.path.join(output_dir, filename) # 保存数据 np.savez_compressed( filepath, world_coordsworld_coords, hit_maskhit_mask, camera_namecamera_obj.name, camera_matrixnp.array(camera_obj.matrix_world), resolutionnp.array([world_coords.shape[1], world_coords.shape[0]]), step_sizeself.step_size, timestamptimestamp ) 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相机信息:, iconCAMERA_DATA) box.label(textf名称: {camera_obj.name}) box.label(textf类型: {camera_obj.data.type}) box.label(textf焦距: {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, iconCAMERA_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相机信息:, iconINFO) box.label(textf名称: {camera_obj.name}) box.label(textf类型: {camera_obj.data.type}) box.label(textf焦距: {camera_obj.data.lens}mm) # 计算按钮 row layout.row() row.operator(world_coord.calculate, text计算世界坐标, iconCAMERA_DATA) else: # 警告信息 box layout.box() box.label(text请选择一个有效的相机, iconERROR) if scene.camera: box.label(textf场景相机: {scene.camera.name}) # 仍然显示计算按钮但会弹出设置对话框 row layout.row() op row.operator(world_coord.calculate, text计算世界坐标, iconCAMERA_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上次计算结果:, iconTEXT) box.label(textf相机: {data.selected_camera}) box.label(textf分辨率: {data.resolution[0]} x {data.resolution[1]}) box.label(textf命中点数: {data.hit_count}) box.label(textf采样步长: {data.step_size}) # 可视化按钮 row layout.row() row.operator(world_coord.visualize, text可视化结果, iconHIDE_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_unlinkTrue) # 场景中所有相机的列表属性用于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(typeWorldCoordData) 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()