4. 图像模态基础
图像模态,说白了就是多模态感知的「眼睛」。你想想看,不管是自动驾驶看路、医疗影像读片,还是咱们后面要做的图文匹配,第一步都得先把图像变成机器能理解的向量。这一章,我就带你把这套「看」的功夫练扎实。
4.1 卷积神经网络(CNN)的核心原理
CNN 不是黑魔法。它的本质就三件事:局部感知、参数共享、层次抽象。我当年刚入行时,总觉得卷积层越多越牛,结果在嵌入式设备上跑了个 50 层的模型,帧率直接掉到 0.5——嗯,那是第一次被工程现实教育。
4.1.1 卷积操作到底在干什么?
一个卷积核,就是一个滑动窗口。它在图像上从左到右、从上到下扫一遍,每次做一次点积运算。你可能会问:为什么非要用卷积?全连接层不行吗?
我举个例子。一张 224×224 的 RGB 图,如果用全连接层,第一个隐藏层 1000 个神经元,参数量就是 224×224×3×1000 ≈ 1.5 亿。而一个 3×3 的卷积核,参数量只有 27。差了 500 多万倍。这就是参数共享的威力——同一个核在整张图上复用。
4.1.2 池化层:降维但不丢关键信息
池化层的作用很直接:缩小特征图,保留最显著的特征。最大池化取区域最大值,平均池化取平均值。我个人习惯在浅层用最大池化,深层用平均池化——前者保留纹理边缘,后者保留整体分布。
4.2 ResNet:让网络真正「深」下去
2015 年之前,大家普遍认为网络越深效果越好。但实际训练时发现,56 层的网络比 20 层的训练误差还大——这叫「退化问题」。不是过拟合,是梯度在反向传播中消失了。
ResNet 的解决方案简单到令人惊讶:残差连接。让输入直接跳过两层卷积,加到输出上。这样一来,梯度可以沿着这条「高速公路」直接传回浅层。
# 一个典型的残差块(PyTorch风格)
class BasicBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, 1)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels, 3, 1, 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, 1, stride),
nn.BatchNorm2d(out_channels)
)
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out += self.shortcut(x) # 残差连接
out = F.relu(out)
return out
4.3 EfficientNet:在算力与精度之间找平衡
EfficientNet 的核心思想很朴素:网络深度、宽度、分辨率应该一起缩放,而不是只调一个维度。Google 用 NAS 搜出了一组缩放系数(α=1.2, β=1.1, γ=1.15),然后按这个比例从 B0 到 B7 逐步放大。
| 模型 | 参数量 | Top-1 精度 | 推理速度(V100, batch=1) |
|---|---|---|---|
| EfficientNet-B0 | 5.3M | 77.1% | 2.3ms |
| EfficientNet-B3 | 12.0M | 81.1% | 5.8ms |
| EfficientNet-B5 | 30.0M | 83.3% | 14.2ms |
| EfficientNet-B7 | 66.0M | 84.3% | 37.5ms |
你看,B0 到 B7 精度只差了 7 个点,但参数量涨了 12 倍,速度慢了 16 倍。所以实际工程中,我一般从 B3 或 B4 开始试——精度够用,速度也能接受。
4.4 图像特征提取实战
理论说完了,咱们直接上手。下面是一个完整的特征提取流程,我用的是 PyTorch 官方预训练的 ResNet-50。
4.4.1 去掉分类头,保留特征层
预训练模型的最后一层是 1000 类的全连接分类头。我们要把它砍掉,把倒数第二层的 2048 维向量作为图像特征。
import torch
import torchvision.models as models
from torchvision import transforms
from PIL import Image
# 加载预训练 ResNet-50,去掉最后一层
model = models.resnet50(pretrained=True)
model = torch.nn.Sequential(*list(model.children())[:-1]) # 去掉 fc 层
model.eval()
# 图像预处理
transform = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
])
# 提取特征
img = Image.open('cat.jpg').convert('RGB')
img_tensor = transform(img).unsqueeze(0) # [1, 3, 224, 224]
with torch.no_grad():
feature = model(img_tensor) # [1, 2048, 1, 1]
feature = feature.squeeze() # [2048]
print(feature.shape) # torch.Size([2048])
4.4.2 特征向量的后处理
提取出来的 2048 维向量,直接拿去算余弦相似度效果一般。我习惯做两步后处理:
- L2 归一化:让所有特征向量落在单位球面上,消除光照和对比度的影响。
- PCA 白化:如果特征维度太高(比如 2048),可以用 PCA 降到 256 或 128 维,同时去除冗余信息。
# L2 归一化
feature = feature / torch.norm(feature, p=2)
# 计算两张图片的相似度
def cosine_similarity(f1, f2):
return torch.dot(f1, f2).item()
# 假设有 feature_a 和 feature_b
sim = cosine_similarity(feature_a, feature_b)
print(f"相似度: {sim:.4f}")
4.5 本章知识结构图
下面这张图把 CNN 到特征提取的完整链路串起来了。你可以把它当作一个「视觉特征提取流水线」来理解。
4.6 工程落地要点
最后,我总结几条在实际项目中反复验证过的经验:
- 选型不要贪大:B0 在手机端能跑 30fps,B7 只有 2fps。先定延迟预算,再选模型。
- 预处理要统一:训练时用 224×224,推理时也必须是 224×224。我见过有人训练用 224,推理用 256,精度直接掉 5 个点。
- 特征维度不是越高越好:2048 维在检索场景下,100 万张图建索引要 8GB 内存。降到 256 维,精度只掉 1%,内存省了 8 倍。
- 别忘了做归一化:这是新手最容易忽略的一步。不归一化,余弦相似度就是废的。