一张蓝色汽车图片,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]。
九、【第六步】推理:让没见过的图片也能生成描述
训练完成后,我们就要“考试”了:给一张新图片(比如蓝色汽车),不提供任何文字,只给图片,看它能生成什么。
推理过程:
-
用 CLIP 提取新图片的 512 维向量。
-
MLP 把它变成 10 个前缀 token 的嵌入。
-
把前缀输入 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 替你写文案
今天我们从头到尾实现了一个图片标题生成模型,核心就四步:
-
用 CLIP 看懂图
:把图片变成 512 维向量。
-
映射网络翻译
:把 512 维变成 10 个 768 维的“假 token”。
-
GPT‑2 续写
:拿着图像前缀,一个字一个字地把描述写完整。
-
训练 & 推理
:准备图片‑描述对,训练映射网络和 GPT‑2,最后给新图片生成描述。
你可以用这个技术做什么?
-
自动给电商商品图写文案。
-
帮盲人“听”懂社交媒体上的图片。
-
给监控画面自动生成文字记录。
最后送给你一句实用建议:
不要被庞大模型吓倒,从这个小而美的 ClipCap 开始,你就能亲手打开“视觉与语言交融”的大门。
所有代码已经完整给出,你只需要准备好几张图片和对应的描述,就可以开始自己的第一个图生文模型了。
如果在运行中遇到任何问题,欢迎在评论区留言,我会一一解答。