ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

动态体素化3D点云检测实战:环境配置与训练全流程

动态体素化3D点云检测实战:环境配置与训练全流程

3D目标检测这几年我接触最多的就是点云这一类,尤其是基于体素的方法。很多刚入门的朋友会有一个直觉:激光雷达返回的不就是一个个坐标点吗,直接把点云丢给网络不就行了?真上手之后才会发现,几万到十几万个点,无序、密度不均、还带着遮挡和噪声,直接处理既费算力又难收敛。所以体素化成为绝大多数点云检测方法的第一道工序,而Dynamic Voxelization(动态体素化)又是在这道工序上做的一次关键改进。

这篇文章是我把整套流程完整跑过一遍之后的记录,不是PPT式的科普。内容包括三块:环境配置怎么落地、训练“代跑”全流程怎么走、以及后续想在模型上做改进创新时可以从哪些方向下手。适合刚要接触3D点云检测的研究生、准备做工程落地的算法工程师,还有想把开源模型跑通之后迁到自己数据上的同学。

标题里说的“代跑训练”,说白了就是模型代码已经是现成的,你只需要把环境、数据和训练流程接起来,让模型真正跑起来并拿到一个合理的结果。我按这个思路把每一步拆开,顺便把容易卡住的地方都标了出来。

1. Dynamic Voxelization到底在解决什么问题

1.1 体素化的本质:把无序点云变成有序网格

先统一一下基础概念。激光雷达点云本质上是一个若干行组成的矩阵,每一行存放一个点的坐标和附加属性,常见格式就是 x、y、z、intensity(反射强度)。麻烦在于这些点在空间里是乱序的,数量大、分布又不均匀:近处的墙体可能密密麻麻几万点,远处一辆车只有十几点。

传统的卷积网络做的是规则网格上的操作,而点云没有“行列”的概念。体素化的思路非常直接:把三维空间按一定尺寸切成长方体网格,每个小格子就是一个体素(voxel),然后把落在同一个格子里的点归到一起,用某种方式聚合成一个特征向量。这样一来,无序点云就变成了有空间位置语义的三维特征网格,后面再接三维稀疏卷积也好,压成鸟瞰图做二维卷积也好,都顺理成章。

这个过程听起来简单,但里面的门道不少。体素尺寸怎么定、每个体素里塞多少点、多余的点怎么办、空体素怎么处理,这些细节直接决定了下游检测头的上限。

1.2 硬体素化的两大致命短板

早期方法大多是“硬体素化”。所谓硬,指的是点与体素的对应关系被固定规则焊死了。具体操作通常是:先设定体素尺寸,再把点云的坐标范围规定好,比如从-50米到50米;然后为每个点计算它落在哪个体素里;最后为每个体素设置一个最大点数阈值T,超过T的点直接丢掉。

这两个短板在真实场景里非常致命。

第一个是信息丢失。激光雷达在近处高密度区域,一个体素里的点可能远远超过T,比如一个近距离障碍物表面可能有几百个点,但体素只保留其中20个;而远处目标的点本来就只有三五个,还可能在量化的时候落到相邻体素里被拆散。信息一旦在输入端被丢弃,后面的网络再怎么设计也没有办法补回来。

第二个是硬体素化下的体素分配通常放在CPU做,既不是端到端可训练的,每次数据增强改变点云分布后还要重新执行整套映射。早些年大家接受这个损耗,是因为计算资源有限,不得不用离散网格换效率。但到了现在,这个“省”往往会让模型精度成为瓶颈。

我早期在一个稀疏场景数据上跑硬体素化模型,漏检特别严重。后来排查原因,发现不是模型不够强,而是很多远距离目标的点在预处理阶段就被截断了。这种问题不在训练里出现,而是在“看不见”的数据入口出现,最容易让人白折腾。

1.3 动态体素化的核心机制

动态体素化的思路,说穿了就是不提前把“哪个体素里有哪些点”钉死。它仍然会把每个点映射到对应的体素索引上,但不会因为超过T就丢弃点,而是通过排序、去重、分组的手段,把落在同一体素里的所有点都保留下来,然后在真正需要特征的时候,根据这些点当前的坐标和特征,动态计算体素特征。

这个过程最大的特点是自适应。体素与点的对应关系在每一轮前向里都可以根据输入点云重新确定,而不是依赖一组写死的规则;同时整个操作放在GPU上完成,梯度可以顺着坐标和特征回流,让体素化这个环节也变成可学习的一部分。

我最看重的优势有两点:

  • 不丢点。无论目标在远还是近,边界点、尾部的点都能参与后续特征计算,对小目标和分布不均匀的场景明显更友好。
  • 少一个敏感超参。硬体素化里“每体素最大点数T”很难调,调小了丢信息,调大了内存爆炸。动态体素化把这个参数的敏感性降了下来,调参压力小很多。

当然,动态体素化也不是万能。保留全部点意味着内存开销相对更高,某些实现的速度也可能比简单的截断式体素化慢。实操里通常还是要设一个合理的最大体素数量,保证显存可控。这个会在后面的环境配置和训练部分具体说。

2. 环境配置:先把地基打牢

2.1 硬件与版本匹配的整体思路

跑3D目标检测和跑二维分类不太一样,它吃显存、吃编译环境、吃CUDA工具链。很多人在环境上卡一周,不是因为操作复杂,而是版本之间互相不兼容。我先给一个我验证过比较省心的版本搭配参考。

组件推荐配置备注
GPU显存8GB起步,16GB以上更从容小显存只能压小batch,影响收敛
操作系统Linux发行版(Ubuntu 20.04/22.04)不建议新手在Windows上硬刚
显卡驱动能支撑目标CUDA版本即可用nvidia-smi看驱动支持的上限
CUDA Toolkit与PyTorch预编译包匹配常见选11.3或11.8
深度学习框架PyTorch 1.10到2.x之间与CUDA版本锁死
编译器GCC 7到10之间太新的GCC会让部分扩展编译失败
第三方稀疏卷积库与PyTorch版本配套编译前先确认版本兼容说明

版本匹配的核心逻辑是:驱动决定能装多新的CUDA,CUDA决定PyTorch预编译包选哪个,PyTorch版本又决定第三方扩展能不能顺利编译。所以配环境的顺序应该是先定框架版本,再倒推CUDA和驱动,而不是反过来。

2.2 逐层搭建:从驱动到扩展模块

第一步,装驱动。这一步现在比较省事,在系统设置里开启专有GPU驱动或者用命令行装好后重启,然后跑一下nvidia-smi,确认GPU已经被系统识别。驱动版本不需要追求最新,只要它支持你后面要用的CUDA版本就行。

第二步,建虚拟环境。我习惯用conda,干净、好回滚:

conda create -n dv python=3.8 -y conda activate dv

第三步,安装PyTorch。这里关键是选对CUDA版本对应的预编译包。我常用的是CUDA 11.3配PyTorch 1.10左右的组合,稳。

pip install torch==1.10.1 torchvision==0.11.2

装完之后立刻验证两件事:

python -c "import torch; print(torch.__version__); print(torch.cuda.is_available())"

torch.cuda.is_available()输出True才说明CUDA链路通了。

第四步,安装基础依赖库。3D检测框架一般依赖numpy、scipy、pyyaml、tqdm、opencv-python、numba这些:

pip install numpy scipy pyyaml tqdm opencv-python numba

第五步,把源码下载下来,编译扩展模块。这一步最容易出事,因为涉及C++和CUDA混合编译。常见的流程是进入项目根目录然后执行:

python setup.py build_ext --inplace

如果项目用到了稀疏卷积库,还要先把对应的稀疏卷积扩展装好。编译过程中报GCC版本错误是最常见的:解决思路是切换系统默认GCC版本,比如用update-alternatives把gcc切到7或9,再重新编译。

第六步,确认所有自定义算子都能被正确导入。找一个项目自带的demo或者单测跑一下,能跑通再继续。

我在这一步踩过最典型的坑:编译时忘了看内存,直接在8GB内存的机器上编译大扩展,直接被系统杀掉进程。解决办法是用make -jN或setup.py build_ext -jN把并行度调低,N取4到6就够,别贪。

2.3 数据集准备与目录规范

模型代码只是骨架,数据才是血肉。公开点云检测数据集通常包含标定文件、原始点云和标注文件三部分。一个稳妥的目录结构长这样:

dataset/ ├── training/ │ ├── calib/ # 每个样本的标定参数 │ ├── velodyne/ # 原始点云bin/pcd文件 │ ├── label_2/ # 标注文件,每行一个目标 │ └── ImageSets/ # 训练、验证、测试划分索引 ├── testing/ └── ...

标注文件每一行大概是这样的格式:类别 截断程度 遮挡程度 观测角度 目标中心坐标 目标尺寸 置信度。坐标x、y、z是目标中心点在激光雷达坐标系下的位置,w、h、l是宽、高、长。

因为数据集动辄几十G,直接把数据复制到项目目录下太浪费。稳妥做法是建一个专用的数据目录,然后在项目里用软链接指过去:

ln -s /path/to/dataset dataset

数据路径在配置文件里一定要写对,路径问题导致的数据加载失败在论坛里出现频率极高,每次都是最基础的排查项。

2.4 环境自检清单

环境配完,我习惯在正式跑训练之前按这个清单过一遍:

  • nvidia-smi能看到GPU型号和驱动版本;
  • 终端里python -c "import torch; print(torch.cuda.is_available())"输出True;
  • 项目自带demo脚本能成功输出检测结果或特征张量;
  • 数据集的目录结构和标注格式与配置文件的读取逻辑一致;
  • 下一页训练之前,先把点云可视化或者数据加载单测跑一遍,确保不是空数据、不是坐标系反了。

这套自检十分钟之内做完,但能帮你把后面几天排查问题的时间省下来。

3. 模型代跑训练:从配置文件到完整流程

3.1 配置文件里每个关键参数的含义

3D检测框架的核心配置一般集中在几个文件里:数据处理参数、模型结构参数、训练超参。很多人喜欢直接改默认config然后开跑,结果出了问题完全不知道从哪查起。我建议先把下面这些参数吃透。

point_cloud_range是点云范围,通常是[x_min, y_min, z_min, x_max, y_max, z_max]。比如[-50, -50, -5, 50, 50, 3]代表在水平方向取前后左右各50米范围,高度上从-5米到3米。这个范围决定数据量和内存,也是后面所有锚框生成的前提。

voxel_size是体素尺寸,常见设置是[0.05, 0.05, 0.1],也就是水平方向5厘米一个格子、垂直方向10厘米。体素越小,空间分辨率越高,但计算量成倍上涨。硬体素化里还有个max_points_per_voxel,动态体素化里这个参数可以放宽甚至只做个上限兜底。

锚框(anchor)配置直接影响召回。公开数据集里常见三类目标的典型尺寸大致如下:

类别长x宽x高(米)正样本IoU阈值
车辆3.9 x 1.6 x 1.560.7
行人0.8 x 0.6 x 1.730.5
骑车人1.76 x 0.6 x 1.730.5

锚框尺寸和数据集目标分布严重不匹配时,会出现“loss很漂亮但mAP很低”的诡异现象,因为网络学会了输出一个平均尺寸的框,恰好和大部分训练样本“凑合”匹配。

训练超参里首先要盯的是batch_size_per_gpu。单卡8GB显存用batch size 2通常没问题,显存不够时可以配合梯度累积保持等效batch size。学习率一般按batch size线性缩放,比如batch翻倍,学习率也翻倍。

3.2 启动训练、日志监控与断点续训

环境配好、数据放好之后,跑起来其实就是一条命令的事。以常见的框架目录结构为例:

python tools/train.py --cfg_file cfgs/dynamic_voxel.yaml

想让每次实验有独立标识,可以加--extra_tag参数命名实验:

python tools/train.py --cfg_file cfgs/dynamic_voxel.yaml --extra_tag dv_baseline_run1

训练启动后,优先盯三个地方:loss能不能顺畅下降、每Epoch耗时多少、日志里有没有出现NaN或warning。我一般建议开一个终端挂着训练日志:

tail -f output/dv_baseline_run1/log.txt

日志里通常能看到每个batch的loss、当前学习率和训练速度。如果前几百个batch里loss纹丝不动,优先怀疑学习率设置太保守或者数据加载环节出了问题。

训练到一半断了是家常便饭。框架一般都会保存最近的checkpoint,续训时直接指定:

python tools/train.py --cfg_file cfgs/dynamic_voxel.yaml --extra_tag dv_baseline_run1 --ckpt output/dv_baseline_run1/latest.pth

这里有个细节:--ckpt加载的是checkpoint里的完整状态,包括优化器状态;--pretrained只加载模型权重。想在公开预训练权重基础上微调时用后者,想继续跑中断的训练用前者,别搞混。

3.3 评估指标与结果可视化

训练结束后,评估命令通常是:

python tools/test.py --cfg_file cfgs/dynamic_voxel.yaml --ckpt output/dv_baseline_run1/checkpoint_epoch_80.pth

评估输出的核心指标是各类目标在不同IoU阈值下的AP。我方前文提到,公开数据集的惯例是车辆IoU阈值0.7,行人和骑车人0.5。看结果不要只盯一个类别的AP,三个类别的变化趋势才能体现模型真实的泛化表现。

我还会做一步:验证集误差分析。具体做法是把预测结果可视化成带点云的3D框,挑几个典型的样本看一眼。有时候mAP数字差不多,但漏检目标的位置完全不同——有的漏远车,有的漏被遮挡的行人。可视化能帮你快速定位是数据问题、锚框问题还是模型能力问题。

框架自带的可视化工具通常需要先pip install open3d之类的依赖,装好后用一条命令就能输出渲染结果。如果可视化太麻烦,也可以把预测结果导出成文件,用通用点云查看器打开。

3.4 训练效果不理想时的排查顺序

训练结果不如预期,我的排查顺序固定如下,从最便宜的开始:

  1. 确认loss确实是下降的,而且梯度尺度正常。loss初始值异常低或异常高都说明数据或初始化有问题。
  2. 确认模型在训练集上能不能过拟合。选一小批数据训几十个epoch,如果loss能压得很低,说明模型容量和执行链路没问题,问题在泛化环节;如果连训练集都拟合不了,优先检查数据增强、学习率和归一化。
  3. 确认验证集评估指标计算方式对不对。坐标系、IoU阈值、类别顺序任何一个错,mAP都会失真。
  4. 确认锚框尺寸是否匹配任务分布。
  5. 最后才考虑加大模型、加更多数据增强。

按照这个顺序排查,绝大多数“效果差”问题都能在半小时内定位到根因。

4. 模型改进创新:几条真实可落地的路线

模型跑通只是起点,真正考验能力的是改进。围绕Dynamic Voxelization做创新,我把它拆成五个层次。

4.1 在体素特征编码端做文章

动态体素化的特征编码环节通常是把体素内所有点的特征过一个小MLP然后做聚合,最常用的是最大池化。这个操作简单有效,但确实粗糙——最大池化只保留“最显著”的点特征,其余点的响应都被丢掉了。

改进思路很直接:把最大池化改成可学习的加权融合。具体做法是让每个点根据自身坐标和特征生成一个注意力权重,然后做加权平均。这样体素特征变成了所有点特征的“商量结果”,而不是“少数人的独裁”。每个体素内点数不多,计算量很可控。

进一步还可以在体素内做轻量自注意力。一个体素里通常只有几个到几十个点,自注意力在这个规模下开销可忽略,但能建模点与点之间的相对关系,比独立MLP更细腻。这是目前很多改进工作的切入点。

4.2 给主干网络和检测头加注意力

体素特征生成之后,通常会送入三维稀疏卷积主干网络。稀疏卷积的问题是感受野固定、上下文建模能力有限。可以做的改进是在主干中插入轻量注意力模块。

一个比较常规的做法是在稀疏卷积的某个阶段,对每个非空体素的通道维度做Squeeze-and-Excitation操作。操作难度主要在数据格式上:稀疏张量不是规则网格,不能直接GAP,但可以对非空体素做通道均值统计,再做通道加权。这个改动一般能带来1到2个点的mAP提升,且速度影响不大。

如果想把步子迈大一点,可以在主干后用先生成鸟瞰图特征的方式,再在二维特征图上加空间注意力。这样三维的视野和二维的分辨率互补,尤其对远距离小目标有帮助。这个方案不算难,参数也少,作为中期改进非常合适。

4.3 多尺度与跨层融合

激光雷达数据天然是“近密远疏”。固定单一分辨率体素化的本质问题就在这里:近距离需要细体素保精度,远距离需要大体素保覆盖。多尺度体素化路线是直接对开阔性问题的回应。

可行的实现方式有两种。一是把三维空间按距离切成区域,近距离用小体素,远距离用大体素,两个分支的特征在后处理时融合。二是保持单一尺度体素化,但在主干网络里加入类似特征金字塔的结构,把高层语义和低层细节拼接在一起。

后者的落地成本低很多,因为三维稀疏主干不同阶段的输出天然就是多尺度的。需要额外注意的就是感受野匹配:拼之前通常要对低层高分辨率特征做一次降采样,或者对高层特征做上采样对齐。

4.4 损失函数和训练策略的改进

模型改进不只有结构,损失函数和训练策略同样是高性价比的发力点。

分类支路上,早期方法常用的标准交叉熵在正负样本极端不平衡时会偏向负样本。把分类损失换成focal loss,或者引入IoU感知的损失,让分类分数和定位质量对齐,这是近几年的主流做法,提升通常很稳定。

回归支路上,方向角回归是一个经典难点。直接用角度差做回归会遇到周期性问题,很多框架已经采用了残差回归加方向分类的组合方式。如果你在跑的目标检测任务里方向误差明显,优先检查这个环节。

训练策略层面,最值得投入的是数据增强。点云数据的GT采样(把标注物体从其他帧“搬”到当前帧)、随机旋转、随机缩放,这些操作对最终mAP的影响往往比更换损失函数还大。我建议在动模型结构之前,先把数据增强配置调到位。

4.5 消融实验的规范设计

改进做完了,还要用规范方式证明:加载体素化、不加你设计的模块,其他条件完全一致,mAP对比才有说服力。

每次只改动一个变量是铁律。你同时换掉了注意力、损失函数、数据增强,最后说“新方案涨了三个点”,审稿人和Leader都会疯掉的。正确流程是:以跑通的baseline为准,记录原始mAP;然后只加模块A,记录效果;再在A的基础上加B,记录效果;最终方案和每个中间版本在相同epoch数下评估。

额外要注意的是随机种子。深度学习训练对随机性敏感,同一条命令跑两次结果也有抖动。严谨做法是固定种子、固定数据加载顺序,或者每个配置跑三次取平均。我个人经验是,公共数据集上0.3到0.5个点的差异都可能是随机性造成的,小于这个幅度的“提升”需要复跑验证。

我把已跑通的baseline、每个中间版本的config文件、日志、checkpoint按版本归档。这个习惯让我在组里复盘实验时能直接拿出证据,而不是靠记忆。

5. 常见问题与避坑实录

5.1 环境层面的高频报错

报错或现象可能原因排查建议
torch.cuda.is_available()为False驱动、CUDA、PyTorch版本链断裂先确认nvidia-smi,再重装匹配的PyTorch
编译时报GCC版本错误系统GCC过新切换GCC版本后再编译
编译时进程被杀内存不足或并行度太高调低编译并行度,关闭大内存程序
稀疏卷积库导入失败编译时没找到匹配的CUDA/PyTorch与框架版本严格对应后重新编译
路径下找不到数据集软链接路径错误用绝对路径重建软链接

环境排查我的经验是先看报错第一行,别翻后面的堆栈。第一行往往直接给出是编译器、CUDA还是Python层面的问题,后面的内容大多是连带报错。

5.2 训练过程中的异常现象

训练最常见的异常是loss变成NaN。优先检查学习率是不是太大,特别是新加模块和主干结构参数比例不当时,直接按baseline学习率去跑很容易炸。另一个因素是原始点云里存在无效点、无穷值,预处理环节没过滤干净。我会在数据处理里加一个坐标合法性过滤,把不在point_cloud_range范围内或含inf的点直接排除。

其次是“loss下降但验证集不涨”。这种情况八成不是模型拟合能力问题,而是过拟合或者评估协议有误。先看验证集loss,如果验证集loss也在下降,那就检查评估代码;如果验证集loss根本不降,那大概率是锚框尺寸或者数据增强和评估场景严重不匹配。

显存溢出也常遇到。优先降batch size,然后用梯度累积补偿。如果这样仍然溢出,就要看point_cloud_range是不是设得太宽,把大量无用的高空区域或者超远区域排除掉,能省出非常可观的显存。

5.3 效果提升层面的排查顺序

最后一个警告:不要一上来就觉得模型结构不够强。我见过太多人把VFE换成花哨结构,结果mAP反而掉,因为根本没排查出baseline的问题。

提升效果我建议按这个顺序来:数据→训练策略→结构。先确认数据干净、标注合理、增强有效;再确认学习率、epoch数和优化器状态正确;最后才动模型结构。结构改动每次只改一处,而且必须保留完整对比记录。

我个人在实际操作中最大的体会是:Dynamic Voxelization这类方法最大的优势不在于某个模块有多炫,而在于把“信息入口”守住了。守住入口之后,任何结构改进都是锦上添花;入口没守住,换再强的头也白搭。跑通一条稳定、可复现的链路,比追求一两个点的涨点重要得多。最后再分享一个小习惯:把每一次实验的配置、日志、checkpoint按日期归档,换数据集、换机器时直接复用模板,能省掉大量重复劳动。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进