纯视觉做3D感知,最难的是把2D像素和带绝对尺度的三维空间对上号。PETRv2这套多摄像头3D感知框架,本质上就是给出了一份"单塔多任务"的答卷:图像进来,一套3D位置编码加一组查询,同时吐出3D检测框、BEV分割图、3D车道线,中间不再需要显式的深度分布估计、也不需要拼BEV稠密栅格。我做BEV感知这条线有些年头,最初看PETR的时候觉得"把相机参数灌进位置编码"这招很讨巧,但也明显感觉到它只有单帧、只做检测,落地时不够用;等看到PETRv2补上时序和多任务这两块短板,才觉得这套东西真的能进工程。这篇就把我复现这套框架时拆开的每一个环节写清楚:它统一了什么、3D位置编码到底怎么算、时序为什么用世界坐标当锚、多任务头的query怎么设计、训练时哪些参数一改就崩、以及踩过的那些坑。不管你是刚接触多相机纯视觉方案的新手,还是已经在调BEV检测的老手,下面这些参数级和代码级的细节应该都能直接拿去用。
1. 这套"统一框架"到底统一了什么
1.1 从分工流水线到单塔多任务
传统的车端感知栈是切开的:2D检测出框,单目深度估个距离,多相机做后处理融合,车道线单独训一个分割网络,BEV占用栅格又是另一个模型。这套流水线的问题不在于精度,而在于误差会逐级放大——2D框抖一下,投影到3D就偏半米,深度估计差10%,远处目标直接飘出车道。更要命的是每个模块各有一套backbone,算力和显存都是重复开销。
PETRv2走的是另一条路:一根图像backbone,一份多视角特征,一组共享的查询向量,然后按任务分成几路输出头。它把"多相机图像到3D空间"这个转换从后处理搬到了特征层面——每张图的特征点在生成时就已经知道自己对应三维空间中的哪个坐标,后面的注意力机制是在这个带几何信息的特征集合上做选择。这样做的好处很直接:检测、分割、车道线共享同一套空间表征,一个任务的监督信号会顺带把backbone的几何感知能力拉起来,实测多任务联合训练时检测的NDS通常比单任务还高一点点,原因就在这。
提示:多任务不等于"什么都往里塞"。PETRv2能统一的前提是这三个任务的输出都能落成"3D空间里的点或框",共享同一套3D位置编码才有意义。硬塞一个纯2D的语义分割进去,反而会拖累。
1.2 为什么选query-based而不是BEV稠密网格
BEV方案(LSS那一类)的思路是:先给每个像素估一组深度分布,把特征"拍"到BEV平面上,再在BEV上跑卷积。这条路直观,但有两个隐性成本。第一,深度分布是隐变量,没有直接监督,只能靠检测loss反传,训练早期特别不稳,经常出现近处堆一团、远处全空白的情况。第二,BEV栅格的分辨率是硬约束,200×200的格子,一格代表0.5米,想看清更细的东西就得翻倍算力。
PETRv2用的是DETR那套query范式:不显式生成BEV图,而是让一组查询向量在3D-aware特征里做注意力,直接回归出目标。这里的"3D-aware"是关键——特征本身携带了世界坐标信息,查询不需要自己去猜深度。这套做法省掉了深度分布的中间估计,也摆脱了固定栅格分辨率的限制,查询点可以稀疏地落在任意位置。代价是收敛慢,DETR系列的匈牙利匹配天生需要更长的训练周期,24个epoch起步是常态,10个epoch就想看结果基本是自欺欺人。
1.3 时序为什么必须补上
单帧纯视觉有个物理上无解的问题:静止目标和运动目标的区分。一辆停在路边的车和一辆正在行驶的车,单张图上看起来一样。PETR只有单帧,速度估计靠回归硬猜,误差大得离谱。PETRv2补时序不是锦上添花,是补足纯视觉方案的物理下限——两帧之间位姿已知,同一个世界点在两帧里的位移就是运动信息。
我见过不少人做时序的方式是把历史帧特征直接concat到当前帧通道上,这种做法在相机位姿变化时会引入严重的错位。PETRv2的做法聪明得多:历史帧的3D点先变换到世界坐标,再用同一套MLP编码位置,这样"同一个世界点"在不同帧得到的位置编码完全一致,特征对齐是天然的,不需要额外学一个对齐模块。这个设计我在复现时反复验证过,确实稳。
2. 架构拆解:一根主干、一套token、四条出口
2.1 图像塔与特征分辨率的取舍
主干用的是ResNet-50/101加FPN,输出1/16和1/32两级的融合特征,实际送进几何模块的是1/16那一级。输入分辨率这块有讲究:常见配置是短边256、长边704,或者短边320、长边800。为什么不做更高?因为注意力机制的序列长度是按特征图算的,6路相机、每路44×80的特征图,加起来就是21120个token,再乘上时序帧数,显存直接爆。我做对比实验时试过896×1600,单卡24G跑batch size 1都紧张。
这里有个容易忽略的点:特征图尺寸必须是整数下采样得到的,而且内参一定要按同样的比例缩放。我用704×256输入、原图是1600×900时,缩放系数在宽高方向是不一样的(0.44和0.284),如果只用一个统一系数去缩内参,位置编码会整体偏出去,表现为检测框系统性向图像边缘漂移。这个bug我调了整整两天。
2.2 3D位置编码:把隐式深度塞进特征里
这是整个框架的心脏,值得掰开讲。做法是:在每路相机的图像平面上铺一个网格,每个格点沿相机射线方向采样若干深度值,得到一组相机坐标系下的3D点;用内参把这些点反投影回像素坐标验证一致性,再用外参和车辆位姿把它们变换到世界坐标;最后用一个三层MLP把这组(x, y, z)映射成C维向量,和图像特征逐元素相加。
关键在于这一步做了什么:图像特征原本只有"看起来像什么",加上位置编码后,它同时知道"在哪里"。同一块纹理,出现在近处和远处,得到的位置编码完全不同,网络不需要再单独估一个深度分布就能区分。这和LSS的思路是两条路——LSS是把特征搬到BEV再算,PETRv2是把几何信息搬进特征里再算,殊途同归但计算路径差别很大。
深度采样数D的选择是个工程权衡。原版PETR用了较大的D(64左右),显存占用按D倍增长;PETRv2为了腾出时序的显存,把D压得很低(常见配置取1,也就是每个像素只采一个代表深度,剩下靠时序和anchor来补)。我建议新手一开始就用D=1跑通,别一上来就追原版配置,显存不够什么都白搭。等流程跑顺了再往D=2、D=4加,看收益是否值那个显存。
2.3 时序融合:世界坐标当锚
时序模块的实现细节是这样的:保存上一帧(或者上两帧)的每一路相机的3D点坐标(世界系),加上上一帧的图像特征;在当前帧前向时,用保存的世界坐标重新过一次同一个位置编码MLP,得到历史位置编码;历史特征加上历史位置编码后,与当前帧的3D-aware特征沿通道维度拼接或者做一次cross-attention融合。
注意:这里的位姿必须是同一套定义。相机到车体、车体到世界,两个矩阵的乘法顺序、坐标轴朝向(前左上还是前右下)、四元数到旋转矩阵的转换方式,任何一处错了,时序特征就会变成噪声。我在第一次复现时把车体坐标系的前向轴搞反了,训练loss能降但验证集mAP只有正常值的三分之一,排查了两天才定位到。
另一个细节是时间差。上一帧的位姿和当前帧的位姿之间有一个真实的Δt(通常0.5秒),如果目标在动,历史特征里的目标位置和当前帧的对应关系要靠位姿变换来补,但位姿只补偿了自车运动,目标自身的运动补偿不了——这部分只能靠网络从两帧特征的差异里自己学。所以速度监督信号其实很重要,PETRv2的检测头里对速度单独回归不是可有可无的。
2.4 多任务query与head设计
PETRv2给不同任务分配不同的查询集合,共享同一个decoder,最后分流到各自的头:
| 任务 | query初始化方式 | 数量级 | 输出头 |
|---|---|---|---|
| 3D检测 | 在车体坐标系下的3D锚点(可学习) | 900 | 分类 + 中心偏移 + 尺寸 + 朝向 + 速度 |
| BEV分割 | BEV平面上的均匀网格点 | 与BEV分辨率对齐 | 逐query分类 |
| 3D车道线 | 车道线上的参考点序列 | 按车道实例数 | 点序列回归 + 存在性分类 |
这三组query拼在一起进同一个decoder做自注意力和交叉注意力,好处是检测query和分割query之间能互相"看"到——比如一条车道线的位置能帮助确定车辆框的横向偏移,实测比三个独立模型各自训要高1到2个点。代价是decoder的序列长度变长,注意力成本上升,需要控制query总数。
交叉注意力的实现有个坑:全量注意力在拼接后的token上做,内存会吃紧。我的做法是换成可变形注意力(deformable attention),每个query只在自己3D锚点投影到各相机图像上的位置附近采样少量点。参考点的计算逻辑是:拿query的3D锚点,用外参和内参投到每个相机,判断是否落在图像范围内,落在范围内的相机才参与采样。这样内存从O(N_query × N_token)降到O(N_query × K × N_cam),K取4到8就够。
3. 关键实现细节与代码骨架
3.1 3D坐标网格生成与内参缩放
这段是整个流程的地基,写错了后面全错。核心是三步:像素坐标乘内参逆得到方向向量、乘深度得到相机坐标点、外参变换到世界系。
import torch import torch.nn as nn def make_3d_points(H, W, stride, D, d_min, d_max, K, cam2ego, ego2global): """ H, W : 特征图高宽(已下采样 16 倍) stride : 下采样倍数 D : 深度采样数,工程上先取 1 K : [3,3] 已按 resize 缩放过的内参 cam2ego : [4,4] 相机到车体 ego2global: [4,4] 车体到世界 """ device = K.device u = (torch.arange(W, device=device) * stride + stride / 2.0) v = (torch.arange(H, device=device) * stride + stride / 2.0) d = torch.linspace(d_min, d_max, D, device=device) uu, vv, dd = torch.meshgrid(u, v, d, indexing='ij') ones = torch.ones_like(uu) uv1 = torch.stack([uu, vv, ones], dim=-1) # [H, W, D, 3] # 像素 -> 相机归一化射线 rays = uv1 @ torch.inverse(K).T # [H, W, D, 3] rays = rays / (rays[..., 2:3] + 1e-6) # 归一化到 z=1 pts_cam = rays * dd.unsqueeze(-1) # 相机 -> 车体 -> 世界(只取旋转+平移) R_ce, t_ce = cam2ego[:3, :3], cam2ego[:3, 3] pts_ego = pts_cam @ R_ce.T + t_ce R_eg, t_eg = ego2global[:3, :3], ego2global[:3, 3] pts_global = pts_ego @ R_eg.T + t_eg return pts_global, pts_ego class PosEmbed3D(nn.Module): """三层的 MLP,把世界坐标映射到 C 维位置编码""" def __init__(self, C): super().__init__() self.mlp = nn.Sequential( nn.Linear(3, C), nn.ReLU(inplace=True), nn.Linear(C, C), nn.ReLU(inplace=True), nn.Linear(C, C), ) def forward(self, pts): # [N, H, W, D, 3] pe = self.mlp(pts) # [N, H, W, D, C] return pe.permute(0, 4, 1, 2, 3) # [N, C, H, W, D]内参缩放的代码单独拎出来,因为它最容易被糊弄过去:
def scale_intrinsics(K, src_hw, dst_hw): """src_hw / dst_hw 都是 (h, w) 形式""" sx = dst_hw[1] / src_hw[1] sy = dst_hw[0] / src_hw[0] S = torch.diag(torch.tensor([sx, sy, 1.0], device=K.device)) return S @ K提示:如果训练时用了随机裁剪增强,内参的主点(cx, cy)也要同步减去裁剪偏移,否则位置编码会整体平移。这是个很隐蔽的bug,验证loss看着正常,可视化才发现框整体往一个方向偏。
3.2 时序3D PE的生成与对齐
时序部分的接口设计要清楚:当前帧产出的是"世界坐标点集",这个点集要缓存给下一帧用。
class TemporalPEBuffer: def __init__(self, T=1): self.T = T self.buf = [] # 元素: dict(pts_global=..., feat=...) def push(self, pts_global, feat): self.buf.append({'pts': pts_global.detach(), 'feat': feat.detach()}) if len(self.buf) > self.T: self.buf.pop(0) def get(self): return self.buf def build_temporal_feats(buf, pe_mlp): """历史帧特征 + 历史坐标经同一 MLP 编码 -> 与当前特征对齐的时序特征""" outs = [] for item in buf: hist_pe = pe_mlp(item['pts']) # 与当前帧共用同一 MLP outs.append(item['feat'] + hist_pe) return outs # list of [N, C, H, W, D]这里有个容易被忽略的设计意图:位置编码MLP必须是共享的。如果当前帧和历史帧各自用一个MLP,那历史特征里携带的"位置信息"和当前帧的对不上,融合就变成了把两个坐标系的东西硬拼。共享MLP + 世界坐标,天然保证了空间一致性,这也是这套方案能只用一帧历史就见效的原因。
实际训练时历史帧特征要detach(stop gradient),否则显存会按帧数线性增长,反向传播能拖到跑不动。这个问题我在早期版本上踩过,一开三帧历史就OOM。
3.3 Decoder与损失构造
Decoder是标准的DETR结构,6层,每层一个自注意力加一个交叉注意力,查询在层间迭代精炼。损失这边三路要分开:
# 检测:匈牙利匹配后的分类 + 回归 loss_cls = focal_loss(pred_logits, matched_labels) loss_reg = l1_loss(pred_boxes, matched_boxes) loss_iou = giou_loss_3d(pred_boxes, matched_boxes) loss_det = 2.0 * loss_cls + 0.25 * loss_reg + 0.5 * loss_iou # BEV分割:逐query分类 + lovasz 缓解类别不均衡 loss_seg = ce_loss(pred_seg, gt_seg) + 0.5 * lovasz_loss(pred_seg, gt_seg) # 车道线:点序列回归 + 存在性 loss_lane = l1_loss(pred_pts, gt_pts) + ce_loss(pred_exists, gt_exists) loss = loss_det + 0.5 * loss_seg + 0.5 * loss_lane权重这块没有万能解,我试过的几组里,检测权重给2.0、分割和车道各给0.5比较平衡。如果发现分割的loss占了总loss的八成,说明分割的query太多或者CE的权重过高,需要往下压。梯度量级差十倍以上是很常见的现象,建议接一个自动权重(比如按各任务loss的滑动均值归一化)作为兜底。
3.4 关键参数速查
| 参数 | 推荐初值 | 说明与调整方向 |
|---|---|---|
| 输入分辨率 | 704×256 | 显存紧张降到640×256,精度不够往800×320走 |
| 深度采样数D | 1 | 加到2或4前先确认显存余量 |
| 位置编码MLP层数 | 3 | 加到4收益不明显,2层会掉点 |
| Decoder层数 | 6 | 减到3层掉约1.5个点,加到9层几乎无收益 |
| 检测query数 | 900 | 按目标数量分布调,城市场景900够用 |
| 历史帧数 | 1 | 加到2收益约0.5点,显存翻倍 |
| 深度范围 | 1.0 ~ 45.0 米 | 与数据集的标注范围对齐,别照抄 |
| 学习率(主干) | 4e-5 | 主干微调,大了容易崩 |
| 学习率(其余) | 4e-4 | 配合余弦退火,warmup 500步 |
4. 训练与调参实操
4.1 数据组织与几何增强
数据这块,多相机方案对时间同步的要求比BEV方案更严格。8路相机的图像时间戳、位姿时间戳要落在同一个采样窗口内,我做预处理时会把差值超过30毫秒的样本直接丢掉——留在训练集里就是给位置编码喂噪声。位姿的插值方式也要统一,如果原始数据给的是10Hz位姿而图像是12Hz,就得在时间轴上插值到位,别图省事用最近邻。
几何增强要格外小心。颜色抖动、随机擦除这类不改变几何的增强随便用;翻转、旋转、裁剪这类改变几何的增强,必须同步更新内参和外参,或者干脆不用。我的做法是只保留颜色类增强加上有限的缩放裁剪(且同步改内参),几何增强全靠数据集本身的多样性来补。这一点和2D检测的经验完全相反,2D里翻转能白捡一个点,这里翻了就是灾难。
4.2 优化器配置与训练节奏
优化器用AdamW,权重衰减0.01,主干和其余部分分组设学习率。训练节奏上,检测任务24个epoch是底线,多任务联合训练建议拉到36到48个epoch,因为分割和车道线的收敛比检测慢不少,经常出现检测已经平了、分割还在缓降的情况。余弦退火配合线性warmup,warmup步数按总步数的5%给。
python train.py \ --config configs/petrv2_r50_704x256.py \ --batch-size 8 \ --epochs 36 \ --lr 4e-4 \ --backbone-lr 4e-5 \ --warmup-steps 500 \ --clip-grad-norm 35 \ --amp梯度裁剪我设的是35,这个值不算严格,主要防的是训练前期匈牙利匹配不稳定时的偶发爆炸。开混合精度能省三成显存,但要注意位置编码的MLP在fp16下容易出精度问题,我一般把那几层用fp32跑。
注意:多卡训练时batch size和learning rate要同步线性放大,但别超过线性缩放的两倍。我用4卡把batch从8提到32时,学习率只敢给到1.2e-3,再往上训练就会发散。
4.3 多任务权重的动态平衡
多任务训练最烦的是"跷跷板":调高检测权重,分割掉点;调高分割,检测掉点。除了固定权重,还有两个更稳的做法。一是用不确定性加权,让网络自己学每个任务的log方差作为权重;二是按各任务loss的滑动均值做归一化,让每个任务的有效梯度量级接近。
我自己用的是第二种,实现简单且可解释。经验值是:训练前5个epoch先只算检测loss,把backbone和decoder的基本能力立起来,再逐步引入分割和车道线。这个"课程式"的训练顺序比一上来三路全开要稳,最终指标也能高0.5个点左右。
4.4 推理侧的关键优化
线上部署时,这套框架的几个热点要单独处理。位置编码的MLP是逐点计算的,H×W×N_cam个点,非常吃算力;好在这部分是纯几何计算,位姿和内参在一次前向里是固定的,可以提前算好缓存下来,每帧只做一次查表。另外位置编码的数值范围和图像特征的量级要对齐,我见过直接相加导致特征被淹没的情况,加之前做个LayerNorm会稳很多。
多帧推理时注意缓存的生命周期,历史特征要跟着track的生命周期走,车辆熄火重启或者长时间遮挡后要清空,否则会用上一段行程的坐标去算位置编码,结果完全不可预期。
5. 常见问题与排查实录
5.1 典型症状与定位路径
| 症状 | 最可能原因 | 排查动作 |
|---|---|---|
| 检测框整体向图像一侧偏移 | 内参缩放系数用错,或裁剪后主点未更新 | 打印缩放前后主点,验证投影回路一致性 |
| 训练loss正常但mAP只有正常值1/3 | 坐标系朝向定义不一致 | 取一个已知世界点,手工算投影到图像验证 |
| 时序开启后精度反而下降 | 历史帧特征未detach,或时间戳错配 | 关掉时序单独跑,对比单帧基线 |
| 远处目标几乎检不出 | 深度范围上限设小了 | 对照数据集标注的距离分布调整上限 |
| 速度回归误差特别大 | 时序帧数不足或位姿精度差 | 增加一帧历史,检查位姿插值逻辑 |
| 分割结果整体平移一格 | BEV网格query的中心点偏移量算错 | 检查网格生成时是否漏了半格补偿 |
| 训练到第3个epoch突然发散 | 学习率过高或梯度爆炸 | 降学习率,开梯度裁剪,检查是否有异常样本 |
| 多卡训练指标低于单卡 | batch增大后学习率未调,或BN统计不一致 | 改用LayerNorm或同步BN |
5.2 几个只有实际动手才会遇到的坑
第一个坑是验证集上的位姿文件格式。训练时用的位姿和验证时用的位姿如果来自不同来源,哪怕只是四元数的顺序从(w,x,y,z)变成(x,y,z,w),肉眼完全看不出异常,但位置编码全错。我的做法是写一个投影校验脚本,取标注框的中心点,用标注的外参内参投到图像上,看是否落在框内,训练前必跑一遍。
第二个坑是图像预处理的顺序。resize、归一化、通道顺序,这三步在不同框架里的默认行为不一样。我遇到过一次通道顺序从RGB变成BGR,位置编码没受影响,但backbone的特征分布整体偏移,表现为所有类别的置信度普遍偏低,怎么调阈值都不对。
第三个坑是时序缓存和分布式训练。多卡训练时如果每个进程各自维护一份历史缓存,而样本是打乱分配的,那缓存里存的可能是完全不相干的样本的历史,时序模块学到的就是随机噪声。解决办法是让时序样本在同一个batch内连续,或者按场景切分数据加载器。
第四个坑是分割任务的query数量。BEV分割如果按分辨率给query(比如200×200就是40000个),decoder直接爆。实际做法是用较粗的网格(比如100×100)再上采样,或者把分割头改成基于query特征的逐点分类。这个取舍我调了很久,粗网格加双线性上采样在指标上几乎不亏。
6. 这套范式的适用边界与影响
6.1 它解决了什么,又留下了什么
从工程角度看,PETRv2最实在的贡献是把"多相机纯视觉多任务"这件事的成本压下来了。一套模型、一份特征、一次前向,同时给出检测、BEV分割和车道线,对车端算力有限的场景是刚需。它省掉了深度估计这个中间环节,也就省掉了深度误差逐级放大的风险,这在远距离目标上体现得特别明显。
但它的边界也很清楚。第一,位置编码强依赖相机参数,一旦相机标定漂移或者在线标定没跟上,性能会掉得很直接,所以量产时必须配套标定监控。第二,它对时间同步的要求比BEV方案高,因为位置编码把几何信息写死在了特征里,时间戳错一点,历史特征和当前特征就对不上。第三,极远处的目标依然吃力,深度采样的分辨率和特征图分辨率共同决定了这个上限,这不是改改损失函数能解决的。
6.2 后续可以往哪扩
从这套框架往下延,我看到几条比较自然的路。一是把占用栅格预测接进来,query从BEV网格换成3D体素网格,位置编码的部分完全复用。二是把语言或者地图先验接进来,让查询的初始化带上前验信息,减少对数据量的依赖。三是把时序从"上一帧"扩到"关键帧加稀疏历史",用类似记忆库的方式管历史特征,这样长时序建模的成本能压住。
提示:往占用栅格扩的时候,注意体素数量会爆炸。我的经验是体素分辨率不要低于0.4米,再细就必须改稀疏查询,否则显存撑不住。
我个人在实际操作中的体会是,这类带几何先验的框架,调参的收益远不如把数据管线捋顺来得大。我前后花了大概三周时间调各种loss权重和网络超参,涨了不到1个点;后来花两天把时间戳对齐和位姿插值重写了一遍,直接涨了3个点。几何这东西是"一错全错",先保证输入干净,再谈模型结构,顺序反了就是浪费时间。另外一个小技巧:每次改完数据管线,别急着看总指标,先挑十帧可视化投影结果,肉眼过一遍,比盯loss曲线有用得多。