图像字幕 Image Captioning:从编码器-解码器到多模态生成

FreeGuideOnline 最新 2026-06-19

图像字幕 Image Captioning:从编码器-解码器到多模态生成

图像字幕任务旨在为给定图像自动生成自然语言描述,弥合了计算机视觉与自然语言处理之间的鸿沟。本教程将从基础架构出发,逐步深入前沿方法,帮助你构建对图像字幕技术的系统认知。

理解图像字幕任务

图像字幕模型需要同时理解“图像中有什么”以及“如何用流畅的句子表达出来”。其核心挑战在于:

  • 语义理解:识别对象、场景、动作及它们之间的关系。
  • 语言生成:组织词汇形成语法正确、语义连贯的句子。
  • 模态对齐:将视觉特征映射到语言空间,保证跨模态一致性。

经典基线:编码器-解码器架构

早期的图像字幕模型源自机器翻译的序列到序列(Seq2Seq)范式,由两个核心组件组成:

编码器:将图像提炼为特征向量

编码器负责从输入图像中提取具有判别力的视觉表示。最常用的骨干网络是卷积神经网络(CNN)。

  • 预训练CNN:通常采用在ImageNet上预训练的VGG、Inception或ResNet。移除最后的全连接分类层,保留倒数第二层或全局平均池化层的输出作为图像特征向量。
  • 特征维度:例如ResNet-152可输出2048维的特征向量,该向量压缩了整张图像的语义信息。
# 使用PyTorch提取图像特征的简化示例
import torchvision.models as models
from torchvision import transforms
from PIL import Image

# 加载预训练ResNet-101,去除分类层
encoder = models.resnet101(pretrained=True)
modules = list(encoder.children())[:-1]   # 移除最后的fc层
encoder = nn.Sequential(*modules)
encoder.eval()

# 图像预处理
preprocess = 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])
])

image = preprocess(Image.open("example.jpg")).unsqueeze(0)
with torch.no_grad():
    features = encoder(image)  # 形状 (1, 2048, 1, 1)
    features = features.squeeze(-1).squeeze(-1)  # 变为 (1, 2048)

解码器:将视觉特征转化为描述文本

解码器通常是一个循环神经网络(RNN),负责从编码向量中生成词序列。

  • 输入转换:将CNN输出的特征向量通过线性层映射为与词嵌入相同维度的向量,作为RNN的第一个隐藏状态或第一个输入。
  • 词生成:在每个时间步,RNN根据当前输入和上一隐藏状态预测下一个词的概率分布,直到生成结束符<end>

典型的训练方式采用“教师强制”(Teacher Forcing):解码器的每一步输入不是上一时刻采样的词,而是来自正确标注序列中的词。

基础数学表达: 给定图像特征 ( \mathbf{v} ),生成句子 ( S = {w_1, w_2, \dots, w_T} ) 的最大似然估计为:

[ p(S|\mathbf{v}) = \prod_{t=1}^{T} p(w_t | w_{<t}, \mathbf{v}) ]

其中 ( w_{<t} ) 表示之前已生成的词。RNN隐藏状态更新:

[ \mathbf{h}t = \text{RNN}( \mathbf{h}{t-1}, \mathbf{E} w_{t-1} ) ] [ p(w_t | w_{<t}, \mathbf{v}) = \text{softmax}( \mathbf{W}_o \mathbf{h}_t + \mathbf{b}_o ) ]

这里 ( \mathbf{E} ) 是词嵌入矩阵,( \mathbf{W}_o, \mathbf{b}_o ) 是输出层参数。

注意力机制的引入:让模型“看得更准”

编码器-解码器框架的最大痛点:整张图像被压缩为一个固定长度的向量,导致细节丢失。注意力机制使解码器在生成每个词时能够动态聚焦于图像的不同区域。

软注意力与硬注意力

  • 软注意力(确定性):通过可微的注意力权重对图像特征图的空间位置加权求和,得到上下文向量,可与RNN联合训练。
  • 硬注意力(随机性):每次只选择一个位置,需要强化学习或变分推断训练,通常效果略好但训练不稳定。

本教程以软注意力为例:

注意力计算步骤

  1. 将CNN的卷积层特征作为空间特征图 ( \mathbf{A} = {\mathbf{a}_1, \dots, \mathbf{a}_L} ),其中 ( \mathbf{a}_i ) 是第 ( i ) 个图像区域的向量(例如 ( 7 \times 7 \times 2048 ) 展平为 ( 49 \times 2048 ))。
  2. 在解码器第 ( t ) 步,根据当前隐藏状态 ( \mathbf{h}t ) 和每个图像区域向量 ( \mathbf{a}i ) 计算注意力权重: [ e{ti} = f{\text{att}}(\mathbf{a}i, \mathbf{h}t) \quad (\text{多层感知机或点积}) ] [ \alpha{ti} = \frac{\exp(e{ti})}{\sum_{k=1}^L \exp(e_{tk})} ]
  3. 计算上下文向量: [ \mathbf{c}t = \sum{i=1}^L \alpha_{ti} \mathbf{a}_i ]
  4. 将 ( \mathbf{c}_t ) 和 ( \mathbf{h}_t ) 拼接后送入输出层,预测下一个词。

实践改进:许多实现将注意力机制扩展为“双线性注意力”或“多头注意力”,并添加了 LSTM 的门控机制来更好地融合上下文向量。

展示可解释性

注意力权重天然提供了对生成词的视觉解释——通过可视化 ( \alpha_{ti} ) 可以看到模型在说“女孩”时关注人脸区域,说“风筝”时关注天空中的物体。

从 LSTM 到 Transformer:多模态生成的新范式

随着 Transformer 在 NLP 领域大获成功,图像字幕领域也迎来了彻底革新。模型不再依赖循环结构,而是通过自注意力和交叉注意力处理视觉与文本信息。

完全基于 Transformer 的字幕模型

经典的架构如 Image TransformerM2 Transformer 等,通常包含:

  • 视觉编码器:使用CNN或Vision Transformer (ViT) 提取图像特征,生成图像区域(或 patch)的序列表示。
  • 文本解码器:标准的Transformer解码器,每一层由两个注意力子层组成:
    • 掩码自注意力层:确保当前词的预测只依赖于之前生成的词。
    • 交叉注意力层:将视觉特征序列作为 Key 和 Value,文本隐藏状态作为 Query,实现视觉与语言的融合。

模型示例结构

Visual Feature: [img1, img2, ..., imgL] (L 个特征向量,每个维度 d)
Text Input: [<start>, w1, w2, ..., wt]  → Word Embedding + Positional Encoding

Decoder Layer:
   Masked Self-Attention:  Q = K = V = text embeddings
   Cross-Attention:        Q = text embeddings, K = V = visual features
   Feed-Forward Network

这种设计完全并行化训练,并能捕捉长距离依赖,生成更准确、更丰富的描述。

CLIP 与大规模预训练模型

近年来,大规模视觉-语言预训练模型如 CLIP、BLIP、GIT、Flamingo 等将图像字幕推向新高度。

  • CLIP:通过对比学习将图像和文本投射到共享的嵌入空间,可作为特征提取器或零样本分类器,但本身不能直接生成字幕。
  • BLIP / BLIP-2:结合了视觉编码器、文本解码器和“过滤-字幕生成”流程。BLIP-2 使用轻量级的 Q-Former 模块对齐视觉特征和语言大模型,实现强大的少样本字幕生成。
  • GIT (Generative Image-to-text Transformer):将图像 patcher 与文本解码器结合,使用大规模图文对进行预训练,架构简洁却取得当时最优性能。

这些模型通常采用因果语言建模目标(给定图像和前缀文本,预测下一个词),训练数据动辄数亿图文对,因此泛化能力极强。

数据准备与评估标准

主流数据集

数据集 图像数量 每图字幕数 特点
MS COCO Captions ~123K 5 句 场景丰富,包含多样物体和动作
Flickr8k 8K 5 句 侧重于人类活动和动物
Flickr30k 31K 5 句 COCO 的扩展加强版
Conceptual Captions 330万 1 句 从网络自动收集,噪声较大,用于大规模预训练

评估指标

字幕生成质量通过自动指标衡量,常用指标包括:

  • BLEU (Bilingual Evaluation Understudy):计算机器生成句子与参考句子的 n-gram 重叠率。简单但忽略语义。
  • METEOR:考虑同义词、词形变换和句子流畅性,比 BLEU 更贴近人工判断。
  • CIDEr (Consensus-based Image Description Evaluation):专门为图像字幕设计,通过 TF-IDF 加权 n-gram 计算生成句子与参考群体的一致性,重视内容准确性。
  • SPICE (Semantic Propositional Image Caption Evaluation):将句子解析为场景图,评估对象、属性和关系的 F-score,更能反映语义精确度。

实践中,常同时报告 SPICE 和 CIDEr,它们与人评相关性更好。

从零实践:搭建一个基于注意力机制的图像字幕模型

我们使用 PyTorch 构建一个可运行的示例,包含编码器(ResNet)、注意力 LSTM 解码器和训练循环。

环境配置与数据加载

首先安装必要的库,并准备 COCO 风格的数据集,假设标注已处理为 PyTorch Dataset 形式。

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
import torchvision.models as models
from torchvision import transforms
from pycocotools.coco import COCO
from vocab import Vocabulary  # 自定义词表类
import numpy as np
import os

定义编码器

使用预训练的 ResNet-101,并固定其权重(微调时可视计算资源解冻后几层)。

class EncoderCNN(nn.Module):
    def __init__(self, embed_size):
        super(EncoderCNN, self).__init__()
        resnet = models.resnet101(pretrained=True)
        modules = list(resnet.children())[:-2]  # 保留到自适应平均池化前的层
        self.resnet = nn.Sequential(*modules)
        for param in self.resnet.parameters():
            param.requires_grad = False  # 固定权重
        # 将2048维特征投影到embed_size,以便与LSTM隐藏状态对齐
        self.proj = nn.Linear(resnet.fc.in_features, embed_size)
        self.bn = nn.BatchNorm1d(embed_size)

    def forward(self, images):
        # images: (batch, 3, 224, 224)
        features = self.resnet(images)  # (batch, 2048, 7, 7)
        batch, C, H, W = features.size()
        features = features.permute(0, 2, 3, 1).contiguous()  # (batch, H, W, C)
        features = features.view(batch, -1, C)  # 展平空间维度 -> (batch, 49, 2048)
        # 投影并正则化
        features = self.proj(features)           # (batch, 49, embed_size)
        features = self.bn(features.permute(0,2,1)).permute(0,2,1)
        return features

定义注意力机制

class BahdanauAttention(nn.Module):
    def __init__(self, encoder_dim, decoder_dim, attention_dim):
        super(BahdanauAttention, self).__init__()
        self.encoder_att = nn.Linear(encoder_dim, attention_dim)
        self.decoder_att = nn.Linear(decoder_dim, attention_dim)
        self.full_att = nn.Linear(attention_dim, 1)
        self.relu = nn.ReLU()
        self.softmax = nn.Softmax(dim=1)

    def forward(self, encoder_out, decoder_hidden):
        # encoder_out: (batch, num_pixels, encoder_dim)
        # decoder_hidden: (batch, decoder_dim)
        att1 = self.encoder_att(encoder_out)          # (batch, num_pixels, att_dim)
        att2 = self.decoder_att(decoder_hidden)       # (batch, att_dim)
        att = self.full_att(self.relu(att1 + att2.unsqueeze(1))).squeeze(2) # (batch, num_pixels)
        alpha = self.softmax(att)                     # (batch, num_pixels)
        context = (encoder_out * alpha.unsqueeze(2)).sum(dim=1) # (batch, encoder_dim)
        return context, alpha

定义解码器

class DecoderLSTMWithAttention(nn.Module):
    def __init__(self, embed_size, hidden_size, vocab_size, attention_dim, encoder_dim=2048, dropout=0.5):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.attention = BahdanauAttention(encoder_dim, hidden_size, attention_dim)
        self.lstm_cell = nn.LSTMCell(embed_size + encoder_dim, hidden_size, bias=True)
        self.fc = nn.Linear(hidden_size, vocab_size)
        self.dropout = nn.Dropout(dropout)
        self.init_h = nn.Linear(encoder_dim, hidden_size)
        self.init_c = nn.Linear(encoder_dim, hidden_size)

    def forward(self, encoder_out, captions, lengths):
        # encoder_out: (batch, num_pixels, encoder_dim)
        # captions: (batch, max_len)
        batch, max_len = captions.shape
        vocab_size = self.fc.out_features
        # 初始化第一个隐藏状态和细胞状态
        mean_enc = encoder_out.mean(dim=1)   # (batch, encoder_dim)
        h = self.init_h(mean_enc)
        c = self.init_c(mean_enc)
        
        predictions = torch.zeros(batch, max_len-1, vocab_size).to(encoder_out.device)
        for t in range(max_len - 1):
            word_emb = self.embedding(captions[:, t])   # (batch, embed_size)
            context, alpha = self.attention(encoder_out, h)  # context (batch, encoder_dim)
            lstm_input = torch.cat([word_emb, context], dim=1)
            h, c = self.lstm_cell(lstm_input, (h, c))
            output = self.dropout(h)
            preds = self.fc(output)
            predictions[:, t, :] = preds
        return predictions

    def sample(self, encoder_out, start_token, end_token, max_len=20):
        # 用于测试时逐个词生成(贪心解码)
        batch = encoder_out.size(0)
        sampled_ids = []
        mean_enc = encoder_out.mean(dim=1)
        h = self.init_h(mean_enc)
        c = self.init_c(mean_enc)
        # 输入为 <start> token
        current_input = torch.full((batch,), start_token, dtype=torch.long).to(encoder_out.device)
        
        for _ in range(max_len):
            word_emb = self.embedding(current_input)
            context, alpha = self.attention(encoder_out, h)
            lstm_input = torch.cat([word_emb, context], dim=1)
            h, c = self.lstm_cell(lstm_input, (h, c))
            output = self.fc(h)
            predicted = output.argmax(1)
            sampled_ids.append(predicted)
            current_input = predicted
        sampled_ids = torch.stack(sampled_ids, 1)  # (batch, max_len)
        return sampled_ids

训练循环框架

def train_one_epoch(encoder, decoder, dataloader, criterion, optimizer, vocab, device):
    encoder.train()
    decoder.train()
    total_loss = 0
    for imgs, caps, lengths in dataloader:
        imgs = imgs.to(device)
        caps = caps.to(device)   # (batch, max_len) 包含 <start> 和 <end>
        # 前向传播
        encoder_out = encoder(imgs)               # (batch, num_pixels, encoder_dim)
        predictions = decoder(encoder_out, caps, lengths)  # (batch, max_len-1, vocab)
        # 损失计算:target 为 caps 去掉 <start>
        targets = caps[:, 1:]   # (batch, max_len-1)
        # 将预测和 targets 展平,忽略填充位置
        loss = criterion(predictions.reshape(-1, vocab_size), targets.reshape(-1))
        
        optimizer.zero_grad()
        loss.backward()
        # 梯度裁剪
        nn.utils.clip_grad_norm_(decoder.parameters(), max_norm=5.0)
        optimizer.step()
        total_loss += loss.item()
    return total_loss / len(dataloader)

训练时可逐步解冻编码器最后若干层,并使用自适应学习率。

前沿趋势与未来方向

  • 区域化与细粒度字幕:除了整图描述,还可生成物体级别的密集字幕(Dense Captioning),结合目标检测定位区域并分别生成描述。
  • 可控字幕:通过风格控制码、情感标签、长度指定等手段,生成符合特定风格的描述。
  • 多模态大语言模型 (MLLM):如 LLaVA、GPT-4V、Gemini 等,可将图像直接作为序列令牌输入通用大语言模型,实现更强大的推理和对话式字幕生成。
  • 评估困境与真实理解:自动指标仍不够完美,更依赖人类评估或CLIPScore等学习型度量,模型是否具备真实理解能力仍是开放问题。

小结

图像字幕经历了从 CNN+LSTM 到全 Transformer 架构的演进,注意力机制和跨模态对齐始终是核心思想。通过掌握经典模型的实现细节与最新预训练方法,你既可以亲手搭建一个可解释的字幕系统,也能快速调用大模型实现高质量生成。建议先从 MS COCO 数据集上的基础实验开始,逐步向大规模预训练模型探索。

延伸阅读:参考论文 “Show, Attend and Tell”、“Bottom-Up and Top-Down Attention”、“BLIP: Bootstrapping Language-Image Pre-training” 以及官方实现仓库。