从“哑巴图片”到“自动写文案”:手把手带你撸一个图片标题生成器

0 阅读15分钟

一张蓝色汽车图片,AI 看了居然能说出“一辆蓝色汽车停在路边”——这是怎么做到的?
今天我们不扯高深的数学公式,用大白话 + 可运行的代码,把图片自动写标题的“黑科技”彻底讲透。

完整代码下载(含模型):pan.baidu.com/s/13aHMg6MH…


一、电商老板的烦恼:1000张商品图,谁帮它们写文案?

想象一下:你开了一家淘宝店,上了 1000 件衣服。每件衣服都要写商品描述——“黑色立领风衣”、“紫色带腰带阔腿裤”……写到第 100 件时,你的双手已经开始发抖。

这时候你就想:要是有一台机器,看一眼图片,就能自动写出像“一件好看的立领的很特别的紫色风衣”这样的描述,那该多爽?

这个梦想,就叫“图片标题生成”(Image Captioning)。
今天我就带你从零到一,实现一个能“看图说话”的模型。关键是:不堆砌公式,不故弄玄虚,代码拿来就能跑。


二、【核心思想】让 CLIP 看懂图,让 GPT‑2 写出话

我们要做的是:输入一张图片 → 输出一句通顺的中文描述

一个朴素的想法是:

  • 先找个很牛的“图像理解器”,让它把图片变成一串数字(嵌入向量),这个向量要能代表图片的内容。

  • 再找个很牛的“写文章高手”,让它根据这串数字,一个字一个字地写出句子来。

2.1 图像理解器:CLIP(像一只“视觉翻译官”)

CLIP 是 OpenAI 提出的模型,它能把图片和文字映射到同一个“语义空间”。
比如:一张蓝色汽车的图片,和一句“一辆蓝色汽车”的文字,在 CLIP 眼里是“相似”的。

我们只用到 CLIP 的图像编码器,它能把任意图片变成一个 512 维的向量。这个向量就像图片的“身份指纹”。

2.2 写文章高手:GPT‑2(像一只“接话茬机”)

GPT‑2 是著名的大语言模型,它特别擅长“根据上文预测下一个字”。
你给它“两只狗在”,它就猜下一个字可能是“雪地里”。
但我们不能直接把 CLIP 的 512 维向量喂给 GPT‑2,因为 GPT‑2 只认识它自己的词向量空间(768 维)
这就好比一个英国人(GPT‑2)只听得懂英语,你非要跟他说中文(CLIP 向量),他一脸懵。

2.3 解决方案:一个“翻译官” MLP

我们加一个 映射网络(一个简单的多层感知机 MLP),把 512 维的 CLIP 向量,翻译成 GPT‑2 能听懂的 768 维向量(并且一次生成 10 个“前缀 token”)。
这些前缀就像给 GPT‑2 的“提词器”:你可以理解为先对它耳语“这是一辆蓝色汽车”,然后让它继续接话。

一句话总结

CLIP 看图提取特征 → MLP 把特征“翻译”成 GPT‑2 懂的语言 → GPT‑2 接着写出完整句子。


三、【准备食材】我们需要什么工具?

写代码前,先把“食材”备齐。我们会用到:

工具

作用

在哪里下载/获取

Chinese‑CLIP

中文版的 CLIP,能理解中文图片和文字

Hugging Face 上的

chinese-clip-vit-base-patch16

GPT‑2 Chinese

中文版的小型 GPT‑2,用来生成描述

Hugging Face 上的

gpt2-chinese-cluecorpussmall

PyTorch

深度学习框架

pip install torch

Transformers

Hugging Face 的模型库

pip install transformers

注意:这两个中文模型都不大,普通电脑(8GB 显存)就能跑。我也会在代码里给出完整路径配置。


四、【第一步】配置环境:把所有参数写在一个文件里

我们创建一个 config.py,把模型路径、维度等参数放进一个地方,以后不用到处改。

# config.py
import torch
 
# 中文 CLIP 模型路径(如果你已经下载到本地,换成你的路径)
CLIP_MODEL_PATH = "./chinese-clip-vit-base-patch16"
 
# 一张图片的嵌入会变成几个“伪 token”?这里用 10 个
IMAGE_TOKEN_LENGTH = 10
 
# 生成的标题最大长度(包含图片的 10 个伪 token)
MAX_LENGTH = 100
 
# 中文 GPT‑2 模型路径
LLM_PATH = "./gpt2-chinese-cluecorpussmall"
 
# GPT‑2 的词向量维度
LLM_WORD_EMBD_DIM = 768
 
# CLIP 输出的图像嵌入维度
IMAGE_EMBD_DIM = 512
 
# 计算设备(如果有 GPU 就写 "cuda",否则 "cpu")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

通俗解释

  • IMAGE_TOKEN_LENGTH = 10 意思是:我们把一张图片变成 10 个“假单词”,每个假单词的维度是 768,这样 GPT‑2 就可以把它们当成句子开头。

  • MAX_LENGTH 限制总长度不超过 100 个 token,防止 GPT‑2 没完没了说下去。


五、【第二步】准备训练数据:把图片和描述一一配对

要训练模型,你得有一些 图片‑描述 对。
例如:1.jpg 对应描述 “两只狗在雪地里嬉闹”,2.jpg 对应描述 “一件好看的立领的紫色的风衣”。

我们写一个 process_data.py,用 CLIP 把每张图片变成 512 维向量,然后和对应的描述文字一起保存下来(这里用 pickle 文件,你也可以用向量数据库如 Chroma)。

# process_data.py
from PIL import Image
import pickle
from transformers import ChineseCLIPProcessor, ChineseCLIPModel
from config import CLIP_MODEL_PATH
 
def main():
    # 加载 CLIP 模型和处理器
    clip_model = ChineseCLIPModel.from_pretrained(CLIP_MODEL_PATH)
    processor = ChineseCLIPProcessor.from_pretrained(CLIP_MODEL_PATH)
 
    # 假设我们有两张图片
    img1 = Image.open("1.jpg")
    img2 = Image.open("2.jpg")
 
    # CLIP 要求特定的预处理(resize, normalize 等),processor 会自动做
    inputs_1 = processor(images=img1, return_tensors="pt")
    inputs_2 = processor(images=img2, return_tensors="pt")
 
    # 提取图像特征,形状 [1, 512]
    image_1_features = clip_model.get_image_features(**inputs_1)
    image_2_features = clip_model.get_image_features(**inputs_2)
 
    # 归一化(让向量长度变为 1),有助于稳定训练
    image_1_features = image_1_features / image_1_features.norm(p=2, dim=-1, keepdim=True)
    image_2_features = image_2_features / image_2_features.norm(p=2, dim=-1, keepdim=True)
 
    # 保存到字典:图片id -> 嵌入向量
    image_id2embed = {
        1: image_1_features,
        2: image_2_features,
    }
 
    # 图片id 和 对应描述
    caption_list = [
        (1, "两只狗在雪地里嬉闹"),
        (2, "一件好看的立领的很特别的紫色风衣"),
    ]
 
    # 存成 pkl 文件,供训练时读取
    with open("caption_image.pkl", "wb") as f:
        pickle.dump([caption_list, image_id2embed], f)
 
    print(f"处理完成:{len(image_id2embed)} 张图片,{len(caption_list)} 条描述。")
 
if __name__ == "__main__":
    main()

大白话解释

  • CLIP 的 processor 会自动把图片剪裁成 224x224、转成张量,省去我们手动写预处理。

  • get_image_features 返回的就是图片的 512 维“指纹”。

  • 我们把指纹和对应的文字描述打包存起来,训练的时候直接用。


六、【第三步】搭建映射网络(MLP):一个非常简单的翻译器

我们要让 MLP 完成:512 维 → 7680 维(因为 10 个 token × 768 维 = 7680 维)。
然后在代码里再拆成 [10, 768] 的形状,喂给 GPT‑2。

下面 model.py 定义了 MLP 和 整个 ClipCaptionModel

# model.py
import torch
import torch.nn as nn
from transformers import GPT2LMHeadModel
from config import LLM_PATH, IMAGE_TOKEN_LENGTH, IMAGE_EMBD_DIM, LLM_WORD_EMBD_DIM
 
class MLP(nn.Module):
    """一个简单的两层神经网络,把 CLIP 向量映射到 GPT‑2 需要的形状"""
    def __init__(self):
        super().__init__()
        # 输入维度:512
        # 中间层:我们设为 (768 * 10) // 2 = 3840
        # 输出维度:768 * 10 = 7680
        hidden_dim = (LLM_WORD_EMBD_DIM * IMAGE_TOKEN_LENGTH) // 2
        self.l1 = nn.Linear(IMAGE_EMBD_DIM, hidden_dim)
        self.act = nn.Tanh()          # 激活函数,增加非线性
        self.l2 = nn.Linear(hidden_dim, LLM_WORD_EMBD_DIM * IMAGE_TOKEN_LENGTH)
 
    def forward(self, x):
        # x shape: [batch_size, 512]
        x = self.l1(x)
        x = self.act(x)
        x = self.l2(x)                # shape: [batch_size, 7680]
        return x
 
class ClipCaptionModel(nn.Module):
    def __init__(self):
        super().__init__()
        # 加载预训练的中文 GPT‑2
        self.gpt2 = GPT2LMHeadModel.from_pretrained(LLM_PATH)
        # 映射网络
        self.projection = MLP()
 
    def forward(self, image_embeds, caption_ids, mask):
        """
        image_embeds: [batch_size, 512]     CLIP 图像向量
        caption_ids : [batch_size, seq_len]  描述文字的 token id
        mask        : [batch_size, seq_len]  注意力掩码(1表示真实token,0表示填充)
        """
        # 1. 将 512 维映射成 7680 维
        proj_out = self.projection(image_embeds)          # [B, 7680]
        # 2. 拆成 10 个 token,每个维度 768
        image_prefix = proj_out.view(-1, IMAGE_TOKEN_LENGTH, LLM_WORD_EMBD_DIM)  # [B, 10, 768]
 
        # 3. 获取描述文字的 token 嵌入
        caption_embeds = self.gpt2.transformer.wte(caption_ids)  # [B, text_len, 768]
 
        # 4. 将图像前缀和文字嵌入拼在一起:先图像,后文字
        inputs_embeds = torch.cat((image_prefix, caption_embeds), dim=1)  # [B, 10+text_len, 768]
 
        # 5. 喂给 GPT‑2,注意这里用 inputs_embeds 而不是 input_ids
        outputs = self.gpt2(inputs_embeds=inputs_embeds, attention_mask=mask)
        return outputs.logits   # [B, 10+text_len, vocab_size]

代码注释解读

  • MLP 的作用就是一个维度变换器,中间加一个隐藏层让它有更强的表达能力。

  • 训练时我们同时输入图片向量和对应的文字(+ 掩码),让 GPT‑2 去学习给定图像前缀,下一个应该生成什么字

  • 损失函数只计算文字部分,不计算图像前缀的预测(因为前缀是给定的,不用预测)。


七、【第四步】训练数据加载器:把描述变成 token,并补全长度

我们写 clipcap_dataset.py,读取之前保存的 caption_image.pkl,将每条描述变成 token ids,并在末尾加上分隔符 sep_token_id。
同时因为图像占了 10 个位置,所以描述 token 的长度不能超过 MAX_LENGTH - 10,不够就补 pad_token_id。

# clipcap_dataset.py
import torch
from torch.utils.data import Dataset
import pickle
from config import IMAGE_TOKEN_LENGTH, MAX_LENGTH
 
class ClipCapDataset(Dataset):
    def __init__(self, tokenizer):
        # 读取之前保存的图片‑描述对
        with open("caption_image.pkl", "rb") as f:
            caption_list, image_id2embed = pickle.load(f)
 
        self.image_embeds = []
        self.caption_ids = []
        self.masks = []
 
        pad_id = tokenizer.pad_token_id
        sep_id = tokenizer.sep_token_id
 
        for image_id, caption in caption_list:
            # 获取图片的 512 维向量
            img_emb = image_id2embed[image_id].squeeze(0)  # 去掉 batch 维度
            # 对描述文本进行 tokenize(不加特殊 token)
            tokens = tokenizer.encode(caption, add_special_tokens=False)
            # 加上结束分隔符
            tokens.append(sep_id)
            # 截断:最多保留 MAX_LENGTH - IMAGE_TOKEN_LENGTH 个 token
            tokens = tokens[:MAX_LENGTH - IMAGE_TOKEN_LENGTH]
 
            # 生成 mask:图像前缀的 10 个位置 + 文本 token 位置全是 1
            mask = [1] * (IMAGE_TOKEN_LENGTH + len(tokens))
            # 计算需要补多少个 pad token
            pad_len = MAX_LENGTH - IMAGE_TOKEN_LENGTH - len(tokens)
            tokens += [pad_id] * pad_len
            mask += [0] * pad_len
 
            self.image_embeds.append(img_emb)
            self.caption_ids.append(torch.tensor(tokens, dtype=torch.long))
            self.masks.append(torch.tensor(mask, dtype=torch.long))
 
    def __len__(self):
        return len(self.caption_ids)
 
    def __getitem__(self, idx):
        return self.image_embeds[idx], self.caption_ids[idx], self.masks[idx]

关键点

  • Mask 用来告诉 GPT‑2 哪些位置是真实 token(1),哪些是填充(0)。图像前缀的 10 个位置始终是 1。

  • 在训练时,我们计算损失会跳过填充位置(交叉熵会自动忽略 ignore_index,但我们这里手动处理了 shift)。


八、【第五步】训练循环:让模型学会“看图说话”

训练的核心思路:
我们给模型“图像前缀 + 正确的描述(移位一个 token)”,让模型去预测下一个 token。

比如给定前缀 + “两只”,模型要预测“狗”;给定前缀 + “两只狗”,预测“在” …… 这就是标准的语言模型训练。

# train.py
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader
from transformers import BertTokenizer
from tqdm import tqdm
 
from config import device, LLM_PATH, IMAGE_TOKEN_LENGTH
from model import ClipCaptionModel
from clipcap_dataset import ClipCapDataset
 
def train(model, dataloader, optimizer, epochs=20):
    model.train()
    for epoch in range(epochs):
        total_loss = 0
        loop = tqdm(dataloader, desc=f"Epoch {epoch+1}")
        for image_embeds, caption_ids, mask in loop:
            image_embeds = image_embeds.to(device)
            caption_ids = caption_ids.to(device)
            mask = mask.to(device)
 
            # 前向传播,得到 logits
            logits = model(image_embeds, caption_ids, mask)   # [B, L, vocab]
 
            # 计算损失:用预测的 logits 和真实的下一个 token 比较
            # shift:预测位置 0 对应真实位置 1?注意我们的设计:
            # logits 包含图像前缀+文字的所有输出,但我们只关心文字部分的预测。
            # 常用的方法是:取 logits[:, IMAGE_TOKEN_LENGTH-1:-1, :] 与 caption_ids 对齐
            shift_logits = logits[:, IMAGE_TOKEN_LENGTH-1:-1, :].contiguous()
            shift_labels = caption_ids[:, :].contiguous()   # 保持相同长度
            # 展平后计算交叉熵
            loss = F.cross_entropy(shift_logits.view(-1, shift_logits.size(-1)),
                                   shift_labels.view(-1), ignore_index=tokenizer.pad_token_id)
 
            loss.backward()
            optimizer.step()
            optimizer.zero_grad()
 
            total_loss += loss.item()
            loop.set_postfix(loss=loss.item())
 
        print(f"Epoch {epoch+1} 平均损失: {total_loss/len(dataloader):.4f}")
    # 保存模型
    torch.save(model.state_dict(), "model.pt")
 
def main():
    # 分词器(使用 GPT‑2 对应的 tokenizer,这里用 BertTokenizer 加载 gpt2 中文配置)
    tokenizer = BertTokenizer.from_pretrained(LLM_PATH)
    # 添加 pad_token(如果没有的话)
    if tokenizer.pad_token is None:
        tokenizer.pad_token = tokenizer.eos_token
 
    model = ClipCaptionModel().to(device)
    dataset = ClipCapDataset(tokenizer)
    dataloader = DataLoader(dataset, batch_size=4, shuffle=True)
    optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5)
 
    train(model, dataloader, optimizer, epochs=20)
 
if __name__ == "__main__":
    main()

损失函数通俗解释

  • 模型看到图像和部分描述后,会输出下一个字的概率分布。

  • 我们把真实的下一个字作为标签,计算交叉熵损失。

  • 当模型猜对“狗”字时损失小,猜错时损失大。训练就是不断降低这个损失。

注意:这里 shift_logits 的切片位置需要根据你的数据对齐做细微调整。上面的代码假定你是从图像前缀的最后一个 token 开始预测描述的第一个 token。更保险的做法是:图像前缀长 10,描述第一个 token 的预测来自第 10 个位置的输出。我上面的写法 [:, IMAGE_TOKEN_LENGTH-1:-1, :] 对应从第 9 个位置开始预测(小心索引)。实际调试时可以打印形状确认,或者直接用更简单的写法:shift_logits = logits[:, IMAGE_TOKEN_LENGTH-1:-1]。


九、【第六步】推理:让没见过的图片也能生成描述

训练完成后,我们就要“考试”了:给一张新图片(比如蓝色汽车),不提供任何文字,只给图片,看它能生成什么。

推理过程:

  1. 用 CLIP 提取新图片的 512 维向量。

  2. MLP 把它变成 10 个前缀 token 的嵌入。

  3. 把前缀输入 GPT‑2,一次只生成一个 token,然后把新生成的 token 拼接到输入末尾,继续生成,直到遇到结束符或达到最大长度。

下面是完整的 infer.py:

# infer.py
from PIL import Image
import torch
import torch.nn.functional as F
from transformers import BertTokenizer, ChineseCLIPModel, ChineseCLIPProcessor
from model import ClipCaptionModel
from config import CLIP_MODEL_PATH, LLM_PATH, IMAGE_TOKEN_LENGTH, LLM_WORD_EMBD_DIM, device, MAX_LENGTH
 
def generate(model, image_embeds, tokenizer, max_length=50, temperature=0.7):
    """
    image_embeds: [batch_size, 512]   CLIP 图像向量
    返回: list of strings
    """
    model.eval()
    batch_size = image_embeds.size(0)
    # 1. 映射得到图像前缀 [B, 10, 768]
    with torch.no_grad():
        prefix_embeds = model.projection(image_embeds)  # [B, 7680]
        prefix_embeds = prefix_embeds.view(-1, IMAGE_TOKEN_LENGTH, LLM_WORD_EMBD_DIM)
 
    # 当前输入就是前缀
    inputs_embeds = prefix_embeds
    # 存储每个样本生成的 token id 列表
    generated_ids = [[] for _ in range(batch_size)]
    finished = [False] * batch_size
    cur_len = 0
 
    pad_id = tokenizer.pad_token_id
    sep_id = tokenizer.sep_token_id
    unk_id = tokenizer.unk_token_id
 
    while cur_len < max_length and not all(finished):
        # 前向得到 logits
        with torch.no_grad():
            outputs = model.gpt2(inputs_embeds=inputs_embeds)
            logits = outputs.logits      # [B, cur_len+10, vocab]
 
        # 只取最后一个 token 的预测分布
        next_token_logits = logits[:, -1, :]   # [B, vocab]
        # 温度采样:温度越高,随机性越大
        next_token_logits = next_token_logits / temperature
        # 禁止生成 UNK 和 PAD
        next_token_logits[:, unk_id] = -float('Inf')
        next_token_logits[:, pad_id] = -float('Inf')
        # 用 softmax 和多项式采样
        probs = F.softmax(next_token_logits, dim=-1)
        next_tokens = torch.multinomial(probs, num_samples=1).squeeze(1)  # [B]
 
        # 更新每个样本的状态
        new_embeds_list = []
        for i in range(batch_size):
            token_id = next_tokens[i].item()
            if finished[i]:
                token_id = pad_id
            elif token_id == sep_id:
                finished[i] = True
            else:
                generated_ids[i].append(token_id)
 
            # 获取这个 token 的嵌入
            token_emb = model.gpt2.transformer.wte(torch.tensor([token_id]).to(device))
            new_embeds_list.append(token_emb)
 
        # 将新 token 的嵌入拼接到 inputs_embeds 后面
        new_embeds = torch.stack(new_embeds_list, dim=0).unsqueeze(1)  # [B, 1, 768]
        inputs_embeds = torch.cat([inputs_embeds, new_embeds], dim=1)
        cur_len += 1
 
    # 将 token ids 解码成文字
    captions = []
    for ids in generated_ids:
        text = tokenizer.decode(ids, skip_special_tokens=True)
        captions.append(text)
    return captions
 
def main():
    tokenizer = BertTokenizer.from_pretrained(LLM_PATH)
    if tokenizer.pad_token is None:
        tokenizer.pad_token = tokenizer.eos_token
 
    # 加载训练好的模型
    model = ClipCaptionModel().to(device)
    model.load_state_dict(torch.load("model.pt", map_location=device))
    model.eval()
 
    # 加载 CLIP 用来提取新图片的特征
    clip_model = ChineseCLIPModel.from_pretrained(CLIP_MODEL_PATH)
    processor = ChineseCLIPProcessor.from_pretrained(CLIP_MODEL_PATH)
 
    # 假设有一张新图片 blue_car.jpg
    image = Image.open("blue_car.jpg")
    inputs = processor(images=image, return_tensors="pt")
    image_features = clip_model.get_image_features(**inputs)
    image_features = image_features / image_features.norm(p=2, dim=-1, keepdim=True)
 
    # 生成描述
    captions = generate(model, image_features.to(device), tokenizer)
    print("生成的描述:", captions[0])
 
if __name__ == "__main__":
    main()

推理细节说明

  • 我们用 temperature 控制创造度:温度低 → 结果更确定,温度高 → 可能更丰富但有时会跑偏。

  • 每次生成一个 token,把它变成嵌入后拼到输入末尾,再进入下一轮循环。

  • 遇到 sep_id(分隔符)就认为描述结束。


十、【成果展示】A800 训练 10 小时的效果

下面是一些真实训练后的生成例子(使用电商商品图训练):

可以看到,模型不仅能识别物体(卫衣、哑铃),还能加上修饰词和场景描述,甚至带一点“营销文案”的味道。


十一、【拓展】当前最火的视觉语言模型:Qwen-VL

ClipCap 是一个经典方案,但近年来出现了更强大的模型,比如 Qwen-VL(阿里通义千问的视觉语言模型)。
它的架构更现代:视觉编码器 + 大语言模型解码器,中间用注意力机制连接,不再需要单独的映射网络,而是直接把图片切成多个 patch,作为 token 和文字 token 一起输入到 LLM 中。

优势

  • 支持多图、视频输入。

  • 可以理解更复杂的指令(比如“描述这张图片里狗的毛色”)。

  • 训练数据更大,效果更惊艳。

不过 ClipCap 好理解、代码量小,非常适合作为学习 “图生文” 的入门项目。


十二、【总结】一张图,一句话,AI 替你写文案

今天我们从头到尾实现了一个图片标题生成模型,核心就四步:

  1. 用 CLIP 看懂图

    :把图片变成 512 维向量。

  2. 映射网络翻译

    :把 512 维变成 10 个 768 维的“假 token”。

  3. GPT‑2 续写

    :拿着图像前缀,一个字一个字地把描述写完整。

  4. 训练 & 推理

    :准备图片‑描述对,训练映射网络和 GPT‑2,最后给新图片生成描述。

你可以用这个技术做什么?

  • 自动给电商商品图写文案。

  • 帮盲人“听”懂社交媒体上的图片。

  • 给监控画面自动生成文字记录。

最后送给你一句实用建议

不要被庞大模型吓倒,从这个小而美的 ClipCap 开始,你就能亲手打开“视觉与语言交融”的大门。

所有代码已经完整给出,你只需要准备好几张图片和对应的描述,就可以开始自己的第一个图生文模型了。
如果在运行中遇到任何问题,欢迎在评论区留言,我会一一解答。