相似图片检索从入门到工程落地:感知哈希、CNN特征与向量索引实战
发布时间:2026/9/26 9:15:47来源:尧图网络
简介一份面向图像搜索与相似度计算的C实现工具包适合计算机视觉开发者、算法学习者及研究者使用。压缩包围绕“以图搜图”需求提供从图像预处理、特征提取、相似度度量到结果展示的完整检索流程可应用于内容推荐、版权检测、视觉识别等实际场景代码结构清晰便于阅读与二次扩展。整个RAR包内共30个文件以11个头文件.h和5个源文件.cpp为核心构成图像处理与界面逻辑主体同时提供cximage.lib库文件、检索.doc说明文档及工程配置等辅助文件压缩后仅300KB体积小巧。目前已有200人学习下载。通过源码可深入理解基于像素直方图、特征点匹配或向量距离的相似图检索实现思路借助附带的示例与文档也能快速将检索功能迁移到自己的项目中进行二次开发是入门图像相似度检索的不错参考。1. 相似图片检索到底在找什么先定规则再谈相似度我接过一个挺实际的活儿两千多张手机拍的图混在一起要从里面把同一件东西在不同角度、不同光线下的照片自动挑出来。只看文件名、体积、拍摄时间这些元数据完全不靠谱真正要做的功能就是“相似图片检索”。一开始我也试着逐像素比对结果两张同一物体的图只要缩放一下、加个滤镜、或者换种压缩格式像素级对比立刻全线崩盘返工返到怀疑人生。后来才明白相似图片检索的核心不是比“像素长得一不一样”而是先把图片压缩成一串稳定、可比较的数学指纹再去量指纹之间的距离。这个思路的落地路径主要有三条感知哈希aHash/pHash/dHash、预训练 CNN 特征向量、以及用来支撑海量图库的向量索引。下面我按“从简单能用到扛住复杂场景”的顺序把整套做法拆开参数、代码和踩坑记录都放在对应章节里。2. 给图片做指纹aHash、pHash、dHash 三种哈希与汉明距离2.1 感知哈希的三个固定动作缩放、灰度、像素与均值比较感知哈希的思路很直接把一张图变成一串 64 位的二进制串。两个图片哈希的汉明距离越小说明这两张图越接近。整个流程可以压缩成四个动作统一尺寸、灰度化、计算代表值、逐像素比较生成二进制串。为什么要先缩放这一步不是偷懒而是刻意丢掉高频细节。比如把图缩到 8×8相当于只保留整体亮度分布和物体的大轮廓缩放、轻微裁剪、JPEG 重压缩这些操作就很难再动摇结果。相比开个什么匹配大模型这套办法启动成本极低一张图从读取到算出指纹一般在几毫秒到十几毫秒级别。灰度化同理去掉颜色维度让算法更关注结构而不是色偏。后面比较时不同哈希算法差别就在“代表值”怎么算aHash 用所有像素的平均灰度pHash 先对图像做 DCT 变换再取低频分量dHash 则比较相邻像素之间的亮度差。这三种哈希各有脾气下面给一套可以直接跑的代码。2.2 用 Python 同时算 aHash、pHash、dHash 的脚本先装依赖Pillow 负责图像读取和缩放OpenCV 的dct只用在 pHash 这一路pip install pillow opencv-python numpy然后是核心实现from PIL import Image, ImageOps import numpy as np import cv2 def resize_to_gray(img, size8): # 统一缩到 size x size转灰度LANCZOS 采样质量高 return img.convert(L).resize((size, size), Image.LANCZOS) def a_hash(img, size8): gray np.asarray(resize_to_gray(img, size), dtypenp.uint8) avg gray.mean() # 大于均值记 1小于等于均值记 0 bits (gray avg).flatten().astype(np.uint8) return bits def p_hash(img, size32, low_freq_size8): gray np.asarray(resize_to_gray(img, size), dtypenp.float32) dct cv2.dct(gray) # 对整块灰度做离散余弦变换 low_freq dct[:low_freq_size, :low_freq_size] avg low_freq.mean() bits (low_freq avg).flatten().astype(np.uint8) return bits def d_hash(img, size8): # 需要 size1 的宽度才能比较相邻像素 gray np.asarray(resize_to_gray(img, size 1), dtypenp.uint8) diff gray[:, 1:] - gray[:, :-1] bits (diff 0).flatten().astype(np.uint8) return bits def hamming(a, b): return int(np.count_nonzero(a ^ b))逻辑上三块是同一套路差别在“代表值”a_hash直接拿灰度图的像素平均值当阈值单像素与均值比较。64 位结果里每一位代表该像素相对整图的明暗整体曝光偏亮或偏暗时很多位的结论不太容易反转。p_hash先对 32×32 灰度图做 DCT把空间信息转到频域保留左上角 8×8 的低频分量。低频对应大范围亮度结构所以 pHash 对 JPEG 压缩和水印干扰的耐受性最好代价是计算成本比其他两个多一个 DCT。d_hash比较每个像素和它右侧邻居的亮度大小关系记录的是渐变方向。它对整体色偏、亮度偏移这类全局变化不太敏感但对物体平移和旋转比较敏感。调用方式也很直白img_a Image.open(sample_a.jpg) img_b Image.open(sample_b.jpg) print(aHash hamming:, hamming(a_hash(img_a), a_hash(img_b))) print(pHash hamming:, hamming(p_hash(img_a), p_hash(img_b))) print(dHash hamming:, hamming(d_hash(img_a), d_hash(img_b)))输出里三个距离一起看比只看某一个要可靠。比如 aHash 距离很小但 pHash 距离很大通常是图片被裁剪过反过来 pHash 距离小、dHash 距离大大概率是颜色被调过但构图没变。2.3 汉明距离的阈值分档我从 5、10、20 三档开始调拿到汉明距离后得定一个“多少算相似”的分界。我的习惯是分三档先出候选再人工复核汉明距离64 位指纹含义建议动作05同一张图或几乎无损复制直接判定重复610高度相似可能经过重压缩、加字幕、小范围裁切进入待确认名单1120有一定相似度可能是同场景不同角度按需保留20 以上在 64 位指纹下基本不相关直接丢弃这套分档不是严格的科学结论而是经验值。不同图像内容会有偏移纯色截图、海报这类大面积同色图片aHash 均值很容易被少数亮色带跑距离波动大分档要适当放宽。我的做法是先跑一批已标注相似结果的样例把距离分布画出来再选分位点不硬套网上抄来的数字。提示哈希相似检索的正反馈来自“召回优先”。先放宽阈值召回足够多的候选再用特征向量精排比一开始就收紧阈值更稳。3. 从哈希升级到特征向量让相似检索扛住旋转、裁剪和调色3.1 哪些场景证明哈希顶不住了调色、旋转、局部裁剪哈希在“几乎相同、只有轻量改动”的任务里很好用但真正做图库去重和商品同款识别时我很快遇到了三种把它打穿的情况。第一种是调色。同一张照片白天变黄昏、加个冷色调滤镜灰度均值会漂移aHash 的位数翻转一大堆距离直接冲到 20 以上。第二种是旋转 45 度再贴到别的背景上哈希的方形网格对旋转非常敏感旋转后每个网格采到的内容都变了。第三种最典型电商场景里一张商品主图被裁掉背景、放大局部、又压了一次画质此时 8×8 网格几乎覆盖不到原图结构64 位指纹形同虚设。这些场景需要的不再是“压缩成 64 位比特”而是把图片映射成一个高维向量让语义层面的“像”在向量空间里距离更近。下面这套方案就是当前做相似图片检索最常见、最可靠的进阶路线。3.2 用预训练 CNN 提取 512 维特征向量pipeline 与余弦相似度我常用 ResNet18 或 MobileNet 这类轻量预训练模型做特征提取。关键技巧是把网络最后一层分类头去掉让模型输出倒数第二层的高维特征。分类头输出的是类别概率对常见物体有强偏置而中间层的特征向量保留的是颜色、纹理、轮廓与语义信息拿来做相似检索更通用。代码用 PyTorch 实现依赖如下pip install torch torchvision完整 pipelineimport torch import torchvision.transforms as T from torchvision import models from PIL import Image import numpy as np device cuda if torch.cuda.is_available() else cpu def build_extractor(backboneresnet18): model models.__dict__[backbone](pretrainedTrue) # 去掉分类头输出 512 维特征向量 model.fc torch.nn.Identity() model.to(device).eval() return model 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_feature(model, img_path): img Image.open(img_path).convert(RGB) x transform(img).unsqueeze(0).to(device) with torch.no_grad(): feat model(x).squeeze().cpu().numpy() norm np.linalg.norm(feat) # L2 归一化后点积等于余弦相似度 return feat / (norm 1e-8)三个细节值得展开model.eval()不是可有可无。预训练网络里带着 BatchNorm 和 Dropout训练模式与推理模式行为差很多漏掉这行会让特征向量不稳定。torch.no_grad()能省显存和计算时间提取特征阶段不需要梯度。L2 归一化这一步很容易被跳过但如果不归一化向量模长差异会污染相似度排序。归一化之后直接点积就是余弦相似度数值落在 -1 到 1。比较两张图extractor build_extractor() feat1 extract_feature(extractor, query.jpg) feat2 extract_feature(extractor, candidate.jpg) sim float(feat1 feat2) print(fcosine similarity: {sim:.4f})这个指标对旋转、裁剪、滤镜的耐受度明显好于哈希。常规经验值是同源图片大体在 0.85 以上相似场景图在 0.750.85完全无关的图往往低于 0.5。但这和业务数据分布强相关广告图、白底商品图、实拍图的分布差异很大不要拿一个固定 0.8 去套所有场景。3.3 用 FAISS 做批量近邻检索IndexFlatIP 与 IVFFlat 的参数单张图逐张比对在库小的时候没感觉等图库到了几万张每张图都全遍历一次就明显卡顿。FAISS 是目前最常用的向量索引库专门解决“给一个查询向量快速取出 topK 个最近邻”的问题。最小用法是暴力索引import faiss import numpy as np # 假设 all_feats 是 N x 512 的 float32 矩阵已经做过归一化 dim all_feats.shape[1] index faiss.IndexFlatIP(dim) index.add(all_feats.astype(float32)) # 查一张图的 top 10 D, I index.search(query_feat.reshape(1, -1).astype(float32), 10) print(I[0]) # 命中向量在库里的下标 print(D[0]) # 对应相似度分数IndexFlatIP是精确的内积索引向量归一化后等同于余弦相似度排序。它有优点也有代价所有向量都存在内存里索引构建快、查询准但十万级向量时每次查询也要全量扫一遍CPU 消耗不小。我一般到十万条以上就换IndexIVFFlatnlist int(np.sqrt(len(all_feats))) # 粗聚类个数 quantizer faiss.IndexFlatIP(dim) index faiss.IndexIVFFlat(quantizer, dim, nlist, faiss.METRIC_INNER_PRODUCT) index.train(all_feats.astype(float32)) index.add(all_feats.astype(float32)) index.nprobe 20 # 查询时扫描多少个聚类中心两个参数最容易翻车nlist太小会让每个聚类里的向量很多查询仍然慢nprobe太小会漏召回尤其是相似向量恰好处在聚类边界时。常见做法是nlistsqrt(N)先把nprobe调到 10观察 topK 结果与暴力索引是否一致再逐步加大到 20 或 30。4. 批量入库与相似查询一个本地图库检索工程的设计细节4.1 扫描图库并写入 SQLite用 BLOB 存特征用哈希做预筛哈希负责粗筛特征向量负责精排这是我现在做本地图库去重的基本盘。把它们结合成一个可长期使用的入库脚本存储层我偏好 SQLite而不是直接丢 numpy 数组文件。SQLite 的好处是路径、哈希、特征可以一起按行管理增删改查都方便重启进程也不用重新加载全部数据。下面是一个完整的入库脚本骨架。假设extract_feature已经用前面第 3.2 节的函数实现from pathlib import Path import sqlite3 import numpy as np from PIL import Image DB_PATH Path(image_index.db) def scan_and_build(root_dir, extractor): conn sqlite3.connect(DB_PATH) # 每次重建索引先清理旧表 conn.execute(DROP TABLE IF EXISTS images) conn.execute( CREATE TABLE images ( id INTEGER PRIMARY KEY AUTOINCREMENT, rel_path TEXT NOT NULL UNIQUE, hash_a TEXT NOT NULL, feature BLOB NOT NULL ) ) extensions {.jpg, .jpeg, .png, .bmp, .webp} batch [] root Path(root_dir) for img_path in root.rglob(*): if not img_path.is_file(): continue if img_path.suffix.lower() not in extensions: continue img Image.open(img_path) hash_a a_hash(img) feat extract_feature(img_path).astype(float32) rel str(img_path.relative_to(root)) # 把哈希转成字符串存起来便于粗筛时走 SQL 条件 hash_str .join(str(bit) for bit in hash_a) batch.append((rel, hash_str, feat.tobytes())) # 每 500 条提交一次避免大事务锁表 if len(batch) 500: conn.executemany( INSERT INTO images(rel_path, hash_a, feature) VALUES(?,?,?), batch ) conn.commit() batch.clear() if batch: conn.executemany( INSERT INTO images(rel_path, hash_a, feature) VALUES(?,?,?), batch ) conn.commit() conn.close()这里有三个参数决策值得说清楚rel_path存相对路径而不是绝对路径。图库目录一旦迁移绝对路径全部失效相对路径只要根目录没变就还能用。feature以float32的 bytes 存进 BLOB。np.float64精度更高但体积翻倍对相似度排序没有明显帮助我统一用float32。读出时用np.frombuffer(raw, dtypenp.float32)还原不要用np.load去读 BLOB。批量提交设为 500 条。事务太大时 SQLite 写锁竞争明显太小则每秒提交次数过多500 是本地机械硬盘上的一个折中值。4.2 查询链路先哈希过滤、再向量精排、最后返回 topK查询阶段如果把全库特征都读进内存做矩阵乘法图库小的时候没问题但库一大就没必要。实际查询我分两级先用 aHash 的汉明距离粗筛掉明显无关的图只对距离小于阈值的候选做余弦相似度精排。def load_feature_from_blob(blob): return np.frombuffer(blob, dtypenp.float32) def search_top_k(conn, query_img_path, top_k10, max_dist20): from PIL import Image q_hash a_hash(Image.open(query_img_path)) q_hash_str .join(str(bit) for bit in q_hash) q_feat extract_feature(query_img_path).astype(float32) rows conn.execute( SELECT rel_path, hash_a, feature FROM images ).fetchall() candidates [] for rel, hash_str, blob in rows: stored np.array([int(c) for c in hash_str], dtypenp.uint8) dist int(np.count_nonzero(stored ^ q_hash)) if dist max_dist: continue feat load_feature_from_blob(blob) sim float(q_feat feat) candidates.append((rel, sim, dist)) candidates.sort(keylambda x: x[1], reverseTrue) return candidates[:top_k]这个设计里max_dist是粗筛的宽进条件。我一般设 20宁可多带一些候选让精排去判断也不在粗筛阶段漏掉真相似的结果。如果图库里图很多可以先加一条 SQL 把相似哈希的候选缩小范围SELECT rel_path, feature FROM images WHERE hash_a LIKE ?;不过 SQLLIKE对 64 位二进制串的匹配能力有限更适合的做法是先把相近哈希的 row 读进内存再用汉明距离过滤避免 SQL 表达式失控。4.3 轻量数据存储与内存边界什么时候该换 FAISS/向量数据库上面这套 SQLite 方案在量级上有明确边界512 维float32单条特征占 2KB一万条图约占 20MB 内存读出来全量做 numpy 计算很轻松。到十万条就是 200MB还在可接受范围百万条就上 2GB单机内存吃紧查询延迟也会明显恶化。这时候我一般把检索层切到 FAISS存储层继续用 SQLite 管路径和元数据。FAISS 负责向量近邻检索SQLite 负责按 id 反查文件路径。如果还要多人并发、跨机器部署再加一层真正的向量数据库但本地单机任务没必要一上来就上向量数据库那个运维成本远高于收益。提示选型不是“哪个高级用哪个”而是“让每个环节都落在自己舒适的量级里”。本地图库用 SQLite numpy 已经能扛住中小规模业务不要为了赶时髦而造一个分布式系统。5. 避坑tuxiangjiansuo 类包最常见的 5 个现场与排查思路5.1 同一张图因为 EXIF 旋转被排在最后一位现象两张内容完全相同的照片一张是手机竖拍、一张是修图软件导出的横图特征向量相似度反而很低排不进 topK。原因手机拍摄时会把旋转信息写进 EXIF 元数据而不少图像处理库默认忽略这个方向标记。一张被标记“需要旋转 90 度”的图直接读进来其实是横躺的画面结构和原图完全对不上。解决在读取阶段统一做 EXIF 转正。Pillow 自带方案from PIL import Image, ImageOps img Image.open(path) img ImageOps.exif_transpose(img) # 按 EXIF 自动旋转入库和查询两端都要走同一个读取函数否则只有一端正过来另一端的特征还是会错位。5.2 透明 PNG 和纯色背景把相似度拉低一半现象同一张产品图一个源文件是透明背景 PNG另一个是白底 JPEG。哈希距离已经很大特征向量相似度也只有 0.6 左右明明是人眼一秒能认出的同一件商品。原因透明通道在转 RGB 时透明区域会被填充成黑色。黑色区域在灰度图上形成极大暗区整体均值和低频结构全被带偏。解决入库前把 RGBA 统一合成到白色背景再做convert(RGB)img Image.open(path).convert(RGBA) bg Image.new(RGB, img.size, (255, 255, 255)) bg.paste(img, maskimg.split()[-1]) img bg这样透明区域不再是一个巨大的黑色空洞和“白底 JPEG”之间的特征距离才会收敛。5.3 超长截图和超大原图耗尽内存现象一张长截图宽度 1000高度 50000直接塞进 224×224 的 Resize得到的特征能反映构图吗不能而且大图解码过程会瞬间吃掉几百 MB 内存。原因截图里大范围纯色、滚动页面拼接占多数直接缩到 224×224 会把内容压成无法辨认的细线另一方面图像解码在内存里是按原始分辨率展开的50000 像素高的图一个数组就是几十 MB 到几百 MB。解决入库前先把最长边限制到 1024统一转成 RGB 再做归一化和缩放。这个压缩步骤建议在extract_feature入口就做掉img Image.open(path) img.thumbnail((1024, 1024), Image.LANCZOS)thumbnail会保持宽高比不会把长截图变形同时能把解码内存压到可控范围。5.4 中文路径和文件名乱码导致读图失败现象Windows 下图库路径带中文比如D:\公司素材\相机\IMG_001.jpg代码在弹出FileNotFoundError或者读到一张全是噪点的坏图。原因Windows 控制台默认编码是 GBKPython 的字符串再传给 OpenCV 底层 API 时编码转换偶发错位尤其是从压缩包里解压出来的文件名经常出现?或乱码。解决不用os.path拼路径统一用pathlib.Path同时在脚本开头强制标准输出编码import sys sys.stdout.reconfigure(encodingutf-8) from pathlib import Path root Path(rD:\公司素材\相机) for p in root.rglob(*.jpg): print(p)我踩过几次之后几乎不用os.path.join了。Path对象在 OpenCV 接口里可以直接str(path)传入不容易触发编码坑。5.5 相似度阈值没有普适值先看分布再定阈值现象同一个阈值在 A 图库上精准在 B 图库里误报一堆。尤其做商品图检索时白底图之间相似度经常偏高阈值 0.85 会把不同款式的商品全看成相似。原因特征向量相似度受图像内容分布影响很大。干净白底商品图天然高度相似而户外实拍图之间特征距离普遍偏大用一个固定阈值应对所有领域数据必然翻车。解决入库后先抽 200 对已标注“相似/不相似”样本算一遍分数画出直方图在波谷处找阈值。更稳妥的是直接用 topK 返回结果让人工复核把“相似度排序”作为结果而不是把某个相似度数值当作真理。阈值可以定但定完要留一个调整入口而不是写死在代码里。6. 把相似图片检索接到 Flask 网页端失物招领图片的小闭环前面的能力都可以装进一个小型网页服务我最近做的一个典型场景是失物招领用户上传丢失物品照片和文字描述系统去匹配库里已登记的招领信息。这个方向跟标题里的“图片相似”结合得很自然图片负责认物品文字关键词负责补上下文两边共同打分。6.1 发布失物与招领图片一次入库的 Flask API用 Flask 做轻量化接口SQLite 继续当后端存储本地局域网就能跑。核心代码结构如下特征提取复用第 3 章的extract_featurefrom io import BytesIO from flask import Flask, request, jsonify from PIL import Image import sqlite3 import numpy as np app Flask(__name__) DB lost_found.db conn sqlite3.connect(DB, check_same_threadFalse) conn.execute( CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, feature BLOB ) ) def feature_from_upload(file_storage): img Image.open(BytesIO(file_storage.read())).convert(RGB) # 这里调用第 3.2 节写好的 transform 和模型 ... return feat_normalized app.post(/publish) def publish(): title request.form.get(title, ) feat feature_from_upload(request.files[image]) conn.execute( INSERT INTO items(title, feature) VALUES(?,?), (title, feat.astype(float32).tobytes()) ) conn.commit() return jsonify({ok: True, message: 已入库})代码逻辑不复杂/publish接收表单里的标题和图片提取特征后存入 SQLite。要注意check_same_threadFalse这行参数Flask 开发服务器默认多线程没有它 SQLite 会报线程错。6.2 图片相似度与关键词匹配加权排序查询接口把图片相似度和关键词相似度加起来排序from difflib import SequenceMatcher def text_score(a, b): # 中文短文本用 sequence matching 足够 return SequenceMatcher(None, a, b).ratio() app.post(/search) def search(): q_feat feature_from_upload(request.files[image]) q_title request.form.get(title, ) rows conn.execute(SELECT id, title, feature FROM items).fetchall() results [] for item_id, title, blob in rows: stored_feat np.frombuffer(blob, dtypenp.float32) image_sim float(q_feat stored_feat) text_sim text_score(q_title, title) total 0.6 * image_sim 0.4 * text_sim results.append({ id: item_id, title: title, image_score: image_sim, text_score: text_sim, total_score: total }) results.sort(keylambda x: x[total_score], reverseTrue) return jsonify(results[:10])这个加权比例只是起步值。纯图片匹配时可以把文本权重降到 0.2检索文字描述很精确的场景再反过来。我现在的习惯是先看一批真实返回确认“文本分很高但图片完全不像”和“图片很像但描述不搭”两类误判各占多少再调比例。这个方向真正值得投入的地方是把“像”的定义做成可配置、可评估的流程而不是停在一个跑起来的 Demo。整套做下来最大的收获是相似图片检索没有银弹哈希负责快、特征向量负责准、索引负责撑规模三者配合比单独调任何一个更实用。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网