
简介基于VGG16的图像检索项目面向人工智能初学者与信息检索方向开发者以“以图搜图”为切入点展示如何利用预训练卷积网络提取图像特征并完成相似图片查找。VGG16由牛津大学视觉几何组提出2014年ImageNet竞赛中表现优异其13个卷积层与3个全连接层堆叠的小卷积核结构可捕获从边缘到语义的丰富图像表征。资源包大小47.96MB工程内包含VGG16模型权重、Python特征提取与索引构建脚本、服务端接口以及JavaScript前端页面可配套现有环境直接运行调试。实现流程覆盖特征提取、索引存储、查询处理、相似度计算与结果返回五个环节上传查询图后后端提取特征向量通过与库中图像向量计算余弦相似度或欧氏距离返回排序后的相似结果前端借助Ajax或Fetch与后端通信交互反馈完整。已有509人学习这一示例适合用来理解深度学习特征表达、向量检索与前后端协作的落地链路也可作为课程设计或毕业设计的改造基础。1. 用 VGG16 做以图搜图从一张图到最相似的 Top-N一张商品图丢进去系统在几万张图库里找出同款或近似款这就是以图搜图最常见的落地形态。很多人第一反应是“像素对比”但真正能用的图像检索系统几乎都不会拿原始像素直接算距离而是先把图片编码成高维特征向量再做向量检索。这个项目提供的正是这条完整链路用 VGG16 预训练模型做特征提取构建特征索引计算余弦相似度或欧氏距离最后返回 Top-N 结果前端用 JavaScript 完成上传与结果展示。它不追求大而全的工程架构但把以图搜图的原理、代码、参数和坑都串了起来非常适合正在做人工智能课程设计、信息检索大作业或者第一次接触图像向量检索的开发者。2. 为什么是 VGG16从分类网络到特征编码器的关键转变2.1 16 层卷积堆叠用 3x3 卷积核换来的深度与感受野VGG16 的网络结构看起来并不复杂13 个卷积层 3 个全连接层一共 16 个可训练层。它的设计核心在于全部使用 3x3 的小卷积核并且通过连续堆叠来扩大感受野。两个 3x3 卷积堆叠等效于一个 5x5 卷积的感受野三个堆叠则等效于 7x7。这样的好处是在获得相同感受野的前提下参数量更少同时中间还多了一次非线性激活让网络对特征的表达能力更强。从数据流角度看输入图片被统一缩放到 224x224x3经过 5 个卷积块处理后进入全连接层。每一层卷积块后面接 max-pooling逐步把空间分辨率从 224 降到 112、56、28、14最后到 7x7。通道数则从 64 逐步增加到 512。这种“空间变小、通道变深”的过程意味着网络在浅层学到的是边缘、纹理这类低级特征到了深层学到的是物体部件、整体轮廓这类高级语义特征。对于图像检索来说我们要的就是这种“语义级别”的抽象表达而不是像素级别的原始差异。具体到每个 block 的输出维度平时配模型的时候经常要看这张表网络层位置输出张量形状说明block1_conv2224x224x64边缘、颜色等低级特征block2_conv2112x112x128纹理、局部模式block3_conv356x56x256部件级特征block4_conv328x28x512语义特征逐渐清晰block5_conv314x14x512高级语义特征block5_pool7x7x512最后一个池化输出fc14096全局语义向量可选特征层fc24096全局语义向量可选特征层这就是 VGG16 在图像检索里最常用的层位你要么取 block5_pool 的输出做全局池化得到 512 维向量要么取 fc1 或 fc2 的 4096 维输出。怎么选下一节展开。2.2 分类头和特征头的分界取哪一层输出当检索向量VGG16 原始的训练目标是 ImageNet 1000 类分类所以网络最后接的是 Softmax输出每个类别的概率。但以图搜图场景里我们根本不需要“这张图是猫还是狗”这种分类结论需要的是“这张图长什么样”的向量表达。分类概率是高度抽象且面向任务标签的不适合直接当检索特征。常见做法是截断网络把最后一层 Softmax 去掉从 fc1、fc2或者更靠前的 block5_pool 处取输出。这版本的 Keras 写法里加载模型时直接指定include_topFalse就能自动去掉全连接层和分类头只保留卷积基也可以用Model(inputsmodel.input, outputsmodel.get_layer(fc2).output)手动截断到指定层。这里有个实际取舍问题4096 维向量表达能力更强但存索引和算距离的成本也更高512 维block5_pool 加全局平均池化在大多数检索场景下已经够用速度还快。我一般建议先跑 fc2 的 4096 维看效果再对比 block5_pool 的 512 维两者差距不大的话就选低维版本——图库超过十万张时维度直接决定索引体积和查询耗时。另外要注意一个很多人踩过的坑从 fc1 或 fc2 取特征时输入图像规范必须是 224x224而且必须走 VGG16 的预处理流程也就是preprocess_input做 RGB 到 BGR 的通道转换和像素减均值归一化。这一步不做提取出来的特征质量会明显下降检索结果自然也不准后面避坑章节我会展开说。3. 把 VGG16 改成图像编码器特征提取脚本的落地细节3.1 环境与模型加载Keras 下两种取层方式这个项目的后端特征提取部分用的是 TensorFlow 2.x 配 Keras 接口。加载预训练 VGG16 的核心代码其实很少但取层方式有讲究。推荐方式一是直接不加载分类头from tensorflow.keras.applications import VGG16 # include_topFalse 表示去掉最上面的全连接层和 Softmax base_model VGG16(weightsimagenet, include_topFalse, input_shape(224, 224, 3)) base_model.summary()逻辑说明weightsimagenet会自动下载在 ImageNet 上预训练好的权重include_topFalse让模型只保留到卷积基结束输出形状是 7x7x512。这样拿到的张量还带着空间结构不能直接当一维向量用通常要接一个全局平均池化。如果不想自己在后面补池化层可以这样写from tensorflow.keras.models import Model from tensorflow.keras.layers import GlobalAveragePooling2D from tensorflow.keras.applications import VGG16 base_model VGG16(weightsimagenet, include_topFalse, input_shape(224, 224, 3)) x base_model.output x GlobalAveragePooling2D()(x) # 7x7x512 - 512 model Model(inputsbase_model.input, outputsx)逻辑说明GlobalAveragePooling2D把 7x7x512 的空间信息压成 512 维向量每个通道取空间平均值。这样做的好处是参数为零而且对输入图片的平移有一定鲁棒性。如果你是第一次跑这个项目建议直接用后面这段代码——输出维度更可控索引构建也更简单。参数说明input_shape(224, 224, 3)是 VGG16 的标准输入尺寸改大会报错或者严重拖慢速度改小则特征质量下降所以一般不动它。如果项目里还带了从 fc2 取特征的版本写法也一并放在下面方便对比from tensorflow.keras.applications import VGG16 from tensorflow.keras.models import Model base_model VGG16(weightsimagenet, include_topTrue) # 保留全连接层 model Model(inputsbase_model.input, outputsbase_model.get_layer(fc2).output)这个写法中include_topTrue会连分类头一起加载但我们截断了fc2所以实际推理时只输出 4096 维特征不会走到 Softmax。代价是前面所有卷积层都要跑完显存占用更大提取速度也慢一些。对小型课程设计数据集512 维和 4096 维的精度差距肉眼很难看出来建议优先 512 维。3.2 批量提取特征目录结构、预处理与显存控制模型就绪后下一步是遍历图库批量提取特征。这个项目的常见目录结构是data/images/下放所有待索引图片图片格式不限jpg、png 都行但要注意中文文件名和非常规扩展名后面避坑章会专门说。批量提取脚本建议按 batch 跑不要一张一张循环否则 GPU 或 CPU 的利用率都很低。import os import cv2 import numpy as np from tensorflow.keras.applications.vgg16 import preprocess_input IMG_SIZE 224 BATCH_SIZE 32 def extract_features_batch(image_paths, model): feats [] for start in range(0, len(image_paths), BATCH_SIZE): batch_paths image_paths[start:start BATCH_SIZE] batch_images [] for p in batch_paths: img cv2.imread(p) # 读成 BGR img cv2.resize(img, (IMG_SIZE, IMG_SIZE)) img img.astype(np.float32) batch_images.append(img) batch_images np.array(batch_images) batch_images preprocess_input(batch_images) # 减均值RGB-BGR batch_feats model.predict(batch_images, verbose0) feats.append(batch_feats) return np.vstack(feats)逻辑说明cv2.imread读出来的是 BGR 顺序preprocess_input内部会按 VGG16 的 ImageNet 统计值做归一化所以这里不需要手动做 RGB 转换。model.predict一次处理一个 batch返回形状是(batch_size, 512)或(batch_size, 4096)最后用np.vstack拼成完整的特征矩阵。参数说明BATCH_SIZE在 GPU 上可以开到 64 或 128CPU 上建议 16 或 32否则容易内存溢出verbose0是为了避免每个 batch 都打印进度条日志太刷屏。3.3 特征与文件名的对齐保存索引时最容易翻车的一步特征矩阵本身没有意义必须跟图片路径一一对应才能在做完相似度检索后把结果映射回图片文件。这一步很多人忽略导致检索出来了向量却不知道是哪张图。我一般会把图片路径列表和特征矩阵同时保存顺序必须严格一致。import pickle image_paths [] for root, _, files in os.walk(./data/images): for f in files: if f.lower().endswith((.jpg, .jpeg, .png)): image_paths.append(os.path.join(root, f)) features extract_features_batch(image_paths, model) # 保存特征向量和路径列表 np.save(./output/features.npy, features) with open(./output/image_paths.pkl, wb) as f: pickle.dump(image_paths, f) print(f提取完成共 {len(image_paths)} 张图特征矩阵 shape: {features.shape})逻辑说明os.walk递归遍历目录收集所有图片路径然后一次性提取特征。保存时用两个文件分别存矩阵和路径列表后续查询时np.load和pickle.load按顺序读回。关键点在于image_paths中第 N 个元素对应features矩阵的第 N 行这个映射关系一旦错位检索结果就会张冠李戴。参数说明文件扩展名过滤只保留.jpg、.jpeg、.png如果你的图库里有.bmp或.webp需要在这里加进去不然会被跳过。4. 相似度检索与排序欧氏距离和余弦相似度怎么选4.1 两种度量的直觉差异特征向量提取完成后检索本质上就是算“查询向量”和“图库向量”之间的距离然后排序。最常用的两种度量是欧氏距离和余弦相似度。欧氏距离衡量的是向量在空间中的直线距离数值越小越相似余弦相似度衡量的是两个向量的方向一致性数值越接近 1 越相似。在图像检索场景里我更偏向余弦相似度。原因是 VGG16 提取的特征向量的绝对值大小受图像亮度、对比度影响较大而检索关心的往往是“内容像不像”而不是“亮度像不像”。余弦相似度只关心方向相当于自动做了归一化对光照差异更鲁棒。当然如果图库本身是同源同风格的图片欧氏距离的表现也不差两者各有利弊。度量方式公式直觉数值含义适用场景欧氏距离各维度差的平方和再开方越小越相似图库风格统一、特征幅度稳定余弦相似度向量夹角余弦值越接近 1 越相似图库来源复杂、光照差异大4.2 检索函数从查询图到 Top-K 结果的全流程有了度量方式检索函数写起来就很直接了查询图先走和建索引时完全相同的预处理流程提取特征向量然后跟图库特征矩阵做批量距离计算排序取前 K 个。下面用余弦相似度举例import numpy as np def cosine_similarity(query_feat, db_feats): # 对查询向量和图库全部向量做 L2 归一化 q_norm query_feat / np.linalg.norm(query_feat) db_norm db_feats / np.linalg.norm(db_feats, axis1, keepdimsTrue) sims np.dot(db_norm, q_norm) # 归一化后点积等价于余弦相似度 return sims def search_top_k(query_path, model, db_feats, image_paths, top_k10, threshold0.5): img cv2.imread(query_path) img cv2.resize(img, (224, 224)) img img.astype(np.float32) img preprocess_input(img[np.newaxis, ...]) query_feat model.predict(img, verbose0)[0] sims cosine_similarity(query_feat, db_feats) idx np.argsort(sims)[::-1] # 从高到低排序 results [] for i in idx[:top_k]: if sims[i] threshold: continue # 相似度过低的直接丢弃 results.append({ path: image_paths[i], score: float(sims[i]) }) return results逻辑说明L2 归一化后点积等于余弦相似度这一步数学上等价于先用余弦公式逐对计算但用矩阵运算一次搞定速度要快得多。argsort()[::-1]得到从大到小的索引序列再截取前top_k个。threshold是相似度阈值低于这个分数的结果会被过滤掉。参数说明top_k10表示返回前 10 张最相似图片threshold0.5对余弦相似度来说是比较宽松的界限。具体调多少要看你的图库内容。如果图库里全是同品类商品图相似度普遍偏高阈值可以拉到 0.75 以上如果是混合图库0.5 以下才不会漏掉太多结果。如果项目里想用欧氏距离替换核心计算部分即可from scipy.spatial.distance import cdist def euclidean_distance(query_feat, db_feats): # 返回每个图库向量到查询向量的欧氏距离 dists cdist(db_feats, query_feat.reshape(1, -1), metriceuclidean) return dists.flatten()逻辑说明cdist计算的是图库中每个向量与查询向量之间的欧氏距离结果越小越相似。使用时排序方向要反过来也就是np.argsort(dists)取前 K 个。参数说明如果图库特征矩阵有几十万行cdist会一次性算出完整距离矩阵内存开销较大这种情况建议分块计算或者直接上 FAISS。5. 避坑指南特征提取与检索阶段最容易翻车的五个问题5.1 忘了 preprocess_input检索效果直接崩塌现象特征提取流程跑通索引也建好了但检索出来的结果莫名其妙相似的图片排不进来不相关的反而靠前。原因VGG16 预训练权重是按照 ImageNet 数据的预处理方式训练的。原始图片输入网络前必须先做 BGR 通道转换和减均值归一化。跳过这一步网络接收到的数据分布和训练时不匹配提取出的特征表达完全走样。解决在特征提取和查询两条路径上强制使用同一个预处理函数。建议写一个统一的load_and_preprocess(image_path)工具函数建索引时用它查询时也用它避免两边处理逻辑不一致。5.2 取错特征层拿了 Softmax 之前的分类概率当向量现象检索结果看起来全是同一类物体但具体到形状、角度、颜色差别很大的图也排在前面。原因如果把include_topTrue的模型直接拿去predict得到的是 1000 维 Softmax 概率。这个向量表达的是“属于每个类别的概率”同一类别的图片概率分布高度相似但类内的细节差异被抹掉了。拿它做检索等于只在类别粒度上比较。解决从fc2或block5_pool层截断输出。推荐用include_topFalse后接GlobalAveragePooling2D的方式省事且维度低。如果项目代码里用了include_topTrue检查一下是否取对了层名。5.3 特征矩阵和文件名列表顺序错位现象检索结果返回的路径和实际相似图片对不上显示出来的图片完全不是查询图的相关内容。原因构建索引时分了多个 batch 提取特征但文件名列表没有按同样的 batch 顺序拼接或者保存索引时用了不同的遍历顺序导致第 N 个特征向量对应的不是第 N 个文件名。解决提取特征前先把image_paths列表定好特征提取完全按这个列表的顺序走然后打包保存。查询阶段只从image_paths里按索引取路径不要重新扫描目录。5.4 显存不足batch_size 设置过大导致 OOM现象特征提取跑到中途程序直接报CUDA out of memory或者进程被杀掉。原因VGG16 网络结构深、参数多前向推理时中间层的特征图非常占显存。batch_size128在高端消费级显卡上也可能直接爆掉更不用说纯 CPU 环境。解决先按batch_size16跑通流程然后观察显存占用再逐步调大。如果是 CPU 环境batch_size 设 8 到 16 即可速度慢一点但至少能跑完。另一个思路是提取时用tf.keras.backend.clear_session()定期释放中间状态。5.5 中文路径和特殊字符导致读取失败现象特征提取阶段部分图片跳过或者索引构建中途 crash报错信息指向cv2.imread返回None。原因Windows 下cv2.imread对中文路径支持不友好返回空对象。如果没有对img is None做判断后面cv2.resize就会直接抛异常。解决读取图片后立即判空为空的路径记录到日志并跳过。或者在建索引前用shutil.copy把图片统一重命名为纯英文路径复制到临时目录再处理。这个做法治标治本后续查询阶段也不会因为路径编码问题翻车。6. 闭环验证用 Flask 接口和前端 fetch 把检索流程串起来整个检索链路在本地跑通后要验证它是不是真的“能用”我习惯的做法是搭一个最小的 Flask 服务前端用 JavaScript 的fetch传图后端返回 Top-N 结果。不指望这段代码直接上线但要能证明链路是通的。后端部分最核心的接口就是接收上传图片、提取特征、检索、返回结果from flask import Flask, request, jsonify import base64 app Flask(__name__) app.route(/search, methods[POST]) def search(): file request.files[image] file.save(/tmp/query.jpg) results search_top_k(/tmp/query.jpg, model, db_feats, image_paths, top_k10) return jsonify([{path: r[path], score: r[score]} for r in results])逻辑说明前端上传的文件流保存到临时路径然后复用之前写好的search_top_k函数返回的路径和相似度分数序列化成 JSON。前端拿到这个数组后逐条把path映射成img标签的src渲染出来。参数说明top_k在接口层可以做成参数?top_k20这样由前端控制调试时不用改后端代码。前端用一个最简单的 HTML 加 fetch 就能撑起来const input document.getElementById(queryImage); const file input.files[0]; const formData new FormData(); formData.append(image, file); fetch(/search, { method: POST, body: formData }) .then(res res.json()) .then(data { data.forEach(item { const img document.createElement(img); img.src item.path; document.getElementById(results).appendChild(img); }); });逻辑说明FormData把文件以表单形式发给后端避免手动处理 Base64 编码。响应数组里每个元素包含path和score直接创建图片节点渲染。参数说明前端这里没有做图片压缩实际上传大图时会很慢生产环境建议先在前端 canvas 压缩到 512px 以内再上传。验证时我会自己造一个几百张图的小数据集挑 20 张明显相似的图分散放进图库拿其中一张做查询看能不能把另外 19 张都召回且排在前面。这个过程能同时检验特征提取、索引构建、相似度计算、前后端交互五个环节是否真的闭环。从那以后我每换一个模型或改一次预处理流程都会强制走一遍这个闭环验证而不是只看单张图的检索结果。这个习惯帮我挡掉了至少三次“改完代码以为没问题、一验证才发现特征层取错”的翻车希望帮到你。本文还有配套的精品资源点击获取