ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

voxel_coors深度解析:mmdetection3d中的体素坐标索引

voxel_coors深度解析:mmdetection3d中的体素坐标索引 开头先讲个我自己的经历。第一次在 mmdetection3d 里调试 PointPillars 的时候我打印中间变量看到了一个形状为[N, 4]的 tensor名字就叫voxel_coors。当时我想当然地以为这是体素中心的 x、y、z 坐标最后一列可能是强度或者时间戳结果拿去画框怎么都对不上。后来翻了实现才意识到这个voxel_coors根本不是“空间坐标”而是非空体素在整张三维网格里的整数格点索引。别看它不起眼稀疏卷积、BEV 特征生成、甚至多帧点云融合都绕不开它。这篇文章就把我在 mmdetection3d 里对体素坐标voxel_coors的理解完整梳理一遍包括它到底是什么、从哪来、被谁消费以及我用下来遇到过的一堆坑。1. voxel_coors 是什么先搞清楚这张“坐标身份证”1.1 为什么点云必须“体素化”点云是一堆无序、密度不均匀的离散点。同一辆汽车近距离可能有几千个点远距离可能只有十几个点而且点与点之间没有任何顺序关系。这种数据结构直接丢给神经网络非常难受——卷积网络假设输入是规则的网格点云显然不是。体素化的思路很简单把三维空间切成一个个小立方体格子然后把离散点塞进对应格子里。格子的边长通常用voxel_size控制常见值像[0.1, 0.1, 0.2]或者[0.25, 0.25, 0.25]。这个过程很像图书馆给书贴标签——书还是一本一本的但每一本都知道自己要放到哪一排哪一层后续管理就方便了。在 mmdetection3d 的体素检测器SECOND、PointPillars、CenterPoint 等里体素化之后会产生几个关键张量voxels每个非空体素内的点特征、voxel_coors非空体素的坐标、num_points_per_voxel每个体素内点数。其中voxel_coors就是我们要聊的主角。1.2 voxel_coors 的四个维度含义与坐标约定这个张量最迷惑人的地方就是它有 4 列而不是 3 列。我先给结论在 mmdetection3d 的常见流程中voxel_coors的每一行代表一个非空体素四列含义分别是列索引含义示例值0batch 索引表示这个体素来自哪一帧/哪一个样本0, 1, 2 ...1体素在 X 方向的网格编号3202体素在 Y 方向的网格编号5123体素在 Z 方向的网格编号15注意第 1、2、3 列不是实际物理坐标比如多少米而是“格子编号”。实际坐标和格点编号之间的关系是grid_x floor((x - point_cloud_range[0]) / voxel_size[0]) grid_y floor((y - point_cloud_range[1]) / voxel_size[1]) grid_z floor((z - point_cloud_range[2]) / voxel_size[2])所以如果你看到一个voxel_coors[i] [0, 320, 512, 15]意思是这个体素属于第 0 帧样本位于点云范围里 x 方向第 320 格、y 方向第 512 格、z 方向第 15 格。要换算回真实坐标就需要乘回voxel_size并加上point_cloud_range的起点。还要强调一下坐标顺序。很多人在这个上面栽跟头包括我。voxel_coors内部的列顺序是[batch, x, y, z]但 spconv 的SparseConvTensor在构造时要求spatial_shape是[z, y, x]这种倒序排列。也就是说坐标张量本身是 x、y、z 顺序但 shape 维度是 z、y、x。如果你拿 coors 去和spatial_shape手动比对很容易把维度对应错。1.3 一个从原始点云到 coors 的简化生成示例为了把上面这些讲得更直白我写一个极简版的体素坐标生成逻辑。假设我们有一帧点云 points形状为[num_points, 3]只包含 x、y、zimport torch def points_to_voxel_coors(points, point_cloud_range, voxel_size): 极简版体素坐标生成仅用于理解流程 # 裁剪掉范围外的点 mask ((points[:, 0] point_cloud_range[0]) (points[:, 0] point_cloud_range[3]) (points[:, 1] point_cloud_range[1]) (points[:, 1] point_cloud_range[4]) (points[:, 2] point_cloud_range[2]) (points[:, 2] point_cloud_range[5])) points points[mask] # 计算格点坐标 coors torch.zeros((points.shape[0], 3), dtypetorch.int32) coors[:, 0] ((points[:, 0] - point_cloud_range[0]) / voxel_size[0]).floor() coors[:, 1] ((points[:, 1] - point_cloud_range[1]) / voxel_size[1]).floor() coors[:, 2] ((points[:, 2] - point_cloud_range[2]) / voxel_size[2]).floor() # 去重只保留非空体素 unique_coors torch.unique(coors, dim0) return unique_coors point_cloud_range [-50, -50, -5, 50, 50, 3] voxel_size [0.25, 0.25, 0.25] # 模拟一帧 1000 个点的点云 fake_points (torch.rand(1000, 3) - 0.5) * 100 fake_points[:, 2] torch.rand(1000) * 8 - 5 coors points_to_voxel_coors(fake_points, point_cloud_range, voxel_size) print(coors.shape) print(coors[:5])实际 mmdetection3d 的实现当然更复杂比如还涉及最大点数截断、随机采样、shuffle 等但坐标生成的数学本质就是上面这个流程。理解了这个voxel_coors就不再是个黑盒了。2. voxel_coors 在 mmdetection3d 里是怎么被生成的2.1 数据管线到 spconv 的 VoxelGeneratorV2在 mmdetection3d 的数据管线里体素化主要由Voxelization这个 transform 完成。它的底层实现来自spconv的VoxelGeneratorV2。整体调用关系大致是数据集读取点云 → 预处理 pipeline → Voxelization transform → voxels coors num_points_per_voxel在典型的配置文件中你会看到这样一段dict( typeVoxelization, voxel_sizevoxel_size, point_cloud_rangepoint_cloud_range, max_num_points64, max_voxels20000 )max_num_points是每个非空体素最多保留多少个点超出部分会被截断max_voxels是整帧最多保留多少个非空体素。这两个参数直接影响显存和精度调参的时候经常要来回权衡。VoxelGeneratorV2在内部会对点云按体素进行分组统计每个体素内的点数然后填充特征。说白了它在做两件事一是算每个点属于哪个格子也就是生成 coors二是把同一个格子的点特征排列到一起得到voxels张量。2.2 point_cloud_range、voxel_size 与 grid_size 的换算关系voxel_coors的取值范围不是无限的。它实际上被point_cloud_range和voxel_size共同约束。假设我们设置point_cloud_range [-50, -50, -5, 50, 50, 3] voxel_size [0.25, 0.25, 0.25]那么每个方向的网格数量是grid_size_x (50 - (-50)) / 0.25 400 grid_size_y (50 - (-50)) / 0.25 400 grid_size_z (3 - (-5)) / 0.25 32注意在 spconv 里构造稀疏张量时spatial_shape是(z_size, y_size, x_size)也就是(32, 400, 400)。而voxel_coors里保存的 x、y、z 坐标必须分别小于 400、400、32。如果点云范围设置得不够余量或者点云坐标本身超出范围就会出现 coors 越界后面 sparse conv 会直接报错或者产生诡异的训练结果。我习惯在写新的数据集配置时先把这几个数自己算一遍而不是照抄别人的配置。很多人改point_cloud_range忘记同步改voxel_size导致网格数量剧烈变化模型几乎不可收敛最后才发现是 coors 分布完全变了。这里还要提一个细节voxel_size不一定是三个方向都相等。比如在 KITTI 风格配置里常见voxel_size [0.05, 0.05, 0.1]z 方向格子更粗。这种各向异性体素对检测任务来说反而合理因为物体高度方向不需要那么细的粒度。2.3 为什么 voxel_coors 必须是 int32而不是 float在 debug 的时候可能有人尝试把voxel_coors转成 float 再参与计算结果 spconv 直接报类型错误。这不是 spconv 矫情而是它有硬性要求稀疏张量的 indices 必须是整数类型通常是torch.int32。原因很直接coors 本质上是“索引”是离散的格子编号不是连续的空间坐标。浮点数坐标可以作为特征输入网络但作为索引它在哈希查找、邻域聚合这些操作里没有任何意义。用 int 类型还能带来额外的性能收益——稀疏卷积前要对坐标进行排序和去重整数比较比浮点比较快得多而且不会出现因精度导致的重复键。我在自定义算子时踩过一次坑手动构造SparseConvTensor时用了torch.int64的 indices跑 CPU 没问题上 GPU 后莫名其妙报错。后来查文档才发现spconv 对 indices 的 dtype 和 shape 要求非常严格shape 必须是[N, 4]dtype 必须是 int32。这个细节值得记在小本本上。3. 下游模块是怎么“消费”这三个坐标索引的3.1 SparseEncoder稀疏卷积如何利用 voxel_coors 查找邻域voxel_coors最核心的使用方是稀疏卷积。在 mmdetection3d 中SparseEncoder接收的输入通常包括体素特征voxel_feats和坐标voxel_coors然后构造一个spconv.SparseConvTensorsparse_tensor spconv.SparseConvTensor( featuresvoxel_feats, # [N, num_features]N 是非空体素数量 indicesvoxel_coors.int(), # [N, 4]batch_idx x y z spatial_shapespatial_shape, # [z_size, y_size, x_size] batch_sizebatch_size )稀疏卷积和普通卷积最大的区别在于它只对非空位置做计算。普通卷积需要在[32, 400, 400]的三维网格上做 3D 卷积这个体积巨大实际很多格子是空的纯属浪费。稀疏卷积通过 coors 维护一个“哪些格子有数据”的列表计算时只需要在这些地方做聚合。你可以把 coors 想成一张藏书索引卡。图书馆很大但你不必每次从第一排扫到最后一排找书而是通过索引卡直接定位到目标区。稀疏卷积就是通过 coors 这些索引卡在稀疏的特征场上快速找到参与计算的体素。在多层稀疏卷积下采样之后体素坐标并不是一成不变的。stride2 的稀疏卷积会让相邻体素合并输出张量的 coors 也会跟着变化。要看清楚中间层输出的 coors别拿早期坐标去对应最终的特征图很多 debug 困惑就是从这里来的。3.2 Scatter 层从体素空间到 BEV 特征图的坐标映射很多基于体素的检测器最终都需要生成鸟瞰图BEV特征比如 PointPillars。从体素特征到 BEV 特征中间靠PillarScatter或者类似模块完成。从稀疏三维体素到二维 BEV一个直观做法是丢掉 z 方向的维度只保留 x 和 y 方向的体素坐标。在 mmdetection3d 的PillarScatter实现里核心伪逻辑是bev_shape [batch_size, num_channels, grid_size_y, grid_size_x] bev_feats torch.zeros(bev_shape, devicefeats.device) # coors 的第 1 列是 x第 2 列是 y bev_feats[batch_idx, :, coors_y, coors_x] feats这个scatter操作其实就是“把体素特征写到 BEV 画布对应的像素位置”。如果你的 coors 中 x、y 对应错了最终 BEV 特征就是错位的检测框也会整体偏移。PointPillars 里常有“把 z 方向压缩掉”的步骤。此时如果只是简单丢弃 z那么一个 x、y 位置上有多个不同 z 的体素需要做一个聚合通常是求和或最大池化。这个聚合逻辑同样依赖 coors 中的 x、y 来分组。一旦 coors 不准确BEV 特征图就会出现噪点或空洞。我在实际项目中遇到过一个诡异问题模型训练 loss 很正常但测试时前方车辆检测框整体向左偏了约 0.5 米。排查到最后发现是某个环节把 x 和 y 列弄反了导致 scatter 时坐标被对调。这类问题肉眼很难看出用点云模拟一个非对称场景检查响应点位置会快得多。3.3 CenterPoint 等检测器中 voxel_coors 的使用差异CenterPoint 这类 anchor-free 检测器虽然也基于体素但它对 coors 的使用方式和 PointPillars 不太一样。CenterPoint 通常在 encoder 之后保留稀疏表示然后在 BEV 空间生成 center heatmap。这里的 coors 需要不断跟随时空特征图的下采样和上采样调整。另一个常见场景是多帧融合。比如某些方法把多帧点云拼接到一起体素化此时 batch 索引不能简单填 0而应该区分帧。如果不小心把多帧数据当成同一帧处理coors 就会混在一起特征表达完全错误。在多模态方案里voxel_coors还承担着点云特征与图像特征对齐的作用。通过体素网格可以把 3D 体素反投影到图像平面再拿到对应位置的图像特征。这种跨模态关联也是以 coors 为桥梁的。一句话总结coors 在检测器里不只是“定位用的索引”它还决定特征在哪存、往哪写、怎么和别模态对齐。想在这类模型上做二次开发看懂 coors 的流转路径比看懂 loss 函数还重要。4. 调试与排查那些年我踩过的 voxel_coors 坑4.1 坐标顺序混淆特征错位到你怀疑人生最典型的问题就是把voxel_coors当成[batch, x, y, z]之外的顺序来理解。比如 spconv 内部涉及到 shape 时是[z, y, x]一些人在自定义损失或者可视化时直接用coors[:, 1]当作 x结果能对上训练但可视化结果漂移。我的建议是在处理 coors 前先固定一个统一约定在 mmdetection3d 中coors 列顺序默认为[batch, x, y, z]。如果要传给另一个底层库例如 spconv、TensorRT 自定义插件先查清楚目标库的 shape 维度顺序再决定是否 trans 列顺序。这个转换最好放在数据边界不要在业务代码里到处改。4.2 批次索引在训练和推理时的表现差异单帧推理时voxel_coors[:, 0]全是 0很多人因此忽略了批次索引的存在。一旦进入训练或者批量推理batch 索引就变成必须的。如果自定义 collate 函数把多个样本的 point cloud 直接 concat 后体素化忘记把样本编号写进 batch 索引模型就会把不同帧的体素混在一起处理训练基本报废。我踩过一次比较深的坑在分布式数据并行下自己写了一个采样器返回的 sample index 和 dataset 顺序有关系但没有把 local rank 的偏移量加进去导致 batch 索引错乱。模型在单卡上正常多卡训练彻底发散。排查半天才发现是 batch 索引在不同进程间对不上。调试技巧在训练脚本里隔几个 step 打印voxel_coors[:, 0]的唯一值。正常情况下值应该覆盖[0, batch_size-1]的所有整数而且数量分布应该相对均匀。如果出现明显缺失说明数据管线某个环节出问题了。4.3 用一段代码快速“体检”voxel_coors遇到模型表现不正常先别急着改网络结构我一般先给voxel_coors做一套快速体检。以下是我常用的 debug 代码简单但有效def inspect_voxel_coors(voxel_coors, grid_size, tag): print(f[{tag}] voxel_coors shape: {voxel_coors.shape}) print(f[{tag}] dtype: {voxel_coors.dtype}) # 1. 批次分布 batch_ids torch.unique(voxel_coors[:, 0]) print(f[{tag}] batch ids: {batch_ids.cpu().tolist()}) # 2. 坐标范围 coors_xyz voxel_coors[:, 1:] print(f[{tag}] x range: {coors_xyz[:, 0].min().item()} ~ {coors_xyz[:, 0].max().item()}) print(f[{tag}] y range: {coors_xyz[:, 1].min().item()} ~ {coors_xyz[:, 1].max().item()}) print(f[{tag}] z range: {coors_xyz[:, 2].min().item()} ~ {coors_xyz[:, 2].max().item()}) # 3. 是否越界 if coors_xyz[:, 0].max().item() grid_size[2]: print(f[{tag}] WARNING: x coors exceed grid_size_x{grid_size[2]}) if coors_xyz[:, 1].max().item() grid_size[1]: print(f[{tag}] WARNING: y coors exceed grid_size_y{grid_size[1]}) if coors_xyz[:, 2].max().item() grid_size[0]: print(f[{tag}] WARNING: z coors exceed grid_size_z{grid_size[0]}) # 4. 非空体素数量 print(f[{tag}] num non-empty voxels: {voxel_coors.shape[0]}) # 5. 每个样本的体素数 if len(batch_ids) 1: counts torch.bincount(voxel_coors[:, 0]) print(f[{tag}] voxel count per batch: {counts.cpu().tolist()})这套检查能快速定位数据范围配置错误、batch 索引错乱、体素数量异常等问题。我在调新数据集配置时都会跑一遍这个函数基本能避免后面花几个小时排查“为什么 loss 不下降”。4.4 下采样与转置卷积带来坐标变化稀疏卷积堆叠的时候stride 会改变体素坐标的语义。例如 stride2 的稀疏卷积输出的 coors每个单位代表原始体素空间的两个格子。如果你在中间层拿 coors 去对应原始点云位置必须乘以对应倍率。转置卷积或者上采样阶段更麻烦。有的实现会把高层特征的 coors 映射回原分辨率或者直接通过 scatter 填回 BEV 特征图。这里如果 coors 处理不一致特征插值的位置就是错的直观表现就是小目标丢失或者检测框偏移。我的建议是每次修改网络结构后做一次前向对比不同层的 coors 坐标范围确认它们符合预期。5. 我个人的一点记忆方法如果你也在 mmdetection3d 里频繁和voxel_coors打交道我推荐你把它理解成一套“门牌号系统”batch 索引是小区编号x、y、z 三个格子坐标是楼栋、单元和楼层。点云体素化就是给每一群点分配门牌号的过程稀疏卷积和 scatter 则是根据门牌号找人办事。每次改动point_cloud_range、voxel_size、max_voxels等参数后先跑一小段数据打印一下 coors 的分布确认范围在预期内再开始正式训练。这个习惯帮我省了太多时间也让我少看了很多凌晨三点的报错日志。希望这篇关于 voxel_coors 的梳理能帮你少踩几个坑。
返回列表