简介:这份资源是一套基于VGG16深度学习模型的图像检索系统毕业设计项目,面向需要完成图像匹配、以图搜图相关课题的高校学生,也适合入门深度学习的开发者作为完整实践案例。项目采用Python与Keras实现,涵盖图像预处理、VGG16特征提取、余弦相似度计算、检索界面展示等流程,代码与数据封装完整,下载解压后即可直接运行。资源包共30个文件,压缩包大小约47.96MB,主要包含8个Python源码文件、5张示例图片、3个JS脚本、2个HTML页面、2个CSS样式文件以及说明文档、数据库文件等,结构清晰便于按模块阅读修改。目前已有428人学习浏览,适合用于毕业设计参考、课程项目实战以及深度学习图像检索入门。通过这套代码,读者可以掌握如何调用预训练模型进行特征表示、构建相似度检索逻辑,并在此基础上替换数据集完成自己的检索实验,省去从零搭建的时间。
1. 毕业设计做图像检索,为什么第一步要把 VGG16 当作特征提取器
图像检索这个题目在毕业设计里出现频率很高,但很多人的方案还停留在 SIFT 特征点匹配或者直方图比较,跑出来的效果往往是一张图换一个角度就找不到。它的核心问题在于“用什么描述一张图片”——如果用像素级或者手工特征,描述能力不够,检索精度上不去。把 VGG16 接入进来之后,问题被转换成“用深度网络提取的向量去描述图片”,这个向量的语义层次远高于 SIFT 和颜色直方图,很多在传统方案里折腾半天的场景(光照变化、角度偏移、同类物体不同外观)都能自动容忍。
这里说的“基于 VGG16 的图像检索系统”,本质是一条清晰的技术链路:用预训练 VGG16 去掉最后的分类层,把每张图片映射成一个固定长度的特征向量;然后对这些向量做建库、归一化、相似度计算;查询时同样提取查询图的向量,在库中找出最接近的 Top-K 张图返回。这套系统完全适合作为本科毕业设计的核心工作量,因为代码量可控、训练成本为零(直接用 ImageNet 预训练权重)、评估指标明确,而且数据可以自己随便凑。读者如果是打算拿这个题目开题,这篇内容会把你从“不知道特征从哪儿来”带到“能跑通一个带评估的完整流程”。
2. VGG16 取哪一层做特征:原理、选型与数据库构建实现
2.1 为什么取 fc7 而不是 pool5,也不是 softmax 输出
VGG16 的网络结构可以拆成两部分:一组卷积层负责提取从边缘到纹理再到物体部件的层级特征,后面接三个全连接层 fc6、fc7、fc8 负责把特征映射到类别得分,最后 softmax 输出 1000 类概率。做检索时,softmax 输出不能用,因为它的维度被压缩成 1000 类语义,类别里没有“这张图是什么样”,只有“这张图最像哪类”。真正有用的是倒数第二层 fc7 输出的 4096 维向量,或者 pool5 输出的 7×7×512 特征图经过全局池化后的向量。
常见做法是取 fc7 输出。原因是 fc7 经过两层全连接的非线性变换后,特征被进一步抽象成全局语义表示,在度量学习里它比 raw 卷积特征更稳定。pool5 保留更多空间细节,适合做物体定位,但直接用于全局检索时冗余太大。需要注意 VGG16 的输入规范是 224×224 的 RGB 图,并且需要按 ImageNet 的均值方差做归一化,这一步漏掉,特征质量会明显下降,检索结果经常出现颜色相近但内容无关的图。
2.1.1 特征提取的最小可运行代码
下面这段 PyTorch 代码可以在单卡上完成建库环节的特征提取。它接收一个图片文件夹,输出一个特征矩阵和一个文件名列表,分别保存为features.npy和filenames.pkl。
import torch import torchvision.transforms as T from torchvision import models from PIL import Image import numpy as np import pickle import os from tqdm import tqdm model = models.vgg16(pretrained=True) # 取 fc7 输出,去掉 fc8 和 softmax model.classifier = torch.nn.Sequential(*list(model.classifier.children())[:6]) model.eval() device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) transform = T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def extract_features(img_path): img = Image.open(img_path).convert("RGB") x = transform(img).unsqueeze(0).to(device) with torch.no_grad(): feat = model(x).cpu().numpy().flatten() # L2 归一化,让内积等价于余弦相似度 feat = feat / np.linalg.norm(feat) return feat image_dir = "./data/images" features, filenames = [], [] for fname in tqdm(os.listdir(image_dir)): if not fname.lower().endswith((".jpg", ".jpeg", ".png")): continue fpath = os.path.join(image_dir, fname) try: fv = extract_features(fpath) features.append(fv) filenames.append(fname) except Exception as e: print(f"{fname} 提取失败: {e}") np.save("./data/features.npy", np.array(features)) with open("./data/filenames.pkl", "wb") as f: pickle.dump(filenames, f)model.classifier 被截断到前 6 层,也就是保留 fc6 和 fc7。最后一层 fc8 的输入是 4096 维,输出是 1000 类得分,检索用不到。特征做了 L2 归一化,这样后续计算内积就等价于余弦相似度,比欧氏距离更符合图像检索的直觉。提取失败时打印文件名而不是中断进程,处理脏数据时很有用。
2.2 数据库目录结构怎么组织,数据从哪来
毕业设计的数据不需要走公开大数据集,自己拍或者下载 500 到 2000 张图就够支撑实验。推荐在项目根目录下这样组织:
project/ ├── data/ │ ├── images/ # 库图像,直接平铺,不分子目录 │ ├── query/ # 查询图像 │ ├── features.npy │ └── filenames.pkl ├── extract_features.py ├── build_index.py ├── search.py └── evaluate.pyimages 平铺而不按类别分子目录,这是有意为之。如果分了子目录,文件名里带上类别信息,后面评估时容易产生“看文件名猜结果”的侥幸心理,掩盖特征质量问题。数据集的组织方式与后续 mAP 评估直接相关,如果用 Oxford5k 或 Paris6k,需要额外准备查询图像和对应的 ground truth 标注文件;自建数据则可以通过文件名前缀约定正样本关系,比如cat_001.jpg、cat_002.jpg视为同一类。
2.2.1 特征维度、显存占用与 batch 提取
VGG16 fc7 输出维度固定为 4096,一张图存成 float32 只有 16KB,1000 张库图的特征矩阵不到 16MB,后续检索阶段的内存压力可以忽略。但提取阶段如果一张一张地处理,VGG16 的参数量約 138M,单张推理虽然不慢,数据量大时会很耗时,更稳妥的做法是加一个 DataLoader 做 batch 提取。
from torch.utils.data import Dataset, DataLoader class ImageFolderDataset(Dataset): def __init__(self, img_dir, transform): self.paths = [os.path.join(img_dir, f) for f in os.listdir(img_dir) if f.lower().endswith((".jpg", ".jpeg", ".png"))] self.transform = transform def __len__(self): return len(self.paths) def __getitem__(self, idx): img = Image.open(self.paths[idx]).convert("RGB") return self.transform(img), self.paths[idx] dataset = ImageFolderDataset("./data/images", transform) loader = DataLoader(dataset, batch_size=32, num_workers=4) features_dict = {} for batch, paths in loader: with torch.no_grad(): fv = model(batch.to(device)).cpu().numpy() for i, path in enumerate(paths): fv_i = fv[i] / np.linalg.norm(fv[i]) features_dict[path] = fv_ibatch_size 的选择取决于显存。VGG16 在 224×224 输入下,batch_size 设为 32 时大约占用 4GB 显存;如果只有 2GB 的旧卡,把 batch_size 降到 8。num_workers 在 Windows 上经常被误解,如果设置为 4 报错,先改成 0 验证是代码问题还是系统问题。
3. 检索核心:相似度计算、Top-K 排序与查询链路
3.1 查库时到底在算什么
建库完成后,系统里存了一个 n×4096 的特征矩阵(n 是库图数量)和一个长度 n 的文件名列表。查询阶段拿到查询图,做同样的预处理,得到 4096 维查询向量,然后计算它与矩阵中每一行的相似度。由于特征已经 L2 归一化,矩阵乘法直接得到全部余弦相似度,取最大的 K 个索引即可。
这一步有个容易做错的地方:特征的归一化在提取阶段已经做过,查询时就不能再次归一化,否则相当于做了两次缩放,虽然对内积的排序没有影响,但会让后续调试时的分数值失真。另外,如果用的是欧氏距离而不是内积,那么建库时归一化会导致距离上限被限制在 0 到 2 之间,检索结果会有微妙差异。始终基于余弦相似度做全链路设计,代码最简洁。
3.2 一个可落地的 search 脚本
下面的脚本实现了完整查询链路:读入查询图路径,提取特征,计算相似度矩阵,返回 Top-K 结果并展示图片名和相似度分数。
import numpy as np import pickle import argparse from extract_features import extract_features parser = argparse.ArgumentParser() parser.add_argument("--query", type=str, required=True, help="查询图片路径") parser.add_argument("--k", type=int, default=10) args = parser.parse_args() features = np.load("./data/features.npy") # shape: (N, 4096) with open("./data/filenames.pkl", "rb") as f: filenames = pickle.load(f) query = extract_features(args.query) # shape: (4096,) # 余弦相似度 = 归一化向量的内积 scores = features @ query top_k_idx = np.argsort(scores)[::-1][:args.k] for rank, idx in enumerate(top_k_idx, 1): print(f"Top-{rank}: {filenames[idx]} 相似度: {scores[idx]:.4f}")scores = features @ query在 numpy 里会把 features 的每一行与 query 做内积,结果是一个长度为 N 的一维数组,这个过程是向量化操作,即使库图有 10 万张,也就几十毫秒级别的开销。arg 是 argsort 降序排列后取前 K 个索引,返回的是索引而不是文件名,所以最后要通过 filenames 映射回去。注意这里没有判断--query指向的图片是否存在,如果路径错误,extract_features 里的 Image.open 会报错,建议在外面加一层 os.path.exists 检查。
3.2.1 线上检索与批量查询的差异
如果一次查询一张图,瓶颈在特征提取,不在相似度计算。CPU 上跑 VGG16 单张约 200ms,GPU 上约 10ms。批量查询场景(比如一次给 100 张查询图)可以直接复用建库时的 batch 提取逻辑,把所有查询图提取成 m×4096 矩阵,然后用一次矩阵乘法results = query_features @ features.T得到 m×N 的相似度矩阵。这种写法在毕业设计的答辩演示里也很实用——可以现场跑一批查询图,展示多行检索结果。
4. 评估指标与数据玩得转的关键:mAP、训练集划分和可复现性
4.1 检索结果怎么量化评价
不能只给老师看几张检索出的图片,毕业设计需要量化指标。最常用的评估指标是 mAP(mean Average Precision),它综合衡量了检索结果的相关性排序质量。mAP 计算的第一步是给每张查询图画 ground truth:哪些库图是真正相关的,其余的都是不相关。自建数据集时,通常按文件名前缀定义相关性,比如查询图dog_01.jpg与库中的所有dog_*.jpg相关。
Average Precision(AP)的计算方式是:按检索排序依次走到每个相关图片的位置,累计计算该位置上的精确率,最后取平均。mAP 是所有查询图的 AP 均值。下面的脚本实现了一个按文件名前缀匹配 ground truth 的评估流程。
import numpy as np import pickle from collections import defaultdict def compute_ap(gt_mask, scores): """gt_mask: bool 数组,标记库中哪些与查询相关;scores: 相似度分数""" order = np.argsort(scores)[::-1] gt_mask = gt_mask[order] pos = 0 ap = 0.0 for i, is_rel in enumerate(gt_mask): if is_rel: pos += 1 ap += pos / (i + 1) return ap / pos if pos > 0 else 0.0 features = np.load("./data/features.npy") with open("./data/filenames.pkl", "rb") as f: filenames = pickle.load(f) # 统计每个前缀类别下的图片索引 class_to_idx = defaultdict(list) for i, name in enumerate(filenames): cls = name.split("_")[0] # 通过文件名前缀判定类别 class_to_idx[cls].append(i) aps = [] for cls, idx_list in class_to_idx.items(): gt = np.zeros(len(filenames), dtype=bool) gt[idx_list] = True # 用该类第一张图模拟查询图 query_feat = features[idx_list[0]] scores = features @ query_feat ap = compute_ap(gt, scores) aps.append(ap) print(f"mAP: {np.mean(aps):.4f}")评估时有一个细节:查询图本身也在库中,检索结果的第一名总是自己,这会把 AP 拉高。避免方法是评估时将查询图从库中剔除,或者保证查询图不参与建库。代码里模拟时直接把查询图包含在库中,实测 mAP 会偏高 0.05 到 0.1。写实验报告时一定要说明这个细节,否则数据可信度会打折扣。
4.2 数据质量决定系统上限
VGG16 的特征是预训练模型给的,库图的内容越接近 ImageNet 的分布,检索精度越高。用手机拍的产品图、截图、扫描件、卡通图混在一起做实验时,通常效果很烂。一个实用的数据清洗规则是:先跑一遍特征提取,把特征矩阵求两两相似度,找出相似度异常低的离群图,人工检查是不是损坏图片或者纯色图。
另外,图片包含大段黑边时,建议先用 OpenCV 做一次边缘裁剪,再把图缩放至 224×224,否则黑边会占据大部分卷积感受野,特征向量被无效区域主导。这里可以用 Pillow 的ImageOps.expand检查,或者直接上 OpenCV:
import cv2 img = cv2.imread(fpath) if img is None: # 文件已经损坏,直接跳过 print(f"跳过损坏图片: {fpath}") continue if np.mean(img) < 5: # 接近纯黑图,跳过或标灰 print(f"跳过纯黑图: {fpath}")4.2.1 避免训练集测试集重复的坑
建库的 images 目录和查询图 query 目录在文件组织上要物理隔离。如果查询图就是库图本身,评估出来的 mAP 没有参考价值,答辩时也很容易被追问。正确做法是:给每个类别准备两张以上的图,一张进库,一张作为查询。数据集类别数量建议在 20 到 50 类左右,每类 10 到 30 张库图,这样总数据量适中且类别区分度高,特征描述能力差异更明显。
5. 进阶优化:PCA 降维、faiss 索引与查询图裁剪技巧
5.1 把 4096 维压缩到 128 维,检索速度提升但精度不掉
4096 维在数据量小的时候不是问题,但毕设如果能做到入库 10 万张,暴力检索的计算量就有点看头了。用 PCA 把特征降到 128 维,可以显著减少相似度计算耗时。关键是 PCA 要在库特征上拟合,而不是用随机生成的矩阵。
from sklearn.decomposition import PCA features = np.load("./data/features.npy") pca = PCA(n_components=128, whiten=True) features_pca = pca.fit_transform(features) np.save("./data/features_pca.npy", features_pca) # 查询时也做同样的变换 query_pca = pca.transform(query.reshape(1, -1)).flatten()whiten=True 会把降维后的各维度方差归一化,让特征分布更接近球状,有利于后续内积检索。PCA 降维后检索速度接近原来的 32 倍,mAP 通常只有 1 到 3 个百分点的损失。论文里的对比实验很适合放一组“4096 维 vs 128 维 mAP 对比表”,用来体现优化思考。
5.2 faiss 索引替换 numpy 暴力检索
如果觉得 numpy 的暴力检索在答辩演示时不够“硬核”,可以用 faiss 建索引。faiss 是 Facebook 开源的相似度检索库(MIT 协议,学术和商业都可以用),它提供的 IndexFlatIP 与前面介绍的余弦相似度完全一致,是最容易替换的起步索引。
import faiss features = np.load("./data/features.npy").astype("float32") index = faiss.IndexFlatIP(features.shape[1]) index.add(features) query = query.astype("float32").reshape(1, -1) scores, indices = index.search(query, 10)faiss 返回的 scores 和 indices 的 shape 都是 (1, 10)。IndexFlatIP 没有训练过程,直接 add 就能用。如果想展示更高级的方案,可以换成 IndexIVFFlat,它先对向量空间做 K-Means 聚类,再在最近的聚类内搜索,适合大规模库。参数 nlist 通常设为库数量的平方根量级,nprobe 设为 10 到 20。但小数据量下 IVFFlat 反而比 IndexFlatIP 慢,因为聚类中心分配也有开销,这点在实验中要如实呈现。
5.3 一个影响检索精度的小技巧:查询图中心裁剪
实际查询时,查询图可能带水印、边框或大面积背景,直接 Resize 到 224×224 会引入大量噪声。常见技巧是先做中心裁剪:取图片短边的 80% 区域作为有效区域,再缩放到 224×224。这个操作对包含主体居中的图片很有效,在自建数据集上通常能带来几个点的 mAP 提升,实现成本极低。
from PIL import Image img = Image.open(query_path).convert("RGB") w, h = img.size short_side = min(w, h) crop_size = int(short_side * 0.8) left = (w - crop_size) // 2 top = (h - crop_size) // 2 img = img.crop((left, top, left + crop_size, top + crop_size))中心裁剪比例 0.8 不是硬性标准,可以做成参数,在验证集上试 0.7、0.8、0.9 三档。如果查询图本身已经是很规整的物体图,裁掉 20% 反而可能裁掉目标边缘,建议只在查询图来自网络下载或手机拍摄时使用。这个判断标准写进论文的方法部分,会让实验设计看起来更严谨。
本文还有配套的精品资源,点击获取