
我到现在还记得第一次在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内非空体素的总数。四列分别代表列索引含义示例0batch索引当前体素属于batch里的第几个样本1z方向的体素索引0~392y方向的体素索引0~9993x方向的体素索引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方向网格数减1coors[:, 2]是y方向网格数减1coors[:, 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。但要注意在分布式训练里每个进程处理的是各自的局部batchcoors里的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/CenterPointcoors和稀疏卷积在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], dtypetorch.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()等操作保持int32coors第一维最大值1不等于batch_sizebatch维度理解错误或使用了局部batch检查分布式环境下rank的划分可视化结果偏移半个体素反算物理坐标时漏加voxel_size/2用4.1里的函数统一加半格非空体素数量异常偏多或偏少max_voxels限制或range设置不合理调整Voxelization的max_voxels参数和point_cloud_range训练时显存突然暴涨max_voxels设置过大或体素尺寸过小适当调大voxel_size或调低max_voxels5.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 1000y方向也是1000z方向是(3 - (-5)) / 0.2 40。coors里z索引应在0到39之间y和x索引应在0到999之间。只要跑到这一步后面模型怎么处理coors你心里都有数。踩过几次坑之后我最大的体会是voxel_coors这个变量虽然看起来只是几个整数但它的正确性直接决定了整个体素类检测器能不能正常工作。很多训练不收敛、可视化错乱、显存爆炸的问题追到根上往往就是坐标映射差了一点点。你把上面这些边界、顺序、dtype、batch索引的问题都排查干净整个流程会顺畅非常多。