简介基于Flickr30k数据集的图像-文本跨模态搜索项目是面向计算机相关专业学生、教师及初学者的Python课程设计与毕设源码。项目覆盖数据预处理、模型训练、测试与查询界面展示其中数据预处理包含训练集/测试集/验证集划分、图像短边统一缩放等典型步骤完整演示跨模态检索系统的构建流程适合作为课程作业、毕业设计或项目初期立项演示。压缩包共35个文件以22个Python脚本为核心涵盖数据划分、图像缩放、模型训练与评估等模块另有6个编译文件、3个说明文档以及辅助脚本、图片、许可文件等整体约1.13MB数据集体量较大未随包附带需按项目说明自行下载。目前已有585人浏览学习。代码经测试运行成功作者注明答辩评审平均分96分包含测试界面与项目说明可帮助理解图像与文本跨模态检索的工程实现也便于在此基础上修改扩展实现其他检索功能。1. 从Flickr30k到跨模态搜索这条课程设计主线到底在做什么Flickr30k 数据集是图像描述场景里的经典 benchmark三万多张自然图片每张都配 5 条人工标注的英文描述天然适合做“给定文字找图片、给定图片找文字”的跨模态检索任务。很多同学拿到这个课程设计题目后第一反应是把它当成图像分类问题用 ResNet 提特征、按 category 标签匹配结果图片和文本根本不在同一个语义空间里检索出来的结果既不稳定也没有可解释性。这个项目的核心难点在于不能只处理图像也不能只处理文本而是要打通两个模态之间的语义鸿沟。常见的工程解法是用双塔结构视觉塔把图片编码成向量文本塔把句子编码成另一个向量然后在对齐的语义空间里用 FAISS 做相似度召回。下面按一条可复现的路线来梳理从 Flickr30k 的数据文件结构、CLIP 选型、特征编码、索引构建到 Streamlit 测试界面和最后的召回率评估新手能逐节跑通熟手可以直接跳到第 4 章看参数边界与常见坑。2. 先搞懂Flickr30k数据结构和跨模态搜索的数学基础2.1 Flickr30k 的文件结构与标注格式标准 Flickr30k 发布包主要包含这几部分images文件夹、annotations文件夹、dataset.json以及train.txt/val.txt/test.txt三个划分文件。images里是原始图片素材annotations里是实体级标注记录句子中的名词短语和对应检测框坐标做基础检索不需要用到它但如果你想在测试界面上高亮“人物”“物体”这些关键词它是现成的材料。dataset.json是核心标注文件记录了每张图片对应的 5 句自然语言描述。dataset.json的结构通常是新手第一道坎。顶层是一个images数组数组中每个元素对应一张图片包含imgid、filename、sentences等字段。sentences里每个元素又有tokens和raw两个常用字段。raw是完整的句子直接用它做文本编码tokens是分词后的数组做清洗过滤或构造负样本时用处更大。真正容易踩坑的是图片和句子之间没有文件名级别的直接对应必须通过imgid关联另外filename字段不带images/前缀读图时需要自己拼接路径。import json with open(dataset.json) as f: data json.load(f) records [] for img in data[images]: sentences [s[raw].strip() for s in img[sentences]] records.append({ image_id: img[imgid], filename: img[filename], sentences: sentences, }) print(records[0])这段代码把嵌套的 JSON 拍平成列表每项包含图片 id、文件名和 5 条句子。逻辑不复杂但要注意imgid是后续对齐 FAISS 索引和原图路径的唯一线索不到最后展示环节都不要拿filename当主键。strip()能去掉句子首尾换行但会保留内部逗号和句号因为 CLIP 分词器对自然状态下的完整句子更友好。如果后面要用train.txt/test.txt做划分直接按每行文件名过滤这个records列表即可比建完索引后再回改要省事得多。2.2 双塔模型与对比学习的核心思路跨模态搜索要解决的本质问题是让图像和文本变得“可比”。业内最常见的双塔做法是图像塔用预训练视觉模型把图片编码成 d 维向量文本塔用预训练语言模型或句子编码器把文本编码成相同维度的向量。训练阶段使用对比学习目标让匹配的图像-文本对在向量空间里彼此靠近让不匹配的对彼此远离。Flickr30k 的每张图带 5 条描述因此图片和这 5 条句子之间天然构成正样本对负样本可以从同一 batch 里的其他句子或图片中随机采样这种构造方式不需要额外人工标注课程设计里自己写一个对比训练循环也很容易。向量之间的距离通常用余弦相似度衡量import numpy as np def cosine_similarity(vec_a, vec_b): return np.dot(vec_a, vec_b) / (np.linalg.norm(vec_a) * np.linalg.norm(vec_b))函数先计算两个向量的点积再除以各自的 L2 范数得到一个[-1, 1]的相似度分数。检索时把全部候选向量预先存好查询向量依次和每个候选向量算余弦相似度降序取前 top_k 就是召回结果。这里有个容易忽略的细节如果直接用模型输出的原始向量做点积向量模长会成为隐式权重句子长、图片纹理密的样本会天然占优。标准做法是先对所有向量做 L2 归一化再用点积替代余弦相似度效果完全等价计算效率却高很多这也是后面 FAISS 的IndexFlatIP能直接用的前提。2.3 模型选型CLIP 还是 VSE 方案课程设计中经常纠结的一个点是直接用现成 CLIP 权重还是仿照 VSE 从头训练一个视觉语义嵌入模型。对比维度CLIP预训练推荐VSE / SCAN自训练训练成本无需训练直接提取特征需要对比损失和成对排序训练数小时特征维度ViT-B/32 为 512 维常用 512 或 1024中文支持需选 mCLIP 或中文 CLIP 变体可以自行构造中文语料检索效果零样本效果已经很稳依赖训练是否充分课程设计适配适合偏工程和应用的题目适合要求展示训练过程的题目如果课程设计时间在 4 周以内我一般建议直接用 CLIP 预训练模型做特征提取。省掉训练环节的同时零样本检索准确率已经能超过很多自训小模型尤其是 Flickr30k 这种自然场景图CLIP 的覆盖率相当高。如果题目明确要求实现“模型训练”过程那就要走 VSE 风格的双塔代码至少把 InfoNCE 或排序损失写出来。无论选哪条路后面的 FAISS 检索和界面部分完全通用本节不再区分。3. 用CLIP实现Flickr30k图像文本跨模态检索的核心代码3.1 python环境配置与依赖清单先把环境准备好。Flickr30k 全量图片有 3 万多张特征提取阶段建议在 GPU 上跑CPU 也能跑但会很慢。本地开发建议先建一个独立的 python 虚拟环境避免和系统环境里的包互相污染。用 VS Code 打开项目目录后在终端里执行python -m venv clip_search_env source clip_search_env/bin/activate # Windows 使用 clip_search_env\Scripts\activate pip install torch torchvision open_clip_torch faiss-cpu streamlit pillow numpyopen_clip_torch是 OpenCLIP 的 python 包能加载多种预训练权重比原版clip包维护更活跃faiss-cpu是向量检索的 CPU 版本3 万条向量规模下检索耗时才几毫秒不需要装 GPU 版streamlit用来做演示用测试界面pillow负责图片读取和预处理。版本方面不用刻意锁定只要 torch 2.x 和open_clip_torch2.x 搭配即可。3.2 解析Flickr30k标注并生成图像/文本特征加载 CLIP 模型并封装图像、文本编码函数from PIL import Image import open_clip import torch model, _, preprocess open_clip.create_model_and_transforms( ViT-B-32, pretrainedlaion2b_s34b_b79k ) tokenizer open_clip.get_tokenizer(ViT-B-32) model.eval() device cuda if torch.cuda.is_available() else cpu model.to(device) def encode_image(image_path): image preprocess(Image.open(image_path).convert(RGB)).unsqueeze(0) with torch.no_grad(): return model.encode_image(image.to(device)).cpu().numpy() def encode_text(text): tokens tokenizer(text) with torch.no_grad(): return model.encode_text(tokens.to(device)).cpu().numpy()create_model_and_transforms返回三个对象中间的_,是训练用的 transform 参数推理时用不到。preprocess会把图片统一 resize 到模型要求的尺寸并做标准化因此传入encode_image的必须是 PIL Image 而不是文件路径字符串。encode_image和encode_text的返回值都是[1, 512]的二维数组后续存索引前要压成一维或竖着堆叠成一个矩阵。特别注意encode_text的入参是 tokenizer 之后的结果不是原始字符串直接传a man playing guitar会报类型错误。接下来遍历第 2 节解析出来的records把全部图片特征和文本特征都提取出来import os IMG_DIR flickr30k/images img_vectors [] txt_vectors [] img_paths [] for rec in records: path os.path.join(IMG_DIR, rec[filename]) img_vec encode_image(path) for sent in rec[sentences]: txt_vec encode_text(sent) img_vectors.append(img_vec[0]) txt_vectors.append(txt_vec[0]) img_paths.append(path) img_vectors np.array(img_vectors, dtypefloat32) txt_vectors np.array(txt_vectors, dtypefloat32)这里把每条句子作为独立的检索单位同一张图会重复出现 5 次。这样设计有两个好处文本搜图时可以直接定位到具体是哪一句描述命中的图像搜文本时返回的 5 条文本对应同一张图的不同角度描述展示出来更直观。代价是文本向量数量是图片的 5 倍但 Flickr30k 全量也就 15 万条文本完全在可控范围。如果你的机器显存有限可以先把encode_image的循环和encode_text的循环分开跑分两批保存为.npy文件避免同时占用大量显存。3.3 FAISS索引构建与检索管线拿到全部向量后用 FAISS 建索引。最直接的是IndexFlatIP也就暴力内积索引。它不搞近似搜索但 15 万条级别的数据量下速度依然很快课程设计里完全够用import faiss import numpy as np def build_index(vectors): dim vectors.shape[1] index faiss.IndexFlatIP(dim) faiss.normalize_L2(vectors) index.add(vectors) return index img_index build_index(img_vectors) txt_index build_index(txt_vectors)normalize_L2会原地修改向量矩阵把每一行归一化到单位长度。做完这一步IndexFlatIP的search结果就是余弦相似度排序。IndexFlatIP(dim)只需要指定向量维度这里就是 512。如果你在后续实验中换成了 ViT-L 或 ResNet 系列的 CLIP 变体维度会变需要重新建索引不能复用旧的index.faiss文件。封装一个可复用的检索函数def search_index(index, query_vec, top_k10): query_vec np.array(query_vec, dtypefloat32).reshape(1, -1) faiss.normalize_L2(query_vec) scores, indices index.search(query_vec, top_k) return scores[0], indices[0]reshape(1, -1)保证查询向量是标准 batch 形状normalize_L2再做一次归一化和库内向量处于同一度量空间。返回的indices是候选向量在原始列表中的下标用它可以反查img_paths拿到图片路径。有一点值得注意文本索引和图像索引是分开的文本搜图时用文本向量去搜img_index图像搜文本时用图像向量去搜txt_index方向不要搞反否则相似度没有实际语义。4. Flickr30k跨模态搜索测试界面与参数调优4.1 用Streamlit搭建可交互的测试界面Streamlit 很适合课程设计演示核心逻辑就是函数加缓存不需要写 HTML 和 JavaScript。下面这个最小界面同时支持“文本搜图”和“图像搜文本”两个方向import streamlit as st st.cache_resource def load_model(): model, _, preprocess open_clip.create_model_and_transforms(ViT-B-32) tokenizer open_clip.get_tokenizer(ViT-B-32) model.eval() return model, tokenizer, preprocess st.set_page_config(page_titleFlickr30k 跨模态搜索, layoutwide) mode st.sidebar.radio(检索方向, [文本搜图, 图像搜文本]) top_k st.sidebar.slider(返回结果数 top_k, 1, 30, 10) model, tokenizer, preprocess load_model() device cuda if torch.cuda.is_available() else cpu model.to(device)st.cache_resource是关键它保证模型只在第一次交互时加载否则每次点击按钮都会重新加载权重页面会卡到无法演示。.radio和.slider是 Streamlit 自带的控件返回结果直接在脚本里参与逻辑。实际搜索时根据mode判断调用encode_text还是encode_image然后走search_index拿结果最后用st.image把候选图渲染出来即可。if mode 文本搜图: query st.text_input(输入英文描述, A man in black shirt is playing guitar) if st.button(搜索) and query: vec encode_text(query) scores, idx search_index(img_index, vec, top_k) st.write(fTop {len(idx)} 张匹配图片) cols st.columns(5) for j, i in enumerate(idx): with cols[j % 5]: st.image(img_paths[i], captionf{scores[j]:.3f}) else: uploaded st.file_uploader(上传一张图片, type[jpg, png]) if uploaded is not None: temp_path temp_query.jpg with open(temp_path, wb) as f: f.write(uploaded.getbuffer()) vec encode_image(temp_path) scores, idx search_index(txt_index, vec, top_k) for j, i in enumerate(idx): st.write(f{scores[j]:.3f} {all_sentences[i]})st.columns(5)让结果按每行 5 张排列匹配分数用caption展示演示时很有说服力。图像搜文本的结果则直接以文字列表展示。注意上传的临时图片要落盘成文件再给encode_image因为preprocess需要一个可读的文件对象直接用uploaded的字节流会报错。4.2 影响检索质量的三个关键参数4.2.1 top_k 与最低相似度阈值top_k 只控制返回数量真正影响观感的是最低分数阈值。如果不设阈值结果列表里可能出现相似度只有 0.15 的图片看起来完全不相关。对 Flickr30k 的正常查询匹配分数通常在 0.25 到 0.45 之间低于 0.2 的结果基本可以判定为噪声。建议在界面上加一个min_score滑块min_score st.sidebar.slider(最小相似度分数, 0.0, 1.0, 0.2) filtered [(s, i) for s, i in zip(scores, idx) if s min_score]这个阈值只做过滤不重新排序。答辩时可以这样演示先展示原始 top_k再打开阈值看筛选后的结果评委能直观看到低分段到底长什么样。4.2.2 文本标准化与标点处理CLIP 的文本编码器对输入句式比较敏感。Flickr30k 里的标注句子五花八门有些以大写开头有些全是小写有些末尾带句号有些不带。最简单的标准化是统一小写并去除首尾空白但不要强行去掉句号和逗号因为 CLIP 训练语料里这些标点是常见的。参数建议范围主要影响top_k10 到 30返回数量太大时干扰重点min_score0.15 到 0.30过滤低质量噪声结果L2 归一化必须开启避免向量模长干扰排序编码 batch size64 到 256影响全量特征提取速度4.2.3 是否微调编码器如果检索只在某几个主题上表现差比如“运动场景”“夜晚街景”可以直接在界面层叠加关键词过滤但那已经不算是真正的语义检索。要真正提升 CLIP 在特定子类上的效果需要用少量 Flickr30k 数据做低学习率微调但课程设计里不建议动这一层因为微调后特征分布会漂移之前建好的 FAISS 索引随之全部失效需要重新提取特征、重建索引成本很高。4.3 常见问题与排查最常出现的问题是encode_text传了原始字符串。CLIP 文本编码器的输入必须是 tokenizer 输出传字符串会直接AttributeError。其次是图片路径不对Flickr30k 的filename只带文件名不带目录前缀拼路径时漏掉images/会报FileNotFoundError。第三是 d 维不一致FAISS 报dimension mismatch时检查特征矩阵第二维是不是 512如果中途换过模型索引必须重建。显存方面的坑也不少见。用 ViT-B/32 的时候单张图片推理只有几 MB 显存占用但如果你一次性把几万张图片全部交给 GPUbatch 不设上限就很容易爆显存。建议分批编码每批 256 张图片或者干脆用 CPU 跑预提取向量检索阶段本来就是 CPU 的强项。另外FAISS 的输入必须是float32用np.float64会报奇怪的类型错误统一astype(float32)即可。5. 用 RecallK 验证 Flickr30k 跨模态搜索的实际效果5.1 一个最小化的 RecallK 评估脚本测试界面能跑通之后还需要一个客观指标来证明系统不是“看着还行”。Flickr30k 的标准评估指标是 RecallK含义是查询结果的前 K 条里是否出现了 ground truth 对应项。文本搜图时K 取 1、5、10 比较常见。def recall_at_k(query_vecs, ground_truth_ids, index, k10): hits 0 for query_vec, gt_id in zip(query_vecs, ground_truth_ids): query_array np.array(query_vec, dtypefloat32).reshape(1, -1) faiss.normalize_L2(query_array) _, indices index.search(query_array, k) if gt_id in indices[0]: hits 1 return hits / len(query_vecs) recall_1 recall_at_k(text_vecs, img_ids, img_index, k1) recall_10 recall_at_k(text_vecs, img_ids, img_index, k10) print(fR1: {recall_1:.3f}, R10: {recall_10:.3f})其中text_vecs是测试集文本向量gt_id是该句子对应的图片在img_index中的下标。这里要明确一个细节由于前面把每条句子作为独立条目编码同一张图片会在索引里出现 5 次所以gt_id需要记录文本和图像的对应下标不能只记录图片文件名。R10 达到 0.5 以上对课程设计来说已经是可以接受的水平如果你用的 CLIP 权重本身是在大型图文对上预训练的往往能到 0.7 以上。5.2 用分数分布图判断检索质量的边界RecallK 只能告诉你“有没有命中”不能告诉你“接近命中的样本长什么样”。更实用的小技巧是画出匹配分数和噪声分数的分布图。随机抽取 500 条文本查询对每一条分别取它对应图片的匹配分数和一批随机图片的最高分数用 matplotlib 画两个直方图。import matplotlib.pyplot as plt match_scores [] noise_scores [] for text_vec, gt_id in zip(text_vecs[:500], gt_ids[:500]): query_array np.array(text_vec, dtypefloat32).reshape(1, -1) faiss.normalize_L2(query_array) scores, indices img_index.search(query_array, 50) gt_pos list(indices[0]).index(gt_id) if gt_id in indices[0] else None if gt_pos is not None: match_scores.append(scores[0][gt_pos]) noise_scores.append(np.max(scores[0][:10])) plt.hist(match_scores, bins30, alpha0.6, labelmatch) plt.hist(noise_scores, bins30, alpha0.6, labelnoise) plt.legend() plt.savefig(score_distribution.png)这个直方图的作用是帮你确认min_score阈值放在哪里。如果两条分布几乎完全重叠说明模型对这批查询根本没有区分能力问题大概率出在特征选择或数据集划分上如果两条分布有明显间隔阈值取两者交界处就是最合理的过滤点。这个方法同样适用于图像搜文本方向反过来即可。每次评估时记得固定随机种子否则不同随机样本下的分数波动会掩盖真实的优化效果。本文还有配套的精品资源点击获取