1. 介绍
均值哈希算法(Average Hash Algorithm) 是哈希算法的一种,主要用来做相似图片的搜索工作。
2. 原理
均值哈希算法(aHash)首先将原图像缩小成一个固定大小的像素图像,然后将图像转换为灰度图像,通过缩小图像的每个像素与平均灰度值的比较,生成一组哈希值。最后,利用两组图像的哈希值的汉明距离来评估图像的相似度。
魔法: 概括地讲,均值哈希算法一共可细分六步:
- 缩小图像: 将目标图像缩小为一个固定的大小,通常为8x8像素,总共64个像素。作用是去除各种图像尺寸和图像比例的差异,只保留结构、明暗等基本信息,目的是确保图像的一致性,降低计算的复杂度。
- 图像灰度化: 将缩小的图像转换为灰度图像。
- 灰度平均值: 计算灰度图像的平均灰度值。减少计算量。
- 比较平均值: 遍历灰度图像的每个像素,比较每个像素的灰度值是否大于或小于平均值。对于大于等于平均值的像素,将其表示为1,对于小于平均值的像素,将其表示为0。最后,得到一个64位的二进制值(8x8像素的图像)。
- 生成哈希值: 由于64位二进制值太长,所以按每4个字符为1组,由2进制转成16进制。这样就转为一个长度为16的字符串。这个字符串也就是这个图像可识别的哈希值,也叫图像指纹,即这个图像所包含的特征。
- 哈希值比较: 通过比较两个图像的哈希值的汉明距离(Hamming Distance),就可以评估图像的相似度,距离越小表示图像越相似。
3. 实验
第一步:缩小图像
将目标图像缩小为一个固定的大小,通常为8x8像素,总共64个像素。作用是去除各种图像尺寸和图像比例的差异,只保留结构、明暗等基本信息,目的是确保图像的一致性,降低计算的复杂度。
1)读取原图
# 测试图片路径
img_path = 'img_test/apple-01.jpg'
# 通过OpenCV加载图像
img = cv2.imread(img_path)
# 通道重排,从BGR转换为RGB
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
2)缩小原图
# 缩小图像:使用OpenCV的resize函数将图像缩放为8x8像素,采用Cubic插值方法进行图像重采样
img_resize = cv2.resize(img, (8, 8), cv2.INTER_CUBIC)
OpenCV 的 cv2.resize() 函数提供了4种插值方法,以根据图像的尺寸变化来进行图像重采样。
- cv2.INTER_NEAREST: 最近邻插值,也称为最近邻算法。它简单地使用最接近目标像素的原始像素的值。虽然计算速度快,但可能导致图像质量下降。
- cv2.INTER_LINEAR: 双线性插值,通过对最近的4个像素进行线性加权来估计目标像素的值。比最近邻插值更精确,但计算成本略高。
- cv2.INTER_CUBIC: 双三次插值,使用16个最近像素的加权平均值来估计目标像素的值。通常情况下,这是一个不错的插值方法,适用于图像缩小。
- cv2.INTER_LANCZOS4: Lanczos插值,一种高质量的插值方法,使用Lanczos窗口函数。通常用于缩小图像,以保留图像中的细节和纹理。
第二步:图像灰度化
将缩小的图像转换为灰度图像。也就是说,所有像素点总共只有64种灰度颜色。
# 图像灰度化:将彩色图像转换为灰度图像。
img_gray = cv2.cvtColor(img_resize, cv2.COLOR_BGR2GRAY)
print(f"缩放8x8的图像中每个像素的颜色=\n{img_gray}")
输出打印:
缩放8x8的图像中每个像素的颜色=
[[253 253 253 253 253 253 253 253]
[253 253 253 148 253 253 253 253]
[253 253 253 215 178 253 253 253]
[253 253 119 93 132 176 253 253]
[253 253 61 61 53 130 253 253]
[253 253 112 67 66 142 253 253]
[253 253 252 54 54 253 253 253]
[253 253 236 63 146 249 253 253]]
第三步:灰度平均值
计算灰度图像的平均灰度值。减少计算量。
img_average = np.mean(img_gray)
print(f"灰度图像中所有像素的平均值={img_average}")
输出打印:
灰度图像中所有像素的平均值=209.890625
第四步:比较平均值
遍历灰度图像的每个像素,比较每个像素的灰度值是否大于或小于平均值。对于大于等于平均值的像素,将其表示为1;对于小于平均值的像素,将其表示为0。最后,得到一组长64位的二进制字符串(8x8像素的图像)。因为对于机器而言,只认识0和1,所以这组64位的二进制就可以表示这张图像的结构和亮度分布。
# 遍历图像像素:嵌套循环遍历图像的所有像素,对比灰度图像的平均灰度值,转换为二进制的图像哈希值
img_hash_binary = []
for i in range(img_gray.shape[0]):
for j in range(img_gray.shape[1]):
if img_gray[i,j] >= img_average:
img_hash_binary.append(1)
else:
img_hash_binary.append(0)
print(f"对比灰度图像的平均像素值降噪(图像的二进制哈希值)数组={img_hash_binary}")
# 将列表中的元素转换为字符串并连接起来,形成一组64位的图像二进制哈希值字符串
img_hash_binary_str = ''.join(map(str, img_hash_binary))
print(f"对比灰度图像的平均像素值降噪(图像的二进制哈希值)={img_hash_binary_str}")
代码分解和含义如下:
- 初始化空列表:创建一个空的列表 img_hash_binary,用于存储图像的哈希值。
- 遍历图像像素:嵌套循环遍历图像的所有像素,其中 img_gray 是输入的灰度图像,img_gray.shape[0] 和 img_gray.shape[1] 分别表示图像的高度和宽度。
- 计算平均值:代码中使用变量 img_average 存储了一个平均值,用于与图像像素的灰度值进行比较。
- 根据亮度值生成哈希值:对于每个像素,代码比较像素的灰度值与平均值 (img_gray[i, j] >= img_average)。如果像素的灰度值大于或等于平均值,就将数字1添加到 img_hash_binary 列表中,表示该像素是亮的。如果像素的灰度值小于平均值,就将数字0添加到 img_hash_binary 列表中,表示该像素是暗的。
- 最终哈希值:完成循环后,img_hash_binary 列表将包含图像的二进制哈希值,其中每个元素代表一个像素的明暗情况。
输出打印:
对比灰度图像的平均像素值降噪(图像的二进制形式)数组=[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1]
对比灰度图像的平均像素值降噪(图像的二进制形式)=1111111111101111111101111100001111000011110000111110011111100111
或者,使用等价的 lambda 表达式。效果一样。
# lambda表达式
img_hash_binary_str = ""
for i in range(8):
img_hash_binary_str += ''.join(map(lambda i: '0' if i < img_average else '1', img_gray[i]))
print(f"对比灰度图像的平均像素值降噪(图像的二进制哈希值)={img_hash_binary_str}")
输出打印:
对比灰度图像的平均像素值降噪(图像的二进制形式)=1111111111101111111101111100001111000011110000111110011111100111
第五步:生成哈希值
由于64位二进制值太长,所以按每4个字符为1组,由2进制转成16进制。这样就转为一个长度为16的字符串。这个字符串也就是这个图像可识别的哈希值,也叫图像指纹,即这个图像所包含的特征。
img_hash = ""
for i in range(0, 64, 4):
img_hash += "".join('%x' % int(img_hash_binary_str[i : i + 4], 2))
print(f"图像可识别的哈希值={img_hash}")
代码分解和含义如下:
- 初始化为空字符串:创建一个空的字符串 img_hash,用于存储图像哈希值的十六进制表示。
- 遍历二进制哈希值:通过循环,代码以4位为一组遍历二进制哈希值 img_hash_binary_str。range(0, 64, 4) 确保代码在哈希值的每4位之间进行迭代。
- 将4位二进制转换为一个十六进制字符:在每次循环中,代码取出哈希值中的4位二进制(例如,img_hash_binary_str[i : i + 4]),然后使用’%x’ % int(…, 2) 将这4位二进制转换为一个十六进制字符。int(…, 2) 将二进制字符串转换为整数,‘%x’ 将整数转换为十六进制字符。
- 将十六进制字符追加到 img_hash:在每次循环中,得到的十六进制字符将被追加到 img_hash 字符串中。
- 最终哈希值:完成循环后,img_hash 将包含图像哈希值的十六进制表示,其中每个字符表示4位二进制。
输出打印:
图像可识别的哈希值=ffeff7c3c3c3e7e7
第六步:哈希值比较
通过比较两个图像的哈希值的汉明距离(Hamming Distance),就可以评估图像的相似度,距离越小表示图像越相似。
def hamming_distance(s1, s2):
# 检查这两个字符串的长度是否相同。如果长度不同,它会引发 ValueError 异常,因为汉明距离只适用于等长的字符串
if len(s1) != len(s2):
raise ValueError("Input strings must have the same length")
distance = 0
for i in range(len(s1)):
# 遍历两个字符串的每个字符,比较它们在相同位置上的值。如果发现不同的字符,将 distance 的值增加 1
if s1[i] != s2[i]:
distance += 1
return distance
4. 测试
我们来简单测试一下基于均值哈希算法的以图搜图 – 基于一张原图找最相似图片,看看效果如何。
这里,我准备了10张图片,其中9张是苹果,但形态不一,1张是梨子。
输出打印:
图片名称:img_test/apple-01.jpg,图片HASH:ffeff7c3c3c3e7e7,与图片1的近似值(汉明距离):0
图片名称:img_test/apple-02.jpg,图片HASH:ffcfc3e3e3e3e7ff,与图片1的近似值(汉明距离):8
图片名称:img_test/apple-03.jpg,图片HASH:ffe7c3c3c3c7c7ff,与图片1的近似值(汉明距离):7
图片名称:img_test/apple-04.jpg,图片HASH:e7e7c3c3c3eff7ff,与图片1的近似值(汉明距离):10
图片名称:img_test/apple-05.jpg,图片HASH:f3f3e7c7c3c7c7e7,与图片1的近似值(汉明距离):7
图片名称:img_test/apple-06.jpg,图片HASH:ffffd981818189dd,与图片1的近似值(汉明距离):13
图片名称:img_test/apple-07.jpg,图片HASH:fff7f3e3e3e3f0ff,与图片1的近似值(汉明距离):10
图片名称:img_test/apple-08.jpg,图片HASH:000006fdf171f9f8,与图片1的近似值(汉明距离):16
图片名称:img_test/apple-09.jpg,图片HASH:ffcfe7c1c1c3e7ff,与图片1的近似值(汉明距离):6
图片名称:img_test/pear-001.jpg,图片HASH:fffbe5c1c3c3c3ef,与图片1的近似值(汉明距离):8
耗时:0.09571695327758789
汉明距离:两个长度相同的字符串在相同位置上的字符不同的个数。
简单的测试分析:
原图 | 相似图片 | 相似值(汉明距离) | 相似图片特点 | 相似图片与原图Hash对比结果 |
---|---|---|---|---|
图片01 | 图片01 | 0 | 自己 | 自己与自己相似度100% |
图片01 | 图片09 | 6 | 青苹果 | 最相似。相同背景相同物体位置下最相似。 |
图片01 | 图片03、图片05 | 7 | 红蛇果(苹果)、青苹果(2D) | 次相似。同上,单物体对比时,背景、物体位置越近越相似。 |
图片01 | 图片02 | 8 | 两者几乎相似 | 比较相似。影响相似距离的似乎是苹果下方的阴影有无。 |
图片01 | 图片pear-001 | 8 | 黄色的梨子 | 意外相似。相似搜索并不能识别物体/内容,因为工作原理是通过图片灰度后的灰色像素点位置与对比。 |
图片01 | 图片04 | 10 | 原图像的180度旋转图 | 相差甚远。对于原图旋转变换相对不敏感,因为均值哈希算法只捕获了图像的平均亮度和粗略结构。 |
图片01 | 图片06、07、08 | 10以上 | 复杂、多主体、多色调 | 较难分辨。复杂、多主体、多色调的图片较难与原图相似。 |
10张测试图片中,汉明距离在5以内1张;汉明距离在5以外9张。
从抽样简单测试结果看,平均哈希简单且计算速度快,但它对图像的细节变化比较敏感,容易受到局部图像的特性的干扰。
备注:如果汉明距离0,则表示这两张图片非常相似;如果汉明距离小于5,则表示有些不同,但比较相近;如果汉明距离大于10,则表明是完全不同的图片。
5. 总结
经过实验和测试,平均哈希算法优缺点明显。
特点: 传统
优点: 简单、计算效率高,适用于快速图像相似性比较。
缺点: 对于图片的旋转和主体内容变换相对不敏感;对于复杂、多主体、多色调的图片较难相似,因为它只捕获了图片的平均亮度和粗略结构。
6. 实验代码
"""
以图搜图:均值哈希算法(Average Hash Algorithm)的原理与实现
测试环境:win10 | python 3.9.13 | OpenCV 4.4.0 | numpy 1.21.1
实验时间:2023-10-20
"""
import cv2
import time
import numpy as np
import matplotlib.pyplot as plt
def get_hash(img_path):
# 读取图像:通过OpenCV的imread加载图像
# 缩小图像:使用OpenCV的resize函数将图像缩放为8x8像素,采用Cubic插值方法进行图像重采样
img_rgb = cv2.cvtColor(cv2.imread(img_path), cv2.COLOR_BGR2RGB)
# 使用OpenCV的resize函数将图像缩放为8x8像素,采用Cubic插值方法进行图像重采样
img_resize = cv2.resize(img_rgb, (8, 8), cv2.INTER_CUBIC)
# 图像灰度化:将彩色图像转换为灰度图像。较少计算量。
img_gray = cv2.cvtColor(img_resize, cv2.COLOR_BGR2GRAY)
# print(f"缩放8x8的图像中每个像素的颜色=\n{img_gray}")
# 灰度平均值:计算灰度图像的平均灰度值
img_average = np.mean(img_gray)
# print(f"灰度图像中所有像素的平均值={img_average}")
"""
# # 比较平均值:嵌套循环遍历图像的所有像素,对比灰度图像的平均灰度值,转换为二进制的图像哈希值
# # img_gray:是灰度图像
# # img_gray.shape[0] 和 img_gray.shape[1] 分别表示图像的高度和宽度
# img_hash_binary = []
# for i in range(img_gray.shape[0]):
# for j in range(img_gray.shape[1]):
# if img_gray[i,j] >= img_average:
# img_hash_binary.append(1)
# else:
# img_hash_binary.append(0)
# print(f"对比灰度图像的平均像素值降噪(图像的二进制哈希值)数组={img_hash_binary}")
# # 将列表中的元素转换为字符串并连接起来,形成一组64位的图像二进制哈希值字符串
# img_hash_binary_str = ''.join(map(str, img_hash_binary))
# print(f"对比灰度图像的平均像素值降噪(图像的二进制哈希值)={img_hash_binary_str}")
# # 生成哈希值
# img_hash = ""
# # 遍历二进制哈希值:通过循环,代码以4位为一组遍历二进制哈希值 img_hash_binary_str。
# # range(0, 64, 4) 确保代码在哈希值的每4位之间进行迭代。
# for i in range(0, 64, 4):
# # 将4位二进制转换为一个十六进制字符
# # 在每次循环中,代码取出哈希值中的4位二进制(例如,img_hash_binary_str[i : i + 4])
# # 然后使用'%x' % int(..., 2)将这4位二进制转换为一个十六进制字符。
# # int(..., 2)将二进制字符串转换为整数,'%x'将整数转换为十六进制字符。
# # 将十六进制字符追加到 img_hash:在每次循环中,得到的十六进制字符将被追加到 img_hash 字符串中。
# img_hash += "".join('%x' % int(img_hash_binary_str[i : i + 4], 2))
# print(f"图像可识别的哈希值={img_hash}")
"""
# 图像二进制哈希值
img_hash_binary_str = ''
for i in range(8):
img_hash_binary_str += ''.join(map(lambda i: '0' if i < img_average else '1', img_gray[i]))
# print(f"对比灰度图像的平均像素值降噪(图像的二进制哈希值)={img_hash_binary_str}")
# 图像可识别哈希值
img_hash = ''.join(map(lambda x:'%x' % int(img_hash_binary_str[x : x + 4], 2), range(0, 64, 4)))
# print(f"图像可识别的哈希值={img_hash}")
return img_hash
# 汉明距离:计算两个等长字符串(通常是二进制字符串或位字符串)之间的汉明距离。用于确定两个等长字符串在相同位置上不同字符的数量。
def hamming_distance(s1, s2):
# 检查这两个字符串的长度是否相同。如果长度不同,它会引发 ValueError 异常,因为汉明距离只适用于等长的字符串
if len(s1) != len(s2):
raise ValueError("Input strings must have the same length")
distance = 0
for i in range(len(s1)):
# 遍历两个字符串的每个字符,比较它们在相同位置上的值。如果发现不同的字符,将 distance 的值增加 1
if s1[i] != s2[i]:
distance += 1
return distance
# --------------------------------------------------------- 测试 ---------------------------------------------------------
time_start = time.time()
img_1 = 'img_test/apple-01.jpg'
img_2 = 'img_test/apple-02.jpg'
img_3 = 'img_test/apple-03.jpg'
img_4 = 'img_test/apple-04.jpg'
img_5 = 'img_test/apple-05.jpg'
img_6 = 'img_test/apple-06.jpg'
img_7 = 'img_test/apple-07.jpg'
img_8 = 'img_test/apple-08.jpg'
img_9 = 'img_test/apple-09.jpg'
img_10 = 'img_test/pear-001.jpg'
img_hash1 = get_hash(img_1)
img_hash2 = get_hash(img_2)
img_hash3 = get_hash(img_3)
img_hash4 = get_hash(img_4)
img_hash5 = get_hash(img_5)
img_hash6 = get_hash(img_6)
img_hash7 = get_hash(img_7)
img_hash8 = get_hash(img_8)
img_hash9 = get_hash(img_9)
img_hash10 = get_hash(img_10)
distance1 = hamming_distance(img_hash1, img_hash1)
distance2 = hamming_distance(img_hash1, img_hash2)
distance3 = hamming_distance(img_hash1, img_hash3)
distance4 = hamming_distance(img_hash1, img_hash4)
distance5 = hamming_distance(img_hash1, img_hash5)
distance6 = hamming_distance(img_hash1, img_hash6)
distance7 = hamming_distance(img_hash1, img_hash7)
distance8 = hamming_distance(img_hash1, img_hash8)
distance9 = hamming_distance(img_hash1, img_hash9)
distance10 = hamming_distance(img_hash1, img_hash10)
time_end = time.time()
print(f"图片名称:{img_1},图片HASH:{img_hash1},与图片1的近似值(汉明距离):{distance1}")
print(f"图片名称:{img_2},图片HASH:{img_hash2},与图片1的近似值(汉明距离):{distance2}")
print(f"图片名称:{img_3},图片HASH:{img_hash3},与图片1的近似值(汉明距离):{distance3}")
print(f"图片名称:{img_4},图片HASH:{img_hash4},与图片1的近似值(汉明距离):{distance4}")
print(f"图片名称:{img_5},图片HASH:{img_hash5},与图片1的近似值(汉明距离):{distance5}")
print(f"图片名称:{img_6},图片HASH:{img_hash6},与图片1的近似值(汉明距离):{distance6}")
print(f"图片名称:{img_7},图片HASH:{img_hash7},与图片1的近似值(汉明距离):{distance7}")
print(f"图片名称:{img_8},图片HASH:{img_hash8},与图片1的近似值(汉明距离):{distance8}")
print(f"图片名称:{img_9},图片HASH:{img_hash9},与图片1的近似值(汉明距离):{distance9}")
print(f"图片名称:{img_10},图片HASH:{img_hash10},与图片1的近似值(汉明距离):{distance10}")
print(f"耗时:{time_end - time_start}")
7. 疑难杂症
问题1: 为什么通过 cv2.imread(img_path) 加载的图像,显示出来之后,原图由红色变成了蓝色?
问题原因: 如果原图是红色的,但通过OpenCV加载显示的图像是蓝色的,这可能是由于图像的通道顺序不同导致的。在OpenCV中,图像的通道顺序通常是BGR(蓝绿红),而在一些其他库(如matplotlib)中,通常使用RGB(红绿蓝)通道顺序。
解决方案: 使用OpenCV的通道重排功能,将图像的通道顺序从BGR转换为RGB,然后再显示图像。以下是修改后的代码:
# 通过OpenCV加载图像
img = cv2.imread(img_path)
# 通道重排,从BGR转换为RGB
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
问题2: 为什么使用了 cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ,但显示出来图像是彩色的?
问题原因: 这可能是由于你使用了 matplotlib 来显示图像,而 matplotlib 默认将灰度图像显示为伪彩色图像。Matplotlib会将单通道的灰度图像(每个像素只有一个亮度值)显示为伪彩色图像以便于可视化。
解决方案: 在使用 imshow 函数显示图像时,添加 cmap 参数,并将其设置为 ‘gray’,以确保图像以灰度形式显示。例如:
# 测试图片路径
img_path = 'img_test/apple-01.jpg'
# 通过OpenCV加载图像
img = cv2.imread(img_path)
# 通道重排,从BGR转换为RGB
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
# 使用OpenCV的resize函数将图像缩放为8x8像素,采用Cubic插值方法
img_resize = cv2.resize(img_rgb, (8, 8), cv2.INTER_CUBIC)
# 灰度化:将彩色图像转换为灰度图像。
img_gray = cv2.cvtColor(img_resize, cv2.COLOR_BGR2GRAY)
# 灰度形式查看图像
plt.imshow(img_gray, cmap='gray')
# 显示图像
plt.show()