1. 从点云到体素:voxel_coors到底在表达什么
做3D目标检测的朋友第一次接触mmdetection3d时,大概率都会对着voxel_coors这个变量愣一下。点云明明是一堆(x, y, z)坐标,怎么中间突然冒出来一个形状怪异的张量?每个维度代表啥?为什么要用这种方式表示体素?
先说结论:voxel_coors保存的是每个非空体素在体素网格中的整数索引,不是物理坐标。也就是说,它回答的问题是"这个体素在网格的第几排、第几列、第几层",而不是"这个体素在真实世界中的x、y、z是多少米"。
举个例子。假设激光雷达扫描得到一帧点云,范围是[-50, 50]米,体素大小是[0.1, 0.1, 0.2]米(对应x、y、z三个方向)。那么x方向会被划分成1000个格子,y方向也是1000个格子,z方向假设点云高度范围是[-3, 1]米,会被划分成20个格子。voxel_coors里存的数值范围大致就是x: [0, 999]、y: [0, 999]、z: [0, 19]这样的整数。
这里有个关键区别需要先讲清楚,否则后面看代码会一直犯迷糊:
| 名称 | 含义 | 典型shape |
|---|---|---|
points | 原始点云,物理坐标 | (N, 4),4维是x, y, z, r(反射强度) |
voxels | 每个非空体素内的点特征 | (M, T, 4),T是每个体素最多容纳的点数 |
voxel_coors | 每个非空体素在网格中的整数索引 | (M, 3)或(M, 4) |
voxel_centers | 每个非空体素的中心点物理坐标 | (M, 3) |
M是非空体素的数量。一帧64线激光雷达的点云通常有几万个点,但经过体素化后,非空体素的数量一般只有几千到一万出头。这个降幅就是体素化最大的价值之一——把无序的、密度不均的点云压缩成规则的、稀疏的网格结构,方便后续用3D稀疏卷积或体素特征提取网络处理。
voxel_coors的shape到底是3还是4,取决于你用的数据加载器和模型配置。在mmdetection3d中,常见的是4维:(M, 4),四个维度分别是batch_index, z, y, x。对,顺序是反的,而且是先batch索引再是空间坐标。这个顺序不是随便定的,它跟稀疏卷积的实现约定直接相关。
2. 为什么是(batch, z, y, x):坐标顺序背后的设计逻辑
很多人第一次打印voxel_coors时会以为数据出错了:为什么不是(batch, x, y, z)?其实这是mmdetection3d和spconv库约定俗成的排布方式,理解这个顺序对调试网络、手动构造数据非常关键。
2.1 稀疏卷积的前向传播需要什么样的坐标
稀疏卷积(如spconv 2.x)要求输入的坐标是离散整数索引,并且遵循(batch_index, z, y, x)的排列。这是因为稀疏卷积在底层实现时,把体素网格看作一个高维稀疏张量,坐标的低维(x、y)是空间上最常连续变化的维度,放在后面有利于内存访问的局部性和哈希查找的效率。
这一点在做PointPillars时会特别明显。PointPillars的体素化其实只在x-y平面上做柱体划分,z方向不划分——不对,严格说PointPillars在z方向只设1个格子,把所有高度的点都塞进同一个柱体里。所以它的voxel_coors里的z索引永远是0。但mmdetection3d的通用数据加载器还是统一生成3D坐标,只是z这一维恒等于0。你会在训练PointPillars时看到voxel_coors[:, 1]全是0,这不是bug,是设计如此。
2.2 坐标原点在哪个角:左下角还是中心
另一个让很多人困惑的点是坐标原点的位置。体素索引(0, 0, 0)对应的物理位置是点云范围的最小角点,不是点云的中心。
假设点云范围是[-50, 50]米,体素大小是0.1米,那么索引为(0, 0, 0)的体素覆盖的物理范围是[-50, -49.9]。索引(500, 500, z)的体素中心大约在(-50 + 500 * 0.1 + 0.05, -50 + 500 * 0.1 + 0.05),也就是(0.05, 0.05)附近。
这个索引转物理坐标的公式很简单:
物理坐标 = 点云范围最小值 + (体素索引 + 0.5) * 体素大小在代码里,这个转换通常由Voxelization层完成。mmdetection3d中常见的PillarVFELayer或VFE层在计算体素特征时会用到体素中心坐标,就是通过这个公式从voxel_coors换算出来的。
2.3 batch维度:一个batch里多帧点云怎么共存
训练时batch size通常大于1。假设batch size是2,那么第0帧和第1帧的点云都会被体素化,但它们的体素坐标如果直接拼在一起,就会产生歧义——第0帧的体素(0, 0, 0)和第1帧的体素(0, 0, 0)无法区分。所以voxel_coors的第一个维度固定是batch索引。
这个设计在推理时要特别小心。单帧推理时batch size为1,所有voxel_coors[:, 0]都是0,没问题。但如果你自己写数据流,想把多帧点云拼成一个batch送进网络,必须手动给每帧点云的voxel_coors[:, 0]赋不同的值,否则稀疏卷积会把不同帧的体素当成同一个batch里的邻居来计算,结果完全乱掉。
我自己就踩过这个坑。当时为了加速推理,想一次性处理4帧点云,直接把voxels和voxel_coors拼接起来送进模型,结果输出的特征图完全无法解释。排查了很久才发现是batch索引没有分层赋值。
3. 体素化全过程拆解:从原始点云到voxel_coors
这部分把mmdetection3d中体素化的完整流程过一遍,包括关键参数的作用、内部的数据流,以及每一步的输出shape变化。只有把这条链路理清楚,后面做自定义数据集或改网络结构时才不会抓瞎。
3.1 mmdetection3d中体素化的入口与关键参数
mmdetection3d的体素化由Voxelization这个算子完成,在训练和推理时由voxelize接口调用。核心参数主要有以下几个:
| 参数 | 作用 | 示例值 |
|---|---|---|
pc_range | 点云边界,[x_min, y_min, z_min, x_max, y_max, z_max] | [-50, -50, -3, 50, 50, 1] |
voxel_size | 体素边长,[x_size, y_size, z_size] | [0.1, 0.1, 0.2] |
max_voxels | 最多保留的非空体素数量 | 20000 |
max_points_per_voxel | 每个体素最多容纳的点数 | 5(PointPillars用)或32(VoxelNet用) |
pc_range和voxel_size是决定voxel_coors数值范围的两个根本参数。它们的配合逻辑很简单:体素数量 = 各轴范围 / 体素大小。比如上面那组参数,体素网格就是1000 x 1000 x 20,这个网格中只有非空的格子才会被保留下来并出现在voxel_coors中。
3.2 内部实现:近邻查询与哈希映射
体素化在底层实现时,并不是真的去创建一个1000 x 1000 x 20的三维数组然后逐点填充——那样内存直接爆炸。它采用的是哈希表 + 近邻搜索的组合策略。
具体来说,每个点会被计算其所在体素的整数索引:
voxel_x = floor((x - x_min) / voxel_size_x) voxel_y = floor((y - y_min) / voxel_size_y) voxel_z = floor((z - z_min) / voxel_size_z)然后以这三个整数作为键,查找这个体素是否已经存在于哈希表中:
- 如果存在,就把当前点追加到这个体素的点列表中(前提是没超过
max_points_per_voxel)。 - 如果不存在,就新建一个体素条目,并把该点作为第一个点。
这个过程是在线且无序的,也就是说,voxel_coors中的体素顺序与点云中点的顺序相关,同一个体素内的点顺序也是随机的,没有空间上的排序保证。这会影响后面VFE层对体素内点特征的聚合逻辑——VFE层内部会做MLP + MaxPooling,顺序无关性由MaxPooling保证,所以随机顺序不会影响最终特征。
3.3 采样与截断:为什么体素数量会被限制
真实场景中,一帧点云可能产生超过max_voxels的非空体素。mmdetection3d的Voxelization默认会随机采样一部分体素参与训练——你没看错,是随机丢弃多余的体素。
这个设计对训练是有益的,原因有两个:
- 限制体素数量可以控制显存占用,避免在点云密集区域(比如近距离地面)产生过多体素导致显存溢出。
- 随机采样带来了训练数据的随机性,轻微的体素丢失可以被看作一种数据增强,有助于提高模型的泛化能力。
但推理时通常建议把max_voxels设得足够大,或者设成-1表示不限制,避免因为采样导致物体漏检。这里要特别提醒:训练和推理的max_voxels保持一致的逻辑。如果训练时设20000,推理时设50000,模型看到的体素分布范围和训练时有差异,可能会影响精度稳定性,虽然差异一般不大,但做严格评测时不要忽略这个细节。
3.4 体素内的点数裁剪
另一个容易忽略的参数是max_points_per_voxel。每个体素内的点数如果超过这个限制,超出的部分会被丢弃(默认是随机丢弃,而OpenPCDet中是截断,两种策略有差异)。
VoxelNet系列网络每个体素只保留固定数量的点,是为了保证VFE层可以按固定shape处理——输入是(M, T, C),其中T就是max_points_per_voxel。如果某个体素实际点数不足T,会用0填充;如果超过T,就随机采样T个点。
值得留意的是:点云中的物体表面点分布是极不均匀的。一个距离很近的物体可能在一个体素内聚集几十个点,而远处物体一个体素可能只有1个点。max_points_per_voxel设得太小会丢失近距离物体的局部几何细节,设得太大则浪费存储和计算。PointPillars通常设5,VoxelNet设32,这两个值都不是拍脑袋定的,而是基于KITTI数据集的点云密度统计得到的经验值。
4. voxel_coors如何参与网络前向:三条主要路径
了解了体素化的过程和voxel_coors的语义后,关键问题来了:这个坐标张量在后来的网络计算中到底是怎么被消费的?不同模型架构对voxel_coors的使用方式有显著差异。我们逐一拆解。
4.1 路径一:VFE层中参与体素特征计算
VFE(Voxel Feature Encoding)层是VoxelNet系列最基础的特征提取模块。它的输入有两个:
voxels:shape(M, T, C),C通常等于4(x、y、z、r),但如果开了sensor2keypoint之类的增强,可能带上额外特征维度。voxel_coors:shape(M, 3)或(M, 4),表示每个体素的位置。
在VFE内部,每个体素里的点会先减去该体素内所有点的均值(或者体素中心坐标),得到相对位置特征。这里voxel_coors被用来计算体素中心坐标,公式就是我前面写的那个:
coors_physical = (voxel_coors[:, 1:] + 0.5) * voxel_size + pc_range[:3]最后拿每个点的原始坐标减去这个中心坐标,得到一个局部相对坐标,再和反射强度拼在一起,过几层MLP。
一个关键理解:VFE层本质上是在对每个体素内的点做特征提取,输出的特征是体素级的。此时voxel_coors的作用是告诉网络"这个体素在哪里",以便计算相对位置特征——它参与的是物理信息到特征空间的映射,不是直接作为索引去查表。
4.2 路径二:稀疏卷积中作为空间索引
这是voxel_coors最核心、也最精妙的应用场景。以SECOND、CenterPoint等模型为例,VFE输出体素特征后,需要构建一个稀疏体素特征图。这时候voxel_coors的价值就完全体现出来了。
spconv库接收一个稀疏张量,包含两部分:features(体素特征)和indices(也就是voxel_coors)。在底层,spconv会建立一个从(batch, z, y, x)到特征向量之间的哈希映射,然后在这个稀疏网格上执行3D稀疏卷积。
稀疏卷积和普通卷积的本质区别在于:普通卷积对整张特征图的所有位置都做计算,而稀疏卷积只在非空位置(即voxel_coors里存在的索引)上执行卷积操作,并通过哈希表快速找到当前体素周围的邻居体素。这个设计大幅降低了计算量——一帧点云通常只有几千个非空体素,而稠密的1000 x 1000 x 20网格有2000万个格子,算力差距一目了然。
4.3 路径三:BEV投影中生成鸟瞰特征图
大部分基于体素的3D检测模型最终会把3D特征压缩到BEV(Bird's Eye View,鸟瞰视角)平面,再通过2D检测头输出目标框。
BEV压缩的常见做法是:把z维度堆叠或求和(比如SECOND在z方向做sum pooling),得到(B, C, H, W)的2D特征图。这一步需要把voxel_coors中的(z, y, x)映射到BEV平面的(y, x)——其实就是直接丢弃z轴。如果你的模型设置了多个不同的z层体素,把它们投影到同一个BEV网格时要特别注意:mmdetection3d中通常会通过一个spconv的SparseConvTensor配合batch_size参数,利用voxel_coors完成从3D到2D的聚合,而不是简单的索引切片。
CenterPoint的做法略有不同,它是在3D稀疏特征上直接做3D卷积提取特征后,再用height_compression方式压缩到BEV,voxel_coors在中间起到关键的索引作用——没有它,你无法知道每个特征应该放在BEV图上的哪个位置。
4.4 路径四:真值分配与正负样本采样
还有一条经常被忽视的路径:训练时的目标分配。CenterPoint等模型在生成热力图时,需要把3D目标中心投影到BEV网格上,然后找到对应的体素位置来计算高斯热力值。
这里的投影计算需要知道体素的物理坐标。虽然可以直接用目标框中心坐标算BEV像素位置,但在某些实现中会通过voxel_coors来反查体素索引——比如判断一个目标中心落在哪个体素内,进而确定正样本的位置。理解这个路径有助于你调试一个现象:目标小、点云稀疏时,热力图中心可能会偏离目标真值,因为目标的中心体素可能本来就是空的(如果没有任何点恰好落在那个网格里)。
5. 改模型必看:voxel_coors在自定义场景的常见坑
这一节讲那些"看起来没什么问题、跑起来就各种妖"的坑。每一个都是我实际调试中遇到过的,也是社区里反复被问到的热点问题。
5.1 坑一:voxel_coors的方向搞反
如前所述,mmdetection3d的voxel_coors是(batch, z, y, x),而一些其他框架(比如OpenPCDet的VoxelNet实现)可能使用(batch, x, y, z)。如果你在复现一个跨框架的模型时,直接把voxel_coors送进spconv,坐标顺序不匹配,模型根本无法正常收敛。
一个快速的验证方法:打印几个体素的坐标,手动换算成物理坐标,看你关心的目标区域是否对应上。比如KITTI中一辆车在x=5米、y=0米附近,换算后的体素索引应该落在对应区间。
5.2 坑二:pc_range与原始点云不匹配
pc_range设置不当会导致大量点被体素化阶段丢弃。常见错误有两种:
pc_range设得太小,远处的点全部落在边界外,Voxelization算子直接丢弃它们,结果就是远处物体永远检测不到。pc_range设得太大但max_voxels没跟着调大,导致近处物体体素被随机采样时误删。
比较典型的场景是:从KITTI切到Waymo或nuScenes时,只改了数据路径,忘了改pc_range和voxel_size。不同数据集的点云范围差异很大,nuScenes的激光雷达能覆盖到50米以上,如果你沿用KITTI的[-50, 50]可能差不多,但如果用更小的范围(比如某些模型预设的[-20, 20]),性能会断崖式下降。
5.3 坑三:稀疏卷积输出的坐标与输入不一致
spconv的稀疏卷积层(SparseConv3d)在stride大于1时,会降低特征图的分辨率。这时输出的稀疏张量坐标会发生变化——比如stride=2时,输出坐标大致等于输入坐标除以2(向下取整)。
如果你自己写了一个新的neck结构,在3D稀疏卷积和BEV投影之间插入了其他操作,需要特别注意坐标空间的一致性。一个常见的报错是spconv的SparseConvTensor在后续操作中遇到坐标范围不匹配,或者其实没报错但特征错位,模型性能异常。
我遇到过一种情况:在稀疏卷积后,想先做一次permute把坐标换成(b, x, y, z)再转BEV,结果忘了把坐标方向改回来,导致鸟瞰图水平翻转。模型的训练loss能下降但检测框完全对不上。
5.4 坑四:数据增强之后要同步改voxel_coors
mmdetection3d的3D数据增强包括随机翻转、随机旋转、随机缩放。这些操作发生在体素化之前还是之后,直接影响你要不要重算voxel_coors。
如果你用的是GlobalRotScaleTrans和RandomFlip3D,它们会在体素化之前对原始点云做变换,因此体素化是发生在增强之后的,voxel_coors天然是增强后的结果,不需要额外处理。
但如果你自己在中间pipe里改了点云坐标(比如手动做了一次去噪或坐标系变换),却没有重新调用voxelize,那么voxel_coors和voxels就对应不上了。这种用起来毫无报错、但结果全错的bug排查起来最为致命。
5.5 坑五:多帧拼接时batch索引赋值错误
前面已经提过batch索引,这里再补充一个具体操作例子。假设你有一个list,里面是4帧点云的体素化结果(voxels_i, voxel_coors_i),要做batch推理:
voxel_batch = [] coors_batch = [] for i in range(4): coors_i = voxel_coors_i.clone() coors_i[:, 0] = i voxel_batch.append(voxels_i) coors_batch.append(coors_i) voxels = torch.cat(voxel_batch, dim=0) coors = torch.cat(coors_batch, dim=0)如果忘了coors_i[:, 0] = i这一步,所有帧的batch索引都等于原值(通常来自mmdetection3d内部的数据组织,第0帧可能是0,第1帧可能也是0),那模型就会把这4帧点云当成同一个场景里的体素来算特征,相邻体素关系完全错乱。
6. 实践:打印并解读一份真实的voxel_coors
理论说了这么多,不如直接上手打印一份真实的voxel_coors看看。这里给出一段最小化的代码,演示如何用mmdetection3d的数据管线加载一帧点云并输出体素化结果。
6.1 最小可运行示例
假设你已经装好了mmdetection3d和依赖库。用PIL、numpy和torch就能验证:
import torch import numpy as np from mmdet3d.datasets import NuScenesDataset # 以nuScenes为例,实际使用时请注释掉下面的初始化过程,改用config直接构建 # 这里为了演示,用最原始的numpy点云模拟 points = np.random.randn(10000, 4).astype(np.float32) points[:, 0] *= 20 # x在[-20, 20] points[:, 1] *= 20 # y在[-20, 20] points[:, 2] = points[:, 2] * 0.5 - 1.0 # z在[-1.5, -0.5]之间 from mmdet3d.ops import Voxelization voxelize = Voxelization( voxel_size=[0.1, 0.1, 0.5], point_cloud_range=[-20, -20, -2, 20, 20, 2], max_num_points=10, max_voxels=20000 ) points_t = torch.from_numpy(points).unsqueeze(0) # shape: (1, N, 4) voxels, coors, num_points_per_voxel = voxelize(points_t, torch.ones(1, dtype=torch.int32)) print("voxels shape:", voxels.shape) print("coors shape:", coors.shape) print("coors[0]:", coors[0]) print("num_points_per_voxel[:10]:", num_points_per_voxel[:10])运行结果类似:
voxels shape: torch.Size([1, 9302, 10, 4]) coors shape: torch.Size([9302, 4]) coors[0]: tensor([0, 4, 321, 87])注意,这里的coors[0]是(batch=0, z=4, y=321, x=87)。换算回物理坐标:
x = -20 + (87 + 0.5) * 0.1 = -11.25 y = -20 + (321 + 0.5) * 0.1 = 12.15 z = -2 + (4 + 0.5) * 0.5 = 0.25可以看到z方向体素大小0.5米,所以z索引的覆盖范围大,索引从0到7(因为z范围是4米)。
6.2 解读每个维度
通过这个例子可以直观看到:
voxels的第一维是M(非空体素数),第二维是T(每个体素最多10个点),第三维是特征数4(x、y、z、r)。coors的第一维是M,和第二维的M完全对齐——第i个体素的信息同时存储在voxels[i]和coors[i]中。coors最后一维是x,倒数第二是y,倒数第三是z,第一维是batch。
这个对齐关系是后续所有网络操作的基础,理解它等于拿到了理解一切体素模型的钥匙。
6.3 坐标范围和shape的含义延伸
如果你的max_voxels设得太大(比如50000),而实际点云只有几百个非空体素,打印结果里大量行会是0填充的?不会。voxels和coors只保存非空体素,不存在0填充行。0填充只发生在体素内部点数不足max_points_per_voxel时,也就是voxels的T维度上会被填充0。
这点很多人混淆。voxels的shape第一维永远是实际非空体素数M,最多不超过max_voxels。coors的行数与voxels的行数严格相等,不是固定大小的网格。
7. 从voxel_coors出发:进阶排查与调优思路
当你对voxel_coors的理解到位后,很多调优工作会变得更顺手。这里分享几个进阶的排查和调优方法,它们都是基于voxel_coors这个核心数据结构展开的。
7.1 用voxel_coors分析点云分布的稀疏性
你可以统计一帧点云中voxel_coors的分布,来判断某个区域点云是否过于稀疏。比如统计每个体素内点数num_points_per_voxel的直方图:
num_points = num_points_per_voxel.cpu().numpy() hist, bins = np.histogram(num_points, bins=[1,2,3,4,5,6,7,8,9,10,100]) print(hist)如果大量体素内的点数只有1,说明该场景点云非常稀疏。这会直接影响VFE层的效果——MLP处理1个点的体素时几乎学不到局部几何结构,只能靠反射强度。这种情况建议适当增大voxel_size(比如从0.1改成0.2),或者降低max_points_per_voxel以减少0填充带来的噪声。
反过来,如果大量体素内点数都达到上限,说明点云密集区域的体素分辨率不够,此时增大体素数量限制或改用更细的voxel_size有助于保留细节。
这个分析方法在做不同传感器平台适配时特别有用。机械式激光雷达和固态激光雷达的点云分布差异极大,用同一组体素参数往往是错误的。
7.2 排查检测不到远处小目标的问题
远处小目标检测不到,除了模型本身的感受野、锚点设置之外,voxel_coors相关的几个因素也常见:
pc_range没有覆盖到目标所在位置。max_voxels设得太小,远处目标的体素在随机采样阶段被丢弃。voxel_size太大,小目标(比如行人,尺寸约0.8米 x 0.8米 x 1.8米)在某个方向只占几个体素,特征信息不足。
有一个简单的可视化工具有助于定位问题:把一帧BEV投影结果的空体素位置画出来,观察远处区域的体素密度到底是"0"还是"稀疏但存在"。如果远处压根没有体素,说明点云采集距离不够或pc_range过小;如果远处有体素但目标位置附近没有,说明目标太小或体素太大。
这类问题在日志中很难发现,配合voxel_coors的直方图做一次统计分析,往往能快速定位。
7.3 自定义数据集的体素参数设计
接触过自定义数据集的人都会遇到一个问题:voxel_size应该怎么设?pc_range应该怎么设?这里给出一个朴素但有效的流程:
- 先统计训练集所有点云的xyz范围,确定合理的
pc_range,留出10%-20%的余量。 - 根据目标最小尺寸来确定
voxel_size。如果最小目标是行人,建议体素大小不超过目标最小边的一半,比如行人宽0.6米,体素x、y方向不要超过0.3米。 - 用
max_voxels控制显存占用,先设一个较大的值(如30000),在训练时不爆显存的前提下尽量大。 - 训练后统计
num_points_per_voxel分布,再决定是否调整max_points_per_voxel。
这套流程虽然在不同的数据集上具体数值不同,但思路是通用的。
7.4 可视化体素与原始点云的对齐
调试3D检测模型时,最有力的工具是可视化。你可以用open3d或matplotlib把原始点云和体素中心一起画出来,检查体素化过程是否把几何结构保持住了。
import open3d as o3d def visualize_voxels(points, voxel_coors, voxel_size, pc_range): pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(points[:, :3]) voxel_centers = (voxel_coors[:, 1:] + 0.5) * voxel_size + pc_range[:3] voxel_pcd = o3d.geometry.PointCloud() voxel_pcd.points = o3d.utility.Vector3dVector(voxel_centers) # 上色的略,用不同颜色区分原始点和体素中心 o3d.visualization.draw_geometries([pcd, voxel_pcd])如果体素中心在点云表面漂移太多,说明pc_range或voxel_size设置有误。肉眼对齐检查虽然粗糙,但能发现大量数值问题和坐标方向错误。
8. 两个高频疑问的深入辨析
关于voxel_coors,有两个疑问在社区里反复出现,这里单独拿出来深聊。它们涉及对数据结构的根本理解,搞不清楚会持续踩坑。
8.1 voxel_coors的索引顺序到底影响什么
前面说(batch, z, y, x)是spconv的约定。但有个细节值得展开:为什么z放在中间而不是最后?
这与稀疏卷积的插值方式和数据布局有关。在三维空间中,相邻体素在x、y方向上的变化更频繁,而z方向由于点云高度范围小、体素数量少,变化相对不频繁。把变化最慢的维度放在中间,可以在哈希表查找时更好地利用CPU的cache局部性,减少缓存缺失。
不同框架在这个约定上不完全一致。PyTorch的THC/ATen里,spconv的历史版本和torchsparse可能会用不同的顺序。所以框架迁移时,这个顺序问题一定是检查清单里的第一项。
实际操作中还有一个相关现象:如果你把一个预训练模型从mmdetection3d导出到ONNX,voxel_coors的坐标顺序必须和训练时保持一致,否则导出的模型在推理时完全不可用。ONNX模型里通常看不到显式的坐标顺序说明,只能在导出前统一约定。
8.2 为什么voxel_coors是整数但某些场景看起来像浮点
在部分代码中,你可能会看到voxel_coors以浮点形式出现。比如数据增强后,某些实现会重新计算voxel_coors,但坐标经过旋转、缩放后可能不再是整数。
严格来说,体素索引必须是整数。但如果做坐标变换后立即做体素化,内部会把浮点坐标除以体素大小后取整,得到新的整数索引。如果你看到浮点型的voxel_coors,大概率是某个API接口占位符,或者是在做多尺度特征融合时的中间表示——它带有浮点信息,但不是最终送入稀疏卷积的索引。
这种情况通常不推荐直接用于spconv的输入。spconv要求indices是整数类型(torch.int32或torch.int64),如果传入浮点数据,要么报类型错误,要么被隐式截断导致无法预料的行为。如果你在调试中遇到"为什么坐标变了但特征没变"的问题,可以先检查voxel_coors的dtype和值域。
9. 结合具体模型谈voxel_coors的位置
不同模型对voxel_coors的使用阶段和方式有差异,挑两个典型的模型做对比,有助于从整体上理解这个张量在架构中的角色。
9.1 VoxelNet / SECOND:从原始体素到稀疏卷积
VoxelNet和SECOND的pipeline比较直接:原始点云 → 体素化 → VFE → 稀疏3D卷积 → 到BEV → 目标检测头。在这个链路中,voxel_coors的职责非常明确:
- 在VFE阶段用于计算相对坐标;
- 在稀疏卷积阶段作为空间索引参与卷积运算;
- 在BEV压缩阶段用于投影坐标定位。
如果voxel_coors的数值有误,链路中各阶段的错误会逐级放大。最危险的是一开始的方向错误,它不会让程序崩溃,但会让所有后续计算全部错位。
9.2 PointPillars:z索引恒为0的体素
PointPillars可以看作是体素化在BEV层面的特例。它把z方向压缩成1个体素,voxel_coors的z索引恒为0。此时,从3D稀疏卷积到BEV的压缩变得极其简单——不需要高度方向上的聚合,voxel_coors[:, 1]可以直接当作BEV的y索引,voxel_coors[:, 3]当作x索引。
但要注意,PointPillars在VFE阶段计算体素内点的相对位置时,仍然保留了每个点的真实z坐标(因为voxels的四个特征通道里有x、y、z、r),所以模型能感知到物体的高度信息。真正丢弃z信息的是体素化后的坐标索引,而不是点特征本身。
这种"坐标索引降维、点特征保留高度"的设计,让PointPillars在效率和精度之间取得了一个很好的平衡。理解了这一层,你就能理解为什么PointPillars参数量不大但性能不俗。
9.3 CenterPoint:从voxel_coors到中心点热力图
CenterPoint沿用了SECOND风格的体素化,但在检测头部分引入了中心点热力图。它的关键变换是:把目标中心从物理坐标映射到BEV网格坐标,然后去匹配voxel_coors对应的体素位置。
如果目标中心对应的体素恰好是空的(该位置没有点),热力图在这个位置会非常弱,可能导致漏检。CenterPoint在训练时会用高斯核在目标中心周围一定半径内填充正样本,就是为了缓解这个体素空置问题。理解这一点,对调参很有帮助:热力图高斯半径过小,正样本覆盖少,模型收敛慢;半径过大,正样本重叠多,检测框定位不准。
10. 调试voxel_coors相关问题时的一套实用排查清单
最后,结合个人经验整理一份排查清单。遇到与voxel_coors相关的神秘bug时,按这个清单逐一检查,大部分问题能快速定位。
- 检查shape是否符合预期:打印
voxels.shape和coors.shape,第一维必须相等。 - 检查dtype是否为整数:
coors.dtype应该是torch.int32或torch.int64。 - 检查坐标值域:打印
coors[:, 1:].min()和coors[:, 1:].max(),换算成物理坐标后必须落在pc_range内。 - 检查batch索引:batch size > 1时,
coors[:, 0]应该恰好覆盖0到batch_size - 1。 - 检查坐标顺序:换算一个已知位置的体素索引,验证是
(z, y, x)还是(x, y, z)。 - 检查与features的对应关系:取一个体素,打印其原始点坐标与
coors换算后的体素中心,误差应该小于一个体素大小。 - 检查增强操作后的同步性:如果重写了数据增强,确认增强后是否重新执行了体素化。
- 检查多尺度特征:使用FPN类结构时,注意低层和高层的体素坐标是否处于同一坐标系。
上面这些检查项,每一项对应一类高频bug。其中第6项是最有效但也最容易被跳过的一项——"看起来没问题"通常意味着还没验证到这一步。
对voxel_coors的理解,本质上是对"点云如何变成规则网格"这件事的理解。把它吃透之后,再去看mmdetection3d的源码,很多看似绕的代码就会变得顺理成章。体素化只是3D检测pipeline的第一步,但恰恰是最基础、最影响全局的一步。后续无论是做新传感器适配、新模型设计,还是部署优化,对这一步的扎实理解都会成为你的底牌。