从“哑巴图片”到“自动写文案”:手把手带你撸一个图片标题生成器
一张蓝色汽车图片AI 看了居然能说出“一辆蓝色汽车停在路边”——这是怎么做到的今天我们不扯高深的数学公式用大白话 可运行的代码把图片自动写标题的“黑科技”彻底讲透。完整代码下载含模型https://pan.baidu.com/s/13aHMg6MHiRw5yXXp4FvCUA?pwd7dfz一、电商老板的烦恼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-patch16GPT‑2 Chinese中文版的小型 GPT‑2用来生成描述Hugging Face 上的gpt2-chinese-cluecorpussmallPyTorch深度学习框架pip install torchTransformersHugging 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(imagesimg1, return_tensorspt) inputs_2 processor(imagesimg2, return_tensorspt) # 提取图像特征形状 [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(p2, dim-1, keepdimTrue) image_2_features image_2_features / image_2_features.norm(p2, dim-1, keepdimTrue) # 保存到字典图片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表示真实token0表示填充 # 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), dim1) # [B, 10text_len, 768] # 5. 喂给 GPT‑2注意这里用 inputs_embeds 而不是 input_ids outputs self.gpt2(inputs_embedsinputs_embeds, attention_maskmask) return outputs.logits # [B, 10text_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_tokensFalse) # 加上结束分隔符 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, dtypetorch.long)) self.masks.append(torch.tensor(mask, dtypetorch.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 哪些位置是真实 token1哪些是填充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, epochs20): model.train() for epoch in range(epochs): total_loss 0 loop tqdm(dataloader, descfEpoch {epoch1}) 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_indextokenizer.pad_token_id) loss.backward() optimizer.step() optimizer.zero_grad() total_loss loss.item() loop.set_postfix(lossloss.item()) print(fEpoch {epoch1} 平均损失: {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_size4, shuffleTrue) optimizer torch.optim.AdamW(model.parameters(), lr2e-5) train(model, dataloader, optimizer, epochs20) 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_length50, temperature0.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_embedsinputs_embeds) logits outputs.logits # [B, cur_len10, 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_samples1).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, dim0).unsqueeze(1) # [B, 1, 768] inputs_embeds torch.cat([inputs_embeds, new_embeds], dim1) cur_len 1 # 将 token ids 解码成文字 captions [] for ids in generated_ids: text tokenizer.decode(ids, skip_special_tokensTrue) 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_locationdevice)) 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(imagesimage, return_tensorspt) image_features clip_model.get_image_features(**inputs) image_features image_features / image_features.norm(p2, dim-1, keepdimTrue) # 生成描述 captions generate(model, image_features.to(device), tokenizer) print(生成的描述, captions[0]) if __name__ __main__: main()推理细节说明我们用 temperature 控制创造度温度低 → 结果更确定温度高 → 可能更丰富但有时会跑偏。每次生成一个 token把它变成嵌入后拼到输入末尾再进入下一轮循环。遇到 sep_id分隔符就认为描述结束。十、【成果展示】A800 训练 10 小时的效果下面是一些真实训练后的生成例子使用电商商品图训练可以看到模型不仅能识别物体卫衣、哑铃还能加上修饰词和场景描述甚至带一点“营销文案”的味道。十一、【拓展】当前最火的视觉语言模型Qwen-VLClipCap 是一个经典方案但近年来出现了更强大的模型比如Qwen-VL阿里通义千问的视觉语言模型。它的架构更现代视觉编码器 大语言模型解码器中间用注意力机制连接不再需要单独的映射网络而是直接把图片切成多个 patch作为 token 和文字 token 一起输入到 LLM 中。优势支持多图、视频输入。可以理解更复杂的指令比如“描述这张图片里狗的毛色”。训练数据更大效果更惊艳。不过 ClipCap 好理解、代码量小非常适合作为学习 “图生文” 的入门项目。十二、【总结】一张图一句话AI 替你写文案今天我们从头到尾实现了一个图片标题生成模型核心就四步用 CLIP 看懂图把图片变成 512 维向量。映射网络翻译把 512 维变成 10 个 768 维的“假 token”。GPT‑2 续写拿着图像前缀一个字一个字地把描述写完整。训练 推理准备图片‑描述对训练映射网络和 GPT‑2最后给新图片生成描述。你可以用这个技术做什么自动给电商商品图写文案。帮盲人“听”懂社交媒体上的图片。给监控画面自动生成文字记录。最后送给你一句实用建议不要被庞大模型吓倒从这个小而美的 ClipCap 开始你就能亲手打开“视觉与语言交融”的大门。所有代码已经完整给出你只需要准备好几张图片和对应的描述就可以开始自己的第一个图生文模型了。如果在运行中遇到任何问题欢迎在评论区留言我会一一解答。