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

python 复制代码
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()