dedup 技术博客

用感知哈希(dHash)识别”相似图片”

本文对应 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:它几乎不要额外依赖,且对”缩图 / 重压缩 / 微调节”足够稳健,是性价比最高的选择。

dHash 的完整流程

原图 → 解码 → 灰度化 → 缩放到 9×8 → 逐行比较相邻像素 → 64 位指纹

1. 解码与灰度化

Go 标准库的 image.Decode 能识别常见格式。灰度化用经典的亮度公式:

luma := 0.299*R + 0.587*G + 0.114*B

(注意 image.Image 的 RGBA() 返回的是 16 位值,要先 >>8 还原成 0–255。)

2. 缩放到 9×8

为什么要 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
}

3. 差分 + 二值化

对每一行,把”左边像素是否比右边亮”编码成 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
}

dedup 里用并查集(union-find)把”汉明距离 ≤ 阈值”的图片并成一组:

if Hamming(hashes[i], hashes[j]) <= threshold {
    union(i, j)
}

阈值由 -threshold 控制,默认 10。想更严格就调小,想更宽松就调大。

一个必须知道的坑:均匀图会”退化”

dHash 比较的是相邻像素的梯度。如果一张图是纯色或整体均匀渐变,相邻像素几乎没差异,算出来的哈希会退化成全 0(或全 1)——于是”黑图”和”白图”的 dHash 可能都是 0,被误判为”相似”。

实测中这也出现过:左→右渐变图的 dHash 全 0、右→左渐变图全 1,二者距离 64,反而不相似;但纯色块之间会塌缩成同一个值。

应对方式:

  1. 在测试 / 边界场景里用有结构的图(比如左→右渐变 vs 右→左渐变)来验证算法,而不是纯色图。
  2. 工程上可以把全 0 / 全 1 这种”退化指纹”单独标记、不纳入相似判定,或要求同组里至少包含一张有明确梯度的图。
  3. 阈值不是越小越好:太小会漏掉”同一图但被压得很狠”的副本;太大又会把不同图误并。建议先用默认值 10 跑一批真实照片,再按结果微调。

性能与取舍


实现见 imageph/imageph.go。下一篇讲为什么”删除”一定要走回收站而不是 rm。