4、视觉Transformer(ViT):图像分块与Patch Embedding、Class Token与位置编码、ViT在BEV感知中的应用
各位同学,欢迎来到第四章。这一章我们聊聊视觉Transformer,也就是ViT。
说实话,ViT刚出来那会儿,我第一反应是:这玩意儿真能把CNN给干趴下?后来在项目里硬着头皮试了试,嗯,真香。今天我就把我在实际落地中踩过的坑、总结的经验,掰开了揉碎了讲给你听。
4.1 图像分块与Patch Embedding
ViT的核心思想,说白了就是把一张图切成一块一块的小方块,然后把这些小方块当成NLP里的“词”来处理。你想想看,Transformer本来是为序列数据设计的,图像是二维的,怎么塞进去?切块。
具体怎么切?
假设输入图像是224x224x3,我们设定每个patch的大小是16x16。那么一共会切出多少块?
224 / 16 = 14
总patch数 = 14 * 14 = 196
每个patch的维度是16*16*3 = 768。这196个patch,每个都拉成一个768维的向量。这就是最原始的“图像词向量”。
关键点:Patch Embedding本质上就是一个线性投影层。输入是patch的像素值,输出是一个固定长度的向量。我在项目中习惯用Conv2d来实现这一步,kernel_size和stride都设为patch_size,效率更高。
代码实现其实很简单:
class PatchEmbed(nn.Module):
def __init__(self, img_size=224, patch_size=16, in_chans=3, embed_dim=768):
super().__init__()
self.proj = nn.Conv2d(in_chans, embed_dim,
kernel_size=patch_size, stride=patch_size)
def forward(self, x):
# x: [B, 3, 224, 224] -> [B, 768, 14, 14] -> [B, 196, 768]
x = self.proj(x)
x = x.flatten(2).transpose(1, 2)
return x
这里有个小细节:为什么用Conv2d而不是直接reshape? 我个人习惯用Conv2d,因为它在处理batch数据时更高效,而且后续如果要接CNN特征提取,参数可以共享。当然,直接用nn.Linear也可以,效果差别不大。
避坑指南:我曾经在某个项目里,图像分辨率不是标准的224x224,而是320x240。结果patch切出来不是整数,导致维度对不上。后来我加了一个自适应padding层,把图像先缩放到能被patch_size整除的尺寸。这个坑,你早晚会遇到。
4.2 Class Token与位置编码
好,现在我们有196个patch向量了。但Transformer需要处理的是序列,而且我们需要一个“全局代表”来做分类。怎么办?加一个特殊的token——Class Token。
Class Token是什么?
就是一个可学习的向量,维度跟patch embedding一样(768维)。它会被拼接到patch序列的最前面。最终序列长度变成197(1个class token + 196个patch token)。
为什么需要它?因为Transformer的Self-Attention会让所有token互相交互,class token经过多层编码后,就能“吸收”整张图的全局信息。最后我们只需要取class token对应的输出,接一个分类头就行了。
# 假设batch_size=2
patch_tokens = torch.randn(2, 196, 768) # 196个patch
cls_token = nn.Parameter(torch.randn(1, 1, 768)) # 可学习的class token
cls_tokens = cls_token.expand(2, -1, -1) # 复制到每个batch
# 拼接: [2, 197, 768]
x = torch.cat([cls_tokens, patch_tokens], dim=1)
位置编码呢?
Transformer本身没有顺序概念。你想想看,如果把patch的顺序打乱,模型根本不知道。所以我们需要给每个token加上位置信息。
ViT用的是可学习的位置编码。就是初始化一个197x768的矩阵,每个位置对应一个向量,跟token相加。这个矩阵会随着训练一起更新。
我个人建议:位置编码的初始化方式很重要。我试过用正弦余弦初始化,也试过全零初始化,最后发现用随机正态分布初始化效果最稳定。另外,位置编码的维度一定要跟embed_dim一致,否则加不了。
代码实现:
self.pos_embed = nn.Parameter(torch.randn(1, num_patches + 1, embed_dim))
# 前向时直接相加
x = x + self.pos_embed
注意:位置编码是加在输入上的,不是拼接。我见过有人搞混,把位置编码拼接到token后面,结果维度爆炸了。记住,是加法,不是concat。
4.3 ViT在BEV感知中的应用
好了,前面都是基础。现在聊聊ViT在自动驾驶BEV感知里怎么用。这才是我们课程的重点。
BEV感知,说白了就是把多视角的摄像头图像,统一转换到鸟瞰图坐标系下。传统方法用IPM(逆透视映射),但效果受限于地面假设。ViT的出现,给了我们一个新的思路。
ViT怎么用在BEV上?
目前主流的方法,比如BEVFormer,核心思路是这样的:
- 用CNN或ViT提取每个视角的图像特征
- 在BEV空间上初始化一组可学习的query(类似class token)
- 通过Cross-Attention,让BEV query去“看”每个视角的图像特征
- 通过Self-Attention,让BEV query之间互相交流
这里ViT扮演的角色,就是第一步的特征提取器。为什么用ViT而不是CNN?
| 对比项 | CNN | ViT |
|---|---|---|
| 感受野 | 局部,需要堆叠层数 | 全局,第一层就是全局 |
| 长距离依赖 | 弱,靠深层 | 强,自注意力机制 |
| 对遮挡的鲁棒性 | 一般 | 较好,能利用全局上下文 |
| 计算量 | 小 | 大,尤其高分辨率 |
我在项目中的实际体验:
曾经在一个多视角融合的项目里,我用ResNet50做backbone,结果在遮挡严重的场景下(比如大卡车挡住小轿车),模型经常漏检。换成ViT-Base之后,漏检率下降了约15%。为什么?因为ViT的全局注意力能“看到”被遮挡物体的边缘线索,而CNN只能看到局部。
但ViT也有缺点——计算量太大。6个视角,每个视角224x224,ViT-Base的FLOPs大约是CNN的3-4倍。所以现在工业界的主流做法是:用CNN做浅层特征提取,然后用ViT做深层全局建模。也就是混合架构。
避坑指南:我曾经在部署时发现,ViT在GPU上推理很快,但换到Jetson Orin上就慢得离谱。后来发现是注意力矩阵的计算没有优化。建议用Flash Attention或者稀疏注意力来加速,效果立竿见影。
一个简单的BEV感知流程示例:
# 伪代码,展示核心逻辑
class BEVPerception(nn.Module):
def __init__(self):
self.vit_backbone = ViT(img_size=224, patch_size=16)
self.bev_queries = nn.Parameter(torch.randn(1, 100, 256)) # 100个BEV query
self.cross_attn = CrossAttention(dim=256)
self.self_attn = SelfAttention(dim=256)
def forward(self, multi_view_images):
# multi_view_images: [B, 6, 3, 224, 224]
B, V, C, H, W = multi_view_images.shape
# 1. 用ViT提取每个视角的特征
features = []
for v in range(V):
feat = self.vit_backbone(multi_view_images[:, v]) # [B, 197, 768]
features.append(feat)
features = torch.stack(features, dim=1) # [B, 6, 197, 768]
# 2. BEV query与多视角特征做Cross-Attention
bev = self.bev_queries.expand(B, -1, -1) # [B, 100, 256]
bev = self.cross_attn(bev, features) # BEV query去“看”图像
# 3. BEV query之间做Self-Attention
bev = self.self_attn(bev) # BEV内部交流
return bev # 输出BEV特征,用于检测/分割
这个流程虽然简化了,但核心思想就是:用ViT提取全局图像特征,用可学习的BEV query作为“桥梁”,通过注意力机制完成视角到BEV的转换。
总结一下:ViT在BEV感知中的优势,说白了就是“看得更全”。CNN像是一个近视眼,只能看清局部;ViT像是戴了眼镜,一眼扫过去全局尽收眼底。但代价是计算量大、部署难。所以实际项目中,要根据算力资源做取舍。
嗯,这一章的内容就到这里。下一章我们会深入BEVFormer的具体实现,包括时序融合和可变形注意力。到时候我会拿一个实际的路测数据案例,带大家手撕代码。
记住,ViT不是万能的,但在BEV这个场景下,它确实比CNN更合适。你想想看,自动驾驶需要的是对周围360度环境的理解,全局视野才是王道。