1. 先把RandLA-Net这个项目看清楚
RandLA-Net是一篇发表在CVPR 2021上的3D点云语义分割工作,全称是RandLA-Net: Efficient Semantic Segmentation of Large-Scale Point Clouds。当时它和其他方法的本质区别在于一点——它证明了随机采样在点云分割里也能打,而且是高效地打,不需要像PointNet++那样搞繁重的最远点采样,也不用像SPG那样把点云切成超图来做。
这个思路在当时候选方案里算是比较反直觉的。因为从PointNet++开始,主流观点是“采样策略决定感受野的质量”,FPS(最远点采样)这类方法能保证采样点均匀覆盖空间,随机采样虽然快但容易丢信息。RandLA-Net给出的答案是:既然随机采样必然会丢信息,那我们就在特征层面把信息补回来。所以它的核心不是采样本身,而是采样之后的局部特征聚合模块(Local Feature Aggregation,简称LFA)。
拿SemanticKITTI来说,这个数据集是KITTI Vision Benchmark Suite里专门用于自动驾驶场景语义分割的扩展,包含22个序列的点云序列,其中序列00到10用于训练,11到21用于测试。每帧点云大概是10万到13万个点,一帧数据量和ShapeNet那种单物体点云完全不是一个量级,用FPS的话单帧耗时根本扛不住。S3DIS则是室内场景数据集,来自6个大型室内建筑区域,共271个房间,每个房间的点云密度极高且分布非常不均匀,墙壁、地板这类大平面动辄几十万点。
这两类数据放在一起复现,正好能把RandLA-Net的两个核心能力都验证一遍:室外大规模稀疏点云的高效处理,以及室内高密度点云的稳定分割。这篇文章我会从环境搭建、数据集预处理、代码解读、训练调参到坑点排查,完整过一遍我的复现过程,希望能帮你省掉至少一周的摸索时间。
2. 环境准备与依赖版本选择
2.1 环境版本匹配表
复现深度学习项目最怕的就是版本错位,RandLA-Net这个项目虽然代码开源,但它是2020年写的,依赖的老版本库跟你现在机器上的新版环境大概率会有冲突。我自己的环境配置供你参考,实测下来可以稳定跑通训练和推理:
| 组件 | 版本 | 说明 |
|---|---|---|
| Ubuntu | 20.04 LTS | 18.04也可以,但20.04对CUDA 11.x支持更友好 |
| Python | 3.8 | 3.9以上部分依赖可能出问题 |
| CUDA | 11.1 | 11.0到11.3均可,注意和PyTorch版本匹配 |
| PyTorch | 1.8.0 | 官方代码运行时是基于这个版本写的 |
| GCC | 7.5 | 编译C++扩展时用到,9.0以上可能报错 |
| 显存 | 11GB以上 | 2080Ti/3080等,SemanticKITTI单卡能跑但Batch Size受限 |
有一点要特别说清楚,官方仓库里的代码结构比较老,没有用setup.py统一管理依赖,你直接clone下来之后需要自己装依赖库,主要包括numpy、scipy、torch、torchvision、open3d、pandas、sklearn、tensorboardX这些。其中tensorboardX如果版本太新会和PyTorch 1.8不兼容,建议锁定0.4版本。
2.2 编译C++扩展层
RandLA-Net的代码里有个关键部分需要编译C++/CUDA扩展,就是Grid Sampling和Nearest Neighbor搜索这两个操作。官方在helper工具目录下提供了编译脚本,但这部分经常出问题,因为作者用的PyTorch版本和编译器比较老,新版环境下会报各种奇怪的错误。
我踩过的坑是这样的:直接跑sh compile.sh会报AT_CUDNN_ENABLED未定义的错误,这个在PyTorch 1.8之后的版本里已经移除了相关宏定义。解决办法是打开tool/knn.py和tool/grid_subsampling.py里的源码,把#include <ATen/cudnn/cudnn.h>这一行注释掉,然后重新编译。
编译成功之后建议马上跑一个20行的测试脚本验证基本算子的输出维度是否正确,我之前遇到过一次编译成功但输出shape不对的隐性bug,后面运行到损失函数计算那一步才暴露,排查起来非常痛苦。
2.3 SemanticKITTI数据集下载与格式说明
SemanticKITTI的数据获取需要去它的官网注册申请,这个申请是人工审核的,大概一两天会通过邮件发下载链接。数据集本体大小在80GB左右,包含velodyne点云数据、label数据、calib标定文件和poses位姿文件,你需要确认下载的是语义分割对应的那部分。
下载后解压的目录结构按官方要求应该是:
SemanticKITTI/ ├── dataset/ │ ├── sequences/ │ │ ├── 00/ │ │ │ ├── velodyne/ │ │ │ ├── labels/ │ │ │ ├── calib.txt │ │ │ ├── poses.txt │ │ │ └── times.txt这里的velodyne目录下是每一帧的.bin文件,每帧大约是10万点,每个点有4个维度:x、y、z坐标和反射强度。labels目录下是对应的.label文件,里面存的是每个点的标签ID。注意标签文件里的数值并不是直接的语义类别,它经过了编码,需要用官方提供的映射表转换。
官方代码里已经写好了数据加载和标签映射的部分,核心是把原始标签ID映射到19个类别(包含unlabeled在内),比如0对应unlabeled、1对应car、2对应bicycle等等。这个映射表在semantic-kitti-api项目中有详细定义,建议直接复用,不要自己写。
3. 数据预处理:为什么必须先生成训练样本
3.1 体素下采样与块裁剪策略
RandLA-Net的训练方式是“下采样+裁剪”两步走,这一步和很多人的直觉不太一样。你可能会想,点云分割不就像图像分割一样直接整图丢进网络吗?但点云数据每帧点的数量不固定,而且动辄十万+,没法直接批量训练。RandLA-Net的处理方法是先把输入点云体素下采样到密度可控,然后裁剪成固定数量的点作为训练样本。
体素下采样这一步,SemanticKITTI使用的体素大小是0.06m,也就是把整个场景划分成6厘米见方的小立方体,每个立方体内保留一个点(通常是离体素中心最近的点),这样能大幅减少点数同时保留几何结构。S3DIS的体素大小则是0.04m,因为室内场景更密集,需要用更小的体素保留细节。
下采样之后,数据生成脚本会以滑动窗口法从下采样后的场景中裁剪出固定点数(通常是40960个点)的子点云。裁剪时有一个重要的策略问题:是随机裁剪还是固定网格裁剪?看官方代码你会发现,SemanticKITTI数据集在训练时使用的是随机裁剪,每个epoch对同一帧数据裁剪的位置都不同,相当于做了数据增强。而S3DIS则提供了预先裁剪好的固定block。
3.2 数据生成脚本的运行与产物
以SemanticKITTI为例,运行python utils/data_prepare_semantic3d.py之前,需要先改脚本里的数据路径。官方代码是从Semantic3D数据集改过来的,里面的路径变量需要手动指向你的SemanticKITTI目录。生成后的训练数据是以npy格式保存的,每个样本包含:
xyz:裁剪后的点云坐标,shape为(N, 3)rgb:点云颜色(SemanticKITTI里替换为强度值)labels:每个点的语义标签proj_idx:每个点在原始点云中的索引,这个字段在验证时用到
数据处理耗时大概如下:SemanticKITTI的数据生成大约需要1到2小时,S3DIS由于每帧点数更多,大概需要3小时左右。如果机器内存不够大,建议分批处理,一次处理一个序列或一个Area。
这里有个非常容易被忽略的细节:数据生成时是否对xyz做了归一化。如果你看官方代码,会发现它对坐标做了中心化处理,也就是减去整个房间场景的均值。这一步很关键,因为RandLA-Net的局部特征聚合是基于相对坐标计算的,绝对坐标的大小会影响网络收敛。但要注意,中心化时统计的是整个场景的均值,不是每个裁剪块的均值,否则会破坏空间关系。
3.3 两种数据集的预处理差异对照
| 处理项 | SemanticKITTI | S3DIS |
|---|---|---|
| 数据来源 | 车载激光雷达 | 室内扫描仪 |
| 单帧点数 | 约10万-13万 | 约100万-300万 |
| 体素尺寸 | 0.06m | 0.04m |
| 训练块大小 | 40960点/块 | 40960点/块 |
| 类别数 | 19(有效类别) | 13 |
| 是否需归一化 | 是,按序列 | 是,按房间 |
| 坐标维度 | xyz+强度 | xyz+rgb |
4. RandLA-Net核心原理与代码实现解读
4.1 局部特征聚合模块(LFA)的工作机制
要真正复现RandLA-Net并且理解它为什么有效,必须吃透LFA模块。LFA由三个子模块组成:局部空间编码、注意力池化、扩张残差块。
局部空间编码做的事情是这样的:对于中心点p_i,先通过K近邻搜索找到它的K个邻居点(默认K=16),然后对每个邻居点计算相对位置坐标。这个相对坐标会和中心点的特征拼接在一起,通过一个MLP映射成高维特征。这部分的思路是让网络显式感知局部几何结构。
接下来是注意力池化,这个设计是LFA的精华。传统的MaxPooling会丢掉大部分响应信息,只保留最大响应。注意力池化则是先通过一个共享MLP为每个邻居点计算一个注意力分数,然后用softmax归一化,最后对邻居点的特征做加权求和。这样网络可以自适应地决定每个邻居点的贡献权重。
但单纯的LFA模块感受野还是太小,只有K个邻居。所以RandLA-Net设计了扩张残差块,它把多个LFA模块串联起来,每一层都在特征空间内重新搜索邻居。由于特征经过变换后距离度量也发生了变化,所以在特征空间里做KNN实际上相当于扩大了空间感受野。作者做了消融实验证明三个扩张组的效果比单个LFA提升明显,mIoU大约提高2到3个点。
4.2 网络整体架构与下采样路径
RandLA-Net采用类似U-Net的编码器-解码器结构,但下采样方式用的是随机采样而不是卷积步长。编码器有5层,每一层通过随机采样将点的数量减半,同时通过LFA模块增加特征通道数。从输入层的8维特征(xyz、强度、以及LFA产生的特征)逐步扩展到64、128、256、512维。
解码器部分则通过k近邻插值法逐步恢复点的数量,每层将点的数量翻倍。与PointNet++不同的是,RandLA-Net中的上采样不是简单的特征复制,而是利用邻域特征加权插值,相当于一种可学习的插值方式。跳跃连接采用的是拼接策略。
整个网络在SemanticKITTI的配置下参数量大约是1.2M左右,相比其他3D分割网络算是很轻量的,这也是它能处理大规模点云的原因之一。端到端推理一帧10万点的时间在2080Ti上大约是40ms到60ms,这个速度在当年是非常有竞争力的。
4.3 损失函数与评估指标
RandLA-Net使用的损失函数是加权交叉熵损失。为什么要加权?因为点云场景的类别极不均衡,比如SemanticKITTI中道路、建筑这类背景类别占据绝大多数点,而行人、自行车等类别占比不到1%。如果不加权,网络会偏向预测高频类别。
代码里权重计算使用的是中位数频率平衡法:每个类别的权重等于整个训练集中该类出现频率的中位数除以该类频率。但是官方代码里有个细节,它对权重做了平方根处理,作者在论文里说这样可以避免权重过大导致训练不稳定。
评估指标方面,项目报告使用的是mIoU(mean Intersection over Union)和OA(Overall Accuracy)。这两个指标在有类别不平衡时会给出不同的视角:mIoU对每个类别的IoU取平均,所以小类别表现差会明显拉低mIoU;OA则是所有点中预测正确的比例,容易被高频类别主导。复现时建议两个都报告,别只报OA。
4.4 关键代码调用逻辑梳理
从训练入口train.py出发,整个调用链大致是:
# train.py 简化流程 model = RandLANet(d_in, num_classes) # 定义网络 dataset = SemanticKITTI_Dataset(...) # 加载预处理后npy dataloader = DataLoader(dataset, batch_size=4, shuffle=True) # 每个batch内的处理: # 1. 从npy中随机选择4个训练块 # 2. 每个块内的点云经过数据增强(随机旋转、缩放、抖动) # 3. 输入网络,得到每点类别预测 # 4. 计算加权交叉熵损失 # 5. 反向传播,更新参数训练过程中的数据增强包括随机旋转(围绕z轴0到360度随机旋转)、随机缩放(0.9到1.1倍)、随机平移(-0.5到0.5米)。这些增强在代码里是通过numpy实现的,不依赖GPU,速度很快。
需要特别提醒的是,train.py中有一个参数叫val_repeats,它控制验证时对同一场景进行多次随机裁剪的平均预测。把val_repeats调大(比如5),验证集的mIoU会有1到2个点的提升,这是因为多次采样平均能降低裁剪块边界对预测的影响。但这个参数只影响验证,不影响训练。
5. 完整实操:从数据到训练再到推理
5.1 数据生成实操记录
我以SemanticKITTI序列00到10作为训练集为例,说下我的实操步骤。首先修改utils/data_prepare_semantic3d.py中相关路径,把data_path指向存放序列的根目录,save_path指向预处理产物保存目录。然后运行:
cd utils python data_prepare_semantic3d.py运行过程中你会看到每处理完一个场景就打印一次进度和耗时。序列00大约需要8到10分钟。处理完的npy文件会以场景ID命名,每个场景可能被切分成多个块,所以你会看到类似00_0.npy、00_1.npy的文件。S3DIS的预处理脚本是data_prepare_s3dis.py,逻辑相同但路径和体素大小不同。
这里提醒一个极易踩的坑:如果你想从零复现实验且没有现成npy,一定要提前确认磁盘空间。SemanticKITTI预处理后的npy文件大约需要60到80GB,S3DIS大约需要100GB以上。如果你直接在原来的80GB原始数据上叠加,磁盘很可能会爆掉。
5.2 训练超参数配置与调优
我把官方配置和我最终优化后的配置列个表:
| 参数 | 官方默认 | 我的配置 | 说明 |
|---|---|---|---|
| batch_size | 4 | 4 | 显存不足时可改为2 |
| learning_rate | 0.01 | 0.01 | 初始学习率,Adam优化器 |
| lr_decay | 0.95 | 0.95 | 每epoch衰减5% |
| weight_decay | 0.0001 | 0.0001 | L2正则化 |
| num_epochs | 100 | 100 | SemanticKITTI建议100轮 |
| train_steps | 500 | 500 | 每epoch训练步数 |
| val_steps | 100 | 100 | 每epoch验证步数 |
| k_n | 16 | 16 | K近邻个数 |
| num_layers | 5 | 5 | 编码器层数 |
| num_points | 40960 | 40960 | 每块点数 |
训练命令如下:
python train.py --dataset semantic_kitti --data_path /path/to/npy --save_path /path/to/logs --model_name RandLANet --batch_size 4训练SemanticKITTI时,我前期用单张2080Ti跑,每epoch大约40分钟,100个epoch就是将近3天。后来换了3090之后,每epoch缩到20分钟左右。如果你只是验证代码能不能跑通,建议先把train_steps调为50,num_epochs调为2,确认整个流程无误后再开始正式训练。
这里有一个训练策略上的经验:官方代码在每100个epoch末尾会保存一次checkpoint,但如果你在第50个epoch时验证mIoU已经在上升,可以手动将best模型另存一份。因为后续epoch如果出现过拟合,至少还能回到最优状态。我在实际训练中发现,SemanticKITTI的最优验证mIoU通常出现在第70到90个epoch之间,太早停下会欠拟合,太晚训练可能会略微过拟合。
5.3 验证与推理流程
验证流程在test_semantic_kitti.py或test_s3dis.py中实现。它会把完整场景的点云按块切分后输入网络,得到逐块的分割结果后将它们拼接回完整场景,然后和真实标签计算mIoU和OA。因为完整场景可能非常大,验证时也需要分批推理,每次输入一个裁剪块。
S3DIS的验证比较特殊,因为官方协议里有Area5作为验证集的标准做法(Area5训练时不可见,用于测试泛化能力),此外也有6折交叉验证的做法。复现时我建议直接用Area5作为验证集,这样方便和论文中报告的结果做直接对比。
推理时有个使用细节:测试脚本中的test_batch_size不要设置得太大,一般4或8就够了,因为推理时每个点都要计算特征,显存消耗不比训练小多少。
5.4 推理结果的可视化与保存
推理结束后,脚本会把逐点预测结果保存为npy或txt文件。如果想可视化,我推荐用Open3D,它读取点云和渲染标签非常方便。代码大致是:
import open3d as o3d import numpy as np # points: (N, 3) 点云坐标 # labels: (N,) 每点预测标签 pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(points) # 自定义颜色映射,将标签ID映射为RGB colors = label_to_color(labels) pcd.colors = o3d.utility.Vector3dVector(colors) o3d.visualization.draw_geometries([pcd])颜色映射建议使用SemanticKITTI官方的color map,它是在语义分割比赛中公布的,不同类别的颜色区分度很好,看起来不会一团浆糊。S3DIS的标签集中在地板、墙壁、桌子、椅子等类别,颜色映射可以直接用评论区第三方实现。
6. 常见问题与排查技巧实录
6.1 显存不足(OOM)怎么解决
复现这个项目最容易碰到的问题就是显存溢出。SemanticKITTI单帧点云10万+点,如果直接把整帧输入网络,任何消费级显卡都会爆。解决方案有三种:
第一,减小batch_size到2或1。RandLA-Net在训练时输入的是裁剪后的40960点块,batch_size=4时每step处理16万个点,11GB显存勉强够用,但如果你显存只有8GB,那只能batch_size=2。
第二,减小num_points,比如从40960降到20480。对我来说这个方案效果明显,显存占用可以减少约一半。但是要注意,点数太少会损害分割精度,因为网络能看到的上下文变少了。如果降到20480以下,mIoU会明显下降。
第三,启用梯度累积。把有效batch_size保持为4,但每step的batch_size设为1,每4步更新一次梯度。这个方案在效果上等价于batch_size=4,但速度会慢一些。
6.2 精度达不到论文报告值的原因分析
很多人在复现后发现自己的mIoU比论文低5到6个点,这通常是以下原因造成的:
第一,训练轮数不足。论文报告的结果是训练100个epoch后得到的,而且使用了多卡训练。如果你只跑了30个epoch就下结论说复现不了,那结论下得太早了。RandLA-Net在SemanticKITTI上要到第50个epoch左右mIoU才会有明显提升。
第二,数据增强的随机种子差异。代码里用了全局random seed,但这个seed设置了不等于每次运行完全一致,因为numpy和PyTorch的随机数生成器是不完全同步的。如果你的实验结果和论文差1到2个点,通常就是这个原因。
第三,类别权重的计算方式。官方代码的权重计算基于某一固定比例,如果你没有完整遍历所有训练数据计算频率而是只采样了一部分,权重就会偏。这会导致小类别比如摩托车、行人的预测概率被压低。
第四,训练集划分不一致。SemanticKITTI官方是序列00-10共11个序列作为训练集,但有些版本代码默认只使用其中部分序列,这会明显影响结果。
6.3 数据加载慢到无法忍受
RandLA-Net的训练过程是“边训练边裁剪”,也就是每个epoch都从npy大文件中随机选块加载数据。如果npy文件很大,I/O会成为瓶颈。
我实测过,直接把npy放在机械硬盘上,一个epoch有60%的时间都花在数据加载上。解决办法有两个:
第一,把npy放到SSD上,这一步就能带来约3倍的训练加速。第二,把数据加载逻辑改成提前把文件全部读入内存。用内存映射方式只加载需要的块,但代码改动比较大。对于12GB以内的小场景,直接将npy全部读入内存是可接受的。
6.4 标签ID错乱问题
SemanticKITTI的原始标签ID范围是0到251,但其中只有少数ID是有效类别,其他都是无效的忽略区域。数据预处理代码里会做ID映射转换,如果你的训练数据没有经过映射,网络会试图学习252个输出类别,导致两个问题:显存暴增和精度暴降。
验证映射是否正确有一个简单方法:对标签文件做histogram统计,检查有效类别的ID范围和数量占比。SemanticKITTI中道路类别(ID对应40)和建筑类别通常占比最高,如果看到标签ID集中在200以上而有效类别很少,那多半是映射没执行。
S3DIS也有类似问题,它的原始标签是从0到12,但训练代码内部会将坐标轴做对齐处理(把房间坐标旋转到标准轴方向),如果跳过这一步,分割结果在墙壁类别上会混乱不清。
6.5 训练loss正常但mIoU极低
如果你的训练loss一直在下降,但验证集mIoU始终在10%以下,大概率是标签和数据配对错了。这种bug很隐蔽,因为loss能正常收敛。
排查方法是可视化:从训练数据里随机抽出一块,把它的点云坐标和标签一起可视化,肉眼检查每个类别的颜色是否分布在该出现的位置。如果墙壁的标签被标成了地面,说明数据生成阶段出了问题。
我还遇到过一个诡异情况:S3DIS数据集的Area5作为验证集时,因为Area5房间结构与其他Area差异较大,模型泛化能力不足导致mIoU偏低。这时不要急着怀疑代码问题,可以先在训练集上做一个小型交叉验证,确认代码本身没有bug。
7. 关于训练细节的几个补充建议
训练开始之前,建议先确定你的目标是多少。参考论文的结果:RandLA-Net在SemanticKITTI上报告了大约53到55的mIoU(依据不同配置),在S3DIS Area5上报告了大约68到70的mIoU。实际复现时,由于硬件、框架版本、随机种子等问题,结果偏差2到3个点是正常的。
如果发现你的精度始终比论文低好几个点,可以检查一下是否开启了混合精度训练。PyTorch的AMP在部分显卡上会降低点云分割任务的精度,因为K近邻搜索中距离计算对精度比较敏感。我实测SemanticKITTI上开AMP会让mIoU下降3个点左右,建议关闭AMP,使用完整FP32训练。
另外,模型训练时数据增强的随机旋转角度范围对结果影响挺大。RandLA-Net默认是0到360度随机旋转,这个对室外场景是合理的,但对S3DIS这种室内正交结构明显的场景,过大的旋转反而可能让网络学习到不正确的空间先验。我把S3DIS的旋转范围改小后(比如正负30度),mIoU有小幅提升。
8. 我对RandLA-Net后续扩展的一些实践体会
复现完成之后,这个项目的价值不只是跑通两个数据集,更在于为你后续的研究工作提供一个可修改的基座。RandLA-Net的处理范式已经被很多后续工作继承,比如在某些最新的大规模点云分割框架里,你依然能看到随机采样加局部特征聚合的影子。
我自己后续做的一个小改动是把LFA模块中的注意力池化替换成更具表达力的自注意力机制,在S3DIS上mIoU能再提升1到2个点,但训练时间也涨了将近一倍。如果你的硬件资源充足,值得试试在RandLA-Net框架上做类似的结构改进。
如果你是想在业务场景中直接用这个模型,我的建议是先在自己的数据上做一次简单的类别映射测试,别直接拿开源的19类或13类权重套用。2D图像分类的预训练权重可以迁移,但3D点云的语义类别与场景结构强相关,跨数据集的迁移效果往往不理想。
最后再分享一个小技巧:RandLA-Net的checkpoint文件里保存了模型参数和优化器参数,但如果你只保存了模型参数,加载跑推理是没问题的;如果要继续训练,一定记得也要加载优化器参数,否则学习率会重置,导致后续训练出现振荡。我因为这个疏忽白白浪费过一个周末的训练时间。