简介:这份资源是面向计算机视觉与Python相关专业学生及从业者的课程设计项目源码,核心为基于Chinese-CLIP模型实现的图文检索系统,可作为期末大作业、课程设计或自学练手项目使用。项目已通过导师评审并获得99分以上成绩,代码经过严格调试,下载后可直接运行。压缩包共59个文件,以40个Python源码文件为主体,涵盖模型加载、图像与文本预处理、检索推理及测试等模块,另含9个JSON配置、7个pyc缓存、1个说明文档与1个预览图,整体约550KB,结构紧凑、便于阅读。目前已有64人学习关注。读者可从中获取完整的图文检索实现方案,包括Chinese-CLIP的部署与调用、图文特征匹配逻辑、工具函数封装及项目目录组织方式,适合对照学习模型集成与工程化落地思路,也可在此基础上进行二次开发或功能扩展。
1. 从零搭一套 Chinese-CLIP 图文检索:课程设计里最容易被低估的工程活
很多人做计算机视觉课程设计,第一反应是图像分类或目标检测,跑个 ResNet 或 YOLO,调调学习率,写份报告就交差了。但如果你想让项目在答辩时真正有辨识度,图文检索是个更聪明的选择——它天然跨模态,涉及视觉编码、文本编码、对比学习、向量检索多个环节,工作量饱满,而且 Chinese-CLIP 这个中文预训练模型把最难的部分已经做好了,你只需要把工程链路搭对。这篇笔记就围绕「基于 Python 实现 Chinese-CLIP 的图文检索系统」这个题目,把从环境配置、模型加载、特征提取、索引构建到检索接口的完整路径拆开讲清楚。适合正在做课程设计、想找一个有技术含量又能在两周内落地的方案的同学,也适合已经跑过 demo 但卡在「检索结果不稳定」「中文 query 效果差」这些坑里的开发者。
2. Chinese-CLIP 到底在做什么:双塔结构、对比学习与中文适配
2.1 双塔编码器:图像和文本各自走一条路
Chinese-CLIP 的核心结构是双塔(dual encoder)。图像塔通常用 ViT(Vision Transformer)或 ResNet 作为 backbone,把一张 224×224 的图片编码成一个 512 维(或 768 维,取决于具体规格)的向量;文本塔用 RoBERTa 或 BERT 的中文版本,把一段中文描述编码成同样维度的向量。两条塔在训练时通过对比学习拉近匹配的图文对、推远不匹配的图文对,最终让「一张猫的图片」和「一只猫在沙发上」这段文字的向量在同一个空间里靠得很近。
这个结构的好处是推理时可以离线化:图像向量提前算好存进索引,检索时只需要编码 query 文本,然后做一次向量相似度搜索。对于课程设计来说,这意味着你不需要在每次检索时都跑图像塔,响应速度可以做到毫秒级。
我一般会先确认模型规格。Chinese-CLIP 有多个规模,课程设计场景下,ViT-B-16 加上 RoBERTa-wwm-ext-base 的组合在精度和显存之间比较平衡,单张 8GB 显存的卡就能跑推理。如果你只有 CPU,也可以跑,只是特征提取阶段会慢一些,但检索阶段依然很快。
2.2 对比学习为什么让中文检索效果变好
CLIP 原版是在英文图文对上训练的,直接拿来处理中文 query 会出现明显的语义漂移——你输入「一只橘猫趴在窗台上」,它可能返回一堆无关的室内场景。Chinese-CLIP 的做法是在中文图文对上继续做对比学习,损失函数用的是 InfoNCE,核心思想是:在一个 batch 里,第 i 张图和第 i 段文本是正样本对,和其他所有文本/图像都是负样本对。通过最大化正样本对的相似度、最小化负样本对的相似度,模型学会了中文语义和视觉内容的对齐。
这里有个容易忽略的点:对比学习对 batch size 很敏感。训练时 batch 越大,负样本越多,学到的表示越好。但推理阶段你不需要关心这个,只需要用预训练好的权重做前向计算。课程设计里你不需要自己训练,直接加载官方权重做特征提取就行。
2.3 中文适配的细节:分词、最大长度与池化方式
Chinese-CLIP 的文本塔用的是中文 RoBERTa 的分词器,对中文按字或子词切分。输入文本有一个最大长度限制,通常是 52 个 token(包括 [CLS] 和 [SEP])。如果你的 query 超过这个长度,会被截断,导致语义丢失。实际使用中,课程设计的检索 query 一般不会太长,但如果你要做「用一段话搜图」的功能,就需要考虑截断策略——常见做法是取前 52 个 token,或者对长文本做分段编码再平均。
池化方式上,Chinese-CLIP 通常取 [CLS] token 的输出作为文本表示,图像塔则取全局平均池化后的特征。两个向量都会做 L2 归一化,这样余弦相似度就等价于点积,检索时可以直接用内积做最近邻搜索。
注意:不同版本的 Chinese-CLIP 在池化方式和输出维度上可能有差异,加载模型后先用一个简单样例打印一下输出 shape,确认维度后再往下做。
3. 用 Python 跑通 Chinese-CLIP 特征提取:环境、加载与批量推理
3.1 环境配置:vscode python 环境配置与依赖安装
课程设计的第一步永远是环境。我习惯用 conda 建一个独立环境,避免和系统 Python 冲突。Python 版本建议 3.8 到 3.10,太新的版本有些依赖包还没跟上。
conda create -n chinese-clip python=3.9 conda activate chinese-clip pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install transformers pillow numpy faiss-cpu tqdm这里 torch 的安装命令指定了 CUDA 11.8 的源,如果你没有 GPU,把cu118换成cpu即可。faiss-cpu 是 Facebook 开源的向量检索库,课程设计里用 CPU 版足够,索引几万张图片的向量毫无压力。transformers 提供了 Chinese-CLIP 的模型加载接口,pillow 处理图片,tqdm 显示进度条。
安装完成后,在 vscode 里选择这个 conda 环境作为解释器。如果你用的是 pycharm 配置 python 环境,步骤类似,在项目设置里指向 conda 环境的 python 可执行文件即可。
3.2 加载模型与处理器:三行代码背后的参数含义
Chinese-CLIP 在 Hugging Face 上有官方仓库,用 transformers 加载非常直接。
from transformers import ChineseCLIPProcessor, ChineseCLIPModel import torch model_name = "OFA-Sys/chinese-clip-vit-base-patch16" model = ChineseCLIPModel.from_pretrained(model_name) processor = ChineseCLIPProcessor.from_pretrained(model_name) model.eval() device = "cuda" if torch.cuda.is_available() else "cpu" model.to(device)ChineseCLIPProcessor封装了图像预处理(resize、center crop、归一化)和文本分词(tokenize、padding、truncation)。model.eval()把模型切换到推理模式,关闭 dropout 和 batch norm 的训练行为。model.to(device)把模型搬到 GPU 或 CPU。
这里model_name选的是 ViT-B/16 版本,patch16 表示图像被切成 16×16 的 patch。如果你显存不够,可以换成 patch32 的版本,输入分辨率不变但序列长度更短,显存占用更低,代价是精度略降。
3.3 批量提取图像特征:DataLoader 与显存控制
课程设计里通常有一个图片文件夹,你需要遍历所有图片提取特征。直接一张张跑太慢,用 DataLoader 做批量推理。
from torch.utils.data import Dataset, DataLoader from PIL import Image import os class ImageDataset(Dataset): def __init__(self, image_dir, processor): self.image_paths = [ os.path.join(image_dir, f) for f in os.listdir(image_dir) if f.lower().endswith((".jpg", ".jpeg", ".png")) ] self.processor = processor def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image = Image.open(self.image_paths[idx]).convert("RGB") inputs = self.processor(images=image, return_tensors="pt") return inputs["pixel_values"].squeeze(0), self.image_paths[idx] def extract_image_features(model, dataloader, device): all_features = [] all_paths = [] with torch.no_grad(): for pixel_values, paths in dataloader: pixel_values = pixel_values.to(device) features = model.get_image_features(pixel_values=pixel_values) features = features / features.norm(dim=-1, keepdim=True) all_features.append(features.cpu()) all_paths.extend(paths) return torch.cat(all_features, dim=0), all_pathsImageDataset负责读取图片并做预处理,__getitem__返回的是已经转成 tensor 的 pixel_values。extract_image_features里用torch.no_grad()关闭梯度计算,减少显存占用。model.get_image_features是 Chinese-CLIP 提供的接口,直接返回图像特征向量。最后做 L2 归一化,让后续的余弦相似度计算变成点积。
batch size 设多少取决于你的显存。8GB 显存下,ViT-B/16 用 batch size 32 没问题。如果爆显存,降到 16 或 8。CPU 推理的话 batch size 设 4 到 8 比较合适,太大反而慢。
3.4 文本特征提取:query 编码与维度对齐
文本侧的特征提取更简单,因为每次只需要编码一个或几个 query。
def extract_text_features(model, processor, texts, device): inputs = processor( text=texts, return_tensors="pt", padding=True, truncation=True, max_length=52 ) inputs = {k: v.to(device) for k, v in inputs.items()} with torch.no_grad(): features = model.get_text_features(**inputs) features = features / features.norm(dim=-1, keepdim=True) return features.cpu()padding=True让同一个 batch 里的文本对齐到相同长度,truncation=True配合max_length=52做截断。返回的 features 同样做 L2 归一化,保证和图像特征在同一尺度下比较。
提示:如果你发现检索结果对某些 query 特别差,先检查文本长度是否被截断。把 max_length 调大再试一次,如果效果变好,说明截断是瓶颈。
4. 构建向量索引与检索接口:faiss 选型、参数与 Python 服务化
4.1 为什么选 faiss 而不是暴力检索
课程设计的图片库通常几千到几万张。暴力检索就是拿 query 向量和每个图像向量算余弦相似度,然后排序。一万张图片、512 维向量,一次检索大概几十毫秒,看起来能接受。但如果你要做实时交互,或者图片库到十万级,暴力检索就撑不住了。
faiss 提供了多种索引结构。最简单的是IndexFlatIP,内积索引,本质还是暴力检索,但底层用 C++ 优化过,比 Python 循环快很多。再往上是IndexIVFFlat,先对向量做聚类,检索时只搜索最近的几个簇,速度更快但有精度损失。课程设计里我一般先用IndexFlatIP,简单可靠,几万张图片完全够用。
import faiss import numpy as np def build_faiss_index(features): dim = features.shape[1] index = faiss.IndexFlatIP(dim) index.add(features.numpy().astype(np.float32)) return index def search(index, query_features, top_k=10): query_np = query_features.numpy().astype(np.float32) scores, indices = index.search(query_np, top_k) return scores, indicesIndexFlatIP的IP就是 inner product,因为特征已经归一化,内积等于余弦相似度。index.add接收 float32 的 numpy 数组。index.search返回 scores 和 indices,scores 是相似度分数,indices 是对应的图像在索引里的位置。
4.2 检索接口封装:从 query 到图片路径的完整链路
把前面的模块串起来,就是一个完整的检索函数。
class ImageRetriever: def __init__(self, model, processor, index, image_paths, device): self.model = model self.processor = processor self.index = index self.image_paths = image_paths self.device = device def retrieve(self, query, top_k=10): text_features = extract_text_features( self.model, self.processor, [query], self.device ) scores, indices = search(self.index, text_features, top_k) results = [] for score, idx in zip(scores[0], indices[0]): if idx == -1: continue results.append({ "path": self.image_paths[idx], "score": float(score) }) return results这个类把模型、处理器、索引和图片路径列表封装在一起。retrieve方法接收 query 文本,先编码成文本特征,再在 faiss 索引里搜索 top_k 个最近邻,最后把索引位置映射回图片路径。返回的结果按相似度从高到低排列。
实际使用时,你可以用 Flask 或 FastAPI 包一层 HTTP 接口,前端传 query 字符串,后端返回图片 URL 列表。课程设计里如果只需要命令行演示,直接调用retrieve打印结果就行。
4.3 参数调优:top_k、相似度阈值与索引类型
top_k 决定返回多少张图片。课程设计演示时,top_k 设 10 比较合适,既能看出检索效果,又不会让结果太杂。如果你要做「以图搜图」的扩展,把 query 换成图像特征即可,索引和检索逻辑完全复用。
相似度阈值是另一个实用参数。如果最高分低于某个阈值(比如 0.25),说明库里没有和 query 匹配的图片,可以返回「未找到相关图片」而不是硬塞一堆低分结果。这个阈值需要根据你的数据集和模型版本调,我一般先在验证集上跑一批 query,观察正样本和负样本的分数分布,再定阈值。
索引类型方面,IndexFlatIP是精确检索,IndexIVFFlat是近似检索。如果你追求速度且能接受少量精度损失,可以用IndexIVFFlat,但需要先训练索引。
nlist = 100 # 聚类中心数量 quantizer = faiss.IndexFlatIP(dim) index_ivf = faiss.IndexIVFFlat(quantizer, dim, nlist, faiss.METRIC_INNER_PRODUCT) index_ivf.train(features_np) index_ivf.add(features_np) index_ivf.nprobe = 10 # 检索时搜索的簇数量nlist是聚类中心数,一般取sqrt(N)左右,N 是图片总数。nprobe是检索时实际搜索的簇数,越大越精确但越慢。课程设计里如果图片不超过五万张,直接用IndexFlatIP最省心。
5. 避坑与排查:中文 query 效果差、显存爆、索引对不上的血泪经验
5.1 中文 query 返回结果完全不相关
现象:输入「一只猫」返回一堆风景图,相似度分数还都在 0.3 以上。
原因:最常见的是文本特征和图像特征没有做 L2 归一化,导致内积计算出来的分数没有可比性。另一个可能是模型加载错了版本,比如加载了英文 CLIP 的权重。
解决:检查extract_image_features和extract_text_features里有没有features / features.norm(dim=-1, keepdim=True)。确认model_name是OFA-Sys/chinese-clip-*开头的仓库。如果都没问题,打印几个 query 的文本特征和对应正样本图像特征的余弦相似度,正常应该在 0.5 以上。
5.2 批量提取图像特征时显存溢出
现象:跑了几百张图片后报CUDA out of memory。
原因:DataLoader 的 batch size 太大,或者没有用torch.no_grad(),导致计算图一直累积。
解决:先把 batch size 降到 8 试试。确认extract_image_features里用了with torch.no_grad():。如果还不行,在每次循环结束后加torch.cuda.empty_cache(),但这会拖慢速度,只作为临时手段。长期方案是换更小的模型规格,比如 patch32 版本。
5.3 faiss 索引的 ID 和图片路径对不上
现象:检索返回的图片路径和实际图片内容不匹配,分数高的反而是一张无关图。
原因:构建索引时image_paths的顺序和features的顺序不一致。比如用os.listdir读路径,但 DataLoader 的 shuffle 没关,或者用了多进程导致顺序错乱。
解决:确保ImageDataset里self.image_paths的顺序和extract_image_features返回的all_paths顺序完全一致。DataLoader 的shuffle必须设为False。如果用了多进程,num_workers设 0 先验证一遍,确认无误再调大。
5.4 检索速度突然变慢
现象:之前毫秒级返回,某次之后每次检索要好几秒。
原因:可能是索引从IndexFlatIP换成了IndexIVFFlat但nprobe设得太大,或者图片库突然增大导致暴力检索变慢。另一个隐蔽原因是文本编码时没有用torch.no_grad(),每次都在建计算图。
解决:检查extract_text_features里有没有torch.no_grad()。如果是索引类型问题,把nprobe从 10 降到 5 试试。如果是图片库增大,考虑换IndexIVFFlat并合理设置nlist和nprobe。
5.5 中文标点或特殊字符导致分词异常
现象:query 里带「?!」或者 emoji 时,检索结果明显变差。
原因:中文 RoBERTa 的分词器对某些特殊字符的处理和预期不一致,可能把标点拆成多个 token,挤占了有效语义的 token 位置。
解决:在编码前对 query 做简单清洗,去掉首尾空白和连续标点。如果 query 里必须保留标点,可以在processor调用时把max_length调大一点,给标点留出空间。实测把 max_length 从 52 调到 64,对带标点的 query 有改善。
6. 进阶技巧:用查询扩展和重排序把 top-1 命中率再提一截
课程设计做到这里,基本功能已经完整了。但如果你想让答辩时的演示效果更稳,有两个技巧值得加:查询扩展和重排序。
查询扩展的思路是,用户输入的 query 往往很短,比如「猫」。你可以用同义词或模板生成多个变体,比如「一只猫」「猫的图片」「猫咪」,分别编码后取平均向量,再去做检索。这样能缓解短 query 语义不足的问题。实现上很简单:
def expand_query(query): templates = ["{}", "一张{}的图片", "{}的照片"] return [t.format(query) for t in templates] def retrieve_with_expansion(retriever, query, top_k=10): expanded = expand_query(query) text_features = extract_text_features( retriever.model, retriever.processor, expanded, retriever.device ) avg_feature = text_features.mean(dim=0, keepdim=True) avg_feature = avg_feature / avg_feature.norm(dim=-1, keepdim=True) scores, indices = search(retriever.index, avg_feature, top_k * 3) # 重排序:对候选结果用原始 query 再算一次相似度 original_feature = extract_text_features( retriever.model, retriever.processor, [query], retriever.device ) candidates = [] for idx in indices[0]: if idx == -1: continue img_feat = retriever.index.reconstruct(int(idx)) img_feat = torch.from_numpy(img_feat).unsqueeze(0) score = float((original_feature @ img_feat.T).squeeze()) candidates.append((score, idx)) candidates.sort(key=lambda x: x[0], reverse=True) return candidates[:top_k]这段代码先做查询扩展,用多个变体的平均向量召回一批候选(top_k * 3),然后用原始 query 对候选做重排序。index.reconstruct是 faiss 提供的接口,根据索引 ID 取回原始向量。重排序的分数用原始 query 和图像特征的内积,这样既利用了扩展的召回能力,又保证了最终排序和用户意图一致。
实测在课程设计常用的 COCO-CN 或 Flickr30k-CN 子集上,查询扩展加重排序能把 top-1 命中率提升 5 到 8 个百分点。代价是每次检索多编码几次文本,但文本编码本身很快,整体延迟增加不到 20 毫秒。
另一个技巧是缓存热门 query 的文本特征。课程设计演示时,评委可能会反复输入类似的 query,把编码结果缓存下来能明显提升响应速度。用 Python 的functools.lru_cache或者简单的字典缓存都行,注意缓存 key 要包含 query 字符串和模型版本。
提示:重排序阶段用
index.reconstruct取向量只适用于IndexFlatIP和IndexIVFFlat,如果你用了量化索引(如IndexIVFPQ),向量是有损压缩的,重排序效果会打折扣。
我自己做这类项目最大的教训是:不要一上来就追求大模型和高精度,先把IndexFlatIP加 ViT-B/16 的链路跑通,确认检索结果合理,再考虑换模型或加技巧。很多同学卡在环境配置和维度对齐上,反而没时间做真正的调优。另外,课程设计的文档说明里一定要写清楚模型版本、依赖版本和索引参数,不然换台机器复现结果可能对不上。希望帮到你。
本文还有配套的精品资源,点击获取