视觉编码器基础:从CNN到CLIP的演进之路

视觉编码器,说白了就是给模型装上一双「眼睛」。在VLA模型里,这双眼睛的质量直接决定了后续所有任务的上限。我做了这么多年机器人视觉,踩过最大的坑就是——视觉特征没提好,后面再怎么调语言模型和动作策略都是白搭。

今天咱们就把这双「眼睛」的构造彻底拆开看看。从经典的CNN,到革命性的ViT,再到多模态预训练的CLIP,一条线捋下来。

卷积神经网络(CNN)回顾

CNN这东西,2012年AlexNet一战成名,之后就成了计算机视觉的标配。它的核心思想其实很简单:用局部连接+权值共享来提取空间特征。

我刚开始做视觉的时候,总觉得CNN就是个「滑动窗口+模板匹配」的高级版。后来做多了才发现,这个理解虽然粗糙,但方向是对的。

卷积层在干什么?

想象一下,你拿一个小窗口在图像上滑动,每个位置都做一次点积运算。这个小窗口就是卷积核,它学习的是某种局部模式——边缘、纹理、颜色块等等。

# 一个简单的2D卷积示例
import torch
import torch.nn as nn

# 输入:1个样本,1个通道,28x28的图像
x = torch.randn(1, 1, 28, 28)

# 卷积层:输入通道1,输出通道32,卷积核3x3
conv = nn.Conv2d(in_channels=1, out_channels=32, 
                 kernel_size=3, stride=1, padding=1)

out = conv(x)
print(out.shape)  # torch.Size([1, 32, 28, 28])

注意看,输出通道变成了32。这意味着我们用了32个不同的卷积核,每个核提取一种特征。浅层网络提取的是边缘、角点,深层网络组合出更抽象的概念——比如「眼睛」、「轮子」。

关键理解:CNN通过堆叠卷积层,构建了一个从低级到高级的特征金字塔。越往后,特征图越小,语义信息越丰富。

池化层的作用

池化层就是降采样。最大池化取局部最大值,平均池化取均值。为什么要降采样?两个原因:一是减少计算量,二是引入平移不变性。

我记得有个项目里,摄像头稍微抖了一下,图像平移了几个像素,结果分类结果就变了。后来加了池化层,这个问题缓解了很多。当然,完全不变性是不可能的,但池化确实给了模型一些「容错空间」。

全连接层与分类头

CNN的末端通常接几个全连接层,把空间特征「拍平」成向量,然后映射到类别空间。这个过程其实是在做「特征到语义的映射」。

注意:全连接层的参数量非常大。比如一个7x7x512的特征图,拍平后是25088维,再接一个4096维的全连接层,光这一层就有1亿多个参数。这也是为什么后来大家倾向于用全局平均池化替代全连接层。

ResNet:残差学习的革命

2015年,ResNet横空出世。它解决了一个困扰学界多年的问题:网络越深,效果反而越差。

为什么会这样?不是过拟合,而是梯度消失/爆炸。深层网络的反向传播,梯度连乘之后要么趋近于0,要么爆炸。ResNet的解决方案简单粗暴——加一条「捷径」。

残差块的核心思想

传统网络学习的是映射 H(x),残差网络学习的是 F(x) = H(x) - x,然后输出 F(x) + x。这个「+x」就是跳跃连接。

class ResidualBlock(nn.Module):
    def __init__(self, in_channels, out_channels, stride=1):
        super().__init__()
        self.conv1 = nn.Conv2d(in_channels, out_channels, 
                               kernel_size=3, stride=stride, padding=1)
        self.bn1 = nn.BatchNorm2d(out_channels)
        self.conv2 = nn.Conv2d(out_channels, out_channels, 
                               kernel_size=3, stride=1, padding=1)
        self.bn2 = nn.BatchNorm2d(out_channels)
        
        # 如果输入输出维度不匹配,用1x1卷积调整
        self.shortcut = nn.Sequential()
        if stride != 1 or in_channels != out_channels:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels, 
                          kernel_size=1, stride=stride),
                nn.BatchNorm2d(out_channels)
            )
    
    def forward(self, x):
        out = torch.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += self.shortcut(x)  # 关键:跳跃连接
        out = torch.relu(out)
        return out

这个设计妙在哪里?梯度可以通过跳跃连接直接回传到浅层,避免了连乘导致的消失问题。我实测过,50层的ResNet训练起来比20层的普通CNN还稳定。

个人经验:在实际项目中,ResNet-50是个很好的起点。它平衡了性能和计算量。如果资源允许,ResNet-101效果更好,但提升幅度边际递减。

Vision Transformer(ViT):当Transformer遇上图像

2020年,ViT论文出来的时候,我第一反应是「这也能行?」把图像切成patch,当成序列送给Transformer,居然在ImageNet上干翻了最好的CNN。

ViT的核心流程:

  1. 把图像分成固定大小的patch(比如16x16)
  2. 每个patch展平后线性投影成向量
  3. 加上位置编码,保持空间信息
  4. 送入标准的Transformer Encoder
  5. 用[CLS] token的输出做分类

为什么ViT有效?

CNN的归纳偏置是「局部性」和「平移不变性」,这限制了它的感受野。ViT没有这些先验,它通过自注意力机制,一开始就能看到全局关系。

你想想看,一张图里,远处的狗和近处的球,CNN要堆很多层才能建立联系,ViT在第一层就能做到。这就是全局建模能力的优势。

class PatchEmbedding(nn.Module):
    """将图像切成patch并投影"""
    def __init__(self, img_size=224, patch_size=16, in_channels=3, embed_dim=768):
        super().__init__()
        self.num_patches = (img_size // patch_size) ** 2
        # 用卷积实现patch切分+投影
        self.proj = nn.Conv2d(in_channels, embed_dim, 
                              kernel_size=patch_size, stride=patch_size)
    
    def forward(self, x):
        # x: [B, 3, 224, 224]
        x = self.proj(x)  # [B, 768, 14, 14]
        x = x.flatten(2)  # [B, 768, 196]
        x = x.transpose(1, 2)  # [B, 196, 768]
        return x

注意:ViT需要大量数据预训练才能发挥威力。如果数据量不够(比如少于100万张),效果反而不如ResNet。这也是为什么后来出现了DeiT(数据高效ViT)等改进版本。

CLIP视觉编码器:多模态预训练的巅峰

CLIP是OpenAI 2021年的工作,它用4亿对图文数据做对比学习。视觉编码器用的是ViT(也有ResNet版本),但训练方式完全不同。

CLIP的训练目标很简单:让匹配的图文对距离近,不匹配的距离远。这个「距离」是余弦相似度。

CLIP视觉编码器的独特之处

  • 对比学习范式:不是预测类别,而是学习图文对齐
  • 零样本能力:训练时没见过的类别,也能通过文本描述识别
  • 通用特征:学到的视觉特征天然具有语义对齐能力

我在一个机器人抓取项目里用过CLIP。传统方法需要标注几千张图片才能识别物体,CLIP零样本就能做到。虽然精度不如微调后的专用模型,但胜在泛化能力强。

CLIP的视觉编码器结构

以ViT-B/32为例:

组件配置
输入尺寸224x224
Patch大小32x32
Transformer层数12
隐藏维度768
注意力头数12
输出特征维度512(经过投影头)

注意最后一行:CLIP在Transformer输出后加了一个投影头,把768维映射到512维。这个512维空间就是「多模态对齐空间」,文本和图像都在这个空间里做对比。

核心理解:CLIP的视觉编码器本身就是一个标准的ViT,但它的权重是通过图文对比学习训练出来的。这意味着它「看」图像的方式,天然带有语义理解能力。

图像特征提取实战

说了这么多理论,咱们动手试试。下面是用预训练CLIP提取图像特征的完整流程。

import torch
import clip
from PIL import Image

# 加载模型和预处理
device = "cuda" if torch.cuda.is_available() else "cpu"
model, preprocess = clip.load("ViT-B/32", device=device)

# 加载并预处理图像
image = Image.open("robot_arm.jpg")
image_input = preprocess(image).unsqueeze(0).to(device)

# 提取视觉特征(不计算梯度)
with torch.no_grad():
    image_features = model.encode_image(image_input)
    # 归一化到单位长度
    image_features = image_features / image_features.norm(dim=-1, keepdim=True)

print(f"特征维度: {image_features.shape}")  # [1, 512]
print(f"特征范数: {image_features.norm().item():.4f}")  # 应该是1.0

这段代码做了三件事:加载模型、预处理图像、提取归一化特征。得到的512维向量就是CLIP对这张图像的「理解」。

实战建议:提取特征时一定要做归一化。我在一个检索项目里忘了归一化,结果余弦相似度算出来全是错的,排查了半天才发现问题。

特征可视化与验证

提取特征后,怎么知道它好不好?一个简单的方法:计算不同图像之间的相似度。

# 假设我们有三张图:机械臂、螺丝刀、风景照
images = ["robot_arm.jpg", "screwdriver.jpg", "landscape.jpg"]
features = []

for img_path in images:
    img = Image.open(img_path)
    img_input = preprocess(img).unsqueeze(0).to(device)
    with torch.no_grad():
        feat = model.encode_image(img_input)
        feat = feat / feat.norm(dim=-1, keepdim=True)
    features.append(feat)

# 计算相似度矩阵
sim_matrix = torch.cat(features, dim=0) @ torch.cat(features, dim=0).T
print(sim_matrix)
# 理想情况:机械臂与螺丝刀相似度高,与风景照相似度低

如果相似度矩阵符合预期,说明特征提取没问题。如果机械臂和风景照的相似度比和螺丝刀还高,那就要检查预处理或者模型加载是否正确了。

我曾经踩过的坑:CLIP的预处理非常严格。图像必须resize到224x224,然后做中心裁剪,最后用ImageNet的均值和标准差做归一化。少一步,特征质量都会下降。

本章小结

视觉编码器从CNN到ViT再到CLIP,本质上是「局部建模→全局建模→语义对齐」的演进。在VLA模型里,我建议优先使用CLIP的视觉编码器,因为它天然具备多模态对齐能力,能省去很多后续训练的工作。

当然,具体选哪个编码器,还要看你的任务场景。如果数据量小、计算资源有限,ResNet-50依然是个稳妥的选择。如果追求极致性能且数据充足,ViT-L/14配合CLIP预训练权重,效果会更好。

下一章咱们会讲文本编码器,到时候CLIP的文本分支也会派上用场。嗯,今天就到这里,代码都在上面了,建议你跑一遍试试。


公众号:蓝海资料掘金营,微信deep3321