资讯详情

资讯详情

Unity C#实现pHash感知哈希:图片相似度计算与查重实战

做游戏或者工具链开发的朋友应该都遇到过一种需求明明是一张图用户上传的是缩略图、你本地存的是原图却要判断它们“是不是同一个东西”。换到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平均值RGB/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, Actionulong 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里也有类似需求照着上面的代码改一版基本就能直接落地。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →