简介:面向点云语义分割与三维视觉入门者,提供基于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_scale=1.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, dtype=np.float32).reshape(-1, 4) labels = np.fromfile(label_path, dtype=np.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, seed=None): n = len(points) if seed is not None: np.random.seed(seed) if n >= num_points: idx = np.random.choice(n, num_points, replace=False) else: idx = np.random.choice(n, num_points, replace=True) 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(axis=0) points = points - center radius = np.linalg.norm(points, axis=1).max() points = points / (radius + 1e-6) return points, center, radius返回center和radius是为了推理后把预测结果映射回原始坐标系。如果你后续要做目标检测或跟踪,这个反变换必不可少;如果只做语义分割评估,也可以不保存,但建议还是留一手,免得后面要画 BEV 图时再重新算。
归一化也有一个边界坑:单帧归一化会让模型丧失“本帧点云绝对距离”信息。比如路面点在同距离下的尺度被拉伸,模型对距离的判别会变得相对化。VKITTI3D 里的車道预测问题不大,但如果你的目标场景是“判断远处障碍物”,可以考虑用全局统计量归一化,即用所有训练帧的均值和标准差做固定变换。两种做法没有绝对优劣,先跑单帧归一化,遇到距离相关指标不涨时再换成全局方案。
3.3 类别不平衡:先算权重再训练
VKITTI3D 的标签分布通常严重偏斜:路面和建筑物可能占掉一半以上,行人和骑行人只有零星几个点。直接用标准交叉熵,模型会倾向于把所有不确定点都判成高频类,行人类别几乎不会被模型输出。
解决办法是统计每个类别的真实频次,构造逆频率权重:
freq = np.bincount(labels, minlength=num_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_dim=3): 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, keepdim=True)[0] cat = torch.cat([x2, global_feat.expand_as(x2)], dim=1) 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_classes=num_classes).to(device) class_weights = torch.from_numpy(weights).float().to(device) criterion = nn.CrossEntropyLoss(weight=class_weights, ignore_index=-1) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.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_points | 16384 | 点数太少,稀疏类别几乎无样本;太多则训练过慢 |
| batch_size | 8~16 | PointNet 依赖 BatchNorm,batch 不建议小于 8 |
| optimizer | Adam | 比 SGD 收敛更快,适合第一版基线 |
| learning_rate | 1e-3 | 如果 loss 振荡,降到 5e-4 |
| lr_scheduler | StepLR(30, 0.5) | 每 30 轮降一半,适合 80~120 轮训练 |
| epochs | 100 | 合成数据拟合快,100 轮基本见分晓 |
| class weights | inverse 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 那个数字掩盖了什么。后来我把“先看可视化,再报告数字”变成自己的固定习惯,能少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取