ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于CLIP与向量检索实现跨模态图文搜索

基于CLIP与向量检索实现跨模态图文搜索 简介面向计算机相关专业学生的跨模态检索课程设计资源基于Flickr30k数据集实现图像与文本的双向搜索。项目提供完整Python源码覆盖数据预处理、模型训练、评估与可视化测试界面等核心环节适合作为课设、毕设或入门进阶的参考项目。压缩包共35个文件其中包含22个Python脚本及6个编译后的pyc文件用于完成数据集划分、resize、训练与评估另有3个Markdown文档与shell脚本便于快速了解项目结构并初始化环境整体体积仅1.13MB轻量易用。目前已有586人学习下载。代码均经过运行验证答辩评审平均分96分资源目录按预处理、训练、测试等模块划分便于定位和二次开发可直接使用或在此基础上扩展其他功能是与跨模态检索相关的完整实践范例。1. 从一个反直觉现象开始跨模态搜索做的是向量比较不是文本匹配在检索框输入a man with a red umbrella walking on the beach返回结果里没有一张图在文件名或标签中带有umbrella字样。传统关键词匹配在跨模态场景下失效原因很简单图片没有原生文本人工打标签也覆盖不了这种组合型长尾。Flickr30k 数据集恰好是最适合课程设计的落地样本31,783 张图片每张附 5 条英文描述规模足够小单卡甚至 CPU 都能在十分钟内完成特征抽取。这个题目的核心不是让程序“看懂”图片而是把图像与文本编码进同一个向量空间。CLIP 这类双塔模型负责把两种模态对齐向量检索负责快速召回。后文按数据清洗、CLIP 特征抽取、相似度检索、Flask 测试界面、RK 评估五个步骤展开每一步都给出可直接运行的 python 源码和参数含义。2. Flickr30k 数据集清洗与 CLIP 特征抽取2.1 认识 captions.txt先读原始标注再做清洗下载到的 Flickr30k 解压后常见目录结构是flickr30k_images/存放 JPGcaptions.txt存放图片描述。每一行格式固定为图片文件名TAB描述文本。torchvision 里虽然有现成的Flickr30k数据类但课程设计改过目录结构后我建议先自己解析一次原始文件后续去重、过滤脏数据也更可控。from pathlib import Path ann_path Path(data/captions.txt) pairs [] with ann_path.open(r, encodingutf-8) as f: for line in f: line line.rstrip(\n) if not line: continue img_name, caption_text line.split(\t, maxsplit1) pairs.append((img_name, caption_text)) print(caption 总条数:, len(pairs)) print(去重图片数:, len({img for img, _ in pairs})) print(平均单词数:, sum(len(c.split()) for _, c in pairs) / len(pairs))split(\t, maxsplit1)是为了防止描述文本里出现制表符导致切出多列。打印统计信息能提前发现两类问题一是明明该有 15 万条标注却少了几千大概率是下载时文件被截断二是平均单词数异常说明 caption 与图片错位。清洗规则直接决定后续检索质量。CLIP 的 tokenizer 最大上下文是 77 个 token超过部分会被截断所以长度过滤比想象中更重要。常用策略如下处理项默认策略说明英文大小写全部转小写CLIP 词表以小写为主保留大小写会稀释命中率特殊符号替换为空格保留, . ? !去掉# $等噪音长度过滤保留 460 词少于 4 词通常语义过弱多于 60 词基本会被截断import re def clean_caption(text: str) - str: text text.lower() text re.sub(r[^a-z0-9\s,\.\?\!], , text) text re.sub(r\s, , text).strip() return text def caption_ok(text: str, min_len: int 4, max_len: int 60) - bool: n len(text.split()) return min_len n max_len texts [clean_caption(cap) for _, cap in pairs if caption_ok(cap)]注意清洗后要重新做一次去重Flickr30k 原始标注里偶尔出现完全相同的句子对应不同图片留着会影响评估阶段的正样本判定。2.2 用 open_clip 双塔模型批量抽取图像与文本向量特征抽取是整个项目的核心开销建议一次性把所有向量算好存成.npy运行时直接加载而不是每次查询都重新编码。这里用open_clip加载 ViT-B/32 权重它输出的向量维度是 512对 15 万条文本规模足够。import numpy as np import torch import open_clip from pathlib import Path from PIL import Image from torch.utils.data import DataLoader, Dataset class Flickr30kImages(Dataset): def __init__(self, image_dir: str, transform): self.images sorted(Path(image_dir).glob(*.jpg)) self.transform transform def __len__(self): return len(self.images) def __getitem__(self, idx): img Image.open(self.images[idx]).convert(RGB) return self.transform(img), str(self.images[idx]) model, _, transform open_clip.create_model_and_transforms( ViT-B-32, pretrainedlaion2b_s34b_b79k, ) model.eval() tokenizer open_clip.get_tokenizer(ViT-B-32) dataset Flickr30kImages(data/flickr30k_images, transform) loader DataLoader(dataset, batch_size64, num_workers4) image_embs, image_paths [], [] with torch.no_grad(): for batch, paths in loader: feats model.encode_image(batch) feats torch.nn.functional.normalize(feats, dim-1) image_embs.append(feats.cpu()) image_paths.extend(paths) image_embeddings torch.cat(image_embs).numpy().astype(float32) np.save(data/image_embeddings.npy, image_embeddings)normalize(dim-1)这一步不能省。CLIP 原始输出不是单位向量直接做点积得到的是带尺度偏差的相似度后面设阈值时会非常难调。统一归一化后点积就等于余弦相似度阈值含义变得直观。文本向量同理但 15 万条 caption 一次性encode_text会把显存撑爆需要分块text_embs [] batch_size 512 for i in range(0, len(texts), batch_size): batch_tokens tokenizer(texts[i:i batch_size]) with torch.no_grad(): batch_emb model.encode_text(batch_tokens) text_embs.append(batch_emb.cpu()) text_embeddings torch.cat(text_embs).numpy().astype(float32) np.save(data/text_embeddings.npy, text_embeddings)参数这里有几处值得按机器配置调整。batch_size64对 8GB 显存是安全的如果显存更小就降到 16 或 8CPU 环境跑图像特征建议num_workers4以上让数据加载和模型推理重叠。ViT-B/32 在 3.1 万张图上通常几分钟能完成换成 ViT-L/14 会明显变慢但它 768 维的特征在做细粒度检索时更稳。参数推荐值调整方向图像 batch_size64显存不足时依次减半文本 batch_size512按显存调整OOM 就降到 256存储精度float3215 万×512 维约 300MB没必要用 float64模型规模ViT-B/32追求效果可换 ViT-L/14维度会变 768第一次运行会从模型仓库拉取预训练权重离线环境需要先在有网机器上把权重缓存好再拷贝到目标机器的~/.cache目录。这个细节在课程设计答辩现场经常被问到。3. 跨模态搜索的相似度计算从暴力遍历到 faiss 向量索引3.1 双向检索方向不同gallery 也随之切换跨模态搜索有两个方向文本查图像、图像查文本。很多课程设计只做了一个方向但测试界面上通常会同时展示。文本查图像时候选库是image_embeddings.npy每一行对应一张图图像查文本时候选库是text_embeddings.npy每一行对应一条 caption。搞清楚这一点代码反而比做单方向更简单。import numpy as np def topk_search(query_vec: np.ndarray, gallery: np.ndarray, k: int 10): scores gallery query_vec top_indices np.argpartition(scores, -k)[-k:] top_indices top_indices[np.argsort(scores[top_indices])[::-1]] return top_indices, scores[top_indices]argpartition只保证前 k 个元素在正确区间不保证有序所以后面要再套一次argsort。对 15 万条文本做一次矩阵乘在 CPU 上只需几十毫秒这是 Flickr30k 这种小规模数据集可以直接暴力检索的原因。文本查图像的调用方式query a man walking his dog in the park tokens tokenizer([query]) with torch.no_grad(): q model.encode_text(tokens) q torch.nn.functional.normalize(q, dim-1).numpy()[0] idx, scores topk_search(q, image_embeddings, k10) for pos, score in zip(idx, scores): print(image_paths[pos], round(float(score), 4))图像查文本则把image_embeddings换成text_embeddings返回的是 caption 文本而非图片路径。课程设计的常见错误是只做文本查图像然后在“以图搜文”按钮上复用同一个 gallery导致结果永远是同一批图。方向不同查询向量和 gallery 的对应关系都要一起换。3.2 数据量上去后用 faiss 保留精确召回Flickr30k 只有 15 万条文本暴力检索够用。如果后续换成 MS COCO 或更大规模数据集argpartition虽然比argsort快但仍是全量扫描这时候切到 faiss 是标准做法。import faiss dim image_embeddings.shape[1] index faiss.IndexFlatIP(dim) index.add(image_embeddings) scores, idx index.search(q[None, :], k10)IndexFlatIP是暴力内积索引精确返回 top-k因为向量已经归一化内积就是余弦相似度。这里有个常踩的坑有人直接用IndexFlatL2不归一化查询向量返回结果和余弦版本完全不是一回事。索引类型适用规模召回率特点使用注意IndexFlatIP100 万以内精确召回必须先 L2 归一化IndexIVFFlat千万级接近精确需设置 nlist、nprobeIndexHNSWFlat千万级高召回构建慢内存占用高切到IndexIVFFlat时只需在 add 前用训练集index.train()建聚类。两个关键参数是nlist和nprobenlist控制分桶数量经验值是sqrt(N)15 万条数据取 400 左右nprobe控制查询时扫描多少个桶调大能提升召回但变慢。课程设计答辩时讲清楚这两个参数的权衡比单纯堆代码得分高得多。阈值设定也很容易出错。不同模型的相似度分数分布差异很大ViT-B/32 的高分样本经常落在 0.3 以上但换成更强的模型可能整体偏移。建议在测试阶段打印一批正样本和负样本的分数分布再决定score_threshold取 0.2 还是 0.3。4. 测试界面与查询服务用 Flask 把检索链路暴露成 API4.1 定义 /api/search/text 与 /api/search/image测试界面本质上是对检索服务的可视化封装。先用 Flask 把两个方向封装成 JSON API方便前端调用也方便后续从网页测试切到命令行测试。import io import numpy as np import torch import open_clip from flask import Flask, request, jsonify, render_template from PIL import Image app Flask(__name__) model None transform None tokenizer None def load_model(): global model, transform, tokenizer if model is None: model, _, transform open_clip.create_model_and_transforms( ViT-B-32, pretrainedlaion2b_s34b_b79k ) model.eval() tokenizer open_clip.get_tokenizer(ViT-B-32) return model, transform, tokenizer懒加载模型的目的是避免服务启动时阻塞但第一次请求会明显慢前端需要做 loading 提示。接下来定义文本查询端点app.route(/api/search/text, methods[POST]) def search_by_text(): data request.get_json() text data.get(text, ).strip() k min(int(data.get(k, 10)), 50) if not text: return jsonify({code: 400, msg: text is empty}), 400 model, _, tokenizer load_model() tokens tokenizer([text]) with torch.no_grad(): q model.encode_text(tokens) q torch.nn.functional.normalize(q, dim-1).numpy().astype(float32)[0] idx, scores topk_search(q, image_embeddings, kk) items [ {image: image_paths[i], score: float(s)} for i, s in zip(idx, scores) ] return jsonify({code: 0, data: items})限制k min(..., 50)是为了防止用户在界面上传一个 10000 导致前端卡死。image_paths[i]需要和保存image_embeddings.npy时的顺序严格一致所以建议在特征抽取阶段把image_paths也存成image_paths.json而不是靠文件名二次排序。图像查询端点接收 multipart 文件处理逻辑几乎一样只是编码器换成encode_imageapp.route(/api/search/image, methods[POST]) def search_by_image(): f request.files.get(image) if f is None: return jsonify({code: 400, msg: image is required}), 400 img Image.open(f.stream).convert(RGB) model, transform, _ load_model() tensor transform(img).unsqueeze(0) with torch.no_grad(): q model.encode_image(tensor) q torch.nn.functional.normalize(q, dim-1).numpy().astype(float32)[0] idx, scores topk_search(q, text_embeddings, k10) items [ {caption: texts[i], score: float(s)} for i, s in zip(idx, scores) ] return jsonify({code: 0, data: items})注意两个端点的 gallery 不同文本查询图像用image_embeddings图像查询文本用text_embeddings。如果复制代码时把 gallery 也复制错返回结果会变成“文本查文本”或“图像查图像”分数看起来正常但语义完全错位。4.2 前端交互输入框、上传按钮与结果展示前端只做一个原生 HTML 页面就够了不引 React 这类框架课程设计阶段反而更利于讲清楚数据流。核心是 fetch 调接口然后把返回的图片路径或文本插入结果区。input idquery placeholder输入英文描述例如 a dog running on grass / button onclicksearchText()检索图片/button div idresults/div script async function searchText() { const text document.getElementById(query).value; const resp await fetch(/api/search/text, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({text: text, k: 12}) }); const data await resp.json(); if (data.code ! 0) return; document.getElementById(results).innerHTML data.data .map(item div styledisplay:inline-block;margin:8px; img src/static/${item.image} width240 / pscore: ${item.score.toFixed(4)}/p /div) .join(); } /script图片能显示的前提是 flask 能找到对应静态文件。常见做法是把flickr30k_images目录软链到static/flickr30k_images或者在 Flask 里额外注册一个静态目录。图像上传的交互类似换成input typefile和FormData即可上传后调用/api/search/image渲染结果时显示 caption 文本。4.3 启动服务后的三类边界情况现象原因处理首次请求等了几十秒模型权重懒加载前端加 loading或启动时主动 load_model()上传非 JPG 文件报 500PIL 打开失败Image.open套 try/except 返回 400检索维度不一致模型换过但 npy 没重新抽检查嵌入维度ViT-B/32 是 512ViT-L/14 是 768注意app.run(debugTrue)在提交作业时最好关掉debug 模式会启动 reloader模型权重会被重复加载内存占用翻倍。5. 评估调优让 Flickr30k 跨模态搜索在课程设计里拿高分5.1 用 RK 量化召回质量只靠肉眼判断几个查询结果说服力不够。课程设计加分项是把评估脚本和检索代码放在一起用 RK 说明方案在 Flickr30k 上的表现。def recall_at_k(query_embs, gallery_embs, query_labels, gallery_labels, k10): scores query_embs gallery_embs.T hits 0 for i in range(len(scores)): top_ids np.argsort(scores[i])[::-1][:k] hits int(query_labels[i] in set(gallery_labels[top_ids])) return hits / len(scores)query_labels和gallery_labels需要按数据集组织。文本查图像方向一张图对应 5 条 caption正确做法是把每张图作为 query 的 label和 5 条 caption 分别计算再取平均图像查文本方向则反过来。R10 的结果不建议跨模型裸比不同预训练权重和清洗策略都会影响绝对数值重点看同一实验内的相对提升。5.2 三个投入产出比最高的调优方向调优手段成本适用场景换更大的 CLIP 模型重新抽特征基线效果普遍偏低时对 Flickr30k 做双塔微调需要少量标注检索概念有明显领域偏差时二阶段重排改服务层逻辑粗排 top-50 内已有正确答案时重排是性价比最高的方案先用 CLIP 粗排取 top-50再用更重的模型或同一个模型对 top-50 内的图文对重新计算相似度效果比盲目换大模型更可控。难例挖掘的具体做法是收集 top-10 里的假正例打印 query 与错误结果的 score观察是颜色词混淆还是场景词混淆再决定是否需要对训练数据进行增强。最后给一个交付级技巧把R1、R5、R10和score_threshold画成一条曲线观察阈值从 0.2 升到 0.4 时准确率与召回的此消彼长这份图表放进项目说明比大段文字更能说明你理解了跨模态搜索的评估逻辑。本文还有配套的精品资源点击获取
返回列表