简介本资源为基于Flickr30k数据集的图像—文本跨模态搜索Python项目面向计算机、人工智能、通信工程等专业的在校学生与教师可用于媒体计算实践作业、课程设计或毕业设计。项目围绕跨模态检索任务提供从数据划分、图像预处理到模型训练与测试的完整流程并附带测试界面方便直观验证检索效果。压缩包共35个文件以22个py源码为主另含6个pyc编译文件、3个md说明文档、1个sh脚本及少量图片与许可证文件整体约1.13MB结构清晰便于按模块阅读与二次开发。目前已有586人学习下载。读者可获取可运行的完整源码、数据预处理脚本、训练与评估代码、测试界面及项目说明适合基础尚可者在此基础上修改功能也适合小白结合说明逐步理解跨模态检索的实现思路与排错方法。1. 从一次课程设计答辩翻车说起Flickr30k 跨模态搜索到底在做什么很多同学做课程设计时第一反应是找个现成 demo 改改界面结果答辩老师一句「你这个模型为什么能把图片和文字对到一起」直接问懵。我当年第一次做基于 Flickr30k 的图像文本跨模态搜索也是这样界面跑起来了但被追问「相似度怎么算的、为什么不用欧氏距离」时答不上来血泪经验。这个方向的核心其实就一句话把图片和文本映射到同一个向量空间让语义相近的图文距离更近。Flickr30k 数据集正好提供了 3 万多张图、每张图 5 句人工描述是做图文对齐最常用的入门数据集之一。适合做课程设计、毕设也适合想入门多模态检索的 Python 开发者。整套方案通常包含三块数据加载与预处理、双塔模型训练、检索测试界面下面按落地顺序拆开讲。2. 数据准备与 Flickr30k 加载从原始文件到可训练张量2.1 为什么选 Flickr30k 而不是 COCO做跨模态检索数据集的选择直接决定你后面调参的天花板。Flickr30k 全量约 31000 张图每张图配 5 条英文描述标注质量高、句子短、场景集中人、动物、日常动作非常适合课程设计这种算力有限、周期短的场景。相比之下 COCO 的 12 万张图虽然更大但训练一轮的时间成本对单卡学生机不友好而且 COCO 的标注更偏检测任务图文对齐的句子长度差异大收敛更慢。我一般建议课程设计用 Flickr30k想冲论文再上 COCO。Flickr30k 官方划分是 train 29000、val 1000、test 1000但注意官方 test 的标注不公开做课程设计时通常把 val 再切一半当 test或者直接用 Karpathy split这个划分在社区里更通用train/val/test 分别是 29000/1014/1000且保证每张图在划分中不重叠。2.2 图像与文本的预处理流水线图像侧的标准做法是统一 resize 到 224×224再做 ImageNet 均值方差归一化因为后面用的视觉 backbone 基本都是在 ImageNet 上预训练的。文本侧要分词、转 id、截断到固定长度一般 30 个 token 够用Flickr30k 的句子很少超过 25 个词。下面这段代码是我常用的最小加载器基于 torchvision 和 transformers 的 tokenizerimport os import torch from torch.utils.data import Dataset from PIL import Image from torchvision import transforms from transformers import BertTokenizer class Flickr30kDataset(Dataset): def __init__(self, img_dir, ann_file, tokenizer, max_len30): # ann_file 每行格式: 图片名#序号\t描述 self.img_dir img_dir self.tokenizer tokenizer self.max_len max_len self.samples [] with open(ann_file, r, encodingutf-8) as f: for line in f: name, caption line.strip().split(\t) img_name name.split(#)[0] self.samples.append((img_name, caption)) # 图像预处理resize 归一化和 ImageNet 预训练对齐 self.img_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_name, caption self.samples[idx] img Image.open(os.path.join(self.img_dir, img_name)).convert(RGB) img self.img_transform(img) # 文本编码padding truncation返回 input_ids 和 attention_mask enc self.tokenizer(caption, paddingmax_length, truncationTrue, max_lengthself.max_len, return_tensorspt) return img, enc[input_ids].squeeze(0), enc[attention_mask].squeeze(0)逻辑说明ann_file用的是 Flickr30k 常见的results_20130124.token格式每行是「图片名#编号\t描述」所以要先按#切出图片名。max_len30是经验值Flickr30k 句子平均 12 个词30 足够覆盖 99% 以上。参数上Resize((224,224))不要改成 256 再 CenterCrop课程设计阶段没必要反而增加预处理时间。tokenizer 建议用bert-base-uncased词表小、下载快中文场景才考虑换中文预训练模型。注意convert(RGB)必须加Flickr30k 里有少量灰度图不加会在归一化时报通道数错误。2.3 划分与 DataLoader 的坑Karpathy split 的文件网上能搜到但格式不统一有的给的是 json有的给的是 txt。我一般统一转成「图片名\t描述」的 txt然后按 split 过滤。DataLoader 的num_workers在 Windows 上设大于 0 容易卡死建议设 0 或 2pin_memoryTrue只在有 GPU 时开。batch size 课程设计用 64 或 128太小了对比学习负样本不够太大了显存爆。这里有个玄学batch size 从 64 调到 128Recall1 经常能涨 2 到 3 个点因为对比学习依赖 batch 内的负样本数量。3. 双塔模型搭建与对比学习训练把图文映射到同一空间3.1 双塔结构的选择理由跨模态检索主流就两条路双塔dual encoder和交叉编码cross encoder。交叉编码把图文拼一起过 Transformer精度高但推理时要对每个「图-文」对算一次检索 1000 张图就是 1000 次前向课程设计答辩现场根本跑不动。双塔是图像一个编码器、文本一个编码器各自出向量检索时算余弦相似度可以预先算好所有图片向量建索引查询时只算一次文本编码速度差几个数量级。所以课程设计强烈建议双塔。视觉侧用 ResNet50 或 ViT-Base文本侧用 BERT-Base各自取 [CLS] 或池化后的向量再过一个投影层映射到 256 或 512 维的共同空间。投影层别省它是把两个模态拉到同一维度的关键省了之后相似度分布会很怪。3.2 对比学习损失与温度系数训练目标用 InfoNCE也就是把 batch 内其他样本当负样本。图像 i 和文本 i 是正对图像 i 和文本 jj≠i是负对。损失就是让正对的余弦相似度尽量大、负对尽量小。温度系数 τ 控制分布的尖锐程度太小了梯度爆炸太大了学不动常用 0.07。下面是最小训练循环import torch import torch.nn as nn import torch.nn.functional as F class DualEncoder(nn.Module): def __init__(self, img_encoder, txt_encoder, embed_dim256): super().__init__() self.img_encoder img_encoder self.txt_encoder txt_encoder # 投影层把两个模态映射到同一维度 self.img_proj nn.Linear(img_encoder.config.hidden_size, embed_dim) self.txt_proj nn.Linear(txt_encoder.config.hidden_size, embed_dim) def forward(self, imgs, input_ids, attention_mask): img_feat self.img_encoder(imgs).pooler_output txt_feat self.txt_encoder(input_ids, attention_mask).pooler_output img_emb F.normalize(self.img_proj(img_feat), dim-1) txt_emb F.normalize(self.txt_proj(txt_feat), dim-1) return img_emb, txt_emb def info_nce(img_emb, txt_emb, temperature0.07): # 相似度矩阵对角线是正对 logits img_emb txt_emb.t() / temperature labels torch.arange(img_emb.size(0), deviceimg_emb.device) loss_i F.cross_entropy(logits, labels) loss_t F.cross_entropy(logits.t(), labels) return (loss_i loss_t) / 2逻辑说明F.normalize把向量归一化到单位长度这样点积就等于余弦相似度。logits是 batch×batch 的相似度矩阵对角线是正对。loss_i是「图找文」loss_t是「文找图」两个方向都算再平均这是标准做法。温度系数 0.07 是 CLIP 论文里的值实测在 Flickr30k 上也稳。优化器用 AdamW学习率图像侧 1e-5、文本侧 2e-5投影层可以给 1e-4因为投影层是随机初始化的需要更大步长。训练 10 到 15 个 epoch 基本收敛课程设计够用。3.3 训练过程中的监控指标别只看 lossloss 降不代表检索效果好。每个 epoch 结束在 val 上算 Recall1、Recall5、Recall10图像检索文本和文本检索图像都要算。RecallK 的定义是对每个查询取相似度 top-K如果正确答案在里面就算命中命中数除以总数。Flickr30k 上双塔模型训练充分的话1K test 集上文本检索图像 R1 能到 40% 到 55%图像检索文本 R1 能到 30% 到 45%低于 30% 说明没训好。如果 loss 一直不降先检查投影层有没有加、归一化有没有做、温度系数是不是设成了 1.0。4. 检索测试界面与相似度计算让答辩老师能点得动4.1 用 Gradio 搭最小测试界面课程设计要交「测试界面」别用 PyQt 折腾Gradio 三行代码出网页答辩时浏览器一开就能演示。核心功能两个上传图片返回最相似的 5 条文本输入文本返回最相似的 5 张图。下面是最小实现import gradio as gr import torch from PIL import Image # 预计算所有图片向量避免每次查询都重新编码 torch.no_grad() def build_image_index(model, dataloader, device): model.eval() all_emb, all_names [], [] for imgs, _, _ in dataloader: imgs imgs.to(device) emb model.img_encoder(imgs).pooler_output emb torch.nn.functional.normalize(model.img_proj(emb), dim-1) all_emb.append(emb.cpu()) # 这里需要同步记录图片名实际用 Dataset 返回 name return torch.cat(all_emb) def text_to_image(query, model, tokenizer, image_index, image_names, topk5): enc tokenizer(query, return_tensorspt, paddingTrue, truncationTrue, max_length30) with torch.no_grad(): txt_feat model.txt_encoder(enc[input_ids], enc[attention_mask]).pooler_output txt_emb torch.nn.functional.normalize(model.txt_proj(txt_feat), dim-1) sims (txt_emb image_index.t()).squeeze(0) topk_idx sims.topk(topk).indices.tolist() return [image_names[i] for i in topk_idx] demo gr.Interface( fnlambda q: text_to_image(q, model, tokenizer, image_index, image_names), inputsgr.Textbox(label输入描述), outputsgr.Gallery(label最相似的图片), titleFlickr30k 跨模态检索 ) demo.launch()逻辑说明build_image_index在启动时把所有图片向量算好存内存1K 张图 256 维也就 1MB完全放得下。查询时只编码一次文本然后做矩阵乘法毫秒级返回。topk默认 5答辩演示够用。注意model.eval()和torch.no_grad()必须加否则显存会涨。Gradio 的launch()默认本地 7860 端口答辩前先跑一遍确认能打开。4.2 相似度用余弦还是欧氏这是答辩高频问题。双塔模型训练时用了F.normalize向量都在单位球面上此时余弦相似度和欧氏距离是等价的欧氏距离平方 2 - 2×余弦所以用哪个都行。但如果训练时没归一化就必须用余弦因为欧氏距离会被向量模长干扰。我一般统一用余弦代码里就是点积简单不易错。另外别用曼哈顿距离或马氏距离前者对高维向量区分度差后者要估协方差矩阵课程设计没必要。4.3 检索结果的评估脚本界面是给人看的评估是给报告写的。写一个脚本在 test 集上算 R1/R5/R10图像检索文本和文本检索图像各一份输出成表格贴进报告。注意评估时要用完整 test 集别只抽 100 张否则数字虚高。如果 R1 明显低于预期先看是不是 test 集里混进了 train 的图Karpathy split 用错很容易出现这种数据泄漏这是最常见的翻车点。5. 避坑与排查课程设计里最容易翻车的 5 个地方5.1 现象loss 正常下降但 R1 只有个位数原因投影层没加或者加了但没归一化导致两个模态的向量尺度差异大相似度矩阵被某一模态主导。解决确认img_proj和txt_proj都存在且输出后都做了F.normalize。另外检查温度系数是不是忘了除设成 1.0 会让 softmax 过于平滑梯度信号弱。5.2 现象训练时显存爆batch size 降到 8 才能跑原因视觉侧用了 ViT-Large 或文本侧用了 BERT-Large参数量太大。解决课程设计用 ResNet50 BERT-Base 足够别盲目上大模型。另外检查是不是把整个数据集预加载到了显存DataLoader 应该是按需加载。如果还爆开混合精度torch.cuda.amp显存能省 30% 到 40%。5.3 现象测试界面第一次查询特别慢后面就快了原因第一次查询触发了模型加载和图片索引构建后面走缓存。解决在demo.launch()之前就把模型加载好、索引建好别放在查询函数里。如果索引构建本身慢检查是不是每张图都重新 resize 了应该在 Dataset 里预处理一次。5.4 现象文本检索图像结果全是同一张图原因那张图的向量模长特别大或者归一化没做导致它和所有文本的相似度都高。解决确认F.normalize在投影层之后、相似度计算之前。另外检查是不是有重复图片Flickr30k 里偶尔有近似重复去重一下。5.5 现象换台机器跑R1 掉了一半原因随机种子没固定或者 PyTorch 版本差异导致 DataLoader 的 shuffle 行为不同。解决在代码开头固定torch.manual_seed(42)、np.random.seed(42)、random.seed(42)并设torch.backends.cudnn.deterministic True。注意开了 deterministic 会慢一点但课程设计可复现性更重要。6. 进阶技巧用硬负样本挖掘把 R1 再提 5 个点基础版跑通后如果想让报告数字更好看可以上硬负样本挖掘。原理很简单随机负样本batch 内其他图太容易区分了模型学不到细粒度。硬负样本是那些「看起来像但不是」的样本比如「一只狗在草地上跑」和「一只狗在沙滩上跑」模型必须关注「草地」和「沙滩」的差异才能分开。实现上每个 epoch 结束后用当前模型对所有训练样本算一遍相似度对每个图取相似度最高的非正样本文本作为硬负样本下一轮训练时把这些硬负样本也放进对比损失里。代码改动不大就是在info_nce里把负样本矩阵从「batch 内」扩展到「batch 内 硬负样本池」。具体做法维护一个hard_negatives字典key 是图片 idvalue 是当前模型认为最像的 5 条错误文本的 id。每轮训练前用上一轮的模型更新这个字典。损失函数改成def info_nce_with_hard_neg(img_emb, txt_emb, hard_txt_emb, temperature0.07): # 正对相似度 pos_sim (img_emb * txt_emb).sum(dim-1, keepdimTrue) # batch 内负对 硬负样本 neg_sim img_emb txt_emb.t() hard_sim img_emb hard_txt_emb.t() logits torch.cat([pos_sim, neg_sim, hard_sim], dim1) / temperature labels torch.zeros(img_emb.size(0), dtypetorch.long, deviceimg_emb.device) return F.cross_entropy(logits, labels)逻辑说明pos_sim是正对相似度放第一列neg_sim是 batch 内负对hard_sim是硬负样本相似度。labels全是 0因为正对永远在第一列。这样模型会被迫把正对和硬负样本拉开。注意硬负样本池别太大每个图 5 条就够太大了训练变慢且容易过拟合。温度系数可以适当调小到 0.05因为硬负样本本身就更难分。实测在 Flickr30k 1K test 上基础版文本检索图像 R1 大概 45%加硬负样本挖掘后能到 50% 左右提升 5 个点。代价是每个 epoch 多花 20% 时间做全量相似度计算课程设计周期内完全可接受。另一个技巧是「动量编码器」就是维护一个缓慢更新的文本编码器副本用它算负样本能进一步稳定训练但代码复杂度高课程设计不强制。最后说个习惯我每次跑完实验都会把配置文件、随机种子、最终指标写进一个result.md和代码一起交。答辩时老师问「你这个数字怎么来的」直接翻文件比现场回忆靠谱得多。做课程设计别只盯着界面好看指标和可复现性才是拿高分的关键。希望帮到你。本文还有配套的精品资源点击获取