ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

PointNet源码解析:从点云数据管道到端到端分类分割

PointNet源码解析:从点云数据管道到端到端分类分割 简介这是面向点云处理学习与研究者的PointNet与PointNet实现源码包适用于3D物体识别、场景分割、自动驾驶等典型应用也适合希望深入理解无序点云深度学习建模方法的读者。资源包共包含33个文件其中22个Python脚本覆盖模型定义、训练流程、数据预处理、评估与推理等核心环节另有5个txt说明、2个md文档、2个shell脚本、1个license及1张结构预览图整体压缩包仅495KB轻量且模块化便于按需查阅。目前已有274人学习下载可作为点云方向入门与进阶的实用参考。通过研读源码可以掌握PointNet全局最大池化与PointNet分层采样、局部上下文聚合的关键实现并借助训练脚本和数据处理工具快速复现分类、分割任务同时还能针对brushomd等自定义点云数据进行二次开发与参数调优更好地服务于实际项目。1. 从无序点云到端到端推理解读 PointNet 源码包的核心价值拿到激光扫描仪或者深度相机输出的原始点云时第一反应往往是先做网格化、体素化再送进 3D CNN。但分辨率稍高一点显存就会迅速见底而且体素化本身会丢失几何细节。PointNet 走了一条完全不同的路直接把 N×3 的坐标序列喂进网络通过共享 MLP 对每个点独立提特征再用全局最大池化把无序点集压成一个全局描述子。这套 pointnet-master.zip 源码包就是 PointNet 官方 TensorFlow 实现并且附带分类、部件分割、室内场景语义分割三套完整训练与评估流程。对于想理解点云深度学习的底层逻辑或者准备在自己数据集上复现点云分割、3D 分类任务的工程师这套代码是目前最值得逐行读的参考实现之一。接下来按数据管道、模型结构、训练评估、扩展迁移的顺序把它拆开。2. 数据管道拆解download_data.sh、provider.py 与 h5 预处理链路2.1 下载脚本与 h5 数据格式先将压缩包解压进入仓库根目录unzip pointnet-master.zip -d pointnet cd pointnet仓库根目录的download_data.sh负责拉取公开数据集并整理为 h5 格式。以 ModelNet40 分类任务为例脚本执行后会在data/modelnet40_ply_hdf5_2048/下生成多个.h5文件。打开一个 h5 文件内部结构如下h5 keyshape说明data(N, 2048, 3)每个样本固定 2048 个点的 xyz 坐标label(N,)类别 ID对应shape_names.txtmask(N, 2048)有效点掩码用于指示哪些点是实际采样点之所以统一到 2048 点是因为 PointNet 理论上能处理变长输入但 TensorFlow 的 batch 训练需要维度对齐所以预处理阶段统一做最远点采样Farthest Point Sampling, FPS。h5 格式在这里的优势很明显读入一个文件就能拿到整个数据集的数组IO 压力和文件碎片都比零散存放的 PLY 文件小得多。之前有人把数据换成散装.npy再喂给tf.data每个 epoch 的文件打开次数直接涨了一个数量级训练速度明显下降所以我一般不建议改掉 h5 这条链路。2.2 provider.py采样、打乱与数据增强provider.py是数据增强的核心模块。其中rotate_point_cloud绕 Y 轴做随机旋转模拟不同朝向的点云def rotate_point_cloud(batch_data): rotated_data np.zeros(batch_data.shape, dtypenp.float32) for k in range(batch_data.shape[0]): rotation_angle np.random.uniform() * 2 * np.pi cosval, sinval np.cos(rotation_angle), np.sin(rotation_angle) rotation_matrix np.array([[cosval, 0, sinval], [0, 1, 0], [-sinval, 0, cosval]]) rotated_data[k, ...] np.dot(batch_data[k, ...], rotation_matrix) return rotated_data这里的旋转矩阵采用右乘形式即对每个点的行向量做变换。需要注意如果在做姿态估计或者配准类任务旋转矩阵必须保存下来否则 augmentation 出来的数据没有对应的真值标签模型学到的分布会和你期望的不一致。provider.py里还有jitter_point_cloud它对每个点的坐标加上服从高斯分布的小扰动模拟传感器噪声random_point_dropout则随机丢弃部分点增强模型对遮挡的鲁棒性。这套组合拳是 PointNet 在 ModelNet40 上拿到不错泛化效果的重要因素建议在自己的数据集上完整保留。2.3 pc_util.pyPLY 解析与几何工具utils/pc_util.py提供 PLY 文件读取和点云几何处理的工具函数。日常调试中最常用的路径是把自有 PLY 点云加载进来经过 FPS 采样后组装成训练数据from utils.pc_util import load_ply, farthest_point_sampling # 读取 PLY 文件返回 (points, faces) points, _ load_ply(scan.ply) # 最远点采样到统一点数 2048 sampled farthest_point_sampling(points, 2048)直接随机采样的问题在于如果点云分布不均匀密集区域的点会反复被选中稀疏区域直接被忽略。FPS 每次选取离已选集合最远的点保证采样结果的覆盖范围尽量铺满整个表面。在自制数据集时我会先用 CloudCompare 把原始扫描数据做一次粗略的噪点过滤再用load_ply读取并统一点数最后落到 h5 文件里。顺带一提pc_util.py里还有 PCA 求法线、计算包围盒等函数写分割任务的预处理脚本时可以直接复用。3. 模型结构分析T-Net、全局最大池化与逐点分割的实现细节3.1 transform_nets.py输入变换与特征对齐models/transform_nets.py实现了 PointNet 的输入变换网络 T-Net。它的本质是一个小型的 PointNet输出一个 K×K 的变换矩阵对输入坐标或者特征做对齐操作def input_transform_net(point_cloud, is_training, K3): num_point point_cloud.get_shape()[1].value input_image tf.expand_dims(point_cloud, -1) net tf_util.conv2d(input_image, 64, [1, 1], paddingVALID, scopetconv1, bnTrue, is_trainingis_training) net tf_util.conv2d(net, 128, [1, 1], paddingVALID, scopetconv2, bnTrue, is_trainingis_training) net tf_util.conv2d(net, 1024, [1, 1], paddingVALID, scopetconv3, bnFalse, is_trainingis_training) net tf_util.max_pool2d(net, [num_point, 1], paddingVALID, scopetmaxpool) net tf.reshape(net, [batch_size, -1]) net tf_util.fully_connected(net, 512, scopetfc1, bnTrue, is_trainingis_training) net tf_util.fully_connected(net, 256, scopetfc2, bnTrue, is_trainingis_training) with tf.variable_scope(transform_XYZ): weights tf.get_variable(weights, [256, K * K], initializertf.zeros_initializer()) biases tf.get_variable(bias, [K * K], initializertf.constant_initializer(0.0)) biases tf.constant(np.eye(K).flatten(), dtypetf.float32) transform tf.matmul(net, weights) transform tf.reshape(transform, [batch_size, K, K]) biases return transform关键点在最后几行偏置初始化为单位矩阵保证训练初期变换近似恒等不会一开始就破坏原始几何结构。第一个 T-Net 输出 3×3 矩阵作用在原始坐标上第二个 T-Net 输出 64×64 矩阵作用在高维特征空间。如果不加 64×64 特征变换分类精度大约会掉两到三个百分点这属于看着不起眼、实际很关键的模块。文件里对应的feature_transform_regularizer会把变换矩阵和单位阵的差引入损失系数通常取 0.001目的是约束特征变换不要偏离恒等映射太远。3.2 pointnet_cls.py分类网络的分层设计models/pointnet_cls.py定义了完整的分类主网络核心逻辑如下with tf.variable_scope(transform_net1): transform input_transform_net(point_cloud, is_training, K3) point_cloud_transformed tf.matmul(point_cloud, transform) net tf_util.conv2d(point_cloud_transformed, 64, [1, 1], paddingVALID, scopeconv1, bnTrue, is_trainingis_training) net tf_util.conv2d(net, 64, [1, 1], paddingVALID, scopeconv2, bnTrue, is_trainingis_training) with tf.variable_scope(transform_net2): transform_feat feature_transform_net(net, is_training, K64) net tf.matmul(net, transform_feat) net tf_util.conv2d(net, 128, [1, 1], paddingVALID, scopeconv3, bnTrue, is_trainingis_training) net tf_util.conv2d(net, 1024, [1, 1], paddingVALID, scopeconv4, bnTrue, is_trainingis_training) net tf_util.max_pool2d(net, [num_point, 1], paddingVALID, scopemaxpool)每个[1, 1]卷积实际作用在单点上等价于对每个点独立做 MLP。经过两层 64 维特征提取后再升到 1024 维最后用max_pool2d在点维度上做全局最大池化。各层输出维度如下层名操作输出维度conv1Conv1d(3, 64) BN ReLU(B, 64, N)conv2Conv1d(64, 64) BN ReLU(B, 64, N)conv3Conv1d(64, 128) BN ReLU(B, 128, N)conv4Conv1d(128, 1024) BN ReLU(B, 1024, N)maxpoolMaxPool(N, 1)(B, 1024, 1)全局最大池化是 PointNet 处理无序性的核心操作无论点的顺序怎么打乱每个维度上的最大值不变输出特征对输入排列保持置换不变。池化之后接全连接层 MLP(512, 256, num_class)并在第一个全连接后加 dropout概率默认 0.3。这个 dropout 放在分类头的位置比较讲究——前面的卷积层都带 BN不需要 dropout 辅助分类头是最后的全局特征汇聚dropout 能有效抑制过拟合。3.3 pointnet_seg.py从全局特征到逐点分割分割网络models/pointnet_seg.py与分类网络的差别在于全局最大池化之后要把 1024 维全局特征拼回每个点的 64 维局部特征上再通过卷积逐点输出分类分数net tf_util.conv2d(point_cloud_transformed, 64, [1, 1], paddingVALID, scopeconv1, bnTrue, is_trainingis_training) net tf_util.conv2d(net, 64, [1, 1], paddingVALID, scopeconv2, bnTrue, is_trainingis_training) # 全局特征与局部特征拼接 net tf.concat([net, net_global], axis2) # (B, N, 64) (B, N, 1024) net tf_util.conv2d(net, 512, [1, 1], paddingVALID, scopeconv3, bnTrue, is_trainingis_training) net tf_util.conv2d(net, 256, [1, 1], paddingVALID, scopeconv4, bnTrue, is_trainingis_training) net tf_util.conv2d(net, 128, [1, 1], paddingVALID, scopeconv5, bnTrue, is_trainingis_training) net tf_util.conv2d(net, seg_classes, [1, 1], paddingVALID, scopeconv6, bnFalse, is_trainingis_training)这里的net_global是全局池化后的特征复制到每个点上得到的。为什么全局特征能帮助逐点分割因为很多语义标签依赖上下文一个点本身可能是白色平板单独看无法判断是桌面还是墙面只有知道它属于整个场景的哪个位置、周围是什么结构才能做出正确分类。全局特征恰好提供了这种上下文信息。仓库的part_seg/目录对应 ShapeNet 部件分割任务sem_seg/目录对应 S3DIS 室内场景语义分割任务两者训练脚本独立但网络骨架都遵循这个局部特征 全局特征拼接的设计模式。4. 训练与评估train.py 调参、IoU 计算与语义分割验证4.1 train.py 的运行方式与启动参数仓库里有多个train.py注意区分职责根目录下的是 ModelNet40 分类训练脚本part_seg/下的是 ShapeNet 部件分割训练脚本sem_seg/下的是 S3DIS 语义分割训练脚本。启动分类训练的命令python train.py \ --log_dir cls_log \ --num_point 2048 \ --num_class 40 \ --batch_size 16 \ --max_epoch 250关键训练参数如下参数默认值说明learning_rate0.001Adam 优化器初始学习率decay_step200000学习率衰减步长decay_rate0.7每经过 decay_step学习率乘 0.7batch_size16分类任务常用 16分割任务受显存限制降到 8max_epoch250总训练轮数momentum0.9BN 层滑动平均动量训练过程中日志里同时输出 loss、classification accuracy 和 point accuracy。判断收敛是否正常主要看 train loss 是否平滑下降以及 val acc 是否同步上升。如果在第二个 T-Net 特征变换没有加正则项的情况下train acc 在 80% 附近反复震荡先检查 loss 里是否包含feature_transformation_regularizer那一项这是最容易被误删的模块。4.2 eval_iou_accuracy.py 的 IoU 计算口径sem_seg/eval_iou_accuracy.py用于 S3DIS 语义分割的评估。它读取测试集的预测结果和真值标签按类别分别统计预测为正的像素数、真实为正的像素数和交叠数n_pred np.zeros(gt_classes) n_gt np.zeros(gt_classes) n_intersect np.zeros(gt_classes) for i in range(num_points): gt gt_label[i] pred pred_label[i] n_gt[gt] 1 n_pred[pred] 1 if gt pred: n_intersect[gt] 1 iou_per_class n_intersect / (n_pred n_gt - n_intersect 1e-6) miou np.mean(iou_per_class)这里算的是逐类别 IoU 后取平均即 mIoU每个类别权重相同不受类别样本数影响。使用这个脚本时注意S3DIS 数据集里不同房间的类别分布差异很大比如走廊里墙和地板占比高会议室里桌椅和黑板占比高如果直接跑完不做逐场景评估mIoU 很容易被大类别带偏。官方实现里按 Area 划分训练集和测试集我一般会在评估时同时输出 per-class IoU 和 overall accuracy 两列前者看少数类别有没有崩后者看整体表现。4.3 调参经验学习率、批量大小和正则化在这套源码上调参优先级最高的几个点分别是学习率衰减策略、BN 开关和 dropout 位置。学习率从 0.001 起步200k 步衰减到原来的 0.7 倍这个策略在分类和分割任务上都表现稳定。不要一上来就用 cosine annealingPointNet 这种轻量网络用阶梯式衰减更容易锁住收敛点。批量大小直接受 GPU 显存约束。分类任务 2048 点批量 16 大约占用 4~5 GB分割任务由于要拼接全局特征并输出逐点分数显存占用会明显上涨批量 8 起步比较稳妥。另外测试时is_training必须设为 False否则 BN 会继续更新全局统计量导致验证集精度和训练过程看到的精度不一致。这个坑在源码里用placeholder传入很多改动过的版本容易在固化模型时把这个参数写死最终推理结果与训练时偏差很大。5. PointNet 的局部聚合改进与 TF 1.x 复现避坑5.1 set abstraction 的核心改进这套pointnet-master.zip本身不包含 PointNet但理解 PointNet 的边界后PointNet 的改进点就变得非常清晰。PointNet 的全局池化把所有点压成一个向量局部几何细节在这个过程里被平均掉了。PointNet 引入 set abstraction 层每一层先做最远点采样选出中心点再以球邻域为单位做分组对每个组内点集跑一个 PointNet得到局部特征逐级聚合。对应到 S3DIS 室内场景语义分割场景PointNet 对桌椅这类局部几何特征明显的物体mIoU 通常比 PointNet 高 7~10 个百分点。如果新项目的核心诉求是精细分割而不是快速验证直接上 PointNet 是更合理的选择。5.2 从 TF 1.x 迁移的注意事项这套代码基于 TensorFlow 1.x在 TF 2.x 环境下运行会直接报module tensorflow has no attribute placeholder之类的错误。常见做法是搭一个tf.compat.v1兼容层但更推荐的迁移路线是直接基于 PyTorch 复现 pointnet 结构模型定义短、反向传播逻辑透明后续换网络也更方便class PointNetCls(nn.Module): def __init__(self, num_classes): super().__init__() self.mlp1 nn.Sequential(nn.Conv1d(3, 64, 1), nn.BatchNorm1d(64), nn.ReLU(), nn.Conv1d(64, 64, 1), nn.BatchNorm1d(64), nn.ReLU()) self.mlp2 nn.Sequential(nn.Conv1d(64, 128, 1), nn.BatchNorm1d(128), nn.ReLU(), nn.Conv1d(128, 1024, 1), nn.BatchNorm1d(1024), nn.ReLU()) self.fc nn.Sequential(nn.Linear(1024, 512), nn.ReLU(), nn.Dropout(0.3), nn.Linear(512, 256), nn.ReLU(), nn.Linear(256, num_classes)) def forward(self, x): # x: (B, N, 3) - permute 到 (B, 3, N) x x.permute(0, 2, 1) local self.mlp1(x) global_feat self.mlp2(local).max(dim2)[0] return self.fc(global_feat)迁移时最容易丢的两个东西是 T-Net 和特征变换正则项。原始分类网络有 3×3 和 64×64 两个变换网络PyTorch 复刻时即使实现了也很容易忘记在 loss 里加正则项导致精度下降。如果模型规模或数据量不大可以先省略 T-Net 观察 baseline再逐步加回来这样定位问题更容易。5.3 用 CloudCompare 验证分割输出跑完分割推理后直接看数字不直观我习惯把预测结果可视化验证。PointNet 分割输出每个点的类别 ID保存成.npy后用 CloudCompare 打开原始点云再通过命令行把 label 作为 scalar field 附加上去cloudcompare.CloudCompare -SILENT \ -O original_cloud.ply \ -ADD_HEADER 0 \ -MERGE_CLOUDS \ -SAVE_CLOUDS更常见的做法是直接在 Python 里写一份 PLY 文件把 label 写到red、green、blue三个属性里然后拖进 CloudCompare 按颜色观察。注意 CloudCompare 默认显示的点尺寸较小分割结果边界处的混色点往往意味着网络对类别边界的预测不稳定这时回头检查训练数据里对应区域的类别标注是否干净——很多分割精度问题不是网络结构造成的而是标注噪声被网络学进去了。点云侠社区里关于 CloudCompare 的点云转三维模型和标注重建的教程很多处理分割结果验证时值得参考。本文还有配套的精品资源点击获取
返回列表