资讯动态

Unity图片相似度检测:pHash感知哈希算法C#实现

发布时间:2026/10/14 15:29:59 来源:尧图企业网站定制
做Unity项目时我经常遇到一个很实际的需求图片素材多了以后怎么自动找出那些看起来几乎一样的图比如UI图标的微调版本、纹理资源的重复导入甚至玩家上传头像的相似度过滤。网上大多数方案不是调用外部库就是用Python离线处理想在Unity里直接跑C#实现的资料并不多。我最后选了个比较轻量的方案pHash感知哈希算法把每张图压缩成64位指纹再用汉明距离判断相似度。这篇文章就把我从原理到Unity实现的整个过程完整记录下来包括踩过的坑和调参经验适合想在不依赖第三方服务的情况下自己搞定图片查重的人。1. 项目背景与方案选型1.1 为什么图片相似度计算会成为Unity开发里的刚需我先聊一下需求场景。Unity项目里图片资源一多重复和相似几乎是必然的。我见过好几个项目美术同学打包资源时发现同名贴图有十几个版本有些只是调了饱和度有些缩放了一下但文件名完全不同。如果不做查重包体白白膨胀打图集的时候还会产生额外依赖。另一个高频场景是自动化测试用截屏对比判断UI状态如果玩家看到的画面和预期画面只是颜色有轻微偏差用传统像素对比很容易误报。还有一个常见需求是社区类游戏的后台审核玩家上传的头像或截图需要快速过滤明显重复的内容。这些场景都靠人眼核对肯定不现实所以我们需要一个能在Unity运行时或者编辑器里直接使用的相似度计算方案。如果只是做一次性的资源清理你当然可以用外部工具但做游戏自动化测试或者线上内容审核时外部调用链路太长延迟和网络都不稳定。在Unity C#内部实现不仅能离线跑还能集成到打包流水线或者运行时服务里自由度要高得多。这也是我决定自己写一个pHash实现的重要原因。1.2 常见图像相似度方案横向对比在选型之前我把能想到的方案都过了一遍。最简单的是像素级差异比如平均绝对误差MAE和均方误差MSE逐像素比较速度快但几乎受不了任何亮度或缩放变化结构相似性SSIM稍微聪明一点能模拟人眼对亮度、对比度、结构的感知但计算成本高而且依然需要图像尺寸对齐。特征点匹配ORB、SIFT这类在图像拼接、物体识别里很强能处理视角变化但实现复杂度高在Unity里想跑起来要么依赖原生库要么自己写特征提取成本明显偏高。下面这个表是我当时做的对比覆盖了典型思路。方案核心原理优点缺点适用场景MSE/MAE逐像素计算颜色差值实现简单速度快对亮度、缩放、压缩极其敏感同尺寸完全对比SSIM亮度、对比度、结构三因子加权贴近人眼感知计算量大仍需尺寸对齐图像质量评估ORB/SIFT提取特征点并匹配能处理缩放旋转视角变化复杂度高依赖原生库物体识别、图像拼接aHash灰度均值二值化代码最短对亮度变化敏感简单缩略图查重dHash相邻像素梯度二值化对亮度鲁棒对旋转、位移不鲁棒图标、截屏对比pHashDCT频域低频二值化对缩放、压缩、轻微调色稳定需要DCT实现图片查重、内容审核感知哈希就成了一个平衡点。它不比较像素而是比较图片的“指纹”。先缩放、灰度化再用某种变换提取频率特征最后二值化成固定长度的数字。两张图只要指纹的汉明距离足够小就认为是相似的。计算量可控C#半天能写出来准确率在大部分业务场景里够用。1.3 为什么选择pHash而不是aHash或dHash感知哈希家族里常用的是aHash、dHash、pHash。aHash平均哈希最简单缩放成8x8计算灰度平均值每个像素跟平均值比大小。它很依赖整体亮度一张白天拍的图稍微变暗一点哈希就变了。dHash差异哈希改为比较相邻像素的亮度差对亮度更鲁棒但只关注局部梯度对主体位移、旋转宽容度差。pHash感知哈希则是把图像变换到频域取低频分量生成哈希可以理解为在描述“这张图像的整体轮廓和纹理分布”而不是个别像素的明暗。实际跑下来pHash在缩放、压缩、轻微调色上的稳定性是最好的。代价就是代码量稍微多一点需要实现一个DCT变换。对于Unity项目来说DCT并不难写尤其我们只需要32x32的输入矩阵性能压力很小。之后我会给出完整的C#实现读者可以直接抄。2. pHash算法原理拆解2.1 感知哈希的基本思想与适用边界感知哈希背后的直觉是人眼判断两张图片相似看的不是每个像素是否相等而是整体结构、轮廓、色块的分布是否接近。所以pHash先把图片压缩到极小尺寸去除高频细节保留低频轮廓再把像素值从空间域转换到频域最后从频域系数中提取最稳定的那一部分作为指纹。这样生成的哈希值并不是密码学意义上的“内容摘要”而是一种感知指纹——两张感知相似的图它们的哈希值在二进制层面也应该很接近。但感知哈希也有边界它适合检测“内容相同但经过了缩放、压缩、轻微亮度/颜色调整”的图片对于严重裁剪、局部遮挡、大幅旋转或者镜像翻转pHash的准确率会明显下降。这些情况需要更重的特征匹配方案不在这次讨论范围内。2.2 完整计算流程与关键参数pHash的标准流程可以拆成五步。第一步把任意尺寸的图片缩放到32x32像素。这一步同时消除分辨率差异也强制性地滤掉高频噪声。第二步把32x32的彩色图转成灰度图用0.299R0.587G0.114B的加权公式这是基于人眼对绿色最敏感、对蓝色最不敏感的生理特性。第三步对32x32的灰度矩阵做二维DCT。DCT会把图像的能量集中到低频区域左上角系数是直流分量代表整体亮度越往右下角频率越高、数值越小。第四步只取左上角8x8的64个低频系数。第五步计算这64个系数的平均值每个系数和平均值比较大于平均值记1否则记0按固定顺序排成64位哈希。为什么是32x32和8x8我一开始也觉得这些数字有些拍脑袋后来看了不少开源实现和论文里的经验参数它们是被反复验证过的权衡32x32足够保留主要轮廓又不会让DCT计算量失控8x8低频系数对应实际图像里最稳定的结构信息。再往下取到4x4会丢失太多细节相似度区分度变差往上取到16x16又会混入高频噪声对压缩和轻微模糊的鲁棒性下降。所以我在项目里也直接沿用这个配置。2.3 汉明距离如何从指纹到相似度有了64位哈希剩下就是比较两个哈希。汉明距离就是两个二进制数对应位上不同的数量。比如两个哈希值有5个bit不一致汉明距离就是5。距离越小图片越相似。实际操作时我会计算汉明距离后除以64得到一个0到1之间的归一化相似度或者直接用原始距离加阈值判断。不同场景阈值差异很大同一张图经过缩放、轻微调色汉明距离一般稳定在0到5完全不同的两张自然图片距离通常大于25。UI图标这类简单图形因为颜色少、结构简单即使内容不同距离也可能只有8到12需要单独压低阈值。这些调参经验后面会在“踩坑记录”里细说。3. Unity C#实现pHash完整流程3.1 图像读取与尺寸标准化Texture2D的正确打开方式在Unity里算图片哈希第一步是把图片读成Texture2D。很多新手会直接Texture2D.LoadImage但要注意几个细节第一纹理格式最好用RGBA32这样GetPixels32拿到的数据最直观第二如果项目开了线性颜色空间普通加载出来的sRGB纹理不做颜色转换灰度值会和Gamma空间不一致导致同一个图在不同工程里哈希不同第三图像的wrapMode应该设成Clamp避免边界采样影响缩放结果。我习惯用下面这个方法统一处理using UnityEngine; using System.IO; public static class TextureLoader { public static Texture2D LoadTexture(string path, bool linear false) { if (!File.Exists(path)) return null; byte[] bytes File.ReadAllBytes(path); Texture2D tex new Texture2D(2, 2, TextureFormat.RGBA32, false, linear); if (!tex.LoadImage(bytes)) { Object.DestroyImmediate(tex); return null; } tex.wrapMode TextureWrapMode.Clamp; return tex; } }这里的linear参数很重要。我的做法是如果QualitySettings.activeColorSpace是Linear并且你不想引入额外转换就直接用linear:true创建纹理让Unity把sRGB数据当成线性数据读进来。这样pHash在同一个项目里的结果是一致的。不同项目之间如果颜色空间不同哈希本来就不应该直接对比需要统一约定。读进来之后别直接拿原始大图做DCT。先缩放到32x32降低运算量。这里我用RenderTexture转一下public static Texture2D ScaleTexture(Texture2D source, int width, int height) { RenderTexture rt RenderTexture.GetTemporary(width, height, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); Graphics.Blit(source, rt); RenderTexture.active rt; Texture2D result new Texture2D(width, height, TextureFormat.RGBA32, false, true); result.ReadPixels(new Rect(0, 0, width, height), 0, 0); result.Apply(); RenderTexture.active null; RenderTexture.ReleaseTemporary(rt); return result; }注意RenderTextureReadWrite.sRGB这个参数在Linear项目里很重要。如果你的项目是Gamma空间可以忽略如果在Linear空间用sRGB读写能保证采到的是Gamma空间的原始颜色和前面linear:true的加载逻辑保持一致。3.2 灰度化与DCT变换的C#实现缩放完成后把32x32的像素读出来转成灰度矩阵。我用的灰度公式是0.299R0.587G0.114B对应整数近似就是Gray(77R150G29B)8但为了精度用float直接算Color32[] pixels small.GetPixels32(); float[,] gray new float[32, 32]; for (int i 0; i pixels.Length; i) { int x i % 32; int y i / 32; gray[x, y] 0.299f * pixels[i].r 0.587f * pixels[i].g 0.114f * pixels[i].b; }为什么不直接平均RGB因为人眼对三种颜色的敏感度不同平均RGB会把亮度信息扭曲掉影响后续DCT的系数分布。加权灰度更贴近人眼感知。接下来是核心的DCT。二维DCT可以拆成两次一维DCT先对每一行做一维DCT得到临时矩阵再对每一列做一维DCT。我对一维DCT的标准实现是static float[] DCT1D(float[] data, int n) { float[] result new float[n]; float scale Mathf.Sqrt(2f / n); float sqrtHalf Mathf.Sqrt(0.5f); for (int u 0; u n; u) { float sum 0f; for (int x 0; x n; x) sum data[x] * Mathf.Cos((2 * x 1) * u * Mathf.PI / (2 * n)); float cu (u 0) ? sqrtHalf : 1f; result[u] sum * scale * cu; } return result; }然后做二维static float[,] DCT2D(float[,] input, int n) { float[,] temp new float[n, n]; for (int y 0; y n; y) { float[] row new float[n]; for (int x 0; x n; x) row[x] input[x, y]; float[] rowDct DCT1D(row, n); for (int x 0; x n; x) temp[x, y] rowDct[x]; } float[,] output new float[n, n]; for (int x 0; x n; x) { float[] col new float[n]; for (int y 0; y n; y) col[y] temp[x, y]; float[] colDct DCT1D(col, n); for (int y 0; y n; y) output[x, y] colDct[y]; } return output; }这里我用了最直白的DCT-II公式。因为n只有32效率完全够没必要引入FFT。后面优化也主要是预计算cos值。3.3 生成64位哈希与汉明距离计算DCT变换后我们只关心左上角8x8的低频系数。取平均值大于平均值记1其余记0最后打包成一个ulongpublic static ulong GetPHash(Texture2D tex) { Texture2D small ScaleTexture(tex, 32, 32); Color32[] pixels small.GetPixels32(); float[,] gray new float[32, 32]; for (int i 0; i pixels.Length; i) { int x i % 32; int y i / 32; gray[x, y] 0.299f * pixels[i].r 0.587f * pixels[i].g 0.114f * pixels[i].b; } float[,] dct DCT2D(gray, 32); const int lowSize 8; float[] lowFreq new float[lowSize * lowSize]; int idx 0; for (int y 0; y lowSize; y) for (int x 0; x lowSize; x) lowFreq[idx] dct[x, y]; float avg 0f; for (int i 0; i lowFreq.Length; i) avg lowFreq[i]; avg / lowFreq.Length; ulong hash 0; for (int i 0; i lowFreq.Length; i) { if (lowFreq[i] avg) hash | 1UL i; } if (small ! tex) Object.DestroyImmediate(small); return hash; }注意这里我把8x8低频系数全部参与平均包括直流分量。直流分量代表整张图的平均亮度会让平均值偏大从而减弱亮度不变性。我在第二个版本中改成了跳过(0,0)的直流分量只对剩下63个系数求平均这样哈希对整体亮度的鲁棒性更好float[,] dct DCT2D(gray, 32); const int lowSize 8; float[] lowFreq new float[lowSize * lowSize - 1]; int idx 0; float sum 0f; for (int y 0; y lowSize; y) for (int x 0; x lowSize; x) { if (x 0 y 0) continue; float v dct[x, y]; lowFreq[idx] v; sum v; } float avg sum / lowFreq.Length;这两个版本我都保留着前者适合对亮度一致性要求不高的场景后者适合比较容易出现整体亮度变化的游戏截图对比。读者可以按需选择。汉明距离计算非常简单用异或再数1的个数public static int HammingDistance(ulong a, ulong b) { ulong x a ^ b; int dist 0; while (x ! 0) { dist; x x - 1; } return dist; }这里用x x - 1每次清除最低位的1比逐位移64次快很多。虽然64位很短但养成好习惯没坏处。4. 编辑器工具与运行时集成4.1 批量图片查重的编辑器窗口有了核心方法落地就很简单了。我先做了一个编辑器窗口用来扫描某个文件夹下所有图片两两比较汉明距离输出重复或相似图片列表。窗口代码大致是这样using UnityEditor; using UnityEngine; using System.Collections.Generic; using System.IO; public class ImageDupWindow : EditorWindow { [MenuItem(Tools/相似图片查重)] static void Open() { GetWindowImageDupWindow(相似图片查重); } private string folderPath Assets/Art; private float threshold 12f; private Vector2 scroll; private void OnGUI() { EditorGUILayout.LabelField(图片相似度查重工具); folderPath EditorGUILayout.TextField(扫描文件夹, folderPath); threshold EditorGUILayout.Slider(相似度阈值, threshold, 0f, 30f); if (GUILayout.Button(开始扫描)) { ScanFolder(folderPath, threshold); } } private void ScanFolder(string folder, float thresh) { if (!Directory.Exists(folder)) { EditorUtility.DisplayDialog(错误, 文件夹不存在, OK); return; } string[] files Directory.GetFiles(folder, *.png, SearchOption.AllDirectories); // 也可加入jpg、tga等格式 Listulong hashes new Listulong(); Liststring names new Liststring(); foreach (string file in files) { Texture2D tex TextureLoader.LoadTexture(file, false); ulong h GetPHash(tex); hashes.Add(h); names.Add(file); Object.DestroyImmediate(tex); } // 两两比较阈值以内就输出 for (int i 0; i hashes.Count; i) { for (int j i 1; j hashes.Count; j) { if (HammingDistance(hashes[i], hashes[j]) thresh) { Debug.Log($相似: {names[i]} - {names[j]}); } } } } }这个版本比较粗糙但已经能用。实际项目里我还会把结果收集到一个列表里放到ScrollView里展示并且支持点击跳转到资源路径。如果你想做得更完善可以在此基础上加按文件夹递归、忽略特定目录、输出报告等。4.2 运行时截图对比与上传校验编辑器只是第一步。运行时集成才是pHash真正发挥价值的地方。比如自动化测试里要做UI截图对比我通常这样用IEnumerator CaptureAndCompare() { yield return new WaitForEndOfFrame(); Texture2D shot new Texture2D(Screen.width, Screen.height, TextureFormat.RGBA32, false); shot.ReadPixels(new Rect(0, 0, Screen.width, Screen.height), 0, 0); shot.Apply(); ulong shotHash GetPHash(shot); ulong refHash loadedRefHash; // 之前计算好的 int dist HammingDistance(shotHash, refHash); Debug.Log(dist); Destroy(shot); }注意Texture2D.ReadPixels必须在帧末或相机渲染完成后调用不然读到的是旧缓冲。还有一点如果场景里有动态粒子、模糊特效截图对比会经常跳动。我的经验是不要直接和原始参考帧对比而是先把两边都缩小到32x32或者先抓两张时间窗内的截图算平均再去算哈希。4.3 性能优化与内存管理pHash本身计算量不大但Unity里读取像素和生成临时纹理很容易造成GC和主线程卡顿。我实际优化中做了几件事第一创建临时Texture2D时尽量复用。频繁new Texture2D再Destroy会导致大量的托管内存分配。可以把缩放后的纹理对象缓存起来用完回收到对象池。第二DCT函数避免重复计算cos。由于n固定为32所有cos值都是固定的可以一次性生成一个float[32,32]的查找表每次DCT直接查表性能能提升不少。我在项目里就是这么干的代码如下static float[,] cosTable; static void BuildCosTable(int n) { cosTable new float[n, n]; for (int u 0; u n; u) for (int x 0; x n; x) cosTable[u, x] Mathf.Cos((2 * x 1) * u * Mathf.PI / (2 * n)); }第三如果要在后台线程批量计算哈希最好把像素数据拷贝到NativeArray里再在Job System里做灰度化和DCT。但要注意Texture2D.GetPixels32本身必须走主线程只有拿到像素数组后的计算可以分离。批量处理大图片时这一步能明显减少主线程耗时。5. 踩坑记录与问题排查实录5.1 颜色空间不一致导致哈希漂移这个坑我印象很深。项目从Gamma空间切到Linear空间后原来的相似图片查询结果几乎全部失效。排查下来发现Texture2D.LoadImage默认把图片当sRGB数据读入在Linear空间下渲染采样时Unity会做sRGB到Linear的转换但我们GetPixels32读到的仍然是原始sRGB字节而用Graphics.Blit缩放时会经过采样器管线状态不同采到的颜色可能已经被转换过。混用两种数据去算DCT结果当然不稳定。我的解决办法是统一约定加载环节用linear:true创建纹理缩放环节用RenderTextureReadWrite.sRGB保证所有操作都在Gamma空间里。这样无论项目开不开Linear哈希计算的结果对同一张原图都保持一致。如果你的项目同时要兼容不同渲染管线建议把颜色空间配置写进工具配置里避免误用。5.2 DCT性能瓶颈与优化第一次写二维DCT时我直接套了二维公式四层for循环嵌套里面还反复调用Mathf.Cos。虽然输入只有32x32但编辑器里扫描几百张图时耗时非常明显。用Profiler一看全耗在Mathf.Cos上了。优化分两步先改成可分离的两次一维DCT再把cos查找表缓存起来。这两步做完单张图的哈希耗时从几毫秒降到了零点几毫秒。其实32x32太小了按行按列两次一维DCT只涉及32*32*32*2次乘加比二维直接算的32^4少了两个数量级。如果你连零点几毫秒都在意还可以把DCT里的乘法和加法改成unsafe指针操作但我觉得没必要。5.3 阈值到底设多少不同类型图片的实测经验阈值是使用pHash最容易困惑的地方。我做了大量测试大致规律是同一张图压缩到不同尺寸、轻微调亮度汉明距离一般小于5同一张图加轻微水印距离在5到10完全不同的自然图片距离通常在25以上而简单的UI图标即使两张完全不同的图标距离也可能落在10到15。原因很简单图标结构简单、色块少DCT低频系数的模式比较接近。因此我不会在全项目统一用一个阈值。UI素材查重用8到10自然图片或截图对比用12到15内容审核这种只抓明显重复的场景直接用5以下。这些数值每个项目都不一样建议先手工抽样几十组图片算一下同类和异类的距离分布再取分界点。5.4 常见问题速查表我把碰过的问题整理成一个表方便排查。问题可能原因解决办法图片加载失败返回null路径不存在或图片格式不被Unity支持检查File.Exists按扩展名过滤哈希值全是0或全是1灰度矩阵全黑/全白或缩放后内容丢失检查纹理读取是否正确调试时输出灰度均值同一张图两次计算距离不为0使用了sRGB/RenderTexture采样不一致统一linear和RenderTextureReadWrite设置编辑器里扫描卡顿大量new Texture2D和DCT重复计算复用纹理对象预计算cos表后台线程计算相似图片距离仍然很大图片被大幅裁剪或翻转换成ORB匹配或预处理做居中裁剪汉明距离计算溢出用了int统计差异位移使用ulong并注意左移位数小于64这些坑并不难解决但如果不提前注意往往要排查很久。希望这个表能帮你少走些弯路。写到这里我的经验基本都分享完了。最后说一点个人的体会pHash不是万能的它适合做“粗查重”和“快速过滤”但不适合做需要精确到像素的对比真正靠谱的做法是把pHash作为第一层过滤器把距离很接近的图片再交给人工或者更重的算法复核。如果你正在做Unity里的图片相似度功能建议先用小批量数据测出自己项目的距离分布再决定阈值别照搬网上的默认值。祝顺利。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑