pHash感知哈希:圖片相似度計算與查重實戰(zhàn))
做游戲或者工具鏈開發(fā)的朋友應該都遇到過一種需求明明是一張圖用戶上傳的是縮略圖、你本地存的是原圖卻要判斷它們“是不是同一個東西”。換到Unity工程里就是素材查重、自動化測試截圖比對、玩家上傳圖片內(nèi)容過濾這一類的場景。你要是拿MD5去對縮放過、轉(zhuǎn)碼過、調(diào)過色的圖片哈希值全都不一樣直接沒法用。這種時候就輪到感知哈希pHash出場了。pHash的全稱是Perceptual Hash感知哈希。它不比較圖片的字節(jié)是否一樣而是比較圖片“看起來像不像”。兩張圖哪怕尺寸不同、分辨率不同、壓縮率不同只要視覺內(nèi)容一致算出來的哈希值就會非常接近。這篇文章我把Unity C#里實現(xiàn)pHash的完整思路、代碼、還有實際工程里的坑和閾值選擇全部整理出來給有同樣需求的人一個可以直接抄作業(yè)的版本。1. 為什么圖片“相似”不能用MD5來判斷1.1 MD5這類精確哈希的適用邊界MD5、SHA1這類哈希算法設計目標就是讓內(nèi)容“哪怕只有一個bit的變化最終哈希值也完全不同”這就是所謂的雪崩效應。這個特性用來校驗文件完整性、判斷賬號密碼是否匹配是完美的但用來判斷圖片相似度就是災難。舉個例子一張原圖你用JPEG壓縮到90%質(zhì)量理論上視覺上幾乎看不出區(qū)別但MD5值已經(jīng)完全變了。壓縮再給圖片加個幾像素的白邊MD5又變了一次。在素材查重場景里最常見的重復素材恰恰是“同一張圖的不同分辨率、不同壓縮率版本”用MD5去重等于漏掉了所有真正該去掉的重復項。我在實際項目里見過最典型的案例美術同學導出了一大批量貼圖其中有相當一部分是同一張圖的不同尺寸版本用MD5腳本跑了一次結(jié)果一個重復的都沒標出來。倒不是說MD5沒用是它壓根不適合這個場景。相似度判斷需要一個“允許小變化”的度量方式而不是“完全相等”的嚴格校驗。1.2 感知哈希家族aHash、pHash、dHash怎么選感知哈希不是一個單獨的算法而是一族。最常見的三個是aHash、pHash、dHash它們都把圖片最終映射成一組二進制位通常64位區(qū)別在于中間的特征提取方式aHash平均值哈希把圖片縮小后轉(zhuǎn)灰度計算所有像素的平均值然后每個像素跟平均值比較大于等于記為1小于記為0。實現(xiàn)最簡單但對細節(jié)變化很敏感。pHash感知哈希核心是DCT離散余弦變換從頻域提取低頻特征。對壓縮、縮放、輕微顏色變化的魯棒性最好也是目前最主流的方案。dHash差異哈希比較相鄰像素之間的灰度差速度快對漸變和模糊有一定容忍度但對裁剪和縮放相對敏感。在Unity里做素材相似度計算我首選pHash。原因很簡單游戲素材最常見的變體就是“被壓縮過”“被縮放過分辨率”“被調(diào)過亮度”這三類變化pHash扛得住其他兩個沒那么穩(wěn)。1.3 pHash的底層邏輯從像素值到頻率特征的抽象pHash比aHash高明在哪里aHash比較的是像素的絕對亮度分布一旦圖片整體變亮或者變暗很多像素的閾值判定就會翻轉(zhuǎn)哈希值直接就崩了。pHash做了一次DCT變換把圖片從像素域轉(zhuǎn)換到頻率域提取的是“圖像在低頻部分的能量分布”。可以這么理解把圖片想象成一個起伏的地形像素值是海拔。像素域看到的是每個點的具體高度而頻率域看到的是一圈一圈的“起伏輪廓”。低頻分量對應大范圍的地形走勢比如一個物體整體是亮還是暗、色塊怎么分布高頻分量對應細微的紋理細節(jié)比如草地上的每一根草。pHash只保留低頻部分相當于把地形圖簡化成一張只有大輪廓的素描那些“局部微調(diào)”造成的高頻變化就被過濾掉了。這正是它抗壓縮、抗縮放的原理。JPEG壓縮丟掉的多半是高頻細節(jié)縮放過程也會抹掉一部分高頻但這些對低頻輪廓影響很小。所以兩張內(nèi)容相同但經(jīng)過不同處理的圖在頻域的低頻部分依然非常接近。2. pHash的完整計算鏈路2.1 縮小到32×32尺寸歸一化是第一道檻pHash第一步把任意尺寸的圖片統(tǒng)一縮放到32×32像素。這一步有兩個目的。第一個目的是消除尺寸差異。一張1920×1080的截圖和一張320×180的縮略圖內(nèi)容相同但像素數(shù)量差了36倍。如果不統(tǒng)一尺寸后續(xù)計算的基準就不一致。第二個目的是剔除高頻細節(jié)。32×32只有1024個像素這在信息量上已經(jīng)丟失了大量細節(jié)但保留了足夠描述圖像結(jié)構(gòu)的低頻信息。這一步等價于“讓參與比較的每一張圖都站到同一條起跑線上”??s放質(zhì)量在這個階段其實沒那么苛刻。因為后續(xù)還要做灰度化、DCT、二值化很多細節(jié)在中間環(huán)節(jié)就被抹掉了。實測下來最近鄰采樣和雙線性采樣的最終哈希值差異很小但雙線性會更穩(wěn)一些尤其是圖片里存在明顯斜線或邊緣的時候。代碼部分我給出的就是雙線性采樣實現(xiàn)。2.2 灰度化與DCT把圖像從像素世界搬到頻率世界縮小到32×32之后把每個像素轉(zhuǎn)成灰度值。標準公式是Y 0.299R 0.587G 0.114B這個加權系數(shù)不是隨便來的對應人眼對紅、綠、藍三種顏色的敏感度差異。直接取RGB平均值RGB/3當然也能跑但得到的結(jié)果會更偏向亮度均勻的色塊不如加權公式貼近人眼感知?;叶然笞鯠CT。DCT全稱Discrete Cosine Transform離散余弦變換把1024個灰度值組成的一個32×32矩陣變換成同樣大小的頻域系數(shù)矩陣。變換后的矩陣左上角是低頻分量右下角是高頻分量。為什么用DCT而不是DFT離散傅里葉變換DCT只使用余弦函數(shù)作為基變換結(jié)果是純實數(shù)計算量更小、能量更集中。JPEG圖像壓縮內(nèi)部用的也是DCT它對自然圖像的頻域特征提取效果很好。pHash的標準實現(xiàn)里DCT這一步就是核心也是整個算法計算開銷最大的部分。2.3 取低頻系數(shù)與二值化64位指紋是怎么來的DCT變換完成后矩陣是32×32共1024個系數(shù)。pHash只取左上角8×8的系數(shù)塊也就是64個低頻系數(shù)。這64個值濃縮了圖像在八個方向上的低頻能量分布足以刻畫圖像的整體結(jié)構(gòu)。接下來計算這64個系數(shù)的平均值然后把每個系數(shù)跟平均值比較大于平均值記為1小于等于平均值記為0。最終得到一個64位的二進制串通常用一個ulong類型存儲每一位代表一個低頻系數(shù)的二值化結(jié)果。這里有個容易誤解的點平均值二值化跟aHash的“像素均值”二值化看起來一樣但語義完全不同。aHash比較的是像素亮度均值對整體曝光變化敏感pHash比較的是頻域系數(shù)均值每個系數(shù)代表的是一類頻率模式的強度整體亮度變化主要影響DC分量第一個系數(shù)對其他系數(shù)的影響相對有限。這就是pHash對亮度變化不敏感的根本原因。2.4 漢明距離兩個指紋怎么算“差多少”得到兩張圖的64位哈希之后用漢明距離來衡量相似度。漢明距離的定義很簡單兩個二進制數(shù)對應位不同的數(shù)量。比如哈希A是1100哈希B是1010逐位比較第一位都是1第二位1和0不同第三位0和1不同第四位0和0相同所以距離是2。距離越小說明兩張圖的低頻特征越接近視覺上越相似。在C#里計算ulong類型的漢明距離最經(jīng)典的方式是先做異或再統(tǒng)計異或結(jié)果里1的個數(shù)。后續(xù)代碼里的實現(xiàn)用了Brian Kernighan算法每次把最低位的1清零循環(huán)次數(shù)等于1的個數(shù)比逐位右移判斷快不少。3. Unity里的C#實現(xiàn)與踩坑記錄3.1 拿到像素的第一個坑紋理不可讀在Unity里做圖像處理第一步就有一道坎Texture2D的getPixels方法要求在紋理導入設置里勾選Read/Write Enabled。如果你加載的是AssetBundle里的紋理、或者運行時通過WWW加載的Texture2D默認情況下這個選項是沒開的直接調(diào)用GetPixels會直接拋異常。我開始實現(xiàn)pHash的時候就在這上面栽過一次。那段代碼在編輯器里跑得好好的一到運行時加載AssetBundle紋理就崩報錯就是“Texture is not readable”。查了半天解決方案是在讀取像素之前判斷isReadable如果不可讀就先通過RenderTexture過一道“拷貝”把像素讀出來。RenderTexture拷貝的方式非常穩(wěn)定但要注意釋放臨時RT否則持續(xù)加載圖片時內(nèi)存會一點點漲上去。代碼里的ReadPixelsThroughRenderTexture方法就是處理這條路的。3.2 完整實現(xiàn)代碼下面是完整的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; } } }調(diào)用方式也很簡單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}, 判定結(jié)果: {(distance 10 ? 相似 : 不相似)}); } }3.3 雙線性縮放為什么自己寫Unity自帶的Texture2D有一個GetPixelsBilinear方法看起來可以一步到位做縮放采樣但實際用下來有兩個問題。第一這個方法對紋理格式有要求在某些平臺上只支持ARGB32格式一旦紋理是壓縮格式返回的數(shù)據(jù)可能是不正確的。第二它在移動端的表現(xiàn)跟編輯器端存在差異我用Android真機測試過一次得到的像素值跟PC上對不上這就導致同一張圖在兩臺設備上算出的哈希值可能不同。所以我在代碼里手動實現(xiàn)了雙線性采樣。邏輯不復雜對于目標像素點映射回原圖坐標找到周圍四個像素按距離做兩次線性插值。這樣不依賴Unity內(nèi)部API所有平臺行為一致。3.4 主線程卡頓和異步方案DCT計算包含四層循環(huán)雖然32×32的規(guī)模不算大C#跑一次大概幾毫秒但如果你在編輯器里批量處理幾百張素材每張都幾毫秒加起來還是有明顯卡頓。更關鍵的是GetPixels返回的Color數(shù)組一張4096×4096的貼圖會有1600萬個Compressed顏色結(jié)構(gòu)體光這份數(shù)組就占幾百MB內(nèi)存主線程上折騰這個非常不劃算。我實際項目里采用的策略是分兩段走主線程只負責讀取像素和轉(zhuǎn)灰度把1024個float數(shù)組準備好耗時短且可控DCT和二值化放到后臺線程。灰度數(shù)組本身不依賴Unity API后臺線程可以安全計算。如果用的是協(xié)程可以這樣組織private IEnumerator ComputeHashAsync(Texture2D texture, Actionulong onDone) { float[] grayscale null; // 主線程讀取像素并轉(zhuǎn)灰度 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不能跨線程訪問但轉(zhuǎn)灰度之后產(chǎn)生的float數(shù)組就跟Unity徹底解耦了線程隨便用。4. 閾值與實測多少距離算“相似”4.1 一組有代表性的對比測試算法實現(xiàn)出來之后最重要的事情是確定判定閾值。我拿一組測試圖跑了一遍涵蓋了最常見的圖片變化場景得到的結(jié)果大致如下處理方式漢明距離直觀判斷原圖 vs 原圖0完全一致原圖 vs 把尺寸縮小50%再放大0~2幾乎看不出區(qū)別原圖 vs JPEG質(zhì)量80壓縮2~4視覺上基本一致原圖 vs 亮度提升20%1~3視覺輕微發(fā)亮原圖 vs 對比度提升30%3~6能看出調(diào)過色但內(nèi)容清晰原圖 vs 四周裁剪比例5%4~8構(gòu)圖略變但主體還在原圖 vs 畫面中加了一小段字幕6~12主體內(nèi)容相同但新增元素明顯原圖 vs 水平翻轉(zhuǎn)30鏡像圖像視覺相同但結(jié)構(gòu)翻轉(zhuǎn)原圖 vs 旋轉(zhuǎn)90度40內(nèi)容方向變化巨大原圖 vs 另一張完全不相關的圖30~50內(nèi)容無關從數(shù)據(jù)能看出來絕大部分“同一張圖的不同形態(tài)”距離都能壓在10以內(nèi)。4.2 結(jié)合場景確定閾值閾值怎么定取決于你的業(yè)務對誤殺和漏放的容忍度。做素材查重我建議閾值設在10。這個區(qū)間能抓到絕大多數(shù)重復項同時誤判率很低。同一目錄下兩張恰好距離小于10但內(nèi)容無關的圖片我印象里還沒遇到過。做自動化測試截圖比對閾值建議收緊到5。測試截圖通常是同一分辨率、同一環(huán)境下的對比如果距離超過5往往確實發(fā)生了可見變化。設得寬松反而會漏掉細微的UI偏移。做玩家上傳圖片過濾這類業(yè)務可以分兩級距離小于5直接判定為同一張圖距離小于10進入人工審核隊列。這樣既避免誤殺又不至于漏得太厲害。4.3 pHash的已知短板和補救pHash有三個明顯的弱點用之前要有心理準備。第一對旋轉(zhuǎn)和翻轉(zhuǎn)沒有抵抗力。旋轉(zhuǎn)90度后頻域分布完全不同漢明距離直接飆到30以上。如果業(yè)務里需要識別旋轉(zhuǎn)過的圖片需要提前做方向歸一化或者干脆不用pHash。第二對裁剪比較敏感。裁剪超過10%之后畫面構(gòu)圖變化會顯著影響低頻分布。不過“輕微裁剪縮放”這種常見的縮略圖場景距離仍然在可接受范圍內(nèi)。第三對大面積純色圖片判別效果有限。比如一張漸變背景的UI圖64個低頻系數(shù)可能大部分都趨近二值化結(jié)果的區(qū)分度就弱了。處理這類素材時我會額外疊加一個尺寸或?qū)捀弑忍卣髯鳛檩o助判斷。5. 從Demo到工程緩存、批量查重和擴展5.1 給哈希結(jié)果加緩存哈希計算本身有開銷在工程里不可能每次都現(xiàn)算。我的做法是對每個文件維護一個哈希緩存鍵用文件路徑拼接文件修改時間。文件沒變就直接從緩存里取結(jié)果只有新增或被修改的文件才重新計算。緩存的存儲格式很簡單直接存ulong的字符串表示[Serializable] public class HashCacheEntry { public string path; public long lastWriteTicks; public string hashHex; }實際操作時可以直接序列化成Json文件。如果項目比較小用Dictionary存在內(nèi)存里就夠了如果幾百上千個素材要長期管理落盤緩存能省掉大量重復計算時間。5.2 批量查重與性能批量查重的核心邏輯是兩兩比較N張圖片就是N×(N-1)/2次漢明距離比較。漢明距離是位運算速度極快一萬次比較也就在毫秒級別瓶頸全部在哈希計算上。假設庫里有1000張圖需要跑1000次哈希計算。我的測試機上一次完整哈希大概5毫秒總耗時5秒左右作為離線工具完全可接受。如果想更快可以把哈希計算并行化用ThreadPool開Worker Thread分片處理。但兩兩比較本身就是O(N2)的問題N到了5000以上就比較吃緊了。這種規(guī)模可以按哈希值分桶取64位哈希的前16位作為桶索引把哈希值接近的圖片放到同一桶里只對桶內(nèi)圖片做兩兩比較。前16位相同意味著漢明距離最多48直接排除了大量無關的比較。5.3 還能往哪擴展這套方法不局限于Unity內(nèi)部。哈希結(jié)果是一個ulong完全可以在服務器端實現(xiàn)一套圖片查重服務??蛻舳松蟼鲌D片時計算哈希服務端拿哈希去數(shù)據(jù)庫里做比對就能實現(xiàn)“用戶上傳的圖片是否和庫中違規(guī)圖相似”這類需求。另一個方向是截圖回歸測試。游戲運行過程中自動截圖用pHash跟上一版本的基準截圖對比距離超過閾值就說明這次改動影響到了畫面表現(xiàn)。這在UI自動化里很好用比逐像素比較靠譜得多因為光照、抗鋸齒、細微時序都會產(chǎn)生像素級差異但這些差異在pHash眼里只是幾個bit的距離波動不會隨便誤報。最后說點個人體會。pHash不是銀彈它對旋轉(zhuǎn)、翻轉(zhuǎn)沒有抵抗力對純色大塊面圖片區(qū)分度也一般但在絕大多數(shù)“素材查重、截圖比對、相似檢索”的場景里性價比高得離譜。我在實際項目里是先拿一小批真實素材把閾值驗證好了再鋪開這個習慣幫我少走了很多彎路。如果你在Unity里也有類似需求照著上面的代碼改一版基本就能直接落地。