简介面向点云语义分割与三维视觉入门者提供基于PointNet在虚拟KITTI数据集上完成3D语义分割的完整工程涵盖数据说明、训练主流程与可视化脚本适合希望复现经典PointNet流程、理解XYZRGBL七维点云特征的读者。压缩包共54个文件大小约21.73MB主要包含20个Python脚本、编译后的pyc文件、训练日志、模型检查点及说明文档Python脚本覆盖数据预处理、模型定义、训练与批量推理checkpoint文件可直接用于评估或继续训练。数据处理方面每个npy场景文件都以N×7矩阵保存点云分别对应三维坐标、RGB颜色和语义标签main.ipynb作为训练入口可逐步运行vis_data_vispy.py用于可视化预测结果便于对比分割效果。目前已有941人学习下载适合作为入门PointNet语义分割、开展课程实验或在虚拟数据上验证算法的实用参考。1. 先把翻车场景摆出来这个组合到底在跑什么前阵子我帮 A 同学把一个叫 PointNet-SemSeg-VKITTI3D 的方案跑通过程很唬人用 PointNet 对 VKITTI3D 的逐帧点云做语义分割输出每个点属于车辆、路面还是行人。第一版验证集 mIoU 有 0.81听起来已经能见人了可点开可视化一看车尾全是碎点路面标签还越过路缘贴到建筑的墙上。后来排查半天病根根本不在网络而在点云和标签的索引错位。这个标题要解决的问题不是把点云“变成图片再用 CNN”而是让 PointNet 这种直接消费原始点云的模型在 VKITTI3D 合成数据上完成逐点语义分割并且不被“完美标签”骗过去。适合刚开始做点云分割、或想用虚拟数据做自动驾驶感知预训练的从业者。下面按“模型为什么这样设计、数据怎么喂、参数怎么调、坑在哪、怎么验证”的顺序讲每一步都给可复现代码。2. PointNet 的语义分割原理与 VKITTI3D 数据特性2.1 无序点云上的全局特征与逐点特征点云不是图像它没有固定格子位置每一帧点数可能从几万到几十万不等点的顺序也随保存方式变化。如果按图像卷积的思路去处理第一层卷积核就会因为“点旁边是谁”不确定而失效。PointNet 的做法是对每个点独立做多层感知机再用最大池化把整个点云压成一个全局特征最大池化天然对点的排列顺序不敏感。语义分割比分类多一个要求不仅要告诉模型“这一帧是城区道路”还要告诉模型“第 17000 个点属于路面”。PointNet 把全局特征拼回每个点的局部特征上相当于每个点都同时看到“我周围这一小组点”和“整帧的大环境”最后再过一层逐点 MLP 输出每个点的类别概率。这里有个结构性取舍全局最大池化会让网络重点关注“最像某类”的代表点局部边界和细长物体会被平滑掉。所以你在 VKITTI3D 上训练出来的道路整体轮廓大概率没问题但车辆边缘容易出现锯齿或空洞这是原始 PointNet 的固有缺点不是调参能完全解决的。第一版复现我不建议一上来就加 T-Net。PointNet 里的 T-Net 是用来学习输入点云变换矩阵的初衷是让网络对旋转鲁棒但它带来的参数和训练不稳定在分割任务里经常得不偿失。VKITTI3D 的点云是车载雷达坐标系方向基本稳定不靠 T-Net 也能把 mIoU 跑到 0.7 以上。等基线跑通、确认数据管线没问题再考虑把输入变换加回去做对照实验才是更稳妥的顺序。2.2 把 VKITTI3D 转成点云标签样本两种常见数据格式VKITTI3D 的发布格式沿用了类似 KITTI 的习惯但很多发行版给的是“图像 深度 语义标签”不是直接给点云数组。拿到深度图后要和相机内参一起反投影才能得到 x/y/z 坐标麻烦在深度图和语义分割标签必须逐像素对齐。假设 depth 是 H×W 的深度图seg 是同样尺寸的逐像素语义标签相机内参 K 是 3×3 矩阵。常用反投影代码是这样import numpy as np def depth_to_points(depth, seg, K, depth_scale1.0): h, w depth.shape[:2] u, v np.meshgrid(np.arange(w), np.arange(h)) z depth.astype(np.float32) / depth_scale x (u - K[0, 2]) * z / K[0, 0] y (v - K[1, 2]) * z / K[1, 1] pts np.stack([x.reshape(-1), y.reshape(-1), z.reshape(-1)], axis-1) labels seg.reshape(-1) valid (z.reshape(-1) 0.1) return pts[valid], labels[valid]depth_scale在很多虚拟数据集里是 1表示深度图直接以米为单位如果读出来是 uint16 的毫米值记得把depth_scale设成 1000。这里的valid过滤掉深度小于 0.1 米的点这些点在车载雷达上通常属于设备盲区不参与训练反而会让标签更干净。如果某个发行版已经帮你转成了 KITTI bin 格式情况会更简单。bin 文件是 N×4 的 float32 数组前三列是坐标第四列是反射强度读取代码如下def load_kitti_bin_points(bin_path, label_path): points np.fromfile(bin_path, dtypenp.float32).reshape(-1, 4) labels np.fromfile(label_path, dtypenp.int32) return points[:, :3], labels基础版 PointNet 只消费坐标反射强度这一列可以先用起来也可以先扔掉。真正要警惕的是标签内容很多标签文件里给的是物体实例 ID而不是语义类别。两辆相邻的车可能分别叫 12 和 33直接拿实例 ID 训练模型会疯狂过拟合到“第 33 辆车”这个伪概念上。常见做法是查一份 class mapping 表把轿车、货车、公交车合并成“车辆”自行车、摩托车合并成“两轮车”再做后续训练。VKITTI3D 最诱人的地方是标签干净没有人工标注漏标和边界犹豫的问题。但这反而是一个危险信号模型会把“点云密度均匀、遮挡规则、标签边界笔直”这些合成数据专属特征记下来迁移到真实雷达时会立刻露怯。第 5 章会专门讲这个坑。3. 搭建 VKITTI3D 点云训练管线采样、归一化和类别权重一帧点云可能有几万到几十万个点PointNet 的全局最大池化理论上能接受不同点数但 GPU batch 要求输入张量形状一致所以工程上必须把所有帧统一到固定点数。这个数字不是越大越好16384 和 32768 是常见选择太小会让稀疏区域真的没点太大则显存和训练时间都吃不消。3.1 随机采样到固定点数训练用随机验证用固定种子统一的采样函数可以这样写def sample_points(points, labels, num_points, seedNone): n len(points) if seed is not None: np.random.seed(seed) if n num_points: idx np.random.choice(n, num_points, replaceFalse) else: idx np.random.choice(n, num_points, replaceTrue) return points[idx], labels[idx]当一帧点数超过目标数量时用无放回采样少数特别稀疏的帧用有放回采样来补足数量。关键点是idx必须同时用于 points 和 labels一旦写成两行随机索引模型就会在错位标签上“学得很开心”验证 mIoU 却上不去。训练阶段我建议每帧都重新随机采样这相当于一种轻量数据增强模型每轮看到的子集不同能提高泛化性。验证阶段则最好固定一个种子让评估结果可复现否则同一帧每次评估采样不同mIoU 会在 1%~2% 的范围内随机跳动很难判断改动是否有效。推理阶段更要用固定的均匀采样或固定种子否则逐帧可视化时会看到标签跳变。3.2 坐标归一化别让网络输在起跑线VKITTI3D 的点云坐标以米为单位x/y 范围可能有几十米z 方向只有几米三个维度量纲差异明显。如果不做归一化第一个 MLP 要同时处理量级相差 10 倍以上的特征BatchNorm 会被尺度抖动干扰训练初期 loss 降得非常慢。我常用的单帧归一化是先减去点云重心再除以所有点到重心的最大距离。这样把整帧缩放到单位球附近同时完全不破坏点与点之间的相对位置关系。def normalize_frame(points): center points.mean(axis0) points points - center radius np.linalg.norm(points, axis1).max() points points / (radius 1e-6) return points, center, radius返回center和radius是为了推理后把预测结果映射回原始坐标系。如果你后续要做目标检测或跟踪这个反变换必不可少如果只做语义分割评估也可以不保存但建议还是留一手免得后面要画 BEV 图时再重新算。归一化也有一个边界坑单帧归一化会让模型丧失“本帧点云绝对距离”信息。比如路面点在同距离下的尺度被拉伸模型对距离的判别会变得相对化。VKITTI3D 里的車道预测问题不大但如果你的目标场景是“判断远处障碍物”可以考虑用全局统计量归一化即用所有训练帧的均值和标准差做固定变换。两种做法没有绝对优劣先跑单帧归一化遇到距离相关指标不涨时再换成全局方案。3.3 类别不平衡先算权重再训练VKITTI3D 的标签分布通常严重偏斜路面和建筑物可能占掉一半以上行人和骑行人只有零星几个点。直接用标准交叉熵模型会倾向于把所有不确定点都判成高频类行人类别几乎不会被模型输出。解决办法是统计每个类别的真实频次构造逆频率权重freq np.bincount(labels, minlengthnum_classes).astype(np.float32) freq np.clip(freq, 1, None) weights 1.0 / freq weights weights / weights.mean()np.clip把频次下限钳到 1防止某个类别在单帧里没有点而得到无穷大权重。最后做均值归一化让权重整体量级在 1 附近不至于把 loss 撑爆。这个weights后续会传给CrossEntropyLoss作为weight参数。逆频率权重不能解决所有问题。如果某个小类别本身标注质量差或点云语义不清晰强行放大权重只会让模型学习噪声。更稳的做法是配合稠密化在训练时对包含行人、骑行人的帧做有偏采样让这些小类别帧在 batch 里出现得更频繁。虚拟数据集的优势在这里体现出来帧数量不受限制可以按类别分布采样。4. 用 PyTorch 训练 PointNet SemSeg网络骨架与超参数4.1 一个开箱即用的 PointNet 语义分割骨架这里给一个不包含 T-Net 的简化版语义分割头足以在 VKITTI3D 上跑通整个流程。核心结构是逐点 MLP 提取局部特征全局最大池化提取整帧特征然后拼接后做逐点分类。import torch import torch.nn as nn import torch.nn.functional as F class PointNetSemSeg(nn.Module): def __init__(self, num_classes, input_dim3): super().__init__() self.mlp1 nn.Conv1d(input_dim, 64, 1) self.mlp2 nn.Conv1d(64, 64, 1) self.mlp3 nn.Conv1d(64, 128, 1) self.mlp4 nn.Conv1d(128, 1024, 1) self.mlp5 nn.Conv1d(1088, 512, 1) self.mlp6 nn.Conv1d(512, 256, 1) self.mlp7 nn.Conv1d(256, 128, 1) self.classifier nn.Conv1d(128, num_classes, 1) def forward(self, x): x1 F.relu(self.mlp1(x)) x2 F.relu(self.mlp2(x1)) x3 F.relu(self.mlp3(x2)) x4 F.relu(self.mlp4(x3)) global_feat x4.max(dim-1, keepdimTrue)[0] cat torch.cat([x2, global_feat.expand_as(x2)], dim1) cat F.relu(self.mlp5(cat)) cat F.relu(self.mlp6(cat)) cat F.relu(self.mlp7(cat)) return self.classifier(cat)这里的nn.Conv1d的 kernel size 固定为 1作用是对“每个点”单独做全连接点的邻域信息完全由最大池化之后的全局特征提供。我没有把x4整个拼回去而是只拼了第二层特征x2这是工程上常用做法x2已经包含较丰富的局部结构而x4的 1024 维特征几乎全被全局池化吸收拼回去会增加显存却不一定涨点。如果你后续要加 T-Net建议把它放在mlp1之前单独用一个子网络预测 3×3 变换矩阵并加上正交性正则化。权重初始化要小否则第一轮训练就会把点云转成奇怪的形状loss 直线上升。4.2 超参数配置与最小训练循环模型定义好之后训练循环可以精简成这样model PointNetSemSeg(num_classesnum_classes).to(device) class_weights torch.from_numpy(weights).float().to(device) criterion nn.CrossEntropyLoss(weightclass_weights, ignore_index-1) optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.5) for epoch in range(epochs): model.train() for points, labels in train_loader: points points.transpose(1, 2).to(device) labels labels.to(device) logits model(points) loss criterion(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() evaluate(model, val_loader)points.transpose(1, 2)是把 B×N×3 转成 B×3×N因为Conv1d期望通道维在第二维。这个转置很容易漏漏掉之后模型不会报错但 loss 会变得非常差甚至像是在用每个点通道做卷积。我常用的一组超参数如下参数推荐值说明num_points16384点数太少稀疏类别几乎无样本太多则训练过慢batch_size8~16PointNet 依赖 BatchNormbatch 不建议小于 8optimizerAdam比 SGD 收敛更快适合第一版基线learning_rate1e-3如果 loss 振荡降到 5e-4lr_schedulerStepLR(30, 0.5)每 30 轮降一半适合 80~120 轮训练epochs100合成数据拟合快100 轮基本见分晓class weightsinverse freq缓解小类别被吞显存不够时先降num_points不要降batch_size。PointNet 的 BatchNorm 在 batch 太小时统计量抖动剧烈训练和验证指标会反复横跳。如果把num_points从 16384 降到 8192显存压力大约减半mIoU 通常下降不超过 2 个百分点。5. 排坑与避坑PointNet VKITTI3D 最常见的 5 个翻车点5.1 现象一loss 降不下来训练集一直高位徘徊第一轮 epoch 的 loss 如果只在 2.8 到 2.5 之间波动20 轮后还在 2.5 附近这不是网络玄学问题多半出在预处理。VKITTI3D 的点云坐标以米为单位x/y 范围几十米z 范围几米三个维度分布差异太大BatchNorm 被尺度抖动干扰梯度方向会一直乱撞。解决步骤很简单先做第 3.2 节的单帧归一化把点云缩放到单位球附近。这一步做好后训练 loss 通常会在前 5 轮明显下降。如果还是不行就把 batch size 提到 8 以上或者检查一下数据加载阶段有没有把归一化的center和radius误存成了全局变量。5.2 现象二mIoU 很高可视化却全是散点这是我在这个项目里翻车最严重的一次验证集 mIoU 0.78把预测结果画回原始图像路面标签在车身边缘碎成芝麻点车内部还有空洞。查到最后是因为采样函数里写了两行随机索引一行给 points一行给 labels两边序号没有对齐。模型在 30% 的错位标签上训练居然还能把全局主干学对但高频类别会互相污染视觉上就成了碎点。血泪经验是写完sample_points后立刻画一张图验证。取前 200 个点用scatter按标签上色再看点和标签是否来自同一个位置。这个检查比任何代码审查都快。如果你确认索引对齐了碎点仍然存在那就是 PointNet 全局池化把局部几何细节吞掉了需要换成带局部分组的 PointNet 或在解码器里加跳层连接。5.3 现象三验证集涨测试集掉在 VKITTI3D 上训练得很好的模型拿到真实场景点云上测试mIoU 可能直接掉 10 到 20 个百分点。原因是合成数据的点云密度均匀、遮挡规律、标签边界干净真实雷达则有反射缺失、距离衰减、运动畸变等问题。解决办法不是马上放弃虚拟数据而是把合成数据当预训练资源先在 VKITTI3D 上训练 80 轮再用少量真实点云微调 20 轮。同时训练阶段加入几何增强随机旋转、随机抖动、随机遮挡水平切片模拟真实传感器的盲区和缺失点。Domain gap 一定是存在的只是不要让它变成不可逾越的墙。5.4 现象四小类别全部被归到背景行人、骑行人这类点数只占千分之一的类别如果不做类别权重模型会把它们全部判成背景。CrossEntropyLoss的默认权重是所有类别平等这对不平衡数据非常不友好。解决方法是第 3.3 节的逆频率权重。要注意的是权重不能无限放大某个类别频次如果只有背景的千分之一逆频率权重可能超出其他类的几百倍导致 loss 被这个类别主导训练指标剧烈振荡。我会在weights上做个钳制比如乘以中位数权重后限制最大倍率到 10保证小类别学到东西但不会劫持整个优化过程。5.5 现象五远处和稀疏点区域预测成洞VKITTI3D 虽然没真实传感器噪声远处的虚拟点云依然会明显变稀疏。PointNet 的全局 max 池化容易把“远处存在感”淹没在大量近处点里于是远处车辆预测成背景或空洞。我一般会做两个调整。第一在采样阶段对远处点过采样或者把点云按距离分层每层单独归一化后再合并第二把反射强度保留为第 4 维输入特征让模型多一个判别线索。反射强度在虚拟数据里可能没有真实物理意义但经过 VKITTI3D 生成的 sensor 模拟后对区分路面和金属车身依然有帮助。实测显示加了一维反射强度后远处车辆 IoU 能提升 3 个百分点。6. 只信 mIoU 会翻车混淆矩阵和逐帧可视化才是最后一道关如果你只拿一个数字判断训练是否成功一定会在某次验收时当众翻车。我会在训练结束后先算每个类别的 IoU而不是直接看均值。下面这段代码会返回每个类别的 IoU 数组一眼就能看出问题落在哪一类。def per_class_iou(pred, target, num_classes, ignore-1): ious [] for c in range(num_classes): tp ((pred c) (target c)).sum() union ((pred c) | (target c)).sum() ious.append(tp / union if union 0 else float(nan)) return np.array(ious)这里有三个细节不要忽略。第一target中的 ignore 区域要提前排除否则 union 被无效点撑大mIoU 虚低第二union 为 0 的类别应跳过而不是记成分母为 0第三最终 mIoU 要在所有有效类别上求平均别把 ignore 类算进去。算完 IoU 再画混淆矩阵。如果“行人”和“骑行人”两个类互相串说明边界信息不足如果“车辆”被分到“建筑”那大概率是全局特征过度主导。混淆矩阵比 mIoU 更早暴露这类系统性问题。最后无论如何都要做逐帧可视化。我习惯把预测和真值并排画出来用固定的颜色映射到每个类别。看到一个帧里车辆被拆成三块你才会知道 mIoU 那个数字掩盖了什么。后来我把“先看可视化再报告数字”变成自己的固定习惯能少走很多弯路。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?