Python实现哈希算法,并检测图片重复的教程

Iconfinder 是一个图标搜索引擎,为设计师、开发者和其他创意工作者提供精美图标,目前托管超过 34 万枚图标,是全球最大的付费图标库。用户也可以在 Iconfinder 的交易板块上传出售原创作品。每个月都有成千上万的图标上传到Iconfinder,同时也伴随而来大量的盗版图。Iconfinder 工程师 Silviu Tantos 在本文中提出一个新颖巧妙的图像查重技术,以杜绝盗版。

我们将在未来几周之内推出一个检测上传图标是否重复的功能。例如,如果用户下载了一个图标然后又试图通过上传它来获利(曾发生过类似案例),那么通过我们的方法,就可以检测出该图标是否已存在,并且标记该账户欺诈。在大量文件中检测某文件是否已经存在的一个常用方法是,通过计算数据集中每一个文件的哈希值,并将该哈希值存储在数组库中。当想要查找某特定文件时,首先计算该文件哈希值,然后在数据库中查找该哈希值。
选择一个哈希算法

加密哈希算法是一个常用的哈希算法。类似MD5,SHA1,SHA256这种在任何一种语言都可以找到可调用的标准库,它们对于简单的用例非常有效。

例如,在Python中先导入hashlib模块,然后调用函数就可以生成某一个字符串或者文件的哈希值。
 

?
1
2
3
4
5
6
7
8
9
10
>>> import hashlib
  
# Calculating the hash value of a string.
>>> hashlib.md5( 'The quick brown fox jumps over the lazy dog' ).hexdigest()
'9e107d9d372bb6826bd81d3542a419d6'
  
# Loading an image file into memory and calculating it's hash value.
>>> image_file = open ( 'data/cat_grumpy_orig.png' ).read()
>>> hashlib.md5(image_file).hexdigest()
'3e1f6e9f2689d59b9ed28bcdab73455f'

这个算法对于未被篡改的上传文件非常有效,如果输入数据有细微变化,加密哈希算法都会导致雪崩效应,从而造成新文件的哈希值完全不同于原始文件哈希值。

比如下面这个例子,它在句子的结尾多加了一个句号。
 

?
1
2
3
4
5
6
7
# Original text.
>>> hashlib.md5( 'The quick brown fox jumps over the lazy dog' ).hexdigest()
'9e107d9d372bb6826bd81d3542a419d6'
  
# Slight modification of the text.
>>> hashlib.md5( 'The quick brown fox jumps over the lazy dog.' ).hexdigest()
'e4d909c290d0fb1ca068ffaddf22cbd0'

如果图像背景色被改变,图像被裁剪,旋转或者某一个像素被修改,那么都无法在图像哈希库中匹配。可见传统哈希算法并不具有实用性。正如你在上面例子中看到的,哈希值9 e107d9d372bb6826bd81d3542a419d6 和e4d909c290d0fb1ca068ffaddf22cbd0几乎是不同的(除了几个字符)。

例如,修改图像中猫咪鼻子的颜色后,图像的哈希值将改变。

 201542101201416.jpg (649×318)

?
1
2
3
4
5
6
7
8
9
# Load the original image into memory and calculate it's hash value.
>>> image_file = open ( 'data/cat_grumpy_orig.png' ).read()
>>> hashlib.md5(image_file).hexdigest()
'3e1f6e9f2689d59b9ed28bcdab73455f'
  
# Load the modified image into memory and calculate it's hash value.
>>> image_file_modified = open ( 'data/cat_grumpy_modif.png' ).read()
>>> hashlib.md5(image_file_modified).hexdigest()
'12d1b9409c3e8e0361c24beaee9c0ab1'

目前已有许多感知哈希算法,本文将要提出一个新的dhash(差异哈希)算法,该算法计算相邻像素之间的亮度差异并确定相对梯度。对于以上的用例,感知哈希算法将非常有效。感知哈希算法从文件内容的各种特征中获得一个能够灵活分辨不同文件微小区别的多媒体文件指纹。

 
dHash

深入学习dHash算法前,先介绍一些基础知识。一个彩色图像是由RGB三原色组成,可以看成一个红绿蓝三原色的颜色集。比如利用用Python图像库(PIL)加载一个图像,并打印像素值。

201542101238378.jpg (717×717)

Test image

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> from PIL import Image
>>> test_image = Image. open ( 'data/test_image.jpg' )
  
# The image is an RGB image with a size of 8x8 pixels.
>>> print 'Image Mode: %s' % test_image.mode
Image Mode: RGB
>>> print 'Width: %s px, Height: %s px' % (test_image.size[ 0 ], test_image.size[ 1 ])
Width: 4 px, Height: 4 px
  
# Get the pixel values from the image and print them into rows based on
# the image's width.
>>> width, height = test_image.size
>>> pixels = list (test_image.getdata())
>>> for col in xrange (width):
...  print pixels[col:col + width]
...
[( 255 , 0 , 0 ), ( 0 , 255 , 0 ), ( 0 , 0 , 255 ), ( 255 , 255 , 255 )]
[( 0 , 0 , 0 ), ( 212 , 45 , 45 ), ( 51 , 92 , 154 ), ( 130 , 183 , 47 )]
[( 206 , 210 , 198 ), ( 131 , 78 , 8 ), ( 131 , 156 , 180 ), ( 117 , 155 , 201 )]
[( 104 , 133 , 170 ), ( 215 , 130 , 20 ), ( 153 , 155 , 155 ), ( 104 , 142 , 191 )]

现在我们回到dHash算法,该算法有四个步骤,本文详细说明每一步并验证它在原始图像和修改后图像的效果。前三个像素的红绿蓝颜色强度值分别为255,其余两个颜色强度值分别为0,纯黑色像素三原色为0,纯白色像素三原色为255。其它颜色像素则是由不同强度三原色值组成的。

 
1.图像灰度化

通过灰度化图像,将像素值减少到一个发光强度值。例如,白色像素(255、255、255)成为255而黑色像素(0,0,0)强度值将成为0。

201542101324348.jpg (628×336)

2.将图像缩小到一个常见大小

将图像缩减到一个常见基础尺寸,比如宽度大高度一个像素值的9*8像素大小(到第三步你就能明白为什么是这个尺寸)。通过这个方法将图像中的高频和细节部分移除,从而获得一个有72个强度值的样本。由于调整或者拉伸图像并不会改变它的哈希值,所以将所有图像归一化到该大小。

201542101403771.jpg (595×259)

3.比较邻域像素

前两步实现后得到一个强度值列表,比较该二进制值数组的每一行的相邻像素。
 

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> from PIL import Image
>>> img = Image. open ( 'data/cat_grumpy_orig_after_step_2.png' )
>>> width, height = img.size
>>> pixels = list (img.getdata())
>>> for col in xrange (width):
...  print pixels[col:col + width]
...
[ 254 , 254 , 255 , 253 , 248 , 254 , 255 , 254 , 255 ]
[ 254 , 255 , 253 , 248 , 254 , 255 , 254 , 255 , 255 ]
[ 253 , 248 , 254 , 255 , 254 , 255 , 255 , 255 , 222 ]
[ 248 , 254 , 255 , 254 , 255 , 255 , 255 , 222 , 184 ]
[ 254 , 255 , 254 , 255 , 255 , 255 , 222 , 184 , 177 ]
[ 255 , 254 , 255 , 255 , 255 , 222 , 184 , 177 , 184 ]
[ 254 , 255 , 255 , 255 , 222 , 184 , 177 , 184 , 225 ]
[ 255 , 255 , 255 , 222 , 184 , 177 , 184 , 225 , 255 ]

第一个值254和第二个254做比较,第二个值和第三个值比,以此类推,从而每行得到8个布尔值。
 

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> difference = []
>>> for row in xrange (height):
...  for col in xrange (width):
...   if col ! = width:
...    difference.append(pixels[col + row] > pixels[(col + row) + 1 ])
...
>>> for col in xrange (width - 1 ):
...  print difference[col:col + (width - 1 )]
...
[ False , False , True , True , False , False , True , False ]
[ False , True , True , False , False , True , False , False ]
[ True , True , False , False , True , False , False , False ]
[ True , False , False , True , False , False , False , True ]
[ False , False , True , False , False , False , True , True ]
[ False , True , False , False , False , True , True , False ]
[ True , False , False , False , True , True , False , False ]
[ False , False , False , True , True , False , False , True ]

4.转换为二值

为了方便哈希值存储和使用,将8个布尔值转换为16进制字符串。Ture变成1,而False变成0。
Python实现

下面是完整Python实现的完成算法:
 

?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
def dhash(image, hash_size = 8 ):
   # Grayscale and shrink the image in one step.
   image = image.convert( 'L' ).resize(
     (hash_size + 1 , hash_size),
     Image.ANTIALIAS,
   )
  
   pixels = list (image.getdata())
  
   # Compare adjacent pixels.
   difference = []
   for row in xrange (hash_size):
     for col in xrange (hash_size):
       pixel_left = image.getpixel((col, row))
       pixel_right = image.getpixel((col + 1 , row))
       difference.append(pixel_left > pixel_right)
  
   # Convert the binary array to a hexadecimal string.
   decimal_value = 0
   hex_string = []
   for index, value in enumerate (difference):
     if value:
       decimal_value + = 2 * * (index % 8 )
     if (index % 8 ) = = 7 :
       hex_string.append( hex (decimal_value)[ 2 :].rjust( 2 , '0' ))
       decimal_value = 0
  
   return ''.join(hex_string)

最常见情况,图片稍有不同,哈希值很可能是相同的,所以我们可以直接比较。
 

?
1
2
3
4
5
6
7
8
9
10
11
12
>>> from PIL import Image
>>> from utility import dhash, hamming_distance
>>> orig = Image.open( 'data/cat_grumpy_orig.png' )
>>> modif = Image.open( 'data/cat_grumpy_modif.png' )
>>> dhash(orig)
'4c8e3366c275650f'
>>> dhash(modif)
'4c8e3366c275650f'
>>> dhash(orig) == dhash(modif)
True
 
如果有一

个保存哈希值的SQL数据库, 可以这样简单判断哈希值“4 c8e3366c275650f ”是否存在:
 

?
1
2
SELECT pk, hash, file_path FROM image_hashes
   WHERE hash = '4c8e3366c275650f' ;

现在,对于一些有较大差别的图像,它们的哈希值可能是不相同的,那么需要计算由一个字符串变成另一个字符串所需替换的最少字符数,即汉明距离。
维基百科上有一些计算两个字符串之间的汉明距离的Python示例代码。但是也可以直接基于MySQL数据库上的计算和查询来实现。
 

?
1
2
3
4
5
6
SELECT pk, hash, BIT_COUNT(
   CONV(hash, 16, 10) ^ CONV( '4c8e3366c275650f' , 16, 10)
) as hamming_distance
   FROM image_hashes
   HAVING hamming_distance < 4
   ORDER BY hamming_distance ASC ;

对所查询的值与数据库中的哈希值进行异或操作,计数不同位数。由于BIT_COUNT只能操作整数,所以要将所有十六进制的哈希值转成十进制。

 
结束语

本文使用Python实现了所介绍的算法,当然了读者可以使用任何编程语言实现算法。

在简介中提过,本文算法将应用到Iconfinder上去防止重复提交图标,可以预想,感知哈希算法还有更多实际应用。因为有相似特征的图像的哈希值也是相似的,所以它可以帮助图像推荐系统寻找相似图像。



===========================================

某些情况下,我们需要检测图片之间的相似性,进行我们需要的处理:删除同一张图片、标记盗版等。
如何判断是同一张图片呢?最简单的方法是使用加密哈希(例如MD5, SHA-1)判断。但是局限性非常大。例如一个txt文档,其MD5值是根据这个txt的二进制数据计算的,如果是这个txt文档的完全复制版,那他们的MD5值是完全相同的。但是,一旦改变副本的内容,哪怕只是副本的缩进格式,其MD5也会天差地别。因此加密哈希只能用于判断两个完全一致、未经修改的文件,如果是一张经过调色或者缩放的图片,根本无法判断其与另一张图片是否为同一张图片。
那么如何判断一张被PS过的图片是否与另一张图片本质上相同呢?比较简单、易用的解决方案是采用感知哈希算法(Perceptual Hash Algorithm)。

感知哈希算法是一类算法的总称,包括aHash、pHash、dHash。顾名思义,感知哈希不是以严格的方式计算Hash值,而是以更加相对的方式计算哈希值,因为“相似”与否,就是一种相对的判定。

  • aHash:平均值哈希。速度比较快,但是常常不太精确。
  • pHash:感知哈希。精确度比较高,但是速度方面较差一些。
  • dHash:差异值哈希。Amazing!精确度较高,且速度也非常快。因此我就选择了dHash作为我图片判重的算法。
一、 相似图片检测步骤:
  1. 分别计算两张图片的dHash值
  2. 通过dHash值计算两张图片的汉明距离(Hamming Distance),通过汉明距离的大小,判断两张图片的相似程度。
二、dHash计算

需要计算dHash值的图片
Step1. 缩放图片

如果我们要计算上图的dHash值,第一步是把它缩放到足够小。为什么需要缩放呢?因为原图的分辨率一般都非常高。一张 200*200 的图片,就有整整4万个像素点,每一个像素点都保存着一个RGB值,4万个RGB,是相当庞大的信息量,非常多的细节需要处理。因此,我们需要把图片缩放到非常小,隐藏它的细节部分,只见森林,不见树木。建议缩放为9*8,虽然可以缩放为任意大小,但是这个值是相对合理的。而且宽度为9,有利于我们转换为hash值,往下面看,你就明白了。

resize_width = 9
resize_height = 8
# 1. resize to (9,8)
smaller_image = image.resize((resize_width, resize_height))

缩放为9*8分辨率后
Step2. 灰度化

dHash全名为差异值hash,通过计算相邻像素之间的颜色强度差异得出。我们缩放后的图片,细节已经被隐藏,信息量已经变少。但是还不够,因为它是彩色的,由RGB值组成。白色表示为(255,255,255),黑色表示为(0,0,0),值越大颜色越亮,越小则越暗。每种颜色都由3个数值组成,也就是红、绿、蓝的值 。如果直接使用RGB值对比颜色强度差异,相当复杂,因此我们转化为灰度值——只由一个0到255的整数表示灰度。这样的话就将三维的比较简化为了一维比较。

# 2. 灰度化 Grayscale
grayscale_image = smaller_image.convert("L")

灰度化后
Step3. 差异计算

差异值是通过计算每行相邻像素的强度对比得出的。我们的图片为9*8的分辨率,那么就有8行,每行9个像素。差异值是每行分别计算的,也就是第二行的第一个像素不会与第一行的任何像素比较。每一行有9个像素,那么就会产生8个差异值,这也是为何我们选择9作为宽度,因为8bit刚好可以组成一个byte,方便转换为16进制值。
如果前一个像素的颜色强度大于第二个像素,那么差异值就设置为True(也就是1),如果不大于第二个像素,就设置为False(也就是0)。

# 3. 比较相邻像素
pixels = list(grayscale_image.getdata())
difference = []
for row in range(resize_height):    
    row_start_index = row * resize_width    
    for col in range(resize_width - 1):        
        left_pixel_index = row_start_index + col
        difference.append(pixels[left_pixel_index] > pixels[left_pixel_index + 1])
Step4. 转换为hash值

我们将差异值数组中每一个值看做一个bit,每8个bit组成为一个16进制值,将16进制值连接起来转换为字符串,就得出了最后的dHash值。

# 转化为16进制(每个差值为一个bit,每8bit转为一个16进制)
decimal_value = 0
hash_string = ""
for index, value in enumerate(difference):    
    if value:  # value为0, 不用计算, 程序优化        
        decimal_value += value * (2 ** (index % 8))   
    if index % 8 == 7:  # 每8位的结束        
        hash_string += str(hex(decimal_value)[2:].rjust(2, "0"))  # 不足2位以0填充。0xf=>0x0f        
        decimal_value = 0
三、 计算汉明距离(Hamming Distance)

汉明距离这个概念不止运用于图片对比领域,也被使用于众多领域,具体的介绍可以参见Wikipedia。
汉明距离表示将A修改成为B,需要多少个步骤。比如字符串“abc”与“ab3”,汉明距离为1,因为只需要修改“c”为“3”即可。
dHash中的汉明距离是通过计算差异值的修改位数。我们的差异值是用0、1表示的,可以看做二进制。二进制0110与1111的汉明距离为2。
我们将两张图片的dHash值转换为二进制difference,并取异或。计算异或结果的“1”的位数,也就是不相同的位数,这就是汉明距离。

difference = (int(dhash1, 16)) ^ (int(dhash2, 16))
return bin(difference).count("1")

如果传入的参数不是两张图的dHash值,而是直接比较两张图片,那么不需要生成dHash值,直接用Step3中的difference数组,统计不相同的位数,就是汉明距离。

hamming_distance = 0
for index, img1_pix in enumerate(image1_difference):   
    img2_pix = image2_difference[index]    
    if img1_pix != img2_pix:        
        hamming_distance += 1

一般来说,汉明距离小于5,基本就是同一张图片。大家可以根据自己的实际情况,判断汉明距离临界值为多少。

Github:

https://github.com/hjaurum/DHash

参考文档:


  • 1
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值