☰
Python以图搜图实战:VGG16特征提取+PyQt桌面工具开发
2026/10/1 18:40:36 网站建设 项目流程

简介:基于Python和VGG16预训练模型构建的以图搜图软件,带PyQt界面,输入一张图片即可在本地图库中检索相同或相似图片并展示,支持对本地目录图片进行离线索引与实时查询,适合批量图片管理、相似图去重、个人相册检索等应用场景。资源包约331MB,包含完整项目源码、测试图片、使用文档、配置文档以及一键爬取百度图片的脚本,源码附带详细注释,同时打包了Python 3.7.2与VS Code相关环境素材,方便按文档从环境配置到运行检索完整走通。已有2708人学习使用;通过该资源既能深入理解VGG16特征提取与相似度匹配原理,也能学习PyQt界面搭建方式,更适合在此基础上扩展图像检索功能,也可用于课程设计与毕业设计参考。

1. 以图搜图到底怎么搜:先用 VGG16 给每张图生成“指纹”

做个人照片库整理时,我碰到过一段很狼狈的经历:存了上万张壁纸和截图,想找一张“记得大概样子但记不清文件名”的图,翻了半天一无所获。后来我决定自己做一个类似百度识图的本地工具,输入一张图,去图片库找出相同或相似的图片,用 PyQt 搭界面,用 VGG16 预训练模型做特征提取。这套“python_以图搜图_pyqt_vgg16”方案就是这样跑通的,它不依赖云端接口,模型权重离线加载,能让你彻底掌控从特征提取到检索展示的完整链路。

它解决的核心问题很简单:人眼能一眼看出两张图“像”,但计算机面对的是像素矩阵,必须先把它压缩成一个可比较的向量,再在这个向量空间里算距离。这套方案把一张张图片变成固定长度的特征向量,入库、查询、排序,最后在 PyQt 界面里展示 Top N 结果。适合谁?正在学视觉检索的开发者、想用预训练模型落地一个完整项目的学生,以及只想把手头图片库整理明白的普通 Python 爱好者。

2. VGG16 做特征提取:为什么选预训练模型而不是自己训网络

2.1 特征到底取哪一层:从卷积层到全连接层的取舍

VGG16 是 2014 年 ImageNet 比赛上的经典网络,结构不复杂,13 个卷积层加 3 个全连接层,但它的迁移学习效果非常稳定。拿来即用的关键原因是预训练权重已经在 ImageNet 上见过上千万张图,学到的边缘、纹理、形状等低级特征和轮廓、部件等高级语义特征,可以被复用到任意图像任务上。自己从零训练一个像样的分类网络动辄几天,而迁移学习只需要前向推断一次,这就是选它的最大理由。

但“用 VGG16”不等于“用整个 VGG16”。不同层输出的语义粒度和维度差别很大,直接影响检索效果和检索速度。下面是我实测下来常用的特征层选择对比:

特征层输出维度语义粒度检索表现备注
block3_pool28×28×256纹理、局部边缘对颜色敏感,误检率高可用于风格近似检索
block5_pool7×7×512物体部件、轮廓中等粒度,泛化好需做池化压缩,配合 PCA 加速常用
fc14096全局语义,偏抽象对“同类不同物”区分好参数量大,内存占用高
fc24096全局语义,更抽象相似度排序较稳本方案采用,适合中等规模图库

我一般会优先用 fc2 输出,因为它是全连接层,已经把卷积特征压扁成全局描述子,对缩放、平移、轻微旋转的容忍度比卷积层高。截取层的写法很直接,用 Keras 的函数式 API 从原模型尾部切一刀:

from tensorflow.keras.applications import VGG16 from tensorflow.keras.models import Model base_model = VGG16(weights='imagenet', include_top=True) feature_model = Model(inputs=base_model.input, outputs=base_model.get_layer('fc2').output) # 测试一张图,特征形状应该是 (1, 4096) import numpy as np dummy = np.random.rand(1, 224, 224, 3).astype('float32') feature = feature_model.predict(dummy) print(feature.shape)

这段代码把 VGG16 原模型加载进来,然后从fc2层截断,构造一个新的feature_model。注意include_top=True,因为我们要用到全连接层,不能像做分类微调那样丢弃顶层。get_layer('fc2')拿到的就是第二个全连接层。测试时用随机数据走一遍前向推断,确认输出维度是(1, 4096),说明模型通路已经打通。

2.2 相似度计算:余弦相似度比欧氏距离好在哪

有了特征向量,剩下的问题是怎么判断“像不像”。欧氏距离算的是向量在特征空间中的直线距离,但 VGG16 提取的特征向量各维度的数值范围差异很大,有的维度接近几百,有的只有几十,直接用欧氏距离会被大数值维度主导。余弦相似度计算的是两个向量的夹角余弦值,更关注方向一致性,对特征的绝对大小不敏感,所以更像是“语义是否对齐”。

实际检索时我会对特征向量先做 L2 归一化,再做矩阵乘法,一次算出查询向量与库中全部向量的余弦相似度:

def cosine_similarity(query_feat, library_feats): # library_feats: (N, 4096) 归一化后的特征矩阵 query_norm = query_feat / np.linalg.norm(query_feat) lib_norm = library_feats / np.linalg.norm(library_feats, axis=1, keepdims=True) scores = np.dot(lib_norm, query_norm.reshape(-1, 1)).flatten() return scores

这里先把查询向量和库向量都做了 L2 归一化,np.dot(lib_norm, query_norm)得到的就是一个长度为 N 的余弦相似度向量。归一化的目的是让计算出来的分数严格落在 -1 到 1 之间,方便后面统一用阈值过滤。keepdims=True很关键,保证广播时维度对齐,不然np.linalg.norm对二维矩阵会输出形状(N,),除矩阵时会有隐患。

2.3 构建特征库:npy 矩阵存向量,路径列表存索引

以图搜图的“图库”本质上不是一个图片文件夹,而是一个“特征矩阵 + 路径索引”的组合。图片本身存在磁盘上,特征矩阵用npy文件保存,这样启动时只需要加载一个几 MB 到几十 MB 的矩阵,不用把成百上千张图片全部读进内存。

import numpy as np import json, os def build_library(feature_model, image_dir, save_npy='features.npy', save_json='paths.json'): feats = [] paths = [] for fname in sorted(os.listdir(image_dir)): fpath = os.path.join(image_dir, fname) img = load_and_preprocess(fpath) # 统一缩放为 (224, 224, 3) feat = feature_model.predict(img)[0] # 单张特征 feats.append(feat) paths.append(fpath) feats = np.array(feats) np.save(save_npy, feats) with open(save_json, 'w', encoding='utf-8') as f: json.dump(paths, f, ensure_ascii=False) return feats, paths

我先遍历图库目录,对每张图调用统一的预处理函数,再经过特征模型得到 4096 维向量。收集完所有向量后合并成二维矩阵,把路径列表单独存成 JSON,这样后续检索时按相似度分数下标就能直接定位图片文件。注意特征矩阵和路径列表的索引顺序必须严格一一对应,一旦出现过删改图片的情况,最好重新生成一次特征库,不要手动拼 JSON。

3. 把项目跑起来:环境配置、爬虫脚本与批量特征提取

3.1 环境搭配:Python 3.7.2 + VSCode + PyQt5 + TensorFlow

这个项目里自带的 Python 版本是 3.7.2,我建议你直接用它,因为在 TensorFlow 2.x 和 PyQt5 的兼容性组合里,3.7 系是最省心的区间。VSCode 做编辑和调试也够用,装好 Python 扩展之后,F5 就能跑起来。关键依赖大概是下面这张表:

依赖库推荐版本用途
tensorflow2.6.2加载 VGG16 预训练模型,前向推断
numpy1.19.5特征矩阵运算
PyQt55.15.4桌面界面
opencv-python4.5.3.56读取和处理图片
requests2.26.0爬取百度图片
pillow8.3.2格式兼容处理,尤其处理 RGBA 图

安装命令用 pip 一把梭就行:

pip install tensorflow==2.6.2 numpy==1.19.5 PyQt5==5.15.4 opencv-python==4.5.3.56 requests==2.26.0 pillow==8.3.2

这里我想提醒一个点:Python 3.7 下不要装最新版 numpy 和 tensorflow,新版 numpy 在高版本上会出现 API 删改,而且 tensorflow 2.10 之后就不再提供 3.7 的 wheel 包了。如果你在 VSCode 里配置环境时选错了解释器,跑起来会直接报No module named 'tensorflow',那多半是解释器选成了全局环境而不是项目虚拟环境,不是包没装成功。

3.2 一键爬取百度图片:requests 拼接 URL 与 UA 伪装

资源包里带的爬虫脚本能按关键词批量拉取百度图片,本质上是请求百度图片的公开接口,拿到 JSON 再解析图片地址。这个脚本非常适合快速构造图库,但要注意它只能用于个人学习,别拿去扒付费图库,也要遵守目标网站的 robots 协议。

import requests import json import os def crawl_baidu_images(keyword, save_dir='images', max_num=50): os.makedirs(save_dir, exist_ok=True) url = 'https://image.baidu.com/search/acjson' headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Referer': 'https://image.baidu.com/' } count = 0 for page in range(max_num // 30 + 1): params = { 'tn': 'resultjson_com', 'word': keyword, 'pn': page * 30, 'rn': 30, } resp = requests.get(url, params=params, headers=headers, timeout=10) data = resp.json() for item in data.get('data', []): img_url = item.get('thumbURL') or item.get('hoverURL') if not img_url: continue try: img_resp = requests.get(img_url, headers=headers, timeout=10) ext = img_url.split('.')[-1].split('?')[0] or 'jpg' fname = os.path.join(save_dir, f'{keyword}_{count}.{ext}') with open(fname, 'wb') as f: f.write(img_resp.content) count += 1 if count >= max_num: return except Exception as e: print(f'下载失败: {img_url}, {e}')

这段爬虫的核心是百度图片搜索接口的acjson,参数里word是搜索词,pn是翻页偏移量,rn是每页条数。拿到 JSON 后,优先取thumbURL作为缩略图地址,取不到再退而求其次用hoverURL。下载时我把图片内容直接以二进制写入文件,扩展名从 URL 里推断,split('?')[0]是为了去掉 URL 里的查询参数。注意加Referer,因为百度会对裸请求做防盗链检查,没有 Referer 很容易返回空列表。

3.3 批量提取特征并保存特征库:预处理函数必须严格统一

图库图片来源五花八门,有 jpg、png,也有 webp,尺寸从几百像素到几千像素都有。如果每张图预处理方式不一样,后续检索必然出问题。我在代码里把预处理单独抽成一个函数,保证入库和查询走同一条路径。

import cv2 def load_and_preprocess(fpath, target_size=(224, 224)): img = cv2.imread(fpath) if img is None: raise ValueError(f'图片读取失败: {fpath}') img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # OpenCV 默认 BGR,模型要 RGB img = cv2.resize(img, target_size, interpolation=cv2.INTER_AREA) img = img.astype('float32') # VGG16 的 preprocess_input 会做 RGB->BGR 并且减均值 img = img[..., ::-1] # RGB 转回 BGR 顺序 img -= [103.939, 116.779, 123.68] return img.reshape(1, 224, 224, 3)

这里有个非常典型的坑:cv2.imread读出来的图像是 BGR 通道顺序,而 VGG16 预训练权重是在 RGB 图像上训练的。我先把 BGR 转成 RGB,再送进模型,但 Keras 的preprocess_input内部还会再做一次 BGR 转换和减均值操作,所以我干脆手动模拟这个流程,保证输入分布和预训练时一致。缩放的插值方式我选了INTER_AREA,它对缩小图像更友好,不容易出现摩尔纹。如果你发现检索结果“看着差不多但总差一点”,十有八九就是通道顺序或者均值减法这里出了问题。

4. PyQt 界面实战:把检索流程封装成“百度识图”式桌面工具

4.1 界面结构:左侧选图、右侧结果列表

PyQt 界面我拆得比较克制:顶部是一个选择图片的按钮和当前图片路径显示,中间左侧放查询图片预览,右侧放检索结果的缩略图列表,底部是状态栏,显示特征库规模、检索耗时等信息。结果展示用QListWidget,设置成图标模式,每项上面显示缩略图,下面用文本标注相似度分数。

from PyQt5.QtWidgets import QListWidget, QListWidgetItem from PyQt5.QtCore import Qt from PyQt5.QtGui import QPixmap, QIcon class ResultList(QListWidget): def __init__(self, parent=None): super().__init__(parent) self.setViewMode(QListWidget.IconMode) self.setIconSize(150, 150) self.setResizeMode(QListWidget.Adjust) self.setSpacing(8) def add_result(self, img_path, score): item = QListWidgetItem() pixmap = QPixmap(img_path) item.setIcon(QIcon(pixmap.scaled(150, 150, Qt.KeepAspectRatio))) item.setText(f'{score:.3f}') self.addItem(item)

IconMode让列表按网格排列缩略图,setIconSize((150, 150))统一格子的图片尺寸,setResizeMode(Adjust)让窗口拉大时列表自动重排。我故意把分数显示在文本上,这样你能直观看到相似度排序的梯度,如果结果分数普遍都在 0.6 以下,说明图库内容比较杂,或者需要调特征层。图片加载用QPixmap直接读文件路径,对 png 和 jpg 都适用,但如果图片是 CMYK 模式或 16 位深度,这里可能显示异常,需要在读图前用 Pillow 做一次转码。

4.2 核心检索逻辑:从点击按钮到结果展示

界面按钮的槽函数承担了整个检索流程,核心步骤是:选图、预处理、提特征、算相似度、排序展示。这里我不建议把特征库加载也放在按钮事件里,否则点击一次就要等好几秒,体验很差,而是放到程序初始化时后台加载。

import sys import numpy as np from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton, QFileDialog, QLabel, QVBoxLayout, QWidget class SearchWindow(QMainWindow): def __init__(self, feature_model, features, paths): super().__init__() self.feature_model = feature_model self.features = features # (N, 4096) 特征矩阵 self.paths = paths # 路径列表 self.btn = QPushButton('选择查询图片', self) self.btn.clicked.connect(self.on_search) self.preview = QLabel('查询图预览', self) self.result_list = ResultList(self) layout = QVBoxLayout() layout.addWidget(self.btn) layout.addWidget(self.preview) layout.addWidget(self.result_list) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) def on_search(self): fpath, _ = QFileDialog.getOpenFileName(self, '选择图片', '', 'Images (*.png *.jpg *.jpeg *.webp)') if not fpath: return pixmap = QPixmap(fpath) self.preview.setPixmap(pixmap.scaled(300, 300, Qt.KeepAspectRatio)) query_feat = self.feature_model.predict(load_and_preprocess(fpath))[0] scores = cosine_similarity(query_feat, self.features) top_idx = np.argsort(scores)[::-1][:20] # 取前 20 个 self.result_list.clear() for idx in top_idx: self.result_list.add_result(self.paths[idx], scores[idx])

on_search里先用文件对话框选图,更新预览图,然后提取查询特征,调相似度函数得到全库分数。np.argsort(scores)[::-1]得到从大到小的排名,取前 20 个结果展示。这里有个参数建议:展示条数不要贪多,20 到 30 张足够,因为相似度排序越靠后参考价值越低,还拖慢界面。load_and_preprocess必须和建库时用的是同一个函数,否则查询特征和库特征分布不一致,排序结果就是乱的。

4.3 主线程卡顿问题:用加载状态缓解启动等待

PyQt 界面是事件循环驱动的,如果在主线程里做特征库矩阵加载,程序会短暂无响应。特征库矩阵(N, 4096)在 N 等于一万时大约是 150 MB,加载和归一化都需要时间。常见做法是在窗口初始化时先显示“正在加载特征库”,加载完成后再刷新状态。

我在实际项目里会先用QApplication.processEvents()强制刷新界面,然后再执行耗时加载,至少让用户看到程序没有死掉。更彻底的方案是QThread,不过对小规模图库来说有点杀鸡用牛刀。如果你后面把图库规模做到几万张,建议再上线程,否则加载期间的界面白屏会被误认为崩溃。

5. 常见问题与排查:从“跑不起来”到“结果不对”的 5 个坑

5.1 环境依赖没装全,运行时提示找不到模块

现象:启动脚本后直接报ModuleNotFoundError: No module named 'PyQt5.sip',或者ImportError: DLL load failed。 原因:PyQt5 安装不完整,或者 Python 解释器选错,常见于 VSCode 里选择了全局 Python 而不是项目虚拟环境。PyQt5.sip是 PyQt5 的底层支撑模块,pip 安装时崩溃或中断会导致它缺失。 解决:先确认解释器路径,再强制重装。命令行里where python看当前解释器,然后跑pip install --force-reinstall PyQt5 PyQt5-sip。装完后在 Python 交互式环境里import PyQt5验证加载,如果 DLL 报错,优先检查是否装了 32 位 Python 而系统是 64 位,换成 64 位解释器重新建虚拟环境。

5.2 特征向量维度对不上,检索时矩阵广播失败

现象:特征库构建成功,但检索时np.dot报ValueError: operands could not be broadcast together with shapes (N, 512) (4096,)。 原因:建库时用的特征层是block5_pool加池化后的 512 维,查询时却用了fc2的 4096 维,或者反过来。更隐蔽的原因是两个阶段分别加载了不同的模型文件,一个include_top=True,一个include_top=False。 解决:在建库和查询的代码入口顶部统一加一个断言,assert query_feat.shape[1] == self.features.shape[1],维度不一致时立刻报错,不要等矩阵运算炸掉。同时确认feature_model定义只出现一次,查询时直接 import 建库时保存的同一个模型对象。

5.3 相似图片排到后面,检索结果“看着像但排序不对”

现象:图库里明明有同一场景的图,结果却排到第十五名开外,前面的结果五花八门。 原因:全连接层特征虽然语义抽象,但对图像中的背景占比非常敏感,背景相似但主体不同的图得分反而高。另外,如果特征没有做 L2 归一化,余弦相似度计算会失真。 解决:第一,先对库特征和查询特征都强制 L2 归一化,这步很多人会漏。第二,如果归一化后还是结果偏移,把特征层从fc2换到block5_pool,然后做全局平均池化降成 512 维,这一步能保留更多空间局部性,对“相同场景不同角度”的召回更友好。我一般会写一个小脚本,分别用两个特征层做同一批查询的排序,肉眼对比前十条的准确率再决定用哪个。

5.4 爬虫抓到一堆打不开的图片,特征库被污染

现象:爬虫脚本跑完,图片目录里多了很多文件名正常但打不开的文件,OpenCV 读图返回 None,建库时直接抛异常中断。 原因:百度图片返回的thumbURL缩略图地址有时会失效,请求回来的是一个 HTML 错误页,但由于 HTTP 状态码是 200,脚本不知道内容已经不是图片。另外,部分重试逻辑会把同一个失败地址写进不同文件名,重复污染目录。 解决:在下载函数里加文件头校验,不要只靠扩展名判断。图片文件的头部字节是固定的,JPEG 以\xFF\xD8\xFF开头,PNG 以\x89PNG开头。下载后先读前三个字节判断,不匹配就直接删掉文件。这样能保证进入图库的每个文件都是可用图片。

5.5 界面启动时卡顿,点击按钮后窗口转圈

现象:程序双击启动后黑屏几秒,加载完才好;点“选择图片”时窗口像卡死一样,标题栏显示“未响应”。 原因:特征库矩阵加载、图片预处理和模型前向推断都在主线程执行,阻塞了 PyQt 的事件循环。尤其是模型第一次predict时会触发权重加载和计算图优化,耗时可能长达几秒到十几秒。 解决:把模型加载和特征库加载挪到程序入口处,在窗口显示之前完成,让“卡顿”发生在出现界面之前而不是之后。检索按钮事件里只做特征提取和矩阵运算,这两步在 CPU 上大约几百毫秒,不会让用户察觉明显卡顿。如果图库规模上万,再考虑用QThread把检索放到子线程,通过信号把结果显示回主线程。

6. 让检索更准的进阶技巧:特征层对比与阈值验证

前面说了fc2和block5_pool的区别,这里给一个可复现的验证方法。我建议你拿测试图里的十张图分别设置成“库外查询图”,每张去跑完整召回,统计 Top 10 里真正相关的图片数量,对比两个特征层的命中率。这个实验做一次,比你凭感觉调参靠谱得多。

# 对比脚本骨架:用同一张查询图,分别用两个特征模型做检索 models = { 'fc2': feature_model_fc2, 'pool5': feature_model_pool5, } for name, model in models.items(): q_feat = model.predict(load_and_preprocess(query_path))[0] scores = cosine_similarity(q_feat, library_feats[name]) top_idx = np.argsort(scores)[::-1][:10] print(f'{name}: {top_idx.tolist()}')

另外我养成了一个习惯:先用一张完全不在图库里的图片做查询,看返回结果的相似度分数分布。如果最高分都在 0.8 以上,说明图库和查询图来源高度一致,阈值可以调高;如果最高分只有 0.4 左右,那要么查询图和图库主题差异太大,要么特征层选取不合适。这个分数分布本身就是你的“指路牌”。工程上当好分数普遍太低时,优先换特征层,而不是去调排序算法,因为排序算法的调整空间远没有特征层面的影响大。

还有一个小技巧值得试:如果图库规模超过两万,直接用全量 4096 维特征做矩阵乘法,单次查询大约几十毫秒,但内存占用会逐渐变大。可以先用 PCA 降维到 256 维,检索速度提升明显,准确率下降可以接受。我之前就是在一万五千张图的新库里直接跑全量,内存涨了快 400 MB,后来做了 PCA 才把启动时间从 8 秒压到 3 秒。从那以后我每次做以图搜图都强制走一遍“查全率对比 + 分数分布检查”这两步,这个习惯帮我少走了很多弯路。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询