做游戏或者工具链开发的朋友,应该都遇到过一种需求:明明是一张图,用户上传的是缩略图、你本地存的是原图,却要判断它们“是不是同一个东西”。换到Unity工程里,就是素材查重、自动化测试截图比对、玩家上传图片内容过滤这一类的场景。你要是拿MD5去对,缩放过、转码过、调过色的图片哈希值全都不一样,直接没法用。这种时候就轮到感知哈希(pHash)出场了。
pHash的全称是Perceptual Hash,感知哈希。它不比较图片的字节是否一样,而是比较图片“看起来像不像”。两张图哪怕尺寸不同、分辨率不同、压缩率不同,只要视觉内容一致,算出来的哈希值就会非常接近。这篇文章我把Unity C#里实现pHash的完整思路、代码、还有实际工程里的坑和阈值选择全部整理出来,给有同样需求的人一个可以直接抄作业的版本。
1. 为什么图片“相似”不能用MD5来判断
1.1 MD5这类精确哈希的适用边界
MD5、SHA1这类哈希算法,设计目标就是让内容“哪怕只有一个bit的变化,最终哈希值也完全不同”,这就是所谓的雪崩效应。这个特性用来校验文件完整性、判断账号密码是否匹配是完美的,但用来判断图片相似度就是灾难。
举个例子:一张原图,你用JPEG压缩到90%质量,理论上视觉上几乎看不出区别,但MD5值已经完全变了。压缩再给图片加个几像素的白边,MD5又变了一次。在素材查重场景里,最常见的重复素材恰恰是“同一张图的不同分辨率、不同压缩率版本”,用MD5去重等于漏掉了所有真正该去掉的重复项。
我在实际项目里见过最典型的案例:美术同学导出了一大批量贴图,其中有相当一部分是同一张图的不同尺寸版本,用MD5脚本跑了一次,结果一个重复的都没标出来。倒不是说MD5没用,是它压根不适合这个场景。相似度判断需要一个“允许小变化”的度量方式,而不是“完全相等”的严格校验。
1.2 感知哈希家族:aHash、pHash、dHash怎么选
感知哈希不是一个单独的算法,而是一族。最常见的三个是aHash、pHash、dHash,它们都把图片最终映射成一组二进制位(通常64位),区别在于中间的特征提取方式:
- aHash(平均值哈希):把图片缩小后转灰度,计算所有像素的平均值,然后每个像素跟平均值比较,大于等于记为1,小于记为0。实现最简单,但对细节变化很敏感。
- pHash(感知哈希):核心是DCT(离散余弦变换),从频域提取低频特征。对压缩、缩放、轻微颜色变化的鲁棒性最好,也是目前最主流的方案。
- dHash(差异哈希):比较相邻像素之间的灰度差,速度快,对渐变和模糊有一定容忍度,但对裁剪和缩放相对敏感。
在Unity里做素材相似度计算,我首选pHash。原因很简单:游戏素材最常见的变体就是“被压缩过”“被缩放过分辨率”“被调过亮度”,这三类变化pHash扛得住,其他两个没那么稳。
1.3 pHash的底层逻辑:从像素值到频率特征的抽象
pHash比aHash高明在哪里?aHash比较的是像素的绝对亮度分布,一旦图片整体变亮或者变暗,很多像素的阈值判定就会翻转,哈希值直接就崩了。pHash做了一次DCT变换,把图片从像素域转换到频率域,提取的是“图像在低频部分的能量分布”。
可以这么理解:把图片想象成一个起伏的地形,像素值是海拔。像素域看到的是每个点的具体高度,而频率域看到的是一圈一圈的“起伏轮廓”。低频分量对应大范围的地形走势(比如一个物体整体是亮还是暗、色块怎么分布),高频分量对应细微的纹理细节(比如草地上的每一根草)。pHash只保留低频部分,相当于把地形图简化成一张只有大轮廓的素描,那些“局部微调”造成的高频变化就被过滤掉了。
这正是它抗压缩、抗缩放的原理。JPEG压缩丢掉的多半是高频细节,缩放过程也会抹掉一部分高频,但这些对低频轮廓影响很小。所以两张内容相同但经过不同处理的图,在频域的低频部分依然非常接近。
2. pHash的完整计算链路
2.1 缩小到32×32:尺寸归一化是第一道槛
pHash第一步,把任意尺寸的图片统一缩放到32×32像素。这一步有两个目的。
第一个目的是消除尺寸差异。一张1920×1080的截图和一张320×180的缩略图,内容相同但像素数量差了36倍。如果不统一尺寸,后续计算的基准就不一致。第二个目的是剔除高频细节。32×32只有1024个像素,这在信息量上已经丢失了大量细节,但保留了足够描述图像结构的低频信息。这一步等价于“让参与比较的每一张图都站到同一条起跑线上”。
缩放质量在这个阶段其实没那么苛刻。因为后续还要做灰度化、DCT、二值化,很多细节在中间环节就被抹掉了。实测下来,最近邻采样和双线性采样的最终哈希值差异很小,但双线性会更稳一些,尤其是图片里存在明显斜线或边缘的时候。代码部分我给出的就是双线性采样实现。
2.2 灰度化与DCT:把图像从像素世界搬到频率世界
缩小到32×32之后,把每个像素转成灰度值。标准公式是:
Y = 0.299R + 0.587G + 0.114B
这个加权系数不是随便来的,对应人眼对红、绿、蓝三种颜色的敏感度差异。直接取RGB平均值(R+G+B)/3当然也能跑,但得到的结果会更偏向亮度均匀的色块,不如加权公式贴近人眼感知。
灰度化之后做DCT。DCT全称Discrete Cosine Transform,离散余弦变换,把1024个灰度值组成的一个32×32矩阵,变换成同样大小的频域系数矩阵。变换后的矩阵左上角是低频分量,右下角是高频分量。
为什么用DCT而不是DFT(离散傅里叶变换)?DCT只使用余弦函数作为基,变换结果是纯实数,计算量更小、能量更集中。JPEG图像压缩内部用的也是DCT,它对自然图像的频域特征提取效果很好。pHash的标准实现里,DCT这一步就是核心,也是整个算法计算开销最大的部分。
2.3 取低频系数与二值化:64位指纹是怎么来的
DCT变换完成后,矩阵是32×32,共1024个系数。pHash只取左上角8×8的系数块,也就是64个低频系数。这64个值浓缩了图像在八个方向上的低频能量分布,足以刻画图像的整体结构。
接下来,计算这64个系数的平均值,然后把每个系数跟平均值比较:大于平均值记为1,小于等于平均值记为0。最终得到一个64位的二进制串,通常用一个ulong类型存储,每一位代表一个低频系数的二值化结果。
这里有个容易误解的点:平均值二值化跟aHash的“像素均值”二值化看起来一样,但语义完全不同。aHash比较的是像素亮度均值,对整体曝光变化敏感;pHash比较的是频域系数均值,每个系数代表的是一类频率模式的强度,整体亮度变化主要影响DC分量(第一个系数),对其他系数的影响相对有限。这就是pHash对亮度变化不敏感的根本原因。
2.4 汉明距离:两个指纹怎么算“差多少”
得到两张图的64位哈希之后,用汉明距离来衡量相似度。汉明距离的定义很简单:两个二进制数对应位不同的数量。
比如哈希A是1100,哈希B是1010,逐位比较:第一位都是1,第二位1和0不同,第三位0和1不同,第四位0和0相同,所以距离是2。距离越小,说明两张图的低频特征越接近,视觉上越相似。
在C#里计算ulong类型的汉明距离,最经典的方式是先做异或,再统计异或结果里1的个数。后续代码里的实现用了Brian Kernighan算法,每次把最低位的1清零,循环次数等于1的个数,比逐位右移判断快不少。
3. Unity里的C#实现与踩坑记录
3.1 拿到像素的第一个坑:纹理不可读
在Unity里做图像处理,第一步就有一道坎:Texture2D的getPixels方法要求在纹理导入设置里勾选Read/Write Enabled。如果你加载的是AssetBundle里的纹理、或者运行时通过WWW加载的Texture2D,默认情况下这个选项是没开的,直接调用GetPixels会直接抛异常。
我开始实现pHash的时候就在这上面栽过一次。那段代码在编辑器里跑得好好的,一到运行时加载AssetBundle纹理就崩,报错就是“Texture is not readable”。查了半天,解决方案是在读取像素之前判断isReadable,如果不可读,就先通过RenderTexture过一道“拷贝”,把像素读出来。
RenderTexture拷贝的方式非常稳定,但要注意释放临时RT,否则持续加载图片时内存会一点点涨上去。代码里的ReadPixelsThroughRenderTexture方法就是处理这条路的。
3.2 完整实现代码
下面是完整的pHash计算类,可以在Unity工程里直接使用。核心流程严格遵循标准pHash:缩放→灰度化→DCT→取低频→均值二值化→ulong哈希。
using System; using UnityEngine; namespace ImageCompare { public static class PHashCalculator { private const int SmallSize = 32; private const int LowFreqSize = 8; public static ulong ComputeHash(Texture2D source) { if (source == null) throw new ArgumentNullException(nameof(source)); float[] grayscale = DownscaleToGrayscale(source); float[,] dct = Dct2D(grayscale); float[] lowFreq = new float[LowFreqSize * LowFreqSize]; float sum = 0f; for (int i = 0; i < LowFreqSize; i++) { for (int j = 0; j < LowFreqSize; j++) { lowFreq[i * LowFreqSize + j] = dct[i, j]; sum += dct[i, j]; } } float average = sum / lowFreq.Length; ulong hash = 0; for (int i = 0; i < lowFreq.Length; i++) { if (lowFreq[i] > average) hash |= 1UL << i; } return hash; } private static float[] DownscaleToGrayscale(Texture2D source) { Color[] pixels = source.isReadable ? source.GetPixels() : ReadPixelsThroughRenderTexture(source); float[] grayscale = new float[SmallSize * SmallSize]; float stepX = source.width / (float)SmallSize; float stepY = source.height / (float)SmallSize; for (int y = 0; y < SmallSize; y++) { for (int x = 0; x < SmallSize; x++) { float srcX = (x + 0.5f) * stepX - 0.5f; float srcY = (y + 0.5f) * stepY - 0.5f; int x0 = Mathf.Clamp(Mathf.FloorToInt(srcX), 0, source.width - 1); int y0 = Mathf.Clamp(Mathf.FloorToInt(srcY), 0, source.height - 1); int x1 = Mathf.Clamp(x0 + 1, 0, source.width - 1); int y1 = Mathf.Clamp(y0 + 1, 0, source.height - 1); float fx = srcX - x0; float fy = srcY - y0; Color c00 = pixels[y0 * source.width + x0]; Color c10 = pixels[y0 * source.width + x1]; Color c01 = pixels[y1 * source.width + x0]; Color c11 = pixels[y1 * source.width + x1]; Color blended = Color.Lerp( Color.Lerp(c00, c10, fx), Color.Lerp(c01, c11, fx), fy); grayscale[y * SmallSize + x] = 0.299f * blended.r + 0.587f * blended.g + 0.114f * blended.b; } } return grayscale; } private static float[,] Dct2D(float[] input) { int n = SmallSize; float[,] output = new float[n, n]; float scale = 2f / n; for (int u = 0; u < n; u++) { for (int v = 0; v < n; v++) { float cu = u == 0 ? 1f / Mathf.Sqrt(2f) : 1f; float cv = v == 0 ? 1f / Mathf.Sqrt(2f) : 1f; float sum = 0f; for (int x = 0; x < n; x++) { for (int y = 0; y < n; y++) { float cosU = Mathf.Cos((2f * x + 1f) * u * Mathf.PI / (2f * n)); float cosV = Mathf.Cos((2f * y + 1f) * v * Mathf.PI / (2f * n)); sum += input[y * n + x] * cosU * cosV; } } output[u, v] = scale * cu * cv * sum; } } return output; } public static int HammingDistance(ulong a, ulong b) { ulong diff = a ^ b; int count = 0; while (diff != 0) { diff &= diff - 1; count++; } return count; } private static Color[] ReadPixelsThroughRenderTexture(Texture2D source) { RenderTexture rt = RenderTexture.GetTemporary( source.width, source.height, 0, RenderTextureFormat.ARGB32); Graphics.Blit(source, rt); RenderTexture previous = RenderTexture.active; RenderTexture.active = rt; Texture2D temp = new Texture2D(source.width, source.height, TextureFormat.RGBA32, false); temp.ReadPixels(new Rect(0, 0, source.width, source.height), 0, 0); temp.Apply(); Color[] pixels = temp.GetPixels(); RenderTexture.active = previous; RenderTexture.ReleaseTemporary(rt); if (Application.isPlaying) UnityEngine.Object.Destroy(temp); else UnityEngine.Object.DestroyImmediate(temp); return pixels; } } }调用方式也很简单:
using UnityEngine; public class PHashDemo : MonoBehaviour { public Texture2D sourceA; public Texture2D sourceB; [ContextMenu("计算相似度")] private void Compute() { ulong hashA = PHashCalculator.ComputeHash(sourceA); ulong hashB = PHashCalculator.ComputeHash(sourceB); int distance = PHashCalculator.HammingDistance(hashA, hashB); Debug.Log($"A的哈希: {hashA:X16}"); Debug.Log($"B的哈希: {hashB:X16}"); Debug.Log($"汉明距离: {distance}, 判定结果: {(distance <= 10 ? "相似" : "不相似")}"); } }3.3 双线性缩放为什么自己写
Unity自带的Texture2D有一个GetPixelsBilinear方法,看起来可以一步到位做缩放采样,但实际用下来有两个问题。
第一,这个方法对纹理格式有要求,在某些平台上只支持ARGB32格式,一旦纹理是压缩格式,返回的数据可能是不正确的。第二,它在移动端的表现跟编辑器端存在差异,我用Android真机测试过一次,得到的像素值跟PC上对不上,这就导致同一张图在两台设备上算出的哈希值可能不同。
所以我在代码里手动实现了双线性采样。逻辑不复杂:对于目标像素点,映射回原图坐标,找到周围四个像素,按距离做两次线性插值。这样不依赖Unity内部API,所有平台行为一致。
3.4 主线程卡顿和异步方案
DCT计算包含四层循环,虽然32×32的规模不算大,C#跑一次大概几毫秒,但如果你在编辑器里批量处理几百张素材,每张都几毫秒,加起来还是有明显卡顿。更关键的是GetPixels返回的Color数组,一张4096×4096的贴图会有1600万个Compressed颜色结构体,光这份数组就占几百MB内存,主线程上折腾这个非常不划算。
我实际项目里采用的策略是分两段走:主线程只负责读取像素和转灰度,把1024个float数组准备好,耗时短且可控;DCT和二值化放到后台线程。灰度数组本身不依赖Unity API,后台线程可以安全计算。
如果用的是协程,可以这样组织:
private IEnumerator ComputeHashAsync(Texture2D texture, Action<ulong> onDone) { float[] grayscale = null; // 主线程读取像素并转灰度 grayscale = ConvertToGray(texture); yield return null; // 后台线程计算DCT与哈希 bool finished = false; ulong hash = 0; ThreadPool.QueueUserWorkItem(delegate { float[,] dct = Dct2D(grayscale); hash = BuildHash(dct); finished = true; }); while (!finished) yield return null; onDone?.Invoke(hash); }这里的第一步在主线程,因为Texture2D不能跨线程访问;但转灰度之后产生的float数组就跟Unity彻底解耦了,线程随便用。
4. 阈值与实测:多少距离算“相似”
4.1 一组有代表性的对比测试
算法实现出来之后,最重要的事情是确定判定阈值。我拿一组测试图跑了一遍,涵盖了最常见的图片变化场景,得到的结果大致如下:
| 处理方式 | 汉明距离 | 直观判断 |
|---|---|---|
| 原图 vs 原图 | 0 | 完全一致 |
| 原图 vs 把尺寸缩小50%再放大 | 0~2 | 几乎看不出区别 |
| 原图 vs JPEG质量80压缩 | 2~4 | 视觉上基本一致 |
| 原图 vs 亮度提升20% | 1~3 | 视觉轻微发亮 |
| 原图 vs 对比度提升30% | 3~6 | 能看出调过色但内容清晰 |
| 原图 vs 四周裁剪比例5% | 4~8 | 构图略变但主体还在 |
| 原图 vs 画面中加了一小段字幕 | 6~12 | 主体内容相同但新增元素明显 |
| 原图 vs 水平翻转 | 30+ | 镜像图像,视觉相同但结构翻转 |
| 原图 vs 旋转90度 | 40+ | 内容方向变化巨大 |
| 原图 vs 另一张完全不相关的图 | 30~50 | 内容无关 |
从数据能看出来,绝大部分“同一张图的不同形态”,距离都能压在10以内。
4.2 结合场景确定阈值
阈值怎么定,取决于你的业务对误杀和漏放的容忍度。
做素材查重,我建议阈值设在10。这个区间能抓到绝大多数重复项,同时误判率很低。同一目录下两张恰好距离小于10但内容无关的图片,我印象里还没遇到过。
做自动化测试截图比对,阈值建议收紧到5。测试截图通常是同一分辨率、同一环境下的对比,如果距离超过5,往往确实发生了可见变化。设得宽松反而会漏掉细微的UI偏移。
做玩家上传图片过滤这类业务,可以分两级:距离小于5直接判定为同一张图,距离小于10进入人工审核队列。这样既避免误杀,又不至于漏得太厉害。
4.3 pHash的已知短板和补救
pHash有三个明显的弱点,用之前要有心理准备。
第一,对旋转和翻转没有抵抗力。旋转90度后频域分布完全不同,汉明距离直接飙到30以上。如果业务里需要识别旋转过的图片,需要提前做方向归一化,或者干脆不用pHash。
第二,对裁剪比较敏感。裁剪超过10%之后,画面构图变化会显著影响低频分布。不过“轻微裁剪+缩放”这种常见的缩略图场景,距离仍然在可接受范围内。
第三,对大面积纯色图片判别效果有限。比如一张渐变背景的UI图,64个低频系数可能大部分都趋近,二值化结果的区分度就弱了。处理这类素材时,我会额外叠加一个尺寸或宽高比特征作为辅助判断。
5. 从Demo到工程:缓存、批量查重和扩展
5.1 给哈希结果加缓存
哈希计算本身有开销,在工程里不可能每次都现算。我的做法是:对每个文件维护一个哈希缓存,键用文件路径拼接文件修改时间。文件没变就直接从缓存里取结果,只有新增或被修改的文件才重新计算。
缓存的存储格式很简单,直接存ulong的字符串表示:
[Serializable] public class HashCacheEntry { public string path; public long lastWriteTicks; public string hashHex; }实际操作时可以直接序列化成Json文件。如果项目比较小,用Dictionary存在内存里就够了;如果几百上千个素材要长期管理,落盘缓存能省掉大量重复计算时间。
5.2 批量查重与性能
批量查重的核心逻辑是两两比较,N张图片就是N×(N-1)/2次汉明距离比较。汉明距离是位运算,速度极快,一万次比较也就在毫秒级别,瓶颈全部在哈希计算上。
假设库里有1000张图,需要跑1000次哈希计算。我的测试机上一次完整哈希大概5毫秒,总耗时5秒左右,作为离线工具完全可接受。如果想更快,可以把哈希计算并行化,用ThreadPool开Worker Thread分片处理。
但两两比较本身就是O(N²)的问题,N到了5000以上就比较吃紧了。这种规模可以按哈希值分桶:取64位哈希的前16位作为桶索引,把哈希值接近的图片放到同一桶里,只对桶内图片做两两比较。前16位相同意味着汉明距离最多48,直接排除了大量无关的比较。
5.3 还能往哪扩展
这套方法不局限于Unity内部。哈希结果是一个ulong,完全可以在服务器端实现一套图片查重服务。客户端上传图片时计算哈希,服务端拿哈希去数据库里做比对,就能实现“用户上传的图片是否和库中违规图相似”这类需求。
另一个方向是截图回归测试。游戏运行过程中自动截图,用pHash跟上一版本的基准截图对比,距离超过阈值就说明这次改动影响到了画面表现。这在UI自动化里很好用,比逐像素比较靠谱得多,因为光照、抗锯齿、细微时序都会产生像素级差异,但这些差异在pHash眼里只是几个bit的距离波动,不会随便误报。
最后说点个人体会。pHash不是银弹,它对旋转、翻转没有抵抗力,对纯色大块面图片区分度也一般,但在绝大多数“素材查重、截图比对、相似检索”的场景里,性价比高得离谱。我在实际项目里是先拿一小批真实素材把阈值验证好了再铺开,这个习惯帮我少走了很多弯路。如果你在Unity里也有类似需求,照着上面的代码改一版,基本就能直接落地。