
简介图像检索系统是一份面向计算机视觉初学者与课程实践者的完整源码资源围绕以图搜图这一典型任务覆盖图像预处理、特征提取、相似度计算、索引构建、检索策略与性能评估等核心环节适合用于理解基于内容的检索原理并动手改造实现。压缩包共48个文件约1.18MB以31个bmp样本图像和5个h头文件、3个cpp源文件为主体另含工程解决方案与可执行文件源码与测试图集齐备便于直接编译运行和调试。已有1209人学习下载说明其在教学与自学场景中具有一定参考价值。读者可借助现成工程结构梳理模块划分对照样本图集观察特征提取与相似度匹配效果并在源码基础上尝试替换描述符或索引方式从而加深对检索流程与性能指标的理解。1. 图像检索系统从一堆图片里精准捞出你要的那张上周帮朋友整理一个摄影素材库三万多张图命名全是IMG_20231012_001.jpg这种他想找“去年秋天拍的那张有红色屋顶和银杏的街景”翻了一下午没找着。这就是图像检索系统要解决的问题不是靠文件名、不是靠手动打标签而是让机器“看懂”图片内容你描述或者丢一张样图进去它把相似的、相关的图给你捞出来。这套系统适合两类人——手里有大量图片素材需要管理的开发者以及想搞懂以图搜图、文本搜图背后到底怎么落地的人。它本质上是一个可跑通的完整工程图片入库、特征提取、向量索引、相似度检索、结果排序一条链路全给你摆明白你照着跑一遍再按自己的业务改比看十篇原理文章都实在。2. 系统架构与目录结构一条检索请求到底走了哪些模块2.1 整体分层离线建库和在线检索是两条腿图像检索系统最容易让人翻车的地方是把“建库”和“查询”混在一起写。合格的工程一定是两条独立链路离线侧负责把图片批量喂进模型、抽出特征向量、写进索引文件在线侧负责接收查询一张图或一段文字、走同一个特征空间编码、拿向量去索引里做近邻搜索、再把结果按相似度排好返回。两条链路共用的是同一个特征提取器和同一套向量维度约定这是整个系统的“契约”一旦离线用 512 维、在线用 768 维检索结果就是玄学全乱。常见做法是离线侧做成一个可重复执行的脚本支持增量入库在线侧做成一个轻量服务启动时把索引加载进内存。我一般会把特征提取器单独抽成一个模块离线在线都 import 它杜绝两边实现不一致。2.2 目录结构每个文件夹对应一个职责一个能二次开发的项目目录必须一眼看懂。下面是这套系统典型的工程结构你拿到源码后先对着它认路image-retrieval/ ├── configs/ # 配置文件模型路径、索引参数、服务端口 │ └── default.yaml ├── data/ │ ├── raw/ # 原始图片按业务分目录存放 │ └── index/ # 生成的向量索引与 id 映射文件 ├── src/ │ ├── encoder/ # 特征提取图像编码器、文本编码器 │ │ ├── image_encoder.py │ │ └── text_encoder.py │ ├── index/ # 向量索引构建与检索封装 │ │ └── vector_index.py │ ├── pipeline/ # 离线建库、在线查询的流程编排 │ │ ├── build_index.py │ │ └── search.py │ └── utils/ # 图片读取、预处理、日志 ├── service/ # 对外 HTTP 接口 │ └── app.py ├── scripts/ # 一键建库、批量导入等入口脚本 └── requirements.txtencoder是心脏index是仓库pipeline是调度service是门面。二次开发时你改得最多的通常是encoder换模型和index换检索算法pipeline和service一般只动参数。2.3 技术选型为什么是“深度特征 向量近邻”传统图像检索靠颜色直方图、SIFT 这类手工特征遇到光照变化、视角变化就歇菜。这套系统走的是深度特征路线用预训练视觉模型把图片压成一个稠密向量语义相近的图在向量空间里距离就近。索引层用近邻搜索小规模用暴力检索上万级用近似最近邻文本搜图则靠图文对齐模型把文字和图片映射到同一空间。选型上视觉编码器常见用 CLIP 系列的图像塔文本编码器用它的文本塔这样“以图搜图”和“以文搜图”共用一套索引不用维护两份。索引库小数据量直接 numpy 算余弦相似度就够数据量上来了再换 FAISS 这类专门的向量检索库。这个取舍很关键新手一上来就上重型索引调试成本高得离谱。3. 核心模块拆解编码器、索引与检索链路的代码实现3.1 图像编码器把一张图压成一个向量编码器干的事很纯粹读图、预处理、过模型、归一化输出一个定长向量。归一化这步别省后面用余弦相似度时归一化过的向量点积就等于余弦值省一次除法。import torch import torch.nn.functional as F from PIL import Image from torchvision import transforms class ImageEncoder: def __init__(self, model, devicecpu): self.model model.to(device).eval() self.device device # 预处理必须和训练时一致尺寸、归一化均值方差都不能改 self.transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize( mean(0.481, 0.457, 0.408), std(0.268, 0.261, 0.275) ), ]) torch.no_grad() def encode(self, image_path): img Image.open(image_path).convert(RGB) tensor self.transform(img).unsqueeze(0).to(self.device) feat self.model(tensor) # 输出 [1, D] feat F.normalize(feat, dim-1) # L2 归一化供余弦检索 return feat.squeeze(0).cpu().numpy()transform里的均值和方差是模型预训练时用的统计量换成别的模型要同步改这是最常见的“检索结果莫名其妙”的根源。F.normalize把向量长度拉成 1之后索引里存的就是单位向量检索时直接矩阵乘法即可。torch.no_grad()省显存也提速建库阶段批量跑几万张图这个装饰器能省不少时间。3.2 向量索引入库、持久化与近邻查询索引模块要解决三件事向量怎么存、id 怎么和原图对上、查询怎么快。小规模场景用 numpy 矩阵最省心一个N×D的矩阵加一个 id 列表落盘用.npy加.json。import numpy as np import json import os class VectorIndex: def __init__(self, dim): self.dim dim self.vectors np.zeros((0, dim), dtypenp.float32) self.ids [] # 与 vectors 行一一对应的图片 id def add(self, vector, image_id): vector vector.reshape(1, -1).astype(np.float32) self.vectors np.vstack([self.vectors, vector]) self.ids.append(image_id) def search(self, query_vec, top_k10): # 向量已归一化点积即余弦相似度 scores self.vectors query_vec.reshape(-1, 1) scores scores.reshape(-1) top_idx np.argsort(-scores)[:top_k] return [(self.ids[i], float(scores[i])) for i in top_idx] def save(self, index_dir): os.makedirs(index_dir, exist_okTrue) np.save(os.path.join(index_dir, vectors.npy), self.vectors) with open(os.path.join(index_dir, ids.json), w) as f: json.dump(self.ids, f) def load(self, index_dir): self.vectors np.load(os.path.join(index_dir, vectors.npy)) with open(os.path.join(index_dir, ids.json)) as f: self.ids json.load(f)add用vstack逐条追加建库阶段图片多时会有性能损耗常见做法是先收集到列表里最后一次性np.vstack或者预分配大矩阵按行写入。search里argsort(-scores)取降序前 k返回的是(图片id, 相似度)元组上层拿到 id 再去映射回文件路径。save/load把向量和 id 分开存是因为向量是二进制大块、id 是文本分开存加载快、也方便单独替换。3.3 建库与查询流程把模块串成一条链建库脚本负责遍历图片目录、逐张编码、写索引查询脚本负责加载索引、编码查询、返回结果。两者共用编码器这是前面强调的“契约”。import os from src.encoder.image_encoder import ImageEncoder from src.index.vector_index import VectorIndex def build_index(image_root, index_dir, encoder): index VectorIndex(dim512) for root, _, files in os.walk(image_root): for name in files: if not name.lower().endswith((.jpg, .png, .jpeg)): continue path os.path.join(root, name) vec encoder.encode(path) index.add(vec, image_idpath) # 直接用路径当 id省一层映射 index.save(index_dir) print(f入库完成共 {len(index.ids)} 张) def search(image_path, index_dir, encoder, top_k10): index VectorIndex(dim512) index.load(index_dir) query_vec encoder.encode(image_path) return index.search(query_vec, top_ktop_k)image_id直接用文件路径是最省事的做法省掉 id 到路径的映射表如果业务里图片会移动或重命名就得换成稳定的业务 id 加一张映射表。dim512要和编码器输出维度严格一致换模型时这里必须同步改否则vstack直接报维度不匹配——这反而是好事报错比静默出错强。3.4 服务层把检索能力暴露成 HTTP 接口在线服务用轻量框架包一层接收上传的图片或文本调search返回结果列表。启动时把索引和编码器加载成全局单例别每次请求都重新加载那是性能杀手。from fastapi import FastAPI, UploadFile import tempfile, shutil app FastAPI() encoder None index None app.on_event(startup) def load_resources(): global encoder, index encoder load_image_encoder() # 项目内封装的模型加载函数 index VectorIndex(dim512) index.load(data/index) app.post(/search/image) async def search_by_image(file: UploadFile): with tempfile.NamedTemporaryFile(deleteFalse, suffix.jpg) as tmp: shutil.copyfileobj(file.file, tmp) tmp_path tmp.name vec encoder.encode(tmp_path) results index.search(vec, top_k10) return {results: [{id: i, score: s} for i, s in results]}on_event(startup)里加载资源保证服务起来后每个请求都复用同一份索引和模型。上传文件落临时文件再编码是因为编码器接口吃的是路径想省这一步可以把编码器改成直接吃 PIL Image 对象。返回结构里带score前端可以按分数做阈值过滤低于某个相似度的直接不展示避免“搜出来一堆不相关的还硬排前十”。4. 跑起来与避坑排查从零到第一次检索成功4.1 环境准备与首次运行先把依赖装齐requirements.txt里通常包含 torch、torchvision、Pillow、numpy、fastapi、uvicorn。模型权重按项目说明放到指定目录别硬编码绝对路径走configs/default.yaml配置。pip install -r requirements.txt # 建库把 data/raw 下的图片全部编码入库 python scripts/build_index.py --config configs/default.yaml # 启动服务 uvicorn service.app:app --host 0.0.0.0 --port 8000建库脚本跑完会打印入库数量这个数字要和你data/raw下的图片数对得上对不上就是遍历逻辑漏了子目录或后缀过滤写错了。服务起来后先用一张库内图片去查排第一的应该是它自己、相似度接近 1.0这是最基础的冒烟测试过不了就别往下调。4.2 常见问题排查五条血泪踩坑记录现象检索结果全是随机的排第一的图和查询图毫无关系。原因多半是离线在线用了不同的预处理比如离线 resize 到 224、在线忘了 resize或者归一化均值方差对不上。解决把预处理逻辑收进编码器类里离线在线只调同一个encode杜绝两处实现。现象建库跑到一半内存爆掉。原因是把所有图片张量攒在内存里没释放或者vstack反复扩容。解决编码完立即转 numpy 存列表最后一次性合并批量建库时控制 batch size图片读完及时del。现象换了个更大的模型检索精度反而下降。原因是索引里还是旧模型的向量新旧向量空间不兼容。解决换模型必须重建索引data/index整个删掉重跑别想着增量混用。现象服务启动报维度不匹配。原因是VectorIndex(dim...)写死的维度和编码器输出不一致。解决把维度从配置读或者启动时用一张样例图编码一次、拿vec.shape[0]动态确定维度。现象查询延迟很高单次好几秒。原因是每次请求都重新load索引或重新加载模型。解决资源加载放启动钩子里做成单例请求内只做编码和矩阵乘法。提示建库和查询用的图片读取方式也要一致一个用 PIL 一个用 cv2通道顺序RGB/BGR不同特征就偏了这种坑最隐蔽。5. 二次开发改造思路换模型、加文本检索与效果验证拿到这套系统后最值钱的改造方向有三个。第一是换编码器把ImageEncoder里的模型换成更强的视觉 backbone只要保证输出向量做 L2 归一化、维度同步改配置上层索引和检索代码一行不用动这就是前面把编码器抽成独立模块的回报。第二是加文本搜图引入图文对齐模型的文本塔写一个TextEncoder和图像编码器输出同一维度、同一空间文本查询走text_encoder.encode(query)再进同一个index.search索引完全复用这是这套架构最划算的扩展点。第三是索引升级数据量过十万后 numpy 暴力检索会拖慢响应把VectorIndex内部换成 FAISS 的IndexFlatIP或 IVF 索引对外接口保持不变调用方无感知。改造完怎么验证我一般准备一个小评测集挑 50 张图每张人工标注 5 张“应该被检索出来”的相关图跑一遍算 Recall10改造前后对比。别只看单张查询的观感那玩意儿太主观玄学得很。另外每次换模型或改预处理都强制重建索引再评测别在旧索引上测新模型测出来的数没有意义。改造点改动文件是否需重建索引验证方式换视觉模型encoder/image_encoder.py 配置是Recall10 对比加文本检索新增encoder/text_encoder.py是同空间可复用文本查询抽样人工看换向量索引index/vector_index.py否接口不变延迟与召回对比调 top_k/阈值service/app.py否前端结果观感有个细节值得单独说文本和图像要在同一向量空间必须用配套的图文对齐模型不能拿一个纯视觉模型加一个纯文本模型硬凑那样两个空间的向量根本不可比检索结果会是灾难。我吃过这个亏当时以为维度对上就行结果搜“红色屋顶”出来一堆蓝天图排查了半天才发现是两个模型各说各话。从那以后我每次接新模型都先拿几张图和几句描述做一次跨模态相似度 sanity check确认空间对齐了再往下建库。希望帮到你。本文还有配套的精品资源点击获取