本文对应 dedup 项目里
imageph包的实现。 代码只用 Go 标准库(image、image/jpeg、image/png…),零第三方依赖。
普通的内容哈希(SHA-256)有一个特点:输入只要变一个字节,输出就完全不一样。这对”精确去重”是优点,但对图片却成了障碍——
这些操作在肉眼看来”基本是同一张图”,但 SHA-256 会把它们判成完全不同的文件。于是就有了感知哈希(Perceptual Hash, pHash):它对”人类视觉上相似”的图片应输出相近的哈希值。
常见的感知哈希有几种:
| 算法 | 做法 | 特点 |
|---|---|---|
| aHash | 缩到 8×8 求平均灰度,每位比较”是否高于均值” | 最简单,但均值容易被大块颜色带偏 |
| dHash | 缩到 9×8,比较相邻像素的亮度差 | 对缩放/压缩鲁棒,计算快,效果够好 |
| pHash | 用 DCT 变换取低频系数 | 最鲁棒,但实现重、慢 |
dedup 选了 dHash:它几乎不要额外依赖,且对”缩图 / 重压缩 / 微调节”足够稳健,是性价比最高的选择。
原图 → 解码 → 灰度化 → 缩放到 9×8 → 逐行比较相邻像素 → 64 位指纹
Go 标准库的 image.Decode 能识别常见格式。灰度化用经典的亮度公式:
luma := 0.299*R + 0.587*G + 0.114*B
(注意 image.Image 的 RGBA() 返回的是 16 位值,要先 >>8 还原成 0–255。)
为什么要 9×8 而不是 8×8?dHash 比较的是水平相邻像素的亮度差,每一行有 9 个像素、产生 8 个差值,8 行共 8 × 8 = 64 位。
缩放用最朴素的最近邻采样即可——缩略图本来就是为了丢弃高频细节,无需高质量插值:
func resizeGray(src image.Image, w, h int) [][]float64 {
b := src.Bounds()
out := make([][]float64, w)
for x := 0; x < w; x++ {
out[x] = make([]float64, h)
sx := int(float64(x) * float64(b.Dx()) / float64(w)) // 原图对应列
for y := 0; y < h; y++ {
sy := int(float64(y) * float64(b.Dy()) / float64(h))
r, g, bl, _ := src.At(b.Min.X+sx, b.Min.Y+sy).RGBA()
out[x][y] = 0.299*float64(r>>8) + 0.587*float64(g>>8) + 0.114*float64(bl>>8)
}
}
return out
}
对每一行,把”左边像素是否比右边亮”编码成 1 位:
var hash uint64
for y := 0; y < h; y++ {
for x := 0; x < w-1; x++ {
hash <<= 1
if gray[x][y] > gray[x+1][y] {
hash |= 1
}
}
}
// 最终得到 64 位指纹
这样就得到了该图的 64 位 dHash。
两个指纹的相似度用汉明距离衡量——有多少位不同:
func Hamming(a, b uint64) int {
x := a ^ b
c := 0
for x != 0 {
c++
x &= x - 1 // 经典技巧:每次消掉最低位的 1
}
return c
}
0:完全相同的图(或几乎无损拷贝)1–10:肉眼几乎一样(重压缩 / 微调节)>30:基本是不同图片dedup 里用并查集(union-find)把”汉明距离 ≤ 阈值”的图片并成一组:
if Hamming(hashes[i], hashes[j]) <= threshold {
union(i, j)
}
阈值由 -threshold 控制,默认 10。想更严格就调小,想更宽松就调大。
dHash 比较的是相邻像素的梯度。如果一张图是纯色或整体均匀渐变,相邻像素几乎没差异,算出来的哈希会退化成全 0(或全 1)——于是”黑图”和”白图”的 dHash 可能都是 0,被误判为”相似”。
实测中这也出现过:左→右渐变图的 dHash 全 0、右→左渐变图全 1,二者距离 64,反而不相似;但纯色块之间会塌缩成同一个值。
应对方式:
10 跑一批真实照片,再按结果微调。dedup 用固定数量的 goroutine(默认 = CPU 核数)并发哈希。实现见 imageph/imageph.go。下一篇讲为什么”删除”一定要走回收站而不是 rm。