多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Chinese-CLIP图文检索实战:从双塔原理到课程设计

Chinese-CLIP图文检索实战:从双塔原理到课程设计 简介基于Chinese-CLIP的图文检索系统源码包及课程设计配套文档面向计算机视觉相关专业学生可作为期末大作业、课程设计的完整参考方案也适合刚接触跨模态检索的开发者快速上手。压缩包共59个文件其中Python源码占40个JSON配置9个另有pyc缓存、txt说明、png示意图和md文档整体大小仅543KBpyc为运行缓存无需改动txt与md提供环境与使用说明png可用于效果展示。目前已有178人学习下载。代码覆盖图文特征提取、中文语义匹配、相似度检索排序和前端演示等关键环节并包含数据预处理、模型评估与部署脚本核心位置留有注释逻辑清晰新手无需从零搭建即可替换成自己的数据与场景实现以文搜图与以图搜文。文档说明还可辅助理解代码结构、环境配置与运行流程适合直接用于课程答辩或作业提交。1. 图文检索课程设计为什么Chinese-CLIP是性价比最高的起点图文检索这个题目课程设计里看着唬人其实拆开就两件事给一张图找到匹配的中文描述给一句中文描述找到匹配的图片。以前做这种系统要自己训练两个网络再配一堆人工特征到交作业那天多半还在过拟合里打转。Chinese-CLIP把这条路缩短了一大截它是面向中文场景的CLIP模型图像和文本各走一条编码器最后映射到同一个向量空间匹配程度直接看向量点积。你不需要从零训练下载预训练权重调用官方接口就能搭出一个可演示的检索系统。这篇笔记从双塔结构讲起到最小推理代码、数据准备和索引构建再列出5个实际会翻车的点适合正在做计算机视觉大作业、或者想快速验证多模态检索想法的同学。每一步都会给出参数说明和取舍理由照着做能少踩很多坑。2. 从零看懂Chinese-CLIP双塔结构、中文预训练与选型理由2.1 双塔结构在做什么图像和文本各走一条编码器CLIP类模型最大的设计特点是把视觉和语言拉进同一个高维空间。图像这边一般用ViT或者ResNet输出一个向量文本这边用BERT类的编码器输出另一个向量。训练时两个塔的向量维度被强制拉成一致比如512维或768维。模型用海量图文对做对比学习一张图和它的文字描述组成正样本随机配对的图文组成负样本损失函数不断拉近正样本的距离同时推远负样本直到语义相同的图文在向量空间里聚在一起。这里很容易忽略一点双塔的输出都经过了L2归一化相似度计算用的不是普通点积而是归一化后的余弦相似度。而且CLIP系列还有一个训练出来的温度系数logit_scale作用是把相似度分数放大到合适范围。很多教程只教你怎么拿embedding做内积忘了乘温度系数导致排序虽然不一定会错但分数绝对值失去了语义后续做阈值截断时就会莫名其妙。举个实际的例子如果一张图片里有一只白猫和一个红色球正样本文本是“一只白猫在玩红球”。模型会把这张图的视觉特征和这条文本特征拉近同时把“一只黑狗在奔跑”这样的负样本推远。随着训练步数增加向量空间里不同语义区域逐渐分离这就是双塔能实现检索的根本原因。理解这一点对后续调参很重要因为第二个文本如果描述的是“猫和球”它和图像特征的夹角就会比“狗”小得多。为什么这种结构适合图文检索传统方案要先做物体检测和属性识别再转成关键词去匹配遇到没有出现在候选词表里的说法就凉了。双塔模型学的是整体语义对齐输入一段中文描述“一只戴草帽的橘猫坐在窗台上”哪怕训练数据里没有这句话只要图像塔能提取出猫、帽子、窗台这些视觉特征文本塔能理解这句话的组合含义检索就能命中。这种开放词汇能力正是课程设计答辩时的加分点。2.2 Chinese-CLIP在CLIP基础上改了什么中文数据与优化目标Chinese-CLIP并不是简单把英文CLIP的文本编码器换成中文。常见做法是图像塔沿用OpenAI CLIP的预训练权重初始化文本塔用中文BERT初始化再用大规模中文图文对做全量或分阶段微调。训练目标除了对比学习还引入了图文匹配的辅助分支让模型在“判断图文是否匹配”这个任务上能力更强检索时对中文语义的敏感度也会更高。你在网上搜代码时会发现有人用transformers加载一个中文BERT再接一个线性层声称等价于Chinese-CLIP。我劝你放弃这种DIY因为语义对齐的效果差了不止一个档次。Chinese-CLIP发布时带了一整套配套的分词器和预训练权重连logit_scale都是训练好的必须用官方同一套代码才能正确加载。自行拼装模型经常出现文本塔和图像塔的向量空间根本没对齐的现象检索结果和随机排序差不多。另一个需要注意的点是中文文本的表达习惯。Chinese-CLIP对中文词组和成语的语义捕捉比英文模型好很多但它不是搜索引擎倒排索引对关键词堆砌并不友好。我测试过“猫 沙发”这种两词查询效果远不如“一只猫趴在浅灰色的沙发上”。原因是训练数据里的caption多是完整通顺的句子模型学到的是句子级语义而不是词袋匹配。所以做检索系统时query最好用自然语句。2.3 为什么不建议自己训练双塔模型算力与数据账课程设计周期通常一两周算力可能只有实验室一张卡数据能翻到几千张带标注图片就算不错了。用这种条件去训练双塔十有八九是验证集指标感人一换真实图片就分布偏移。对比学习对数据量和batch size极其敏感CLIP原论文用了4亿图文对batch size达到32768普通设备batch size 64甚至16正负样本的区分度根本拉不开loss曲线一路震荡。如果你的目标是毕业设计级别的效果我一般会建议在预训练权重上做轻量微调比如LoRA只更新一部分参数既保证中文能力不退化又能适配自己的小数据集。但对课程设计来说直接用官方预训练权重做特征提取效果已经足够撑起一个演示还能省出时间做检索界面和实验对比。把精力花在数据整理和接口展示上性价比远高于调一个不收敛的训练脚本。选型方面我有一个常用的参考表不同模型的显存开销和速度差异明显。下表是经验值实际占用和你的图片分辨率、batch size有关不要当精确数据模型名图像塔主干文本塔主干推理显存参考fp16适合场景RN50ResNet-50BERT-base约1GBCPU为主或显存受限ViT-B-16ViT-B/16BERT-base约2GB课程设计主力ViT-L-14ViT-L/14BERT-base约6GB追求更高检索精度ViT-B-16是我用得最多的一档推理速度、显存占用和检索效果之间比较平衡。如果你机器没有独立显卡别选ViT-L-14CPU上跑一张图可能要等好几秒做演示时体验很差。3. 把最小系统跑起来Chinese-CLIP的安装与推理示例3.1 环境依赖和模型权重准备先说环境。Chinese-CLIP的推理依赖PyTorch和transformersPython版本建议3.8以上。我一般在动手前先建一个干净的Anaconda环境避免把系统Python弄乱。安装顺序是先装PyTorch再装项目依赖否则transformers版本冲突会让人疯掉。# 建环境并激活 conda create -n clip_demo python3.9 -y conda activate clip_demo # 安装PyTorch这里以CUDA 11.8为例 # 如果只有CPU把index-url换成CPU版本即可 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118PyTorch装好之后去Chinese-CLIP官方渠道拿到源码压缩包或克隆仓库进入根目录安装依赖cd Chinese-CLIP pip install -r requirements.txt安装依赖时最常见的坑是transformers被装成了老版本导致部分API不兼容。我建议把transformers装到4.30以上如果遇到奇怪报错优先检查依赖列表里是否锁定了版本。依赖装完还不够你需要下载预训练权重。用官方接口加载时会自动触发下载但网络状况不佳时会卡住所以更好的做法是先手动拿权重文件放到本地目录。这里有一个常见误区有人用pip install cn_clip直接装然后import报错因为模块的实际路径和项目结构有关。我一般不用全局pip安装而是把项目根目录加入sys.path或者直接在项目根目录下运行脚本这样cn_clip包可以被正确找到。如果你用的是从别处下载的整合包也要先确认包名和入口脚本一致。import torch from cn_clip.clip import load_from_name device cuda:0 if torch.cuda.is_available() else cpu model, preprocess load_from_name(ViT-B-16, devicedevice, download_root./checkpoints) model.eval()download_root指定权重保存目录首次运行会自动拉取。如果自动下载失败去模型发布页把对应权重文件放入该目录再运行就不会重复下载。注意权重文件名要和代码预期匹配否则加载时报找不到文件。3.2 图像编码与文本编码的最小调用模型加载成功后编码一张图和一段文本只需要几行代码。这里直接给一个完整的最小片段from PIL import Image from cn_clip.clip import tokenize image preprocess(Image.open(demo.jpg)).unsqueeze(0).to(device) text tokenize([一只猫趴在沙发上, 一条狗在草地上奔跑]).to(device) with torch.no_grad(): image_features model.encode_image(image) text_features model.encode_text(text) # 关键L2归一化后再算相似度 image_features image_features / image_features.norm(dim-1, keepdimTrue) text_features text_features / text_features.norm(dim-1, keepdimTrue)preprocess是模型自带的预处理管线内部包含resize、center crop和归一化它会和模型输入规格保持一致所以不要自己写一套torchvision transform。unsqueeze(0)的作用是增加一个batch维度因为模型默认接收[B, C, H, W]的输入。tokenize返回的tokenizer输出已经包含attention mask直接丢给encode_text即可。这里必须强调归一化。如果你省略除以norm的操作直接用原始特征点积相似度会被向量长度干扰同一张图和不同文本比较时分数范围会漂移后面的阈值设置全部失真。我见过不少源码demo没做归一化分数看着像模像样实际换个数据就翻车。tokenize的默认上下文长度在Chinese-CLIP里一般是52个token超过部分会被截断。写中文caption时最好控制在50个字以内否则后半句对检索的贡献会丢失。如果你发现自己写的长句子检索效果不好先检查是不是被截断了。3.3 相似度计算与排序从logits到概率有了图像和文本的特征下一步就是计算匹配分数并排序。注意要乘上模型训练出来的温度系数# logit_scale是训练出的对数温度参数exp之后用于还原分数尺度 logit_scale model.logit_scale.exp() logits logit_scale * image_features text_features.t() # 按文本维度做softmax得到当前图像匹配各条文本的概率 probs logits.softmax(dim-1) print(probs)logits的形状是[1, num_texts]第i个元素表示输入图像与第i条文本的匹配程度。乘logit_scale很重要因为训练时模型就在归一化特征与温度系数共同作用下计算对比损失推理时如果不乘softmax的结果会非常平缓看起来所有文本概率都差不多排序关系虽然还在但让人分不清哪个是真正命中。做检索场景时你通常需要返回TopK结果。最简单的方式是用torch.topktopk_indices logits[0].topk(k2).indices.tolist() for idx in topk_indices: print(f{idx}: score{logits[0][idx]:.3f})k就是你要的返回条数业务上一般取5或10。有人习惯用numpy的argsort拿TopK数据量小的时候没问题但每次都要做全量排序。torch.topk在索引规模很大时更快而且可以直接回到GPU上跑。如果要做批量查询一次传入多个文本logits会变成[num_queries, num_texts]TopK就要按dim1处理。这一节跑通之后你已经具备图文检索最核心的能力输入文本模型返回图片与文本的匹配度输入图片模型返回文本与图片的匹配度。后面的工作都围绕怎么把单样本推理扩展成一套可演示的系统。4. 做成一门课程设计数据集、特征索引与检索接口4.1 数据集准备从哪里来、怎么整理成需要的格式课程设计需要一个可演示的图库不能靠单张图片撑场面。常见做法是取公开数据集的一个子集比如COCO验证集的一部分或者你自己维护一个小型图片包。我建议优先选带中文标注的数据集如果没有也可以先用其他数据集的英文caption机器翻译成中文后人工粗查一遍。注意别从陌生网站下载来路不明的压缩包。为了统一处理我把数据整理成MSCOCO风格一个图片目录加一个JSON文件JSON里包含图片列表和标注列表。格式如下{ images: [ {file_name: 000001.jpg, id: 1}, {file_name: 000002.jpg, id: 2} ], annotations: [ {image_id: 1, caption: 一只猫趴在浅灰色沙发上}, {image_id: 2, caption: 夕阳下的城市街道} ] }读取这个JSON并构建检索目录的脚本很简单import json from pathlib import Path data_root Path(data/images) annotations json.load(open(data/annotations.json)) catalog [] for ann in annotations[annotations]: img_id ann[image_id] file_name next( img for img in annotations[images] if img[id] img_id )[file_name] catalog.append({ image_path: str(data_root / file_name), caption: ann[caption] }) print(len(catalog), 条图文对准备完成)next遍历查找图片文件名数据量大时会慢但几千条级别完全够用。如果要快可以先按id建一个字典映射避免O(n)查找。将catalog保存为pickle或json后续特征提取直接复用。caption质量对检索系统的演示效果影响非常大。如果你的数据是从公开数据集搬来的建议过滤掉实在太短的标注比如只有一个词还要删除与图片内容明显无关的标注。我一般会写一个简单脚本统计caption长度分布把长度小于4个字的挑出来人工看一眼。这一步能让你在答辩时少被问倒。4.2 特征提取与离线索引构建在线查询时实时编码整个图库不现实因此我把图片特征提前算好存成numpy矩阵。查询时将文本特征和这个矩阵做一个矩阵乘法就能得到所有图片的相似度。import numpy as np import torch from PIL import Image image_features [] image_paths [] batch_size 16 for i in range(0, len(catalog), batch_size): batch catalog[i:ibatch_size] imgs torch.stack([ preprocess(Image.open(item[image_path])) for item in batch ]).to(device) with torch.no_grad(): feats model.encode_image(imgs) feats feats / feats.norm(dim-1, keepdimTrue) image_features.append(feats.cpu().numpy()) image_paths.extend([item[image_path] for item in batch]) image_features np.concatenate(image_features, axis0) np.save(image_features.npy, image_features) # 图片路径用json保存避免numpy保存字符串数组时的长度问题 import json json.dump(image_paths, open(image_paths.json, w))batch_size直接影响显存占用16在大多数显卡上没问题显存不够就调到4或8。image_features.npy的形状是[N, 512]ViT-B-16的特征维度就是512。这里把所有特征都在GPU上归一化后再转回CPU因为后续查询只需要矩阵乘法CPU也能处理不用一直占着显存。如果图库里有损坏的图片Image.open会在预处理阶段抛出异常。稳妥做法是在读取时加一个try遇到无法打开的图片就跳过并通过日志记录。另外图片颜色模式和通道数也要注意遇到RGBA图片要先转成RGB否则会报错或产生错误结果。如果图片数量上万numpy矩阵乘积仍然很快因为一次全量运算也就几毫秒。十万级别以上才需要考虑faiss课程设计规模用numpy足够。值得注意的是图片路径保存用json而不是npy是因为numpy保存字符串数组时会统一长度路径长短不一很容易出错。4.3 查询接口与结果可视化索引准备好之后查询逻辑就非常简单了。实现一个search函数def search(query, top_k5): text tokenize([query]).to(device) with torch.no_grad(): text_feat model.encode_text(text) text_feat text_feat / text_feat.norm(dim-1, keepdimTrue) # 图像特征矩阵已经归一化这里直接用矩阵乘法 similarity text_feat image_features.T # [1, N] topk_idx similarity[0].topk(top_k).indices.cpu().tolist() return topk_idxsimilarity的每一列是当前query与对应图片的余弦相似度。topk返回的indices就是图片在矩阵中的行号用它到image_paths.json里查路径即可。如果你想展示分数similarity[0][idx]就是该图的匹配分数。实际演示中用户可能连续输入多个query所以search函数里最好加一个空query检查。如果query全是空白字符直接返回空列表不要调用模型。另外分数显示保留三位小数就够了太多小数位反而让人觉得不专业。可视化我用matplotlib简单直接答辩时也看得清楚import matplotlib.pyplot as plt image_paths_list json.load(open(image_paths.json)) def show_results(query, top_k5): idxs search(query, top_k) plt.figure(figsize(3 * top_k, 3)) for pos, idx in enumerate(idxs): plt.subplot(1, top_k, pos 1) img Image.open(image_paths_list[idx]) plt.imshow(img) plt.axis(off) plt.title(f{similarity[0][idx]:.3f}) plt.suptitle(Query: query) plt.show()如果做Web演示也可以换成gradio的image gallery组件但matplotlib排版更可控。课程设计里这一套离线索引加在线查询已经能应付大部分需求了。5. 避坑指南Chinese-CLIP图文检索的5个常见翻车点与排查方案这些坑每个课程设计都会遇到提前写进调试记录遇到问题能快速定位。以下每条都按“现象—原因—解决”来写方便你直接对照。5.1 模型加载慢或权重下载失败现象load_from_name执行后长时间卡住或者报ConnectionError最后进程退出。原因默认下载源在海外网络状况不佳时连接超时或断流。解决先手动获取权重文件放到download_root指定的目录文件名要和代码预期完全一致。然后运行load_from_name它会检查本地文件不会再发起下载。如果还是卡住检查download_root目录是否可写以及是否有同名但损坏的残留文件删掉重新放一次。如果你换了一台机器把这个权重目录整体拷过去最省事。千万别让启动脚本每次联网下载演示现场卡住就尴尬了。5.2 tokenize之后中文变成乱码现象打印tokenize结果看到很多[UNK]或者检索效果奇差排名几乎随batch乱跳。原因你使用了英文CLIP的CLIPTokenizer它没有中文字表中文大部分被切成无法识别的词片。或者用了transformers的BertTokenizer但加载的是多语言词表中文分词结果同样不可靠。解决统一使用cn_clip.clip.tokenize它内部已经绑定了Chinese-CLIP的中文词表和分词规则。排查时打印tokenize([测试]).shape和具体token ids看看是否包含正常的id而不是UNK。如果batch内每个样本的token长度参差不齐要检查tokenize是否自动做了padding必要时手动指定context_length。不要自己混搭两个tokenizer文本塔输入一旦变成乱序token后面怎么调参都白搭。5.3 图像预处理尺寸不匹配导致形状报错现象构造batch时抛RuntimeError提示tensor尺寸不一致比如一个维度是224另一个是288。原因手动调用torchvision.transforms.Resize尺寸与模型实际输入不符。Chinese-CLIP的不同模型可能使用不同输入分辨率有些是224有些是288。另外图片本身可能是灰度图或RGBA图通道数不是3也会导致形状对不上。解决完整使用load_from_name返回的preprocess它内部已经按模型规格配置好resize、center crop和归一化。如果你需要自定义预处理务必先打印一下preprocess.transforms确认每一步的参数不要想当然。对灰度图在预处理前先.convert(RGB)。排查时打印preprocess后的tensor形状确认是[3, H, W]同时读一下报错信息里期望的维度。5.4 显存不足以及CPU推理速度的取舍现象离线提取特征时CUDA out of memory或者CPU上查询一张图要好几秒。原因ViT图像塔对显存敏感batch size过大直接爆显存。CPU推理则受限于算力ViT-L这类大模型在CPU上尤其慢。解决显存不足时把batch size降到1也可以用model.half()转成fp16节省显存但注意CPU环境不要用half速度不会快还可能出错。排查时用nvidia-smi查看显存占用如果单张图都爆显存检查是否有其他进程占用显存或者模型是否被加载到了默认显卡。离线特征提取放GPU在线查询的文本编码量很小放CPU完全没问题。这样可以错开资源占用演示时也不会因为GPU被占满而卡顿。5.5 检索结果和直觉差距大特征归一化与温度系数现象返回的图片看着不像是对的或者所有相似度分数都集中在0.2~0.3怎么调阈值都搜不出几条。原因两个典型错误一是特征没有L2归一化二是计算相似度时忘了乘logit_scale。前者让向量长度成为干扰项后者让分数分布偏离训练时的尺度。还有一种情况是特征来自不同模型版本两个塔不匹配。解决严格按第3章的流程先归一化特征再乘model.logit_scale.exp()计算相似度。排查时手动取一个已知匹配对打印相似度如果还是低检查image_features和text_features是否都来自同一个model实例不要加载了两个版本。之后再用验证集定阈值不要在单个样本上调。这些坑并不隐蔽但每个都会浪费半天到一天时间。把现象和解决步骤记下来下次遇到直接翻记录不用重新试错。6. 进阶融合相似度与阈值调参让检索结果离“可用”更近一步单一方向的图文相似度经常出偏差某个文本描述很泛比如“风景”它和许多图片的视觉相似度都不低排在前面的可能是颜色杂乱的城市街景而不是你想要的日落山海。我习惯做双向融合除了用文本特征去匹配图像特征还把每张图片自带的原始caption编码成文本特征再计算query与这条caption的相似度最后加权。def encode_text_batch(texts): tokens tokenize(texts).to(device) with torch.no_grad(): feats model.encode_text(tokens) return feats / feats.norm(dim-1, keepdimTrue) # 离线提取所有caption的文本特征 caption_features encode_text_batch([item[caption] for item in catalog]) def fused_search(query, alpha0.7, top_k5): query_feat encode_text_batch([query]) # 图像语义相似度query与每张图的视觉特征 image_sim (query_feat image_features.T).squeeze(0) # 文本语义相似度query与每张图原始caption的文本特征 text_sim (query_feat caption_features.T).squeeze(0) # 加权融合 fused alpha * image_sim (1 - alpha) * text_sim topk_idx fused.topk(top_k).indices.tolist() return topk_idxalpha控制视觉和文本两路分数的权重。alpha太大等价于纯图像检索模型理解中文的能力没完全用上alpha太小会变成纯文本检索忽略视觉细节。我通常从0.7开始然后在验证集上用小步长搜索比如0.5到0.9之间每隔0.05试一次挑Recall5最高的一组。阈值设置也有讲究。不要用绝对值比如“分数大于0.5才返回”因为不同数据集的特征分布不同。正确做法是用验证集上所有正确样本的融合分数取最小值再减一个0.03~0.05的margin作为阈值低于阈值的查询返回“无匹配结果”。这样能过滤掉大量无关图片也让答辩时的演示看起来更智能。验证时我习惯固定随机种子把候选query列表和期望命中图片写进一个JSON每次调参都跑同一份测试集记录Recall1和Recall5。没有这套评测脚本所有调参都是“手感调试”翻车了都不知道是模型问题还是参数问题。课程设计如果需要展示改进效果把融合前后的Recall表格和可视化结果贴上去比讲一堆原理更有说服力。做这个项目的最大教训是一开始总想自己训一个中文CLIP结果浪费了两周算力后来老老实实用预训练模型做特征提取和融合排序反而两天就拿到了满意效果。希望帮到你。本文还有配套的精品资源点击获取
返回列表