做3D深度学习的人几乎都绕不开一个问题:手里的数据来自四面八方。激光雷达导出的是PCD,摄影测量软件吐出来的是PLY和OBJ,有些老项目还在用LAS,甚至有人直接给你一堆E57。格式本身不是最大的麻烦,麻烦的是这些文件背后的坐标系、单位、点数规模、属性字段都不一致。如果不先做一轮统一的转换和标准化,训练代码里会布满if else,后期维护成本极高。这篇文章就来系统地聊聊怎么把多来源的3D数据整合成一份格式统一、可直接喂给模型的数据集。

先弄清楚3D数据格式的本质差异
3D数据格式虽然五花八门,但从数据结构上看只有两大类:一类是网格数据,记录顶点和面片,典型代表是OBJ、PLY和FBX;另一类是点云数据,只记录散乱的空间点及其属性,典型代表是PCD、LAS和E57。理解这个分类很重要,因为它决定了转换的方向是否可行。
网格转点云非常容易,把顶点抽出来就行,或者对面片做采样得到更均匀的分布。但点云转网格就困难得多,需要对散点做表面重建,比如泊松重建或者Delaunay三角化,重建结果的质量受点云密度和噪声影响很大,而且是一个有损过程。所以在做数据集整合时,通常把目标格式定为点云类格式,除非你的任务本身就需要保留网格拓扑,比如纹理重建或者几何编辑。
再说说几种常见格式的特点。PLY是斯坦福开发的格式,既能存点也能存网格,支持二进制存储,读写效率高,是目前学术界交换数据的主流选择。OBJ是文本格式,历史悠久,几乎所有建模软件都支持,但文件体积偏大,且没有统一的点云属性规范。PCD是PCL库的原生格式,对点云的属性字段定义最灵活,适合工业级的点云处理流水线。LAS是激光雷达测绘领域的标准格式,支持严格的坐标参照系统定义,但在通用3D开发里生态一般。选目标格式时,建议根据下游框架来定:Open3D和PyTorch3D对PLY支持最好,PCL生态则优先PCD。
用Python搭建批量格式转换流水线
格式转换本身不难,Open3D这个库就能覆盖绝大部分需求,它同时支持PLY、OBJ、PCD、PTS、XYZ等格式的读写,而且API风格统一。下面这段脚本实现了目录级批量的转换,支持递归扫描源目录,把所有支持的3D文件统一转成PLY格式。
import os
import open3d as o3d
SUPPORTED_EXT = ['.ply', '.obj', '.pcd', '.xyz', '.pts']
def convert_to_ply(src_path, dst_path):
# 读取支持的三种主要结构:点云或网格都先转成点云处理
ext = os.path.splitext(src_path)[1].lower()
try:
if ext in ('.obj',):
mesh = o3d.io.read_triangle_mesh(src_path)
if len(mesh.vertices) == 0:
print(f"跳过空网格: {src_path}")
return False
# 对网格做均匀采样,得到固定点数的点云
pcd = mesh.sample_points_uniformly(number_points=50000)
else:
pcd = o3d.io.read_point_cloud(src_path)
if len(pcd.points) == 0:
print(f"跳过空点云: {src_path}")
return False
# 统一写出到目标路径
return o3d.io.write_point_cloud(dst_path, pcd)
except Exception as e:
print(f"转换失败 {src_path}: {e}")
return False
def batch_convert(src_dir, dst_dir):
ok, fail = 0, 0
for root, _, files in os.walk(src_dir):
for name in files:
ext = os.path.splitext(name)[1].lower()
if ext not in SUPPORTED_EXT:
continue
src = os.path.join(root, name)
rel = os.path.relpath(src, src_dir)
dst = os.path.join(dst_dir, os.path.splitext(rel)[0] + '.ply')
os.makedirs(os.path.dirname(dst), exist_ok=True)
if convert_to_ply(src, dst):
ok += 1
else:
fail += 1
print(f"转换完成: 成功 {ok} 个, 失败 {fail} 个")
if __name__ == '__main__':
batch_convert('data/raw', 'data/processed')
这段脚本的关键点在于对OBJ网格的处理:直接读取顶点虽然也能得到点云,但顶点分布取决于建模时的拓扑密度,往往集中在细节多的区域,直接用于训练会有分布偏差。用sample_points_uniformly做面片均匀采样可以得到空间分布更均匀的点,这是很多初学者容易忽略的细节。另外异常处理不能省,实际数据里混有空文件、损坏文件几乎是常态,脚本必须能在脏数据下稳定跑完。
坐标系统一与单位对齐
格式转好只是第一步,更隐蔽的坑是坐标系和单位。激光雷达数据常用米制且Z轴朝上,而一些建模软件导出的数据是Y轴朝上甚至Z轴朝下;有的数据集以毫米为单位,有的以米为单位,混在一起后模型学到的尺度特征会完全混乱。如果数据来自不同采集批次,务必在转换阶段就把这些差异抹平。
处理思路是给每个数据源维护一份配置,记录它的坐标系约定和单位缩放因子,转换时统一应用。下面是补上标准化逻辑的转换函数。
import numpy as np
# 每个数据源的坐标系与单位约定,按实际采集情况填写
SOURCE_CONFIG = {
'kitchen_scan': {'up_axis': 'z', 'scale': 1.0}, # 米制, Z朝上
'street_lidar': {'up_axis': 'z', 'scale': 1.0},
'archaeology': {'up_axis': 'y', 'scale': 0.001}, # 毫米制, Y朝上
}
def standardize(pcd, cfg):
points = np.asarray(pcd.points)
# 单位统一到米
points = points * cfg['scale']
# 坐标轴统一到 Z 朝上
if cfg['up_axis'] == 'y':
points = points[:, [0, 2, 1]] * np.array([1.0, 1.0, -1.0])
elif cfg['up_axis'] == 'x':
points = points[:, [2, 1, 0]] * np.array([-1.0, 1.0, 1.0])
pcd.points = o3d.utility.Vector3dVector(points)
# 中心化: 以点云质心为原点, 消除平移差异
center = pcd.get_center()
pcd.translate(-center)
return pcd这里有两个容易争论的处理:一是要不要做中心化,二是要不要归一化到单位球。如果你的任务依赖绝对坐标(比如室外定位、地理测绘),中心化会破坏信息,应该保留原始坐标并把偏移量存到元数据里;如果是形状分类、分割这类只关心相对几何的任务,中心化加尺度归一化几乎是标配,能显著加快训练收敛。二是噪声和重复点的清理,建议顺手做一次体素降采样,既去了重又控制了单帧点数,代码只需一行pcd.voxel_down_sample(voxel_size=0.02),体素大小根据数据尺度调整。
数据集目录组织与命名规范
最后聊一下工程层面的事。格式和坐标统一之后,如果目录结构混乱,后续切分训练集验证集照样痛苦。建议原始数据和加工后数据物理分开,目录里用数据来源、场景编号、文件类型做层级,命名里避免中文和空格。一个可参考的结构是data/raw下按来源分目录存放原始文件,data/processed下保持相同子目录结构存放标准化后的PLY,再配一个manifest.csv记录每个文件的来源、点数、是否做过降采样等元信息。这样即使半年后回头看,数据的来龙去脉也一清二楚。
总结一下整条流水线:先用统一的读取层把多格式文件拉平成点云,再根据数据源配置做单位与坐标系校正,接着中心化、降采样、去噪,最后按规范落盘并记录元数据。这套流程搭好之后,新来一种格式只需要在读取层加一个分支,在配置表里加一行约定,整体维护成本非常低。对于还在为3D数据格式发愁的团队来说,花一两天把这条流水线搭起来,远比在训练代码里到处打补丁划算得多。