| New file |
| | |
| | | using System; |
| | | using System.Collections.Generic; |
| | | using System.IO; |
| | | using System.Threading.Tasks; |
| | | using UnityEditor; |
| | | using UnityEngine; |
| | | |
| | | [Serializable] |
| | | public class FingerPrint |
| | | { |
| | | public string path; |
| | | public long size; |
| | | public long mtime; |
| | | public int width; |
| | | public int height; |
| | | public long hashLo; |
| | | public long hashHi; |
| | | public long cropHashLo; |
| | | public long cropHashHi; |
| | | public long edgeHashLo; |
| | | public long edgeHashHi; |
| | | public long shapeHashLo; |
| | | public long shapeHashHi; |
| | | public long nineSliceHashLo; |
| | | public long nineSliceHashHi; |
| | | public long nineSliceColorHashLo; |
| | | public long nineSliceColorHashHi; |
| | | public long[] transformHashLo; |
| | | public long[] transformHashHi; |
| | | public long[] patchHashLo; |
| | | public long[] patchHashHi; |
| | | public int patchCount; |
| | | public string md5; |
| | | public int ver; |
| | | |
| | | public ulong GetHash() |
| | | { |
| | | return GetHash(hashLo, hashHi); |
| | | } |
| | | |
| | | public void SetHash(ulong h) |
| | | { |
| | | SetHash(h, out hashLo, out hashHi); |
| | | } |
| | | |
| | | public ulong GetCropHash() |
| | | { |
| | | return GetHash(cropHashLo, cropHashHi); |
| | | } |
| | | |
| | | public void SetCropHash(ulong h) |
| | | { |
| | | SetHash(h, out cropHashLo, out cropHashHi); |
| | | } |
| | | |
| | | public ulong GetEdgeHash() |
| | | { |
| | | return GetHash(edgeHashLo, edgeHashHi); |
| | | } |
| | | |
| | | public void SetEdgeHash(ulong h) |
| | | { |
| | | SetHash(h, out edgeHashLo, out edgeHashHi); |
| | | } |
| | | |
| | | public ulong GetShapeHash() |
| | | { |
| | | return GetHash(shapeHashLo, shapeHashHi); |
| | | } |
| | | |
| | | public void SetShapeHash(ulong h) |
| | | { |
| | | SetHash(h, out shapeHashLo, out shapeHashHi); |
| | | } |
| | | |
| | | public ulong GetNineSliceHash() |
| | | { |
| | | return GetHash(nineSliceHashLo, nineSliceHashHi); |
| | | } |
| | | |
| | | public void SetNineSliceHash(ulong h) |
| | | { |
| | | SetHash(h, out nineSliceHashLo, out nineSliceHashHi); |
| | | } |
| | | |
| | | public ulong GetNineSliceColorHash() |
| | | { |
| | | return GetHash(nineSliceColorHashLo, nineSliceColorHashHi); |
| | | } |
| | | |
| | | public void SetNineSliceColorHash(ulong h) |
| | | { |
| | | SetHash(h, out nineSliceColorHashLo, out nineSliceColorHashHi); |
| | | } |
| | | |
| | | public ulong GetTransformHash(int index) |
| | | { |
| | | return GetArrayHash(transformHashLo, transformHashHi, index); |
| | | } |
| | | |
| | | public void SetTransformHash(int index, ulong h) |
| | | { |
| | | SetArrayHash(transformHashLo, transformHashHi, index, h); |
| | | } |
| | | |
| | | public ulong GetPatchHash(int index) |
| | | { |
| | | return GetArrayHash(patchHashLo, patchHashHi, index); |
| | | } |
| | | |
| | | public void SetPatchHash(int index, ulong h) |
| | | { |
| | | SetArrayHash(patchHashLo, patchHashHi, index, h); |
| | | } |
| | | |
| | | static ulong GetHash(long lo, long hi) |
| | | { |
| | | return (((ulong)hi & 0xFFFFFFFFUL) << 32) | ((ulong)lo & 0xFFFFFFFFUL); |
| | | } |
| | | |
| | | static void SetHash(ulong h, out long lo, out long hi) |
| | | { |
| | | lo = (long)(h & 0xFFFFFFFFUL); |
| | | hi = (long)((h >> 32) & 0xFFFFFFFFUL); |
| | | } |
| | | |
| | | static ulong GetArrayHash(long[] loArray, long[] hiArray, int index) |
| | | { |
| | | if (loArray == null || hiArray == null || index < 0 || index >= loArray.Length || index >= hiArray.Length) |
| | | return 0; |
| | | return GetHash(loArray[index], hiArray[index]); |
| | | } |
| | | |
| | | static void SetArrayHash(long[] loArray, long[] hiArray, int index, ulong h) |
| | | { |
| | | if (loArray == null || hiArray == null || index < 0 || index >= loArray.Length || index >= hiArray.Length) |
| | | return; |
| | | SetHash(h, out loArray[index], out hiArray[index]); |
| | | } |
| | | } |
| | | |
| | | [Serializable] |
| | | public class FingerPrintDB |
| | | { |
| | | public List<FingerPrint> items = new List<FingerPrint>(); |
| | | } |
| | | |
| | | public enum SimilarityMatchType |
| | | { |
| | | Exact, |
| | | VisualSame, |
| | | Similar, |
| | | SubjectSimilar, |
| | | TransformSimilar, |
| | | EdgeSimilar, |
| | | NineSliceSimilar, |
| | | LocalSimilar |
| | | } |
| | | |
| | | public class SearchResult |
| | | { |
| | | public string path; |
| | | public int dist; |
| | | public int cropDist; |
| | | public int transformDist; |
| | | public int edgeDist; |
| | | public int shapeDist; |
| | | public int nineSliceDist; |
| | | public int nineSliceColorDist; |
| | | public long nineSliceSizeScore; |
| | | public int localHits; |
| | | public int score; |
| | | public bool md5Same; |
| | | public string dirName; |
| | | public SimilarityMatchType matchType; |
| | | |
| | | public string label |
| | | { |
| | | get |
| | | { |
| | | if (matchType == SimilarityMatchType.Exact) return "完全重复"; |
| | | if (matchType == SimilarityMatchType.VisualSame) return "视觉相同"; |
| | | if (matchType == SimilarityMatchType.SubjectSimilar) return $"主体相似({cropDist})"; |
| | | if (matchType == SimilarityMatchType.TransformSimilar) return $"变换相似({transformDist})"; |
| | | if (matchType == SimilarityMatchType.EdgeSimilar) return $"边缘相似({edgeDist})"; |
| | | if (matchType == SimilarityMatchType.NineSliceSimilar) return $"九宫相似(结构{nineSliceDist}/颜色{nineSliceColorDist})"; |
| | | if (matchType == SimilarityMatchType.LocalSimilar) return $"局部相似({localHits})"; |
| | | return $"相似({dist})"; |
| | | } |
| | | } |
| | | } |
| | | |
| | | public class SpriteSimilarityFinder : EditorWindow |
| | | { |
| | | const string SEARCH_ROOT = "Assets/ResourcesOut/Sprite"; |
| | | const string CACHE_PATH = "Assets/Editor/Tool/SpriteSimilarityCache.json"; |
| | | const int ALGO_VER = 13; |
| | | const int HASH_SIZE = 32; |
| | | const int LOW_FREQ_SIZE = 8; |
| | | const int SHAPE_HASH_SIZE = 8; |
| | | const int TRANSFORM_COUNT = 8; |
| | | const int PATCH_GRID = 4; |
| | | const int PATCH_COUNT = PATCH_GRID * PATCH_GRID; |
| | | const int BUILD_PROGRESS_MIN_MS = 150; |
| | | const int MAX_BUILD_JOB_MULTIPLIER = 2; |
| | | const int MAX_BUILD_JOB_LIMIT = 64; |
| | | const long BUILD_MEMORY_BUDGET_BYTES = 1024L * 1024L * 1024L; |
| | | const int NINE_SLICE_THRESHOLD = 8; |
| | | const int NINE_SLICE_SHAPE_THRESHOLD = 2; |
| | | const int NINE_SLICE_COLOR_THRESHOLD = 12; |
| | | const int NINE_SLICE_MIN_AREA_RATIO = 4; |
| | | const int NINE_SLICE_MIN_AXIS_RATIO = 3; |
| | | const int NINE_SLICE_SMALL_AREA = 4096; |
| | | const int SCORE_DIST_MAX = 32; |
| | | const int SCORE_SHAPE_MAX = 16; |
| | | const int SCORE_NINE_SLICE_MAX = 16; |
| | | const int SCORE_NINE_SLICE_COLOR_MAX = 32; |
| | | const int SCORE_WEIGHT_DIST = 18; |
| | | const int SCORE_WEIGHT_CROP = 14; |
| | | const int SCORE_WEIGHT_TRANSFORM = 10; |
| | | const int SCORE_WEIGHT_EDGE = 8; |
| | | const int SCORE_WEIGHT_LOCAL = 12; |
| | | const int SCORE_WEIGHT_SHAPE = 8; |
| | | const int SCORE_WEIGHT_NINE_SLICE = 8; |
| | | const int SCORE_WEIGHT_NINE_SLICE_COLOR = 18; |
| | | const int SCORE_WEIGHT_NINE_SLICE_SIZE = 4; |
| | | const int SCORE_DIFFERENT_FOLDER_BONUS = 5; |
| | | const int SCORE_NINE_SLICE_BONUS_BASE = 8; |
| | | const int SCORE_NINE_SLICE_BONUS_STRUCTURE = 4; |
| | | const int SCORE_NINE_SLICE_BONUS_COLOR = 6; |
| | | const int SCORE_NINE_SLICE_BONUS_SMALL_SOURCE = 24; |
| | | const int SCORE_NINE_SLICE_BONUS_TINY_SOURCE = 15; |
| | | const int NINE_SLICE_TINY_SOURCE_AREA = 1024; |
| | | const int RESULT_COLUMNS = 2; |
| | | const float RESULT_ROW_HEIGHT = 76f; |
| | | const int RESULT_VISIBLE_BUFFER_ROWS = 4; |
| | | |
| | | Texture2D _queryTexture; |
| | | Vector2 _resultScroll; |
| | | int _threshold = 20; |
| | | int _edgeThreshold = 8; |
| | | int _localPatchThreshold = 3; |
| | | int _localMinHits = 4; |
| | | int _scoreThreshold = 35; |
| | | bool _followSelection = true; |
| | | UnityEngine.Object _ignoreNextSelectionObject; |
| | | List<SearchResult> _results; |
| | | string _lastQueryPath; |
| | | FingerPrintDB _db; |
| | | Dictionary<string, FingerPrint> _cacheByPath; |
| | | string _queryMD5; |
| | | FingerPrint _queryFingerprint; |
| | | bool _queryIncludesPatch; |
| | | bool _queryFpValid; |
| | | static double[][] _cosTable; |
| | | static readonly object _cosTableLock = new object(); |
| | | static byte[] _bitCountTable; |
| | | Dictionary<string, Texture2D> _thumbCache = new Dictionary<string, Texture2D>(); |
| | | |
| | | class FingerprintContext |
| | | { |
| | | public Color32[] pixels; |
| | | public int width; |
| | | public int height; |
| | | public float[] integral; |
| | | public int cropX; |
| | | public int cropY; |
| | | public int cropW; |
| | | public int cropH; |
| | | public bool hasShape; |
| | | public int shapeMinX; |
| | | public int shapeMinY; |
| | | public int shapeMaxX; |
| | | public int shapeMaxY; |
| | | public int bgR; |
| | | public int bgG; |
| | | public int bgB; |
| | | public int bgA; |
| | | public int shapeThresholdSq; |
| | | } |
| | | |
| | | class FingerprintBuildResult |
| | | { |
| | | public int index; |
| | | public string assetPath; |
| | | public FingerPrint fp; |
| | | public long md5Ticks; |
| | | public long cpuTicks; |
| | | public string error; |
| | | } |
| | | |
| | | class PendingFingerprintTask |
| | | { |
| | | public Task<FingerprintBuildResult> task; |
| | | public long estimatedBytes; |
| | | } |
| | | |
| | | class PHashWorkspace |
| | | { |
| | | public double[] fullSmall = new double[HASH_SIZE * HASH_SIZE]; |
| | | public double[] small = new double[HASH_SIZE * HASH_SIZE]; |
| | | public double[] transformedSmall = new double[HASH_SIZE * HASH_SIZE]; |
| | | public double[] edgeSmall = new double[HASH_SIZE * HASH_SIZE]; |
| | | public double[] tmp = new double[HASH_SIZE * LOW_FREQ_SIZE]; |
| | | public double[] dct = new double[LOW_FREQ_SIZE * LOW_FREQ_SIZE]; |
| | | } |
| | | |
| | | [MenuItem("程序/相似图片查找")] |
| | | static void OpenWindow() |
| | | { |
| | | var w = GetWindow<SpriteSimilarityFinder>("相似图片查找"); |
| | | w.Show(); |
| | | } |
| | | |
| | | [MenuItem("Assets/查找相似图片", true)] |
| | | static bool ValidateFindSimilar() |
| | | { |
| | | var obj = Selection.activeObject; |
| | | if (obj == null) return false; |
| | | string p = AssetDatabase.GetAssetPath(obj); |
| | | return !string.IsNullOrEmpty(p) && (p.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || p.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || p.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)); |
| | | } |
| | | |
| | | [MenuItem("Assets/查找相似图片", false, 1000)] |
| | | static void OpenAndFind() |
| | | { |
| | | var w = GetWindow<SpriteSimilarityFinder>("相似图片查找"); |
| | | w.Show(); |
| | | w.OnQueryFromSelection(); |
| | | } |
| | | |
| | | static void EnsureCosTable() |
| | | { |
| | | if (_cosTable != null) return; |
| | | lock (_cosTableLock) |
| | | { |
| | | if (_cosTable != null) return; |
| | | var table = new double[HASH_SIZE][]; |
| | | for (int u = 0; u < HASH_SIZE; u++) |
| | | { |
| | | table[u] = new double[HASH_SIZE]; |
| | | for (int x = 0; x < HASH_SIZE; x++) |
| | | { |
| | | table[u][x] = System.Math.Cos(System.Math.PI * (2 * x + 1) * u / (2.0 * HASH_SIZE)); |
| | | } |
| | | } |
| | | _cosTable = table; |
| | | } |
| | | } |
| | | |
| | | static void MapTransform(int transform, int x, int y, int width, int height, out int sx, out int sy) |
| | | { |
| | | if (transform == 1) |
| | | { |
| | | sx = width - 1 - x; |
| | | sy = y; |
| | | } |
| | | else if (transform == 2) |
| | | { |
| | | sx = x; |
| | | sy = height - 1 - y; |
| | | } |
| | | else if (transform == 3) |
| | | { |
| | | sx = width - 1 - x; |
| | | sy = height - 1 - y; |
| | | } |
| | | else if (transform == 4) |
| | | { |
| | | sx = y; |
| | | sy = height - 1 - x; |
| | | } |
| | | else if (transform == 5) |
| | | { |
| | | sx = width - 1 - y; |
| | | sy = x; |
| | | } |
| | | else if (transform == 6) |
| | | { |
| | | sx = y; |
| | | sy = x; |
| | | } |
| | | else if (transform == 7) |
| | | { |
| | | sx = width - 1 - y; |
| | | sy = height - 1 - x; |
| | | } |
| | | else |
| | | { |
| | | sx = x; |
| | | sy = y; |
| | | } |
| | | } |
| | | |
| | | static ulong ComputePHashFromSmall(double[] small, PHashWorkspace workspace) |
| | | { |
| | | EnsureCosTable(); |
| | | |
| | | double[] tmp = workspace.tmp; |
| | | for (int y = 0; y < HASH_SIZE; y++) |
| | | { |
| | | int rowBase = y * HASH_SIZE; |
| | | for (int u = 0; u < LOW_FREQ_SIZE; u++) |
| | | { |
| | | double s = 0; |
| | | double[] cosU = _cosTable[u]; |
| | | for (int x = 0; x < HASH_SIZE; x++) |
| | | s += small[rowBase + x] * cosU[x]; |
| | | tmp[y * LOW_FREQ_SIZE + u] = s; |
| | | } |
| | | } |
| | | |
| | | double[] dct = workspace.dct; |
| | | const double InvSqrt2 = 0.7071067811865475; |
| | | for (int u = 0; u < LOW_FREQ_SIZE; u++) |
| | | { |
| | | double cu = (u == 0) ? InvSqrt2 : 1.0; |
| | | for (int v = 0; v < LOW_FREQ_SIZE; v++) |
| | | { |
| | | double s = 0; |
| | | double[] cosV = _cosTable[v]; |
| | | for (int y = 0; y < HASH_SIZE; y++) |
| | | s += tmp[y * LOW_FREQ_SIZE + u] * cosV[y]; |
| | | double cv = (v == 0) ? InvSqrt2 : 1.0; |
| | | dct[v * LOW_FREQ_SIZE + u] = s * cu * cv; |
| | | } |
| | | } |
| | | |
| | | double sum8 = 0; |
| | | for (int v = 0; v < LOW_FREQ_SIZE; v++) |
| | | for (int u = 0; u < LOW_FREQ_SIZE; u++) |
| | | sum8 += dct[v * LOW_FREQ_SIZE + u]; |
| | | double avg = (sum8 - dct[0]) / 63.0; |
| | | |
| | | ulong hash = 0; |
| | | int bit = 0; |
| | | for (int v = 0; v < LOW_FREQ_SIZE; v++) |
| | | { |
| | | for (int u = 0; u < LOW_FREQ_SIZE; u++) |
| | | { |
| | | if (dct[v * LOW_FREQ_SIZE + u] > avg) |
| | | hash |= (1UL << bit); |
| | | bit++; |
| | | } |
| | | } |
| | | return hash; |
| | | } |
| | | |
| | | static FingerprintContext CreateFingerprintContext(Color32[] pixels, int width, int height) |
| | | { |
| | | var ctx = new FingerprintContext |
| | | { |
| | | pixels = pixels, |
| | | width = width, |
| | | height = height, |
| | | integral = new float[(width + 1) * (height + 1)], |
| | | cropX = 0, |
| | | cropY = 0, |
| | | cropW = width, |
| | | cropH = height, |
| | | shapeMinX = width, |
| | | shapeMinY = height, |
| | | shapeMaxX = -1, |
| | | shapeMaxY = -1 |
| | | }; |
| | | |
| | | EstimateBackgroundColor32(pixels, width, height, out ctx.bgR, out ctx.bgG, out ctx.bgB, out ctx.bgA); |
| | | |
| | | int cropMinX = width; |
| | | int cropMinY = height; |
| | | int cropMaxX = -1; |
| | | int cropMaxY = -1; |
| | | int maxDistSq = 0; |
| | | int stride = width + 1; |
| | | for (int y = 0; y < height; y++) |
| | | { |
| | | float rowSum = 0f; |
| | | int rowBase = y * width; |
| | | int integralRow = (y + 1) * stride; |
| | | int prevIntegralRow = y * stride; |
| | | for (int x = 0; x < width; x++) |
| | | { |
| | | Color32 c = pixels[rowBase + x]; |
| | | if (c.a >= 128) |
| | | { |
| | | if (x < cropMinX) cropMinX = x; |
| | | if (y < cropMinY) cropMinY = y; |
| | | if (x > cropMaxX) cropMaxX = x; |
| | | if (y > cropMaxY) cropMaxY = y; |
| | | int distSq = ColorDistanceSq32(c, ctx.bgR, ctx.bgG, ctx.bgB); |
| | | if (distSq > maxDistSq) |
| | | maxDistSq = distSq; |
| | | } |
| | | |
| | | rowSum += Gray01(c); |
| | | ctx.integral[integralRow + x + 1] = ctx.integral[prevIntegralRow + x + 1] + rowSum; |
| | | } |
| | | } |
| | | |
| | | if (cropMaxX >= cropMinX && cropMaxY >= cropMinY) |
| | | { |
| | | ctx.cropX = cropMinX; |
| | | ctx.cropY = cropMinY; |
| | | ctx.cropW = cropMaxX - cropMinX + 1; |
| | | ctx.cropH = cropMaxY - cropMinY + 1; |
| | | } |
| | | |
| | | int minThresholdSq = (int)(0.015f * 255f * 255f); |
| | | ctx.shapeThresholdSq = (int)(maxDistSq * 0.18f); |
| | | if (ctx.shapeThresholdSq < minThresholdSq) |
| | | ctx.shapeThresholdSq = minThresholdSq; |
| | | |
| | | int fgCount = 0; |
| | | for (int y = 0; y < height; y++) |
| | | { |
| | | int rowBase = y * width; |
| | | for (int x = 0; x < width; x++) |
| | | { |
| | | if (!IsShapeForeground32(ctx, rowBase + x)) |
| | | continue; |
| | | if (x < ctx.shapeMinX) ctx.shapeMinX = x; |
| | | if (y < ctx.shapeMinY) ctx.shapeMinY = y; |
| | | if (x > ctx.shapeMaxX) ctx.shapeMaxX = x; |
| | | if (y > ctx.shapeMaxY) ctx.shapeMaxY = y; |
| | | fgCount++; |
| | | } |
| | | } |
| | | |
| | | ctx.hasShape = fgCount >= 4 && ctx.shapeMaxX >= ctx.shapeMinX && ctx.shapeMaxY >= ctx.shapeMinY; |
| | | return ctx; |
| | | } |
| | | |
| | | static void EstimateBackgroundColor32(Color32[] pixels, int width, int height, out int bgR, out int bgG, out int bgB, out int bgA) |
| | | { |
| | | int sampleW = width < 8 ? 1 : 4; |
| | | int sampleH = height < 8 ? 1 : 4; |
| | | if (sampleW > width) sampleW = width; |
| | | if (sampleH > height) sampleH = height; |
| | | |
| | | int r = 0; |
| | | int g = 0; |
| | | int b = 0; |
| | | int a = 0; |
| | | int count = 0; |
| | | for (int cy = 0; cy < 2; cy++) |
| | | { |
| | | int startY = cy == 0 ? 0 : height - sampleH; |
| | | for (int cx = 0; cx < 2; cx++) |
| | | { |
| | | int startX = cx == 0 ? 0 : width - sampleW; |
| | | for (int y = 0; y < sampleH; y++) |
| | | { |
| | | int rowBase = (startY + y) * width; |
| | | for (int x = 0; x < sampleW; x++) |
| | | { |
| | | Color32 c = pixels[rowBase + startX + x]; |
| | | r += c.r; |
| | | g += c.g; |
| | | b += c.b; |
| | | a += c.a; |
| | | count++; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | if (count == 0) |
| | | { |
| | | bgR = 0; |
| | | bgG = 0; |
| | | bgB = 0; |
| | | bgA = 0; |
| | | return; |
| | | } |
| | | |
| | | bgR = r / count; |
| | | bgG = g / count; |
| | | bgB = b / count; |
| | | bgA = a / count; |
| | | } |
| | | |
| | | static int ColorDistanceSq32(Color32 c, int bgR, int bgG, int bgB) |
| | | { |
| | | int dr = c.r - bgR; |
| | | int dg = c.g - bgG; |
| | | int db = c.b - bgB; |
| | | return dr * dr + dg * dg + db * db; |
| | | } |
| | | |
| | | static bool IsShapeForeground32(FingerprintContext ctx, int index) |
| | | { |
| | | Color32 c = ctx.pixels[index]; |
| | | if (c.a < 128) |
| | | return false; |
| | | if (ctx.bgA < 128) |
| | | return true; |
| | | return ColorDistanceSq32(c, ctx.bgR, ctx.bgG, ctx.bgB) >= ctx.shapeThresholdSq; |
| | | } |
| | | |
| | | static float Gray01(Color32 c) |
| | | { |
| | | if (c.a < 128) |
| | | return 0f; |
| | | return (77f * c.r + 150f * c.g + 29f * c.b) / 65280f; |
| | | } |
| | | |
| | | static float IntegralSum(FingerprintContext ctx, int x0, int y0, int x1, int y1) |
| | | { |
| | | if (x0 < 0) x0 = 0; |
| | | if (y0 < 0) y0 = 0; |
| | | if (x1 > ctx.width) x1 = ctx.width; |
| | | if (y1 > ctx.height) y1 = ctx.height; |
| | | if (x1 <= x0 || y1 <= y0) |
| | | return 0f; |
| | | int stride = ctx.width + 1; |
| | | return ctx.integral[y1 * stride + x1] |
| | | - ctx.integral[y0 * stride + x1] |
| | | - ctx.integral[y1 * stride + x0] |
| | | + ctx.integral[y0 * stride + x0]; |
| | | } |
| | | |
| | | static void BuildSmallGrayFromIntegral(FingerprintContext ctx, int regionX, int regionY, int regionWidth, int regionHeight, double[] small) |
| | | { |
| | | if (regionWidth <= 0 || regionHeight <= 0) |
| | | { |
| | | Array.Clear(small, 0, small.Length); |
| | | return; |
| | | } |
| | | |
| | | for (int by = 0; by < HASH_SIZE; by++) |
| | | { |
| | | int y0 = regionY + by * regionHeight / HASH_SIZE; |
| | | int y1 = regionY + (by + 1) * regionHeight / HASH_SIZE; |
| | | for (int bx = 0; bx < HASH_SIZE; bx++) |
| | | { |
| | | int x0 = regionX + bx * regionWidth / HASH_SIZE; |
| | | int x1 = regionX + (bx + 1) * regionWidth / HASH_SIZE; |
| | | int area = (x1 - x0) * (y1 - y0); |
| | | small[by * HASH_SIZE + bx] = area > 0 ? IntegralSum(ctx, x0, y0, x1, y1) / area : 0.0; |
| | | } |
| | | } |
| | | } |
| | | |
| | | static void BuildTransformedSmall(double[] source, int transform, double[] dest) |
| | | { |
| | | for (int y = 0; y < HASH_SIZE; y++) |
| | | { |
| | | for (int x = 0; x < HASH_SIZE; x++) |
| | | { |
| | | int sx; |
| | | int sy; |
| | | MapTransform(transform, x, y, HASH_SIZE, HASH_SIZE, out sx, out sy); |
| | | dest[y * HASH_SIZE + x] = source[sy * HASH_SIZE + sx]; |
| | | } |
| | | } |
| | | } |
| | | |
| | | static ulong ComputeEdgeHashFromSmall(double[] small, PHashWorkspace workspace) |
| | | { |
| | | double[] edge = workspace.edgeSmall; |
| | | for (int y = 0; y < HASH_SIZE; y++) |
| | | { |
| | | for (int x = 0; x < HASH_SIZE; x++) |
| | | { |
| | | int index = y * HASH_SIZE + x; |
| | | double gx = (x + 1 < HASH_SIZE) ? System.Math.Abs(small[index + 1] - small[index]) : 0.0; |
| | | double gy = (y + 1 < HASH_SIZE) ? System.Math.Abs(small[index + HASH_SIZE] - small[index]) : 0.0; |
| | | edge[index] = gx + gy; |
| | | } |
| | | } |
| | | return ComputePHashFromSmall(edge, workspace); |
| | | } |
| | | |
| | | static ulong ComputeShapeHash(FingerprintContext ctx) |
| | | { |
| | | if (!ctx.hasShape) |
| | | return 0UL; |
| | | |
| | | int shapeW = ctx.shapeMaxX - ctx.shapeMinX + 1; |
| | | int shapeH = ctx.shapeMaxY - ctx.shapeMinY + 1; |
| | | ulong hash = 0UL; |
| | | int bit = 0; |
| | | for (int by = 0; by < SHAPE_HASH_SIZE; by++) |
| | | { |
| | | int y0 = ctx.shapeMinY + by * shapeH / SHAPE_HASH_SIZE; |
| | | int y1 = ctx.shapeMinY + (by + 1) * shapeH / SHAPE_HASH_SIZE; |
| | | if (y1 <= y0) y1 = y0 + 1; |
| | | for (int bx = 0; bx < SHAPE_HASH_SIZE; bx++) |
| | | { |
| | | int x0 = ctx.shapeMinX + bx * shapeW / SHAPE_HASH_SIZE; |
| | | int x1 = ctx.shapeMinX + (bx + 1) * shapeW / SHAPE_HASH_SIZE; |
| | | if (x1 <= x0) x1 = x0 + 1; |
| | | |
| | | int total = 0; |
| | | int hits = 0; |
| | | for (int y = y0; y < y1 && y <= ctx.shapeMaxY; y++) |
| | | { |
| | | int rowBase = y * ctx.width; |
| | | for (int x = x0; x < x1 && x <= ctx.shapeMaxX; x++) |
| | | { |
| | | total++; |
| | | if (IsShapeForeground32(ctx, rowBase + x)) |
| | | hits++; |
| | | } |
| | | } |
| | | |
| | | if (total > 0 && hits * 5 >= total) |
| | | hash |= (1UL << bit); |
| | | bit++; |
| | | } |
| | | } |
| | | return hash; |
| | | } |
| | | |
| | | static ulong ComputeNineSliceHash(FingerprintContext ctx) |
| | | { |
| | | if (!ctx.hasShape) |
| | | return 0UL; |
| | | |
| | | int shapeW = ctx.shapeMaxX - ctx.shapeMinX + 1; |
| | | int shapeH = ctx.shapeMaxY - ctx.shapeMinY + 1; |
| | | ulong hash = 0UL; |
| | | int bit = 0; |
| | | for (int by = 0; by < SHAPE_HASH_SIZE; by++) |
| | | { |
| | | int y0 = ctx.shapeMinY + by * shapeH / SHAPE_HASH_SIZE; |
| | | int y1 = ctx.shapeMinY + (by + 1) * shapeH / SHAPE_HASH_SIZE; |
| | | if (y1 <= y0) y1 = y0 + 1; |
| | | for (int bx = 0; bx < SHAPE_HASH_SIZE; bx++) |
| | | { |
| | | bool borderCell = bx == 0 || bx == SHAPE_HASH_SIZE - 1 || by == 0 || by == SHAPE_HASH_SIZE - 1; |
| | | if (!borderCell) |
| | | { |
| | | bit++; |
| | | continue; |
| | | } |
| | | |
| | | int x0 = ctx.shapeMinX + bx * shapeW / SHAPE_HASH_SIZE; |
| | | int x1 = ctx.shapeMinX + (bx + 1) * shapeW / SHAPE_HASH_SIZE; |
| | | if (x1 <= x0) x1 = x0 + 1; |
| | | |
| | | int total = 0; |
| | | int hits = 0; |
| | | for (int y = y0; y < y1 && y <= ctx.shapeMaxY; y++) |
| | | { |
| | | int rowBase = y * ctx.width; |
| | | for (int x = x0; x < x1 && x <= ctx.shapeMaxX; x++) |
| | | { |
| | | total++; |
| | | if (IsShapeForeground32(ctx, rowBase + x)) |
| | | hits++; |
| | | } |
| | | } |
| | | |
| | | if (total > 0 && hits * 4 >= total) |
| | | hash |= (1UL << bit); |
| | | bit++; |
| | | } |
| | | } |
| | | return hash; |
| | | } |
| | | |
| | | static ulong ComputeNineSliceColorHash(FingerprintContext ctx) |
| | | { |
| | | if (!ctx.hasShape) |
| | | return 0UL; |
| | | |
| | | int shapeW = ctx.shapeMaxX - ctx.shapeMinX + 1; |
| | | int shapeH = ctx.shapeMaxY - ctx.shapeMinY + 1; |
| | | int radius = System.Math.Min(shapeW, shapeH) / 64; |
| | | if (radius < 1) |
| | | radius = 1; |
| | | if (radius > 4) |
| | | radius = 4; |
| | | |
| | | ulong hash = 0UL; |
| | | for (int i = 0; i < 16; i++) |
| | | { |
| | | int edge = i / 4; |
| | | int slot = i % 4; |
| | | int sampleX; |
| | | int sampleY; |
| | | if (edge == 0) |
| | | { |
| | | sampleX = ctx.shapeMinX + (slot * 2 + 1) * shapeW / 8; |
| | | sampleY = ctx.shapeMinY; |
| | | } |
| | | else if (edge == 1) |
| | | { |
| | | sampleX = ctx.shapeMaxX; |
| | | sampleY = ctx.shapeMinY + (slot * 2 + 1) * shapeH / 8; |
| | | } |
| | | else if (edge == 2) |
| | | { |
| | | sampleX = ctx.shapeMinX + (slot * 2 + 1) * shapeW / 8; |
| | | sampleY = ctx.shapeMaxY; |
| | | } |
| | | else |
| | | { |
| | | sampleX = ctx.shapeMinX; |
| | | sampleY = ctx.shapeMinY + (slot * 2 + 1) * shapeH / 8; |
| | | } |
| | | |
| | | int code = SampleColorCode32(ctx, sampleX, sampleY, radius); |
| | | hash |= ((ulong)code & 0xFUL) << (i * 4); |
| | | } |
| | | return hash; |
| | | } |
| | | |
| | | static int SampleColorCode32(FingerprintContext ctx, int centerX, int centerY, int radius) |
| | | { |
| | | int r = 0; |
| | | int g = 0; |
| | | int b = 0; |
| | | int a = 0; |
| | | int count = 0; |
| | | int startX = centerX - radius; |
| | | int endX = centerX + radius; |
| | | int startY = centerY - radius; |
| | | int endY = centerY + radius; |
| | | if (startX < 0) startX = 0; |
| | | if (startY < 0) startY = 0; |
| | | if (endX >= ctx.width) endX = ctx.width - 1; |
| | | if (endY >= ctx.height) endY = ctx.height - 1; |
| | | |
| | | for (int y = startY; y <= endY; y++) |
| | | { |
| | | int rowBase = y * ctx.width; |
| | | for (int x = startX; x <= endX; x++) |
| | | { |
| | | Color32 c = ctx.pixels[rowBase + x]; |
| | | if (c.a < 26) |
| | | continue; |
| | | r += c.r; |
| | | g += c.g; |
| | | b += c.b; |
| | | a += c.a; |
| | | count++; |
| | | } |
| | | } |
| | | |
| | | if (count == 0) |
| | | return 0; |
| | | r /= count; |
| | | g /= count; |
| | | b /= count; |
| | | a /= count; |
| | | if (a < 64) |
| | | return 0; |
| | | |
| | | int luma = (77 * r + 150 * g + 29 * b) >> 8; |
| | | int code = 0; |
| | | if (luma >= 64) code |= 1; |
| | | if (luma >= 140) code |= 2; |
| | | if (r >= g) code |= 4; |
| | | if (b >= g) code |= 8; |
| | | return code; |
| | | } |
| | | |
| | | static FingerPrint CreateFingerprint(Texture2D tex, string assetPath, long size, long mtime, string md5, bool includePatch) |
| | | { |
| | | return CreateFingerprint(tex.GetPixels32(), tex.width, tex.height, assetPath, size, mtime, md5, includePatch); |
| | | } |
| | | |
| | | static FingerPrint CreateFingerprint(Color32[] pixels, int width, int height, string assetPath, long size, long mtime, string md5, bool includePatch) |
| | | { |
| | | FingerprintContext ctx = CreateFingerprintContext(pixels, width, height); |
| | | PHashWorkspace workspace = new PHashWorkspace(); |
| | | |
| | | var fp = new FingerPrint |
| | | { |
| | | path = assetPath, |
| | | size = size, |
| | | mtime = mtime, |
| | | width = width, |
| | | height = height, |
| | | md5 = md5, |
| | | ver = ALGO_VER, |
| | | transformHashLo = new long[TRANSFORM_COUNT], |
| | | transformHashHi = new long[TRANSFORM_COUNT], |
| | | patchHashLo = includePatch ? new long[PATCH_COUNT] : null, |
| | | patchHashHi = includePatch ? new long[PATCH_COUNT] : null, |
| | | patchCount = includePatch ? PATCH_COUNT : 0 |
| | | }; |
| | | |
| | | EnsureCosTable(); |
| | | |
| | | double[] fullSmall = workspace.fullSmall; |
| | | BuildSmallGrayFromIntegral(ctx, 0, 0, width, height, fullSmall); |
| | | |
| | | ulong hash = ComputePHashFromSmall(fullSmall, workspace); |
| | | ulong cropHash = (ctx.cropX == 0 && ctx.cropY == 0 && ctx.cropW == width && ctx.cropH == height) |
| | | ? hash |
| | | : ComputePHashFromIntegral(ctx, ctx.cropX, ctx.cropY, ctx.cropW, ctx.cropH, workspace); |
| | | ulong edgeHash = ComputeEdgeHashFromSmall(fullSmall, workspace); |
| | | ulong shapeHash = ComputeShapeHash(ctx); |
| | | ulong nineSliceHash = ComputeNineSliceHash(ctx); |
| | | ulong nineSliceColorHash = ComputeNineSliceColorHash(ctx); |
| | | |
| | | fp.SetHash(hash); |
| | | fp.SetCropHash(cropHash); |
| | | fp.SetEdgeHash(edgeHash); |
| | | fp.SetShapeHash(shapeHash); |
| | | fp.SetNineSliceHash(nineSliceHash); |
| | | fp.SetNineSliceColorHash(nineSliceColorHash); |
| | | |
| | | fp.SetTransformHash(0, hash); |
| | | double[] transformedSmall = workspace.transformedSmall; |
| | | for (int i = 1; i < TRANSFORM_COUNT; i++) |
| | | { |
| | | BuildTransformedSmall(fullSmall, i, transformedSmall); |
| | | fp.SetTransformHash(i, ComputePHashFromSmall(transformedSmall, workspace)); |
| | | } |
| | | |
| | | if (includePatch) |
| | | { |
| | | for (int index = 0; index < PATCH_COUNT; index++) |
| | | { |
| | | int px = index % PATCH_GRID; |
| | | int py = index / PATCH_GRID; |
| | | int patchX0 = ctx.cropX + px * ctx.cropW / PATCH_GRID; |
| | | int patchX1 = ctx.cropX + (px + 1) * ctx.cropW / PATCH_GRID; |
| | | int patchY0 = ctx.cropY + py * ctx.cropH / PATCH_GRID; |
| | | int patchY1 = ctx.cropY + (py + 1) * ctx.cropH / PATCH_GRID; |
| | | fp.SetPatchHash(index, ComputePHashFromIntegral(ctx, patchX0, patchY0, patchX1 - patchX0, patchY1 - patchY0, workspace)); |
| | | } |
| | | } |
| | | return fp; |
| | | } |
| | | |
| | | static ulong ComputePHashFromIntegral(FingerprintContext ctx, int regionX, int regionY, int regionWidth, int regionHeight, PHashWorkspace workspace) |
| | | { |
| | | double[] small = workspace.small; |
| | | BuildSmallGrayFromIntegral(ctx, regionX, regionY, regionWidth, regionHeight, small); |
| | | return ComputePHashFromSmall(small, workspace); |
| | | } |
| | | |
| | | static string ComputeMD5(string filePath) |
| | | { |
| | | return ComputeMD5(File.ReadAllBytes(filePath)); |
| | | } |
| | | |
| | | static string ComputeMD5(byte[] bytes) |
| | | { |
| | | using (var md5 = System.Security.Cryptography.MD5.Create()) |
| | | { |
| | | byte[] hash = md5.ComputeHash(bytes); |
| | | return BitConverter.ToString(hash).Replace("-", "").ToLower(); |
| | | } |
| | | } |
| | | |
| | | static Texture2D LoadPngAsTexture(string absolutePath) |
| | | { |
| | | byte[] bytes = File.ReadAllBytes(absolutePath); |
| | | return LoadPngAsTexture(bytes); |
| | | } |
| | | |
| | | static Texture2D LoadPngAsTexture(byte[] bytes) |
| | | { |
| | | var tex = new Texture2D(2, 2, TextureFormat.RGBA32, false); |
| | | if (ImageConversion.LoadImage(tex, bytes, false)) |
| | | return tex; |
| | | DestroyImmediate(tex); |
| | | return null; |
| | | } |
| | | |
| | | static string ToAbsolutePath(string assetPath) |
| | | { |
| | | return Application.dataPath.Substring(0, Application.dataPath.Length - "Assets".Length) + assetPath; |
| | | } |
| | | |
| | | static string FormatMetric(int value) |
| | | { |
| | | return value >= 0 ? value.ToString() : "-"; |
| | | } |
| | | |
| | | static GUIStyle CreateResultNameStyle() |
| | | { |
| | | var style = new GUIStyle(EditorStyles.label); |
| | | style.fontStyle = FontStyle.Bold; |
| | | style.normal.textColor = new Color(0.35f, 0.78f, 1f); |
| | | return style; |
| | | } |
| | | |
| | | static GUIStyle CreateResultMetaStyle() |
| | | { |
| | | var style = new GUIStyle(EditorStyles.label); |
| | | style.normal.textColor = new Color(0.78f, 0.78f, 0.78f); |
| | | return style; |
| | | } |
| | | |
| | | static void DrawResultSeparator() |
| | | { |
| | | Rect rect = EditorGUILayout.GetControlRect(false, 1f); |
| | | EditorGUI.DrawRect(rect, new Color(0.6f, 0.6f, 0.6f, 0.45f)); |
| | | } |
| | | |
| | | void DrawResultRow(Rect rowRect, SearchResult r, GUIStyle resultNameStyle, GUIStyle resultMetaStyle, bool drawSeparator) |
| | | { |
| | | if (drawSeparator) |
| | | EditorGUI.DrawRect(new Rect(rowRect.x, rowRect.y + 2f, rowRect.width, 1f), new Color(0.6f, 0.6f, 0.6f, 0.45f)); |
| | | |
| | | Rect contentRect = new Rect(rowRect.x, rowRect.y + 9f, rowRect.width, 64f); |
| | | Rect thumbRect = new Rect(contentRect.x, contentRect.y, 64f, 64f); |
| | | |
| | | Texture2D thumb; |
| | | if (!_thumbCache.TryGetValue(r.path, out thumb)) |
| | | { |
| | | thumb = AssetDatabase.LoadAssetAtPath<Texture2D>(r.path); |
| | | _thumbCache[r.path] = thumb; |
| | | } |
| | | if (thumb != null) |
| | | GUI.DrawTexture(thumbRect, thumb, ScaleMode.ScaleToFit); |
| | | else |
| | | GUI.Box(thumbRect, "?"); |
| | | |
| | | float buttonWidth = 50f; |
| | | float buttonHeight = 22f; |
| | | float buttonGap = 4f; |
| | | Rect copyRect = new Rect(contentRect.xMax - buttonWidth * 2f - buttonGap, contentRect.y + 20f, buttonWidth, buttonHeight); |
| | | Rect locateRect = new Rect(contentRect.xMax - buttonWidth, contentRect.y + 20f, buttonWidth, buttonHeight); |
| | | |
| | | float textX = thumbRect.xMax + 8f; |
| | | float textWidth = locateRect.x - textX - 8f; |
| | | if (textWidth > 20f) |
| | | { |
| | | GUI.Label(new Rect(textX, contentRect.y, textWidth, 18f), Path.GetFileName(r.path), resultNameStyle); |
| | | |
| | | Color origColor = GUI.contentColor; |
| | | if (r.matchType == SimilarityMatchType.Exact) GUI.contentColor = Color.red; |
| | | else if (r.matchType == SimilarityMatchType.VisualSame) GUI.contentColor = Color.yellow; |
| | | else if (r.matchType == SimilarityMatchType.NineSliceSimilar) GUI.contentColor = Color.green; |
| | | else if (r.matchType == SimilarityMatchType.LocalSimilar) GUI.contentColor = Color.cyan; |
| | | else GUI.contentColor = Color.white; |
| | | GUI.Label(new Rect(textX, contentRect.y + 19f, textWidth, 18f), $"分数:{r.score} {r.label}"); |
| | | GUI.contentColor = origColor; |
| | | |
| | | GUI.Label(new Rect(textX, contentRect.y + 38f, textWidth, 18f), $"整体:{FormatMetric(r.dist)} 形状:{FormatMetric(r.shapeDist)} 九宫:{FormatMetric(r.nineSliceDist)} 九色:{FormatMetric(r.nineSliceColorDist)} 主体:{FormatMetric(r.cropDist)} 变换:{FormatMetric(r.transformDist)} 边缘:{FormatMetric(r.edgeDist)} 局部:{FormatMetric(r.localHits)}", resultMetaStyle); |
| | | GUI.Label(new Rect(textX, contentRect.y + 55f, textWidth, 18f), "图集: " + r.dirName, resultMetaStyle); |
| | | } |
| | | |
| | | if (GUI.Button(locateRect, "定位")) |
| | | { |
| | | var asset = AssetDatabase.LoadAssetAtPath(r.path, typeof(UnityEngine.Object)); |
| | | if (asset != null && Selection.activeObject != asset) |
| | | _ignoreNextSelectionObject = asset; |
| | | EditorGUIUtility.PingObject(asset); |
| | | Selection.activeObject = asset; |
| | | } |
| | | if (GUI.Button(copyRect, "复制")) |
| | | { |
| | | GUIUtility.systemCopyBuffer = Path.GetFileNameWithoutExtension(r.path); |
| | | } |
| | | } |
| | | |
| | | static int Hamming(ulong a, ulong b) |
| | | { |
| | | EnsureBitCountTable(); |
| | | ulong x = a ^ b; |
| | | return _bitCountTable[(int)(x & 0xFFUL)] |
| | | + _bitCountTable[(int)((x >> 8) & 0xFFUL)] |
| | | + _bitCountTable[(int)((x >> 16) & 0xFFUL)] |
| | | + _bitCountTable[(int)((x >> 24) & 0xFFUL)] |
| | | + _bitCountTable[(int)((x >> 32) & 0xFFUL)] |
| | | + _bitCountTable[(int)((x >> 40) & 0xFFUL)] |
| | | + _bitCountTable[(int)((x >> 48) & 0xFFUL)] |
| | | + _bitCountTable[(int)((x >> 56) & 0xFFUL)]; |
| | | } |
| | | |
| | | static int HammingLimited(ulong a, ulong b, int threshold) |
| | | { |
| | | EnsureBitCountTable(); |
| | | ulong x = a ^ b; |
| | | int cnt = 0; |
| | | for (int shift = 0; shift < 64; shift += 8) |
| | | { |
| | | cnt += _bitCountTable[(int)((x >> shift) & 0xFFUL)]; |
| | | if (cnt > threshold) |
| | | return cnt; |
| | | } |
| | | return cnt; |
| | | } |
| | | |
| | | static int WeightedDistanceScore(int distance, int maxDistance, int weight) |
| | | { |
| | | if (distance <= 0) |
| | | return weight; |
| | | if (distance >= maxDistance) |
| | | return 0; |
| | | return (maxDistance - distance) * weight / maxDistance; |
| | | } |
| | | |
| | | static int WeightedHitScore(int hits, int maxHits, int weight) |
| | | { |
| | | if (hits <= 0) |
| | | return 0; |
| | | if (hits >= maxHits) |
| | | return weight; |
| | | return hits * weight / maxHits; |
| | | } |
| | | |
| | | static int ComputeSimilarityScore(int dist, int cropDist, int transformDist, int edgeDist, int localHits, int shapeDist, int nineSliceDist, int nineSliceColorDist, bool nineSliceSizeRelation) |
| | | { |
| | | int score = 0; |
| | | score += WeightedDistanceScore(dist, SCORE_DIST_MAX, SCORE_WEIGHT_DIST); |
| | | score += WeightedDistanceScore(cropDist, SCORE_DIST_MAX, SCORE_WEIGHT_CROP); |
| | | score += WeightedDistanceScore(transformDist, SCORE_DIST_MAX, SCORE_WEIGHT_TRANSFORM); |
| | | score += WeightedDistanceScore(edgeDist, SCORE_DIST_MAX, SCORE_WEIGHT_EDGE); |
| | | score += WeightedHitScore(localHits, PATCH_COUNT, SCORE_WEIGHT_LOCAL); |
| | | score += WeightedDistanceScore(shapeDist, SCORE_SHAPE_MAX, SCORE_WEIGHT_SHAPE); |
| | | score += WeightedDistanceScore(nineSliceDist, SCORE_NINE_SLICE_MAX, SCORE_WEIGHT_NINE_SLICE); |
| | | score += WeightedDistanceScore(nineSliceColorDist, SCORE_NINE_SLICE_COLOR_MAX, SCORE_WEIGHT_NINE_SLICE_COLOR); |
| | | if (nineSliceSizeRelation) |
| | | score += SCORE_WEIGHT_NINE_SLICE_SIZE; |
| | | if (score > 100) |
| | | return 100; |
| | | return score; |
| | | } |
| | | |
| | | static int ComputeNineSliceBonus(int shapeDist, int nineSliceDist, int nineSliceColorDist, bool nineSliceSizeRelation, long queryArea, long fpArea, int fpWidth, int fpHeight) |
| | | { |
| | | if (!nineSliceSizeRelation) |
| | | return 0; |
| | | if (shapeDist > NINE_SLICE_SHAPE_THRESHOLD || nineSliceDist > NINE_SLICE_THRESHOLD || nineSliceColorDist > NINE_SLICE_COLOR_THRESHOLD) |
| | | return 0; |
| | | |
| | | int score = SCORE_NINE_SLICE_BONUS_BASE; |
| | | if (shapeDist <= 1 && nineSliceDist <= 2) |
| | | score += SCORE_NINE_SLICE_BONUS_STRUCTURE; |
| | | if (nineSliceColorDist <= 4) |
| | | score += SCORE_NINE_SLICE_BONUS_COLOR; |
| | | if (queryArea >= NINE_SLICE_SMALL_AREA && fpArea <= NINE_SLICE_SMALL_AREA) |
| | | score += SCORE_NINE_SLICE_BONUS_SMALL_SOURCE; |
| | | if (queryArea >= NINE_SLICE_SMALL_AREA && fpArea <= NINE_SLICE_TINY_SOURCE_AREA && IsBalancedSize(fpWidth, fpHeight)) |
| | | score += SCORE_NINE_SLICE_BONUS_TINY_SOURCE; |
| | | return score; |
| | | } |
| | | |
| | | static bool IsBalancedSize(int width, int height) |
| | | { |
| | | if (width <= 0 || height <= 0) |
| | | return false; |
| | | int small = width < height ? width : height; |
| | | int large = width < height ? height : width; |
| | | return large <= small * 2; |
| | | } |
| | | |
| | | static bool IsSameAssetDirectory(string a, string b) |
| | | { |
| | | if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) |
| | | return false; |
| | | int aSlash = a.LastIndexOf('/'); |
| | | int bSlash = b.LastIndexOf('/'); |
| | | if (aSlash != bSlash) |
| | | return false; |
| | | if (aSlash < 0) |
| | | return true; |
| | | return string.CompareOrdinal(a, 0, b, 0, aSlash) == 0; |
| | | } |
| | | |
| | | static void EnsureBitCountTable() |
| | | { |
| | | if (_bitCountTable != null) return; |
| | | |
| | | var table = new byte[256]; |
| | | for (int i = 0; i < table.Length; i++) |
| | | { |
| | | int count = 0; |
| | | for (int bit = 0; bit < 8; bit++) |
| | | { |
| | | if (((i >> bit) & 1) != 0) |
| | | count++; |
| | | } |
| | | table[i] = (byte)count; |
| | | } |
| | | _bitCountTable = table; |
| | | } |
| | | |
| | | static int MinTransformDistance(FingerPrint a, FingerPrint b) |
| | | { |
| | | int best = Hamming(a.GetHash(), b.GetHash()); |
| | | if (a.transformHashLo == null || a.transformHashHi == null || b.transformHashLo == null || b.transformHashHi == null) |
| | | return best; |
| | | |
| | | ulong aHash = a.GetHash(); |
| | | ulong bHash = b.GetHash(); |
| | | for (int i = 0; i < TRANSFORM_COUNT; i++) |
| | | { |
| | | int d1 = Hamming(aHash, b.GetTransformHash(i)); |
| | | if (d1 < best) best = d1; |
| | | |
| | | int d2 = Hamming(a.GetTransformHash(i), bHash); |
| | | if (d2 < best) best = d2; |
| | | } |
| | | return best; |
| | | } |
| | | |
| | | static int MinTransformDistanceLimited(FingerPrint a, FingerPrint b, int threshold) |
| | | { |
| | | int best = HammingLimited(a.GetHash(), b.GetHash(), threshold); |
| | | if (best <= threshold) |
| | | return best; |
| | | if (a.transformHashLo == null || a.transformHashHi == null || b.transformHashLo == null || b.transformHashHi == null) |
| | | return best; |
| | | |
| | | ulong aHash = a.GetHash(); |
| | | ulong bHash = b.GetHash(); |
| | | for (int i = 0; i < TRANSFORM_COUNT; i++) |
| | | { |
| | | int d1 = HammingLimited(aHash, b.GetTransformHash(i), threshold); |
| | | if (d1 < best) best = d1; |
| | | if (best <= threshold) return best; |
| | | |
| | | int d2 = HammingLimited(a.GetTransformHash(i), bHash, threshold); |
| | | if (d2 < best) best = d2; |
| | | if (best <= threshold) return best; |
| | | } |
| | | return best; |
| | | } |
| | | |
| | | static bool IsWeakPatchHash(ulong hash) |
| | | { |
| | | return hash == 0UL || hash == ulong.MaxValue; |
| | | } |
| | | |
| | | static int CountLocalPatchHits(FingerPrint a, FingerPrint b, int patchThreshold) |
| | | { |
| | | if (a.patchHashLo == null || a.patchHashHi == null || b.patchHashLo == null || b.patchHashHi == null) |
| | | return 0; |
| | | |
| | | int aCount = a.patchCount; |
| | | int bCount = b.patchCount; |
| | | if (aCount > PATCH_COUNT) aCount = PATCH_COUNT; |
| | | if (bCount > PATCH_COUNT) bCount = PATCH_COUNT; |
| | | |
| | | int hits = 0; |
| | | for (int i = 0; i < aCount; i++) |
| | | { |
| | | ulong ah = a.GetPatchHash(i); |
| | | if (IsWeakPatchHash(ah)) |
| | | continue; |
| | | |
| | | bool matched = false; |
| | | for (int j = 0; j < bCount; j++) |
| | | { |
| | | ulong bh = b.GetPatchHash(j); |
| | | if (IsWeakPatchHash(bh)) |
| | | continue; |
| | | if (HammingLimited(ah, bh, patchThreshold) <= patchThreshold) |
| | | { |
| | | matched = true; |
| | | break; |
| | | } |
| | | } |
| | | if (matched) |
| | | hits++; |
| | | } |
| | | return hits; |
| | | } |
| | | |
| | | static bool CacheNeedsRebuild(FingerPrintDB db) |
| | | { |
| | | if (db == null || db.items == null) |
| | | return false; |
| | | for (int i = 0; i < db.items.Count; i++) |
| | | { |
| | | if (db.items[i] != null && (db.items[i].ver != ALGO_VER || !HasPatchFingerprint(db.items[i]))) |
| | | return true; |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | static bool HasNineSliceSizeRelation(FingerPrint a, FingerPrint b) |
| | | { |
| | | if (a == null || b == null) |
| | | return false; |
| | | if (a.width <= 0 || a.height <= 0 || b.width <= 0 || b.height <= 0) |
| | | return false; |
| | | |
| | | long areaA = (long)a.width * a.height; |
| | | long areaB = (long)b.width * b.height; |
| | | long smallArea = areaA < areaB ? areaA : areaB; |
| | | long largeArea = areaA < areaB ? areaB : areaA; |
| | | if (smallArea <= 0) |
| | | return false; |
| | | if (largeArea >= smallArea * NINE_SLICE_MIN_AREA_RATIO) |
| | | return true; |
| | | |
| | | int smallW = a.width < b.width ? a.width : b.width; |
| | | int largeW = a.width < b.width ? b.width : a.width; |
| | | int smallH = a.height < b.height ? a.height : b.height; |
| | | int largeH = a.height < b.height ? b.height : a.height; |
| | | if (smallW <= 0 || smallH <= 0) |
| | | return false; |
| | | if (largeW >= smallW * NINE_SLICE_MIN_AXIS_RATIO && largeH >= smallH) |
| | | return true; |
| | | return largeH >= smallH * NINE_SLICE_MIN_AXIS_RATIO && largeW >= smallW; |
| | | } |
| | | |
| | | static bool HasPatchFingerprint(FingerPrint fp) |
| | | { |
| | | return fp != null && fp.patchHashLo != null && fp.patchHashHi != null && fp.patchCount >= PATCH_COUNT; |
| | | } |
| | | |
| | | void LoadCache() |
| | | { |
| | | _cacheByPath = new Dictionary<string, FingerPrint>(); |
| | | string cacheFile = ToAbsolutePath(CACHE_PATH); |
| | | if (!File.Exists(cacheFile)) |
| | | { |
| | | _db = new FingerPrintDB(); |
| | | return; |
| | | } |
| | | try |
| | | { |
| | | string json = File.ReadAllText(cacheFile); |
| | | _db = JsonUtility.FromJson<FingerPrintDB>(json); |
| | | if (_db == null) |
| | | { |
| | | _db = new FingerPrintDB(); |
| | | Debug.LogWarning("指纹库解析失败,已重置为空库"); |
| | | return; |
| | | } |
| | | if (_db.items == null) |
| | | _db.items = new List<FingerPrint>(); |
| | | foreach (var fp in _db.items) |
| | | { |
| | | if (fp != null && !string.IsNullOrEmpty(fp.path)) |
| | | _cacheByPath[fp.path] = fp; |
| | | } |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | _db = new FingerPrintDB(); |
| | | Debug.LogWarning("加载指纹库失败: " + e.Message); |
| | | } |
| | | if (_db != null && _db.items.Count > 0 && _db.items[0].ver != ALGO_VER) |
| | | Debug.LogWarning("检测到旧版指纹库(算法版本不符),将在下次构建/查询时自动全量重建"); |
| | | } |
| | | |
| | | void SaveCache() |
| | | { |
| | | string dir = Path.GetDirectoryName(ToAbsolutePath(CACHE_PATH)); |
| | | if (!Directory.Exists(dir)) |
| | | Directory.CreateDirectory(dir); |
| | | string json = JsonUtility.ToJson(_db, true); |
| | | File.WriteAllText(ToAbsolutePath(CACHE_PATH), json); |
| | | } |
| | | |
| | | static int GetMaxFingerprintBuildJobs() |
| | | { |
| | | int workers = Environment.ProcessorCount * MAX_BUILD_JOB_MULTIPLIER; |
| | | if (workers < 1) |
| | | workers = 1; |
| | | if (workers > MAX_BUILD_JOB_LIMIT) |
| | | workers = MAX_BUILD_JOB_LIMIT; |
| | | return workers; |
| | | } |
| | | |
| | | static long EstimateFingerprintTaskBytes(long byteLength) |
| | | { |
| | | long estimate = byteLength * 32L + 512L * 1024L; |
| | | long minEstimate = 2L * 1024L * 1024L; |
| | | if (estimate < minEstimate) |
| | | estimate = minEstimate; |
| | | return estimate; |
| | | } |
| | | |
| | | static Task<FingerprintBuildResult> StartDecodedFingerprintBuildTask(int index, string assetPath, long size, long mtime, byte[] bytes, Color32[] pixels, int width, int height, bool includePatch) |
| | | { |
| | | return Task.Run(() => |
| | | { |
| | | var result = new FingerprintBuildResult |
| | | { |
| | | index = index, |
| | | assetPath = assetPath |
| | | }; |
| | | var cpuWatch = new System.Diagnostics.Stopwatch(); |
| | | try |
| | | { |
| | | var md5Watch = System.Diagnostics.Stopwatch.StartNew(); |
| | | string md5 = ComputeMD5(bytes); |
| | | bytes = null; |
| | | md5Watch.Stop(); |
| | | result.md5Ticks = md5Watch.ElapsedTicks; |
| | | |
| | | cpuWatch.Start(); |
| | | result.fp = CreateFingerprint(pixels, width, height, assetPath, size, mtime, md5, includePatch); |
| | | pixels = null; |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | result.error = e.Message; |
| | | } |
| | | cpuWatch.Stop(); |
| | | result.cpuTicks = cpuWatch.ElapsedTicks; |
| | | return result; |
| | | }); |
| | | } |
| | | |
| | | static void CollectFingerprintTask(Task<FingerprintBuildResult> task, FingerPrint[] fingerprints, ref int recomputeCount, ref long md5Ticks, ref long cpuTicks) |
| | | { |
| | | try |
| | | { |
| | | FingerprintBuildResult result = task.Result; |
| | | if (result == null) |
| | | return; |
| | | md5Ticks += result.md5Ticks; |
| | | cpuTicks += result.cpuTicks; |
| | | if (result.fp != null) |
| | | { |
| | | fingerprints[result.index] = result.fp; |
| | | recomputeCount++; |
| | | } |
| | | else if (!string.IsNullOrEmpty(result.error)) |
| | | { |
| | | Debug.LogWarning("指纹计算失败,已跳过: " + result.assetPath + " " + result.error); |
| | | } |
| | | } |
| | | catch (Exception e) |
| | | { |
| | | Debug.LogWarning("指纹计算任务失败: " + e.Message); |
| | | } |
| | | } |
| | | |
| | | static void WaitForOneFingerprintTask(List<PendingFingerprintTask> tasks, FingerPrint[] fingerprints, ref int recomputeCount, ref long md5Ticks, ref long cpuTicks, ref long pendingBytes) |
| | | { |
| | | if (tasks.Count == 0) |
| | | return; |
| | | |
| | | int completedIndex = -1; |
| | | for (int i = 0; i < tasks.Count; i++) |
| | | { |
| | | if (tasks[i].task.IsCompleted) |
| | | { |
| | | completedIndex = i; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (completedIndex < 0) |
| | | { |
| | | Task<FingerprintBuildResult>[] snapshot = new Task<FingerprintBuildResult>[tasks.Count]; |
| | | for (int i = 0; i < tasks.Count; i++) |
| | | snapshot[i] = tasks[i].task; |
| | | completedIndex = Task.WaitAny(snapshot); |
| | | } |
| | | |
| | | PendingFingerprintTask finished = tasks[completedIndex]; |
| | | pendingBytes -= finished.estimatedBytes; |
| | | if (pendingBytes < 0) |
| | | pendingBytes = 0; |
| | | int lastIndex = tasks.Count - 1; |
| | | tasks[completedIndex] = tasks[lastIndex]; |
| | | tasks.RemoveAt(lastIndex); |
| | | CollectFingerprintTask(finished.task, fingerprints, ref recomputeCount, ref md5Ticks, ref cpuTicks); |
| | | } |
| | | |
| | | static string FormatElapsedMs(long ticks) |
| | | { |
| | | double ms = ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency; |
| | | return ms.ToString("0.0") + "ms"; |
| | | } |
| | | |
| | | void BuildCache(bool forceRebuild) |
| | | { |
| | | string absRoot = ToAbsolutePath(SEARCH_ROOT); |
| | | var dirInfo = new DirectoryInfo(absRoot); |
| | | if (!dirInfo.Exists) |
| | | { |
| | | Debug.LogWarning("搜索目录不存在: " + absRoot); |
| | | return; |
| | | } |
| | | |
| | | FileInfo[] files = dirInfo.GetFiles("*.png", SearchOption.AllDirectories); |
| | | if (files.Length == 0) |
| | | { |
| | | Debug.Log("目录下没有 PNG 文件"); |
| | | return; |
| | | } |
| | | |
| | | if (_cacheByPath == null) |
| | | { |
| | | _cacheByPath = new Dictionary<string, FingerPrint>(); |
| | | _db = new FingerPrintDB(); |
| | | } |
| | | |
| | | FingerPrint[] fingerprints = new FingerPrint[files.Length]; |
| | | var pendingTasks = new List<PendingFingerprintTask>(); |
| | | int recomputeCount = 0; |
| | | bool cancelled = false; |
| | | int maxBuildJobs = GetMaxFingerprintBuildJobs(); |
| | | long pendingBytes = 0; |
| | | long readTicks = 0; |
| | | long md5Ticks = 0; |
| | | long decodeTicks = 0; |
| | | long waitTicks = 0; |
| | | long cpuTicks = 0; |
| | | long saveTicks = 0; |
| | | var totalWatch = System.Diagnostics.Stopwatch.StartNew(); |
| | | long progressIntervalTicks = System.Diagnostics.Stopwatch.Frequency * BUILD_PROGRESS_MIN_MS / 1000L; |
| | | long lastProgressTicks = -progressIntervalTicks; |
| | | |
| | | EnsureCosTable(); |
| | | |
| | | try |
| | | { |
| | | for (int i = 0; i < files.Length; i++) |
| | | { |
| | | if (pendingTasks.Count >= maxBuildJobs) |
| | | { |
| | | var waitWatch = System.Diagnostics.Stopwatch.StartNew(); |
| | | WaitForOneFingerprintTask(pendingTasks, fingerprints, ref recomputeCount, ref md5Ticks, ref cpuTicks, ref pendingBytes); |
| | | waitWatch.Stop(); |
| | | waitTicks += waitWatch.ElapsedTicks; |
| | | } |
| | | |
| | | long progressTicks = totalWatch.ElapsedTicks; |
| | | if ((progressTicks - lastProgressTicks >= progressIntervalTicks || i + 1 == files.Length) && EditorUtility.DisplayCancelableProgressBar( |
| | | "构建指纹库", |
| | | $"正在处理 ({i + 1}/{files.Length}) {files[i].Name}", |
| | | (float)(i + 1) / files.Length)) |
| | | { |
| | | cancelled = true; |
| | | break; |
| | | } |
| | | if (progressTicks - lastProgressTicks >= progressIntervalTicks || i + 1 == files.Length) |
| | | lastProgressTicks = progressTicks; |
| | | |
| | | var fi = files[i]; |
| | | string fullName = fi.FullName.Replace('\\', '/'); |
| | | string assetPath = "Assets" + fullName.Substring(Application.dataPath.Length); |
| | | long size = fi.Length; |
| | | long mtime = fi.LastWriteTimeUtc.Ticks; |
| | | |
| | | FingerPrint fp = null; |
| | | |
| | | if (!forceRebuild && _cacheByPath.TryGetValue(assetPath, out fp)) |
| | | { |
| | | if (fp.size == size && fp.mtime == mtime && fp.ver == ALGO_VER && HasPatchFingerprint(fp)) |
| | | { |
| | | fingerprints[i] = fp; |
| | | continue; |
| | | } |
| | | fp = null; |
| | | } |
| | | |
| | | byte[] bytes; |
| | | var readWatch = System.Diagnostics.Stopwatch.StartNew(); |
| | | bytes = File.ReadAllBytes(fullName); |
| | | readWatch.Stop(); |
| | | readTicks += readWatch.ElapsedTicks; |
| | | |
| | | Texture2D tex = null; |
| | | Color32[] pixels = null; |
| | | int width = 0; |
| | | int height = 0; |
| | | var decodeWatch = System.Diagnostics.Stopwatch.StartNew(); |
| | | tex = LoadPngAsTexture(bytes); |
| | | if (tex != null) |
| | | { |
| | | try |
| | | { |
| | | width = tex.width; |
| | | height = tex.height; |
| | | pixels = tex.GetPixels32(); |
| | | } |
| | | finally |
| | | { |
| | | DestroyImmediate(tex); |
| | | } |
| | | decodeWatch.Stop(); |
| | | decodeTicks += decodeWatch.ElapsedTicks; |
| | | |
| | | long estimatedBytes = EstimateFingerprintTaskBytes(bytes.Length); |
| | | for (int guard = 0; pendingTasks.Count > 0 && (pendingTasks.Count >= maxBuildJobs || pendingBytes + estimatedBytes > BUILD_MEMORY_BUDGET_BYTES); guard++) |
| | | { |
| | | var waitWatch = System.Diagnostics.Stopwatch.StartNew(); |
| | | WaitForOneFingerprintTask(pendingTasks, fingerprints, ref recomputeCount, ref md5Ticks, ref cpuTicks, ref pendingBytes); |
| | | waitWatch.Stop(); |
| | | waitTicks += waitWatch.ElapsedTicks; |
| | | } |
| | | pendingTasks.Add(new PendingFingerprintTask |
| | | { |
| | | task = StartDecodedFingerprintBuildTask(i, assetPath, size, mtime, bytes, pixels, width, height, true), |
| | | estimatedBytes = estimatedBytes |
| | | }); |
| | | pendingBytes += estimatedBytes; |
| | | } |
| | | else |
| | | { |
| | | decodeWatch.Stop(); |
| | | decodeTicks += decodeWatch.ElapsedTicks; |
| | | Debug.LogWarning("图片加载失败,已跳过: " + assetPath); |
| | | } |
| | | } |
| | | |
| | | for (int pending = pendingTasks.Count; pending > 0; pending--) |
| | | { |
| | | var waitWatch = System.Diagnostics.Stopwatch.StartNew(); |
| | | WaitForOneFingerprintTask(pendingTasks, fingerprints, ref recomputeCount, ref md5Ticks, ref cpuTicks, ref pendingBytes); |
| | | waitWatch.Stop(); |
| | | waitTicks += waitWatch.ElapsedTicks; |
| | | } |
| | | } |
| | | finally |
| | | { |
| | | EditorUtility.ClearProgressBar(); |
| | | } |
| | | |
| | | if (!cancelled) |
| | | { |
| | | var newDB = new FingerPrintDB(); |
| | | var newCache = new Dictionary<string, FingerPrint>(); |
| | | for (int i = 0; i < fingerprints.Length; i++) |
| | | { |
| | | FingerPrint fp = fingerprints[i]; |
| | | if (fp == null) |
| | | continue; |
| | | newDB.items.Add(fp); |
| | | newCache[fp.path] = fp; |
| | | } |
| | | |
| | | _db = newDB; |
| | | _cacheByPath = newCache; |
| | | var saveWatch = System.Diagnostics.Stopwatch.StartNew(); |
| | | SaveCache(); |
| | | saveWatch.Stop(); |
| | | saveTicks = saveWatch.ElapsedTicks; |
| | | totalWatch.Stop(); |
| | | Debug.Log($"指纹库构建完成: 共 {_db.items.Count} 张, 重算 {recomputeCount} 张, 并发上限 {maxBuildJobs}, 内存预算 {BUILD_MEMORY_BUDGET_BYTES / 1024L / 1024L}MB, 总耗时 {FormatElapsedMs(totalWatch.ElapsedTicks)}, 读取累计 {FormatElapsedMs(readTicks)}, MD5累计 {FormatElapsedMs(md5Ticks)}, 解码累计 {FormatElapsedMs(decodeTicks)}, CPU累计 {FormatElapsedMs(cpuTicks)}, 等待后台 {FormatElapsedMs(waitTicks)}, 保存 {FormatElapsedMs(saveTicks)}"); |
| | | } |
| | | else |
| | | { |
| | | totalWatch.Stop(); |
| | | Debug.Log($"指纹库构建已取消: 已启动重算 {recomputeCount} 张, 总耗时 {FormatElapsedMs(totalWatch.ElapsedTicks)}"); |
| | | } |
| | | } |
| | | |
| | | void DoMatch(string queryAssetPath) |
| | | { |
| | | _results = new List<SearchResult>(); |
| | | if (_cacheByPath == null || _cacheByPath.Count == 0 || _queryFingerprint == null) |
| | | { |
| | | Repaint(); |
| | | return; |
| | | } |
| | | foreach (var kvp in _cacheByPath) |
| | | { |
| | | if (kvp.Key == queryAssetPath) continue; |
| | | var fp = kvp.Value; |
| | | int dist = Hamming(_queryFingerprint.GetHash(), fp.GetHash()); |
| | | int cropDist = Hamming(_queryFingerprint.GetCropHash(), fp.GetCropHash()); |
| | | int transformDist = MinTransformDistance(_queryFingerprint, fp); |
| | | int edgeDist = Hamming(_queryFingerprint.GetEdgeHash(), fp.GetEdgeHash()); |
| | | int shapeDist = Hamming(_queryFingerprint.GetShapeHash(), fp.GetShapeHash()); |
| | | int nineSliceDist = Hamming(_queryFingerprint.GetNineSliceHash(), fp.GetNineSliceHash()); |
| | | int nineSliceColorDist = Hamming(_queryFingerprint.GetNineSliceColorHash(), fp.GetNineSliceColorHash()); |
| | | long queryArea = (long)_queryFingerprint.width * _queryFingerprint.height; |
| | | long fpArea = (long)fp.width * fp.height; |
| | | long nineSliceSizeScore = queryArea >= NINE_SLICE_SMALL_AREA ? fpArea : -fpArea; |
| | | int localHits = CountLocalPatchHits(_queryFingerprint, fp, _localPatchThreshold); |
| | | bool md5Same = (fp.md5 == _queryMD5); |
| | | bool nineSliceSizeRelation = HasNineSliceSizeRelation(_queryFingerprint, fp); |
| | | int generalScore = ComputeSimilarityScore(dist, cropDist, transformDist, edgeDist, localHits, shapeDist, nineSliceDist, nineSliceColorDist, nineSliceSizeRelation); |
| | | int nineSliceBonus = ComputeNineSliceBonus(shapeDist, nineSliceDist, nineSliceColorDist, nineSliceSizeRelation, queryArea, fpArea, fp.width, fp.height); |
| | | int score = md5Same ? 100 : generalScore + nineSliceBonus; |
| | | if (score > 100) |
| | | score = 100; |
| | | if (!md5Same && !IsSameAssetDirectory(queryAssetPath, fp.path)) |
| | | { |
| | | score += SCORE_DIFFERENT_FOLDER_BONUS; |
| | | if (score > 100) |
| | | score = 100; |
| | | } |
| | | |
| | | SimilarityMatchType matchType = SimilarityMatchType.Similar; |
| | | if (md5Same) |
| | | { |
| | | matchType = SimilarityMatchType.Exact; |
| | | } |
| | | else if (dist == 0) |
| | | { |
| | | matchType = SimilarityMatchType.VisualSame; |
| | | } |
| | | else if (dist <= _threshold) |
| | | { |
| | | matchType = SimilarityMatchType.Similar; |
| | | } |
| | | else if (nineSliceBonus > 0) |
| | | { |
| | | matchType = SimilarityMatchType.NineSliceSimilar; |
| | | } |
| | | else if (localHits >= _localMinHits) |
| | | { |
| | | matchType = SimilarityMatchType.LocalSimilar; |
| | | } |
| | | else if (cropDist <= _threshold) |
| | | { |
| | | matchType = SimilarityMatchType.SubjectSimilar; |
| | | } |
| | | else if (transformDist <= _threshold) |
| | | { |
| | | matchType = SimilarityMatchType.TransformSimilar; |
| | | } |
| | | else if (edgeDist <= _edgeThreshold) |
| | | { |
| | | matchType = SimilarityMatchType.EdgeSimilar; |
| | | } |
| | | else |
| | | { |
| | | matchType = SimilarityMatchType.Similar; |
| | | } |
| | | |
| | | if (!md5Same && score < _scoreThreshold) |
| | | continue; |
| | | |
| | | string dirFull = Path.GetDirectoryName(fp.path); |
| | | string dirName = Path.GetFileName(dirFull); |
| | | _results.Add(new SearchResult |
| | | { |
| | | path = fp.path, |
| | | dist = dist, |
| | | cropDist = cropDist, |
| | | transformDist = transformDist, |
| | | edgeDist = edgeDist, |
| | | shapeDist = shapeDist, |
| | | nineSliceDist = nineSliceDist, |
| | | nineSliceColorDist = nineSliceColorDist, |
| | | nineSliceSizeScore = nineSliceSizeScore, |
| | | localHits = localHits, |
| | | score = score, |
| | | md5Same = md5Same, |
| | | dirName = dirName, |
| | | matchType = matchType |
| | | }); |
| | | } |
| | | _results.Sort((a, b) => |
| | | { |
| | | if (a.md5Same != b.md5Same) |
| | | return a.md5Same ? -1 : 1; |
| | | if (a.score != b.score) |
| | | return b.score.CompareTo(a.score); |
| | | if (a.shapeDist != b.shapeDist) |
| | | return a.shapeDist.CompareTo(b.shapeDist); |
| | | if (a.nineSliceDist != b.nineSliceDist) |
| | | return a.nineSliceDist.CompareTo(b.nineSliceDist); |
| | | if (a.nineSliceColorDist != b.nineSliceColorDist) |
| | | return a.nineSliceColorDist.CompareTo(b.nineSliceColorDist); |
| | | if (a.transformDist != b.transformDist) |
| | | return a.transformDist.CompareTo(b.transformDist); |
| | | if (a.localHits != b.localHits) |
| | | return b.localHits.CompareTo(a.localHits); |
| | | if (a.edgeDist != b.edgeDist) |
| | | return a.edgeDist.CompareTo(b.edgeDist); |
| | | if (a.cropDist != b.cropDist) |
| | | return a.cropDist.CompareTo(b.cropDist); |
| | | if (a.dist != b.dist) |
| | | return a.dist.CompareTo(b.dist); |
| | | return string.CompareOrdinal(a.path, b.path); |
| | | }); |
| | | Repaint(); |
| | | } |
| | | |
| | | bool EnsureQueryFingerprint(string queryAssetPath, Texture2D reuseTexture) |
| | | { |
| | | // 路径未变且指纹有效 → 直接复用缓存指纹 |
| | | if (_queryFpValid && _lastQueryPath == queryAssetPath && _queryIncludesPatch) |
| | | return true; |
| | | |
| | | // 确保缓存库已加载 |
| | | if (_cacheByPath == null || _cacheByPath.Count == 0) |
| | | { |
| | | LoadCache(); |
| | | if (_cacheByPath == null || _cacheByPath.Count == 0 || CacheNeedsRebuild(_db)) |
| | | BuildCache(false); |
| | | } |
| | | else if (CacheNeedsRebuild(_db)) |
| | | { |
| | | BuildCache(false); |
| | | } |
| | | |
| | | if (CacheNeedsRebuild(_db)) |
| | | { |
| | | Debug.LogWarning("指纹库仍是旧版本,请完成刷新/重建后再查询"); |
| | | _queryFingerprint = null; |
| | | _queryIncludesPatch = false; |
| | | _queryFpValid = false; |
| | | return false; |
| | | } |
| | | |
| | | string absPath = ToAbsolutePath(queryAssetPath); |
| | | Texture2D tex; |
| | | bool ownsTex = false; |
| | | if (reuseTexture != null) |
| | | { |
| | | tex = reuseTexture; |
| | | } |
| | | else |
| | | { |
| | | tex = LoadPngAsTexture(absPath); |
| | | ownsTex = true; |
| | | } |
| | | |
| | | if (tex == null) |
| | | { |
| | | Debug.LogWarning("无法加载图片: " + queryAssetPath); |
| | | _queryFingerprint = null; |
| | | _queryIncludesPatch = false; |
| | | _queryFpValid = false; |
| | | return false; |
| | | } |
| | | |
| | | try |
| | | { |
| | | _queryMD5 = ComputeMD5(absPath); |
| | | _queryFingerprint = CreateFingerprint(tex, queryAssetPath, 0, 0, _queryMD5, true); |
| | | _queryIncludesPatch = true; |
| | | } |
| | | finally |
| | | { |
| | | // 仅销毁本方法内部加载的临时纹理;复用传入的纹理不销毁(预览要用) |
| | | if (ownsTex) |
| | | DestroyImmediate(tex); |
| | | } |
| | | _queryFpValid = true; |
| | | _lastQueryPath = queryAssetPath; |
| | | return true; |
| | | } |
| | | |
| | | void RunSearch(string queryAssetPath) |
| | | { |
| | | if (EnsureQueryFingerprint(queryAssetPath, null)) |
| | | DoMatch(queryAssetPath); |
| | | else |
| | | { |
| | | _results = null; |
| | | Repaint(); |
| | | } |
| | | } |
| | | |
| | | public void OnQueryFromSelection() |
| | | { |
| | | var obj = Selection.activeObject; |
| | | if (obj == null) return; |
| | | string path = AssetDatabase.GetAssetPath(obj); |
| | | if (string.IsNullOrEmpty(path)) return; |
| | | if (!path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && |
| | | !path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) && |
| | | !path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)) |
| | | return; |
| | | |
| | | if (_queryTexture != null) |
| | | { |
| | | DestroyImmediate(_queryTexture); |
| | | _queryTexture = null; |
| | | } |
| | | |
| | | // 加载预览纹理(一次) |
| | | _queryTexture = LoadPngAsTexture(ToAbsolutePath(path)); |
| | | |
| | | // 显式操作(选择变化/开始查找)总是重算指纹,避免图片被外部修改后用到 stale hash; |
| | | // RunSearch(阈值滑块)不受影响,仍可复用缓存指纹 |
| | | _queryFpValid = false; |
| | | if (EnsureQueryFingerprint(path, _queryTexture)) |
| | | DoMatch(path); |
| | | else |
| | | { |
| | | _results = null; |
| | | Repaint(); |
| | | } |
| | | } |
| | | |
| | | void OnEnable() |
| | | { |
| | | LoadCache(); |
| | | if (_thumbCache == null) |
| | | _thumbCache = new Dictionary<string, Texture2D>(); |
| | | else |
| | | _thumbCache.Clear(); |
| | | } |
| | | |
| | | void OnDisable() |
| | | { |
| | | if (_queryTexture != null) |
| | | { |
| | | DestroyImmediate(_queryTexture); |
| | | _queryTexture = null; |
| | | } |
| | | } |
| | | |
| | | void OnDestroy() |
| | | { |
| | | if (_queryTexture != null) |
| | | { |
| | | DestroyImmediate(_queryTexture); |
| | | _queryTexture = null; |
| | | } |
| | | } |
| | | |
| | | void OnSelectionChange() |
| | | { |
| | | if (_ignoreNextSelectionObject != null) |
| | | { |
| | | if (Selection.activeObject == _ignoreNextSelectionObject) |
| | | { |
| | | _ignoreNextSelectionObject = null; |
| | | Repaint(); |
| | | return; |
| | | } |
| | | _ignoreNextSelectionObject = null; |
| | | } |
| | | |
| | | if (_followSelection) |
| | | { |
| | | OnQueryFromSelection(); |
| | | Repaint(); |
| | | } |
| | | } |
| | | |
| | | void OnGUI() |
| | | { |
| | | GUILayout.Label("选中一张图片,自动在 ResourcesOut/Sprite 下查找相同/相似 PNG"); |
| | | |
| | | bool newFollow = EditorGUILayout.Toggle("跟随选择自动查询", _followSelection); |
| | | if (newFollow != _followSelection) |
| | | { |
| | | _followSelection = newFollow; |
| | | if (_followSelection) |
| | | OnQueryFromSelection(); |
| | | } |
| | | |
| | | int prevThreshold = _threshold; |
| | | int prevEdgeThreshold = _edgeThreshold; |
| | | int prevLocalPatchThreshold = _localPatchThreshold; |
| | | int prevLocalMinHits = _localMinHits; |
| | | int prevScoreThreshold = _scoreThreshold; |
| | | _scoreThreshold = EditorGUILayout.IntSlider("总分阈值", _scoreThreshold, 0, 100); |
| | | _threshold = EditorGUILayout.IntSlider("标签阈值(整体/主体/变换)", _threshold, 0, 32); |
| | | _edgeThreshold = EditorGUILayout.IntSlider("标签边缘阈值", _edgeThreshold, 0, 20); |
| | | _localPatchThreshold = EditorGUILayout.IntSlider("局部单块阈值", _localPatchThreshold, 0, 10); |
| | | _localMinHits = EditorGUILayout.IntSlider("标签局部命中块数", _localMinHits, 1, PATCH_COUNT); |
| | | if ((prevScoreThreshold != _scoreThreshold || prevThreshold != _threshold || prevEdgeThreshold != _edgeThreshold || prevLocalPatchThreshold != _localPatchThreshold || prevLocalMinHits != _localMinHits) && !string.IsNullOrEmpty(_lastQueryPath)) |
| | | RunSearch(_lastQueryPath); |
| | | |
| | | if (_queryTexture != null) |
| | | { |
| | | EditorGUILayout.BeginHorizontal(); |
| | | GUILayout.Label(_queryTexture, GUILayout.Width(96), GUILayout.Height(96)); |
| | | GUILayout.Label(string.IsNullOrEmpty(_lastQueryPath) ? "(未知)" : Path.GetFileName(_lastQueryPath)); |
| | | EditorGUILayout.EndHorizontal(); |
| | | } |
| | | |
| | | EditorGUILayout.BeginHorizontal(); |
| | | if (GUILayout.Button("刷新指纹库")) |
| | | BuildCache(false); |
| | | if (GUILayout.Button("重建指纹库")) |
| | | BuildCache(true); |
| | | if (GUILayout.Button("开始查找")) |
| | | OnQueryFromSelection(); |
| | | EditorGUILayout.EndHorizontal(); |
| | | |
| | | if (_cacheByPath == null || _cacheByPath.Count == 0) |
| | | { |
| | | EditorGUILayout.HelpBox("指纹库为空,请点击「刷新指纹库」按钮构建", MessageType.Info); |
| | | } |
| | | |
| | | EditorGUILayout.Separator(); |
| | | |
| | | string countText; |
| | | if (_results != null) |
| | | { |
| | | countText = $"找到 {_results.Count} 个相同/相似图片"; |
| | | } |
| | | else |
| | | { |
| | | countText = "请选择图片后查找"; |
| | | } |
| | | GUILayout.Label(countText); |
| | | |
| | | if (_results != null && _results.Count > 0) |
| | | { |
| | | GUIStyle resultNameStyle = CreateResultNameStyle(); |
| | | GUIStyle resultMetaStyle = CreateResultMetaStyle(); |
| | | _resultScroll = EditorGUILayout.BeginScrollView(_resultScroll); |
| | | int resultRows = (_results.Count + RESULT_COLUMNS - 1) / RESULT_COLUMNS; |
| | | float contentHeight = resultRows * RESULT_ROW_HEIGHT; |
| | | Rect contentRect = GUILayoutUtility.GetRect(1f, contentHeight, GUILayout.ExpandWidth(true), GUILayout.Height(contentHeight)); |
| | | int firstVisible = Mathf.FloorToInt(_resultScroll.y / RESULT_ROW_HEIGHT) - RESULT_VISIBLE_BUFFER_ROWS; |
| | | if (firstVisible < 0) |
| | | firstVisible = 0; |
| | | if (firstVisible >= resultRows) |
| | | firstVisible = resultRows - 1; |
| | | int visibleCount = Mathf.CeilToInt(position.height / RESULT_ROW_HEIGHT) + RESULT_VISIBLE_BUFFER_ROWS * 2 + 1; |
| | | int lastVisible = firstVisible + visibleCount; |
| | | if (lastVisible > resultRows) |
| | | lastVisible = resultRows; |
| | | |
| | | float columnWidth = contentRect.width / RESULT_COLUMNS; |
| | | |
| | | for (int row = firstVisible; row < lastVisible; row++) |
| | | { |
| | | for (int column = 0; column < RESULT_COLUMNS; column++) |
| | | { |
| | | int resultIndex = row * RESULT_COLUMNS + column; |
| | | if (resultIndex >= _results.Count) |
| | | continue; |
| | | Rect rowRect = new Rect(contentRect.x + column * columnWidth, contentRect.y + row * RESULT_ROW_HEIGHT, columnWidth, RESULT_ROW_HEIGHT); |
| | | DrawResultRow(rowRect, _results[resultIndex], resultNameStyle, resultMetaStyle, row > 0); |
| | | } |
| | | } |
| | | EditorGUILayout.EndScrollView(); |
| | | } |
| | | } |
| | | } |