我到现在还记得第一次在mmdetection3d里调试PointPillars时的情景:断点打在模型forward里,盯着一路传下来的voxel_coors这个Tensor发呆。形状是(N, 4),数值看起来像是坐标,但又不完全是坐标,四个维度分别是什么?为什么顺序这么怪?为什么有的模型里它长这样,有的模型里它又是另一种用法?
后来在CenterPoint、SECOND、HTC等模型里反复跟这个体素坐标打交道,才慢慢把这件事彻底想明白。说实话,voxel_coors在整个mmdetection3d里属于那种不起眼但贯穿始终的东西——从数据预处理出来,穿过体素编码器,要么被scatter成伪图像,要么被喂给稀疏卷积,最后还影响到你训练时的显存和收敛效果。如果你之前只是把代码跑通了,但对这玩意儿还是一知半解,那这篇应该能帮你把最后一块拼图补上。
1. 先搞明白:体素坐标到底从哪来
1.1 从“点云体素化”说起
点云本身是一堆三维空间中的点,每个点有(x, y, z)和特征维度。但神经网络不能直接吃这种无序、稀疏、数量不固定的数据,所以要先做一次离散化——把连续的三维空间切成一个个小格子,这就叫体素化。
举个例子,假设我们设定一个感知范围:
- x方向从-50米到50米
- y方向从-50米到50米
- z方向从-5米到3米
- 每个体素的尺寸是0.1米 × 0.1米 × 0.2米
那整个空间就被切成了1000 × 1000 × 40个网格。点云里的每个点,根据它落在哪个格子里,就会被分配一个网格编号,也就是体素坐标。这个坐标不是物理坐标系下的米,而是网格索引,本质上是“这个点所在的格子在哪一行、哪一列、哪一层”。
在mmdetection3d里,这个体素化过程一般通过mmcv的Voxelization算子完成,返回三个东西:voxels(每个体素里的点特征)、coors(每个非空体素的坐标)、num_points_per_voxel(每个体素里有多少个点)。voxel_coors就是指中间那个coors,但大家更习惯叫它体素坐标。
1.2 一条coors记录长什么样
大多数情况下,voxel_coors的形状是(N, 4),其中N是所有batch内非空体素的总数。四列分别代表:
| 列索引 | 含义 | 示例 |
|---|---|---|
| 0 | batch索引 | 当前体素属于batch里的第几个样本 |
| 1 | z方向的体素索引 | 0~39 |
| 2 | y方向的体素索引 | 0~999 |
| 3 | x方向的体素索引 | 0~999 |
注意这个顺序:不是(x, y, z),而是(z, y, x)。很多刚接触的人就在这里被绕晕了。之所以这样排列,是因为后面要构造三维特征张量或者伪图像的时候,索引顺序按照(z, y, x)来组织最方便,这在后面模型流转部分会详细说。
还有一个细节:voxel_coors只记录非空体素。也就是说,一个batch里可能有多少万个网格,但实际有点的格子可能只有几万个,所以N远小于总网格数。这种稀疏表示是体素类3D检测器能省显存的关键。
1.3 为什么排序是(z, y, x)而不是(x, y, z)
这个点我得单独拎出来说,因为它太容易被忽略。我们平时写点云坐标,习惯是(x, y, z),但在体素化和后续的特征排列里,顺序反过来了。
想象一下你有一个三维数组,维度是[Z, Y, X],访问某个元素时,第一个索引是层数,第二个是行,第三个是列。这样设计不是为了为难人,而是因为后续不管是生成BEV图还是做稀疏卷积,都需要快速定位某个体素在整个空间网格中的位置。如果沿用(x, y, z)的顺序,在构造Batch-C-Z-Y-X的张量时还得来回转置,既麻烦又容易出错。
所以在mmdetection3d里,从Voxelization算子输出的coors开始,就一直沿用[batch_idx, z_idx, y_idx, x_idx]的顺序。你记住这一点,后面看代码会顺畅很多。
2. 最容易被坑的地方:坐标顺序、方向和边界
2.1 顺序问题:x/y/z的顺序会搞反
我在实际调试中见过不止一次,有人自己在代码里解析coors时,按(x, y, z)去取值,结果可视化出来的点云位置完全错乱,车和路乱成一团。
这个问题的根子在于:coors的四列是batch、z、y、x,而不是batch、x、y、z。所以如果你要做鸟瞰图可视化,取y和x的时候千万别搞混。尤其当voxel_size在x和y方向相同的时候,搞混之后从数值上看不出异常,但可视化后方向就是反的。
我自己习惯在拿到coors之后,第一时间打印出shape、dtype、min、max,然后按列统计一下。正常情况下,coors[:, 1]的最大值应该是z方向网格数减1,coors[:, 2]是y方向网格数减1,coors[:, 3]是x方向网格数减1。这样就能快速验证顺序对不对。
2.2 方向问题:坐标映射别漏了range_min和voxel_size/2
从点云坐标到体素坐标的公式其实很朴素:
ix = int((p_x - x_min) / voxel_size_x) iy = int((p_y - y_min) / voxel_size_y) iz = int((p_z - z_min) / voxel_size_z)这里的x_min、y_min、z_min就是point_cloud_range的前三个数。反过来,从体素坐标还原物理坐标时:
p_x = x_min + ix * voxel_size_x + voxel_size_x / 2 p_y = y_min + iy * voxel_size_y + voxel_size_y / 2 p_z = z_min + iz * voxel_size_z + voxel_size_z / 2别小看后面加的voxel_size / 2,这是取体素的中心点,而不是体素的角点。很多可视化结果有偏移,就是这里少加了一个半格。
我第一次做体素级别的可视化时,就是因为忘了加半格,所有体素的中心点整体往左下角偏移了半个体素长度。在点云密度高的时候根本看不出来,但一旦叠加目标的3D框,偏移就非常明显。
2.3 边界点溢出
这个坑更隐蔽。点云里如果有个点恰好落在x_max边界上,比如x = 50,那么按公式算出来ix = (50 - (-50)) / 0.1 = 1000,而x方向网格索引范围是0到999,直接就越界了。
很多实现会在体素化前先过滤掉超出range的点,也就是mmdet3d pipeline里的PointsRangeFilter。但边界上的浮点误差可能导致某些本应被过滤的点还是进了体素化阶段,最后coors里出现一个超出网格范围的非法索引,后面做scatter或者稀疏卷积时直接崩溃。
我的建议是:在自定义pipeline或者调试时,显式检查coors的max值是否在合法范围内,不要默认range过滤就一定安全。尤其是自己写数据增强的时候,旋转和平移后的点云很容易超出原始range。
2.4 dtype和batch索引
voxel_coors必须是整数张量,而且传给spconv或scatter的时候,通常要求int32。如果中间某步不小心把它转成了float,后面会报类型不匹配的错。这种错其实很好排查,看报错信息里的dtype提示就行。
batch索引这一列也在coors[:, 0]。它的最大值加1就是当前batch_size。但要注意,在分布式训练里,每个进程处理的是各自的局部batch,coors里的batch索引是局部的,不是全局的。如果你在某个reducer或者自定义loss里根据coors的batch索引去索引全局batch,多半会出问题,需要先搞清楚当前是在哪个rank下。
3. voxel_coors在模型里是怎么流转的
3.1 PointPillars:从coors到伪图像
PointPillars这类模型采用Pillar化的思路,把一个竖条柱体看作一个“pillar”,然后把柱体内的点用一个简单的网络编码成特征向量。这里的关键是,Pillar的体素化通常在z方向只用一层,也就是说coors里的z索引基本是0,或者只有一个固定值。
接下来进入PointPillarScatter。这一步做的事,就是把稀疏的pillar特征放回一个稠密的伪图像张量里,形状类似(B, C, H, W),其中H和W对应y和x方向的网格数。
示意代码如下:
# 每个非空体素的pillar特征对应一行coors for batch_itt in range(batch_size): indices = coors[:, 0] == batch_itt x = coors[indices, 3].long() # x方向索引 y = coors[indices, 2].long() # y方向索引 batch_canvas[batch_itt][:, y, x] = pillar_features[indices].transpose(0, 1)注意这里取的是coors[:, 2]和coors[:, 3],也就是y和x,没有z。因为对PointPillars来说,z方向已经被pillar合并掉了,伪图像只需要平面位置。
你可能要问,为什么不直接存一个(B, C, H, W)稠密张量,而是非要先存稀疏的coors?因为点云数据本质稀疏,大部分网格是空的。如果一开始就初始化一个巨大的稠密张量,浪费显存不说,计算量也大。coors相当于一张“地址簿”,告诉模型哪些位置有有效特征,哪些位置不用管。
3.2 SECOND/CenterPoint:coors和稀疏卷积
在CenterPoint、SECOND这类使用三维稀疏卷积的模型里,voxel_coors的用法又不太一样。它们不把体素压成BEV伪图像,而是直接在三维体素空间做稀疏卷积。
spconv的SparseConvTensor在初始化时需要传入features和indices。这个indices就是voxel_coors,但顺序要调整为(batch_idx, z_idx, y_idx, x_idx)。每一行代表一个非空体素的三维位置和所属batch。
稀疏卷积的特点是:卷积核只作用在那些有特征的体素及其邻域上,而不是在整个三维网格上做稠密计算。coors在这里的作用,就是告诉卷积核“我在哪”。卷积核在某个位置计算时,会去看这个位置在coors里有没有记录,有记录就做运算,没有就直接跳过。
这个过程和PointPillars里scatter的区别在于:scatter是把稀疏特征“铺”回稠密网格,而稀疏卷积是让特征继续以稀疏方式在网络里流动。到最后需要输出BEV特征的时候,才会有一个类似稀疏转稠密的操作,把所有z层的信息合并成一个平面。
正因为多了z维度的参与,CenterPoint这类模型能利用高度信息,对重叠目标的区分能力往往比纯BEV方案更强。voxel_coors里的z索引,在稀疏卷积的每一层都会被用到,所以它的取值范围和体素尺寸设置直接决定了模型能感知到的垂直分辨率。
3.3 多模态和BEV视角下的coors使用
除了纯点云检测器,现在很多融合方案也会用到voxel_coors。典型的思路是:把图像视角的特征变换到BEV视角,然后在BEV空间和点云特征对齐。这个对齐的核心,就是需要知道每个点云体素在BEV网格上的位置,而这个位置正是从coors的y和x索引推导出来的。
这类场景下,voxel_coors其实承担了“坐标对齐”的角色。比如你有一张(B, C, H, W)的BEV特征图,想从这个特征图里取第i个体素位置的特征,那就先看coors[i]里的(y, x),再去特征图的对应位置取值。
我见过一个工程做法:直接把coors中的(y, x)当作网格坐标,构建一个从稀疏索引到稠密特征图索引的映射表,这样在前向推理时就不用每个样本都重复计算坐标映射,能省不少时间。当然,这么做的前提是voxel_size和point_cloud_range固定不变,一旦改了配置,映射表就得重新构建。
4. 实操中的调试与可视化
4.1 从coors反算体素的物理坐标
有时候你训练完一个模型,想分析它某个预测框对应的体素特征分布,或者想检查某个区域的体素划分是否合理,就需要从coors反推出该体素的中心坐标。
我之前写过一个简单的函数,直接用张量操作批量反算:
import torch def voxel_coors_to_points(coors, point_cloud_range, voxel_size): """ coors: (N, 4) -> [batch, z, y, x] point_cloud_range: [x_min, y_min, z_min, x_max, y_max, z_max] voxel_size: [vx, vy, vz] """ x_min, y_min, z_min = point_cloud_range[:3] vx, vy, vz = voxel_size x_center = x_min + coors[:, 3] * vx + vx / 2 y_center = y_min + coors[:, 2] * vy + vy / 2 z_center = z_min + coors[:, 1] * vz + vz / 2 return torch.stack([x_center, y_center, z_center], dim=-1)这个函数在调试时特别好用,比如你想确认某个体素到底对应物理空间哪个位置,或者想画体素中心点和原始点云的叠图,都可以直接用。
4.2 鸟瞰图可视化检查
另一个很实用的调试手段,是把非空体素的coors画成鸟瞰图,和原始点云的鸟瞰投影做对比,检查体素化有没有出问题。
思路很简单:先根据coors生成一个稠密的掩码图,再和原始点云投影的掩码图做差集,差异大的地方就是可疑区域。
import torch def coors_to_bev_mask(coors, grid_size): """ grid_size: [x_grid, y_grid] 对应W和H """ bev = torch.zeros(grid_size[1], grid_size[0], dtype=torch.int32) x_idx = coors[:, 3].long() y_idx = coors[:, 2].long() bev[y_idx, x_idx] = 1 return bev然后把你预处理后的点云按相同规则投到BEV平面上,生成另一个掩码图。两个掩码图叠在一起看,如果发现有些区域点云投影有值但coors掩码没有,那说明这些点可能在体素化时被过滤了,或者coors和点云之间的对应关系被破坏。
这招在排查自定义数据增强问题的时候特别管用。有一次我在自己的增强代码里对点云做了旋转,但忘了重新体素化,结果coors还是旧坐标,可视化掩码图里两个地图完全错位,一眼就能看出问题。
4.3 数据流向自查清单
调试voxel_coors相关问题时,我一般按下面这个顺序排查:
- coors的shape是否符合预期:第一维非0,第二维是4。
- coors的dtype是否为整数型,通常是int32或int64。
- coors[:, 0]的最大值加1是否等于当前batch_size。
- coors[:, 1]的最大值是否小于z方向网格数。
- coors[:, 2]的最大值是否小于y方向网格数。
- coors[:, 3]的最大值是否小于x方向网格数。
- 第i个体素的voxel特征是否和coors[i]一一对应。
基本把这几点过一遍,大多数问题都能定位到原因。
5. 常见问题速查与避坑心得
5.1 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| coors里出现超出网格范围的索引 | 点云边界点溢出 | 在pipeline里加PointsRangeFilter,并检查自定义增强后的边界 |
| scatter时index越界报错 | x/y索引取错列 | 确认coors是[batch, z, y, x],取y时用[:, 2],取x时用[:, 3] |
| spconv报dtype错误 | coors被转成了float | 检查前向过程中有无float()等操作,保持int32 |
| coors第一维最大值+1不等于batch_size | batch维度理解错误或使用了局部batch | 检查分布式环境下rank的划分 |
| 可视化结果偏移半个体素 | 反算物理坐标时漏加voxel_size/2 | 用4.1里的函数,统一加半格 |
| 非空体素数量异常偏多或偏少 | max_voxels限制或range设置不合理 | 调整Voxelization的max_voxels参数和point_cloud_range |
| 训练时显存突然暴涨 | max_voxels设置过大或体素尺寸过小 | 适当调大voxel_size或调低max_voxels |
5.2 几个容易忽略的隐藏坑
第一个是max_voxels参数。Voxelization的算子通常会限制最大非空体素数量,超过的部分会被丢弃。如果你发现某些样本的体素数明显少于预期,不一定是点云变少了,可能是这个上限设置得太低,导致远处或稀疏区域的信息被丢掉了。
第二个是数据增强的同步问题。点云旋转、平移、缩放之后,点的坐标变了,体素坐标必须重新计算。mmdetection3d的pipeline设计里这个一般会自动处理,但如果你自己写增强函数或者在自定义模型里手动操作pointcloud,一定记得后续重新走一遍体素化流程。
第三个是coors的batch索引在并行环境下的语义。有些人以为coors[:, 0]是全局样本ID,其实在分布式训练里它只是局部rank内的ID,跨rank做特征交互时必须把rank信息一起带进去。
5.3 我的个人调试习惯
最后分享一个我自己的习惯。每次拿到一个新的基于体素的3D检测模型,我第一件事不是直接训练,而是先用一个小batch跑一次前向,把voxel_coors打印出来,对照配置里的point_cloud_range和voxel_size手算一遍最大索引,确认它们能对上。
比如point_cloud_range是[-50, -50, -5, 50, 50, 3],voxel_size是[0.1, 0.1, 0.2],那x方向网格数是(50 - (-50)) / 0.1 = 1000,y方向也是1000,z方向是(3 - (-5)) / 0.2 = 40。coors里z索引应在0到39之间,y和x索引应在0到999之间。只要跑到这一步,后面模型怎么处理coors,你心里都有数。
踩过几次坑之后,我最大的体会是:voxel_coors这个变量虽然看起来只是几个整数,但它的正确性直接决定了整个体素类检测器能不能正常工作。很多训练不收敛、可视化错乱、显存爆炸的问题,追到根上往往就是坐标映射差了一点点。你把上面这些边界、顺序、dtype、batch索引的问题都排查干净,整个流程会顺畅非常多。