模拟结构光3D Scanner时,常见的一个问题是如何获取重建点云的真值?在Blender中,可以使用光线求交的方法,从相机光心沿各像素发出射线,与场景物体求交,并将交点导出为点云文件。
本文介绍了一个Blender插件,用于通过光线追踪技术将相机视角下的3D场景转换为点云数据。该插件从相机光心向每个像素发射射线,与场景物体求交后将交点坐标导出为ASC或NPZ格式的点云文件。核心功能包括:1)支持设置分辨率、采样步长等参数;2)可选择场景中的任意相机;3)提供点云数据可视化和导出功能。代码实现了射线投射计算、数据存储和用户界面交互,适用于3D扫描、计算机视觉等需要将3D场景转换为点云数据的应用场景。插件安装后可在Blender的3D视图侧边栏中调用。
以下是Blender插件代码,在Preference->Add-ons中从文件导入,导入方法参见
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()