| | |
| | | public long mtime; |
| | | public int width; |
| | | public int height; |
| | | public bool hasDetail; |
| | | public bool hasCrop; |
| | | public bool hasCropDetail; |
| | | public bool hasShape; |
| | | public int averageR; |
| | | public int averageG; |
| | | public int averageB; |
| | | public int averageA; |
| | | public long hashLo; |
| | | public long hashHi; |
| | | public long cropHashLo; |
| | |
| | | public long[] patchHashHi; |
| | | public int patchCount; |
| | | public string md5; |
| | | public string pixelMd5; |
| | | public int ver; |
| | | |
| | | public ulong GetHash() |
| | |
| | | TransformSimilar, |
| | | EdgeSimilar, |
| | | NineSliceSimilar, |
| | | NineSliceStretched, |
| | | LocalSimilar |
| | | } |
| | | |
| | |
| | | public int shapeDist; |
| | | public int nineSliceDist; |
| | | public int nineSliceColorDist; |
| | | public long nineSliceSizeScore; |
| | | public int localHits; |
| | | public int colorDist; |
| | | public int alphaDist; |
| | | public int aspectSimilarity; |
| | | public int score; |
| | | public bool md5Same; |
| | | public bool pixelSame; |
| | | public string dirName; |
| | | public bool isQueryDirectory; |
| | | public bool isQueryGroup; |
| | | public SimilarityMatchType matchType; |
| | | |
| | | public string label |
| | |
| | | if (matchType == SimilarityMatchType.TransformSimilar) return $"变换相似({transformDist})"; |
| | | if (matchType == SimilarityMatchType.EdgeSimilar) return $"边缘相似({edgeDist})"; |
| | | if (matchType == SimilarityMatchType.NineSliceSimilar) return $"九宫相似(结构{nineSliceDist}/颜色{nineSliceColorDist})"; |
| | | if (matchType == SimilarityMatchType.NineSliceStretched) return score >= 90 ? "九宫拉伸一致" : "九宫拉伸相似"; |
| | | if (matchType == SimilarityMatchType.LocalSimilar) return $"局部相似({localHits})"; |
| | | return $"相似({dist})"; |
| | | } |
| | |
| | | { |
| | | const string SEARCH_ROOT = "Assets/ResourcesOut/Sprite"; |
| | | const string CACHE_PATH = "Assets/Editor/Tool/SpriteSimilarityCache.json"; |
| | | const int ALGO_VER = 13; |
| | | const string NINE_SLICE_CACHE_PATH = "Assets/Editor/Tool/SpriteSimilarityNineSliceCache.bin"; |
| | | const int NINE_SLICE_CACHE_VER = 1; |
| | | const int ALGO_VER = 14; |
| | | const int HASH_SIZE = 32; |
| | | const int LOW_FREQ_SIZE = 8; |
| | | const int SHAPE_HASH_SIZE = 8; |
| | |
| | | const int MAX_BUILD_JOB_MULTIPLIER = 2; |
| | | const int MAX_BUILD_JOB_LIMIT = 64; |
| | | const long BUILD_MEMORY_BUDGET_BYTES = 1024L * 1024L * 1024L; |
| | | const byte VISIBLE_ALPHA_THRESHOLD = 8; |
| | | const int SHAPE_COLOR_DISTANCE_SQ = 32 * 32; |
| | | const double DETAIL_VARIANCE_THRESHOLD = 0.0001; |
| | | 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 int SCORE_COLOR_MAX = 192; |
| | | const int SCORE_ALPHA_MAX = 255; |
| | | const int SCORE_WEIGHT_STRUCTURE = 30; |
| | | const int SCORE_WEIGHT_CROP = 10; |
| | | const int SCORE_WEIGHT_EDGE = 15; |
| | | const int SCORE_WEIGHT_COLOR = 25; |
| | | const int SCORE_WEIGHT_ALPHA = 5; |
| | | const int SCORE_WEIGHT_ASPECT = 10; |
| | | const int SCORE_WEIGHT_SHAPE = 5; |
| | | const int LOW_DETAIL_COLOR_MAX = 48; |
| | | const int LOW_DETAIL_ALPHA_MAX = 48; |
| | | const int LOW_DETAIL_ASPECT_MIN = 50; |
| | | const int LOW_DETAIL_SCORE_WEIGHT_COLOR = 60; |
| | | const int LOW_DETAIL_SCORE_WEIGHT_ALPHA = 20; |
| | | const int LOW_DETAIL_SCORE_WEIGHT_ASPECT = 20; |
| | | const int DEFAULT_SCORE_THRESHOLD = 45; |
| | | const float RESULT_MIN_COLUMN_WIDTH = 150f; |
| | | const float RESULT_ROW_HEIGHT = 76f; |
| | | const int RESULT_VISIBLE_BUFFER_ROWS = 4; |
| | | |
| | | Texture2D _queryTexture; |
| | | UnityEngine.UI.Image _replacementTarget; |
| | | Vector2 _resultScroll; |
| | | int _threshold = 20; |
| | | int _edgeThreshold = 8; |
| | | int _localPatchThreshold = 3; |
| | | int _localMinHits = 4; |
| | | int _scoreThreshold = 35; |
| | | int _scoreThreshold = DEFAULT_SCORE_THRESHOLD; |
| | | [SerializeField] int _maxResultColumns = 8; |
| | | bool _followSelection = true; |
| | | UnityEngine.Object _ignoreNextSelectionObject; |
| | | List<SearchResult> _results; |
| | | string _lastQueryPath; |
| | | FingerPrintDB _db; |
| | | Dictionary<string, FingerPrint> _cacheByPath; |
| | | Dictionary<string, int> _nineSliceScores = new Dictionary<string, int>(); |
| | | List<NineSliceCandidate> _nineSliceCandidates; |
| | | IEnumerator<object> _nineSliceCacheBuild; |
| | | int _nineSliceCacheScanned; |
| | | string _queryMD5; |
| | | FingerPrint _queryFingerprint; |
| | | bool _queryIncludesPatch; |
| | |
| | | static byte[] _bitCountTable; |
| | | Dictionary<string, Texture2D> _thumbCache = new Dictionary<string, Texture2D>(); |
| | | |
| | | class NineSliceCandidate |
| | | { |
| | | public string path; |
| | | public Color32[] samples; |
| | | public int width; |
| | | public int height; |
| | | public Vector4 border; |
| | | } |
| | | |
| | | class FingerprintContext |
| | | { |
| | | public Color32[] pixels; |
| | | public int width; |
| | | public int height; |
| | | public float[] integral; |
| | | public int cropX; |
| | | public int cropY; |
| | | public int cropW; |
| | |
| | | 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]; |
| | | public double[] ac = new double[LOW_FREQ_SIZE * LOW_FREQ_SIZE - 1]; |
| | | } |
| | | |
| | | [MenuItem("程序/相似图片查找")] |
| | |
| | | } |
| | | } |
| | | |
| | | double sum8 = 0; |
| | | double[] ac = workspace.ac; |
| | | int acIndex = 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; |
| | | { |
| | | if (u == 0 && v == 0) |
| | | continue; |
| | | ac[acIndex] = dct[v * LOW_FREQ_SIZE + u]; |
| | | acIndex++; |
| | | } |
| | | } |
| | | Array.Sort(ac); |
| | | double median = ac[ac.Length / 2]; |
| | | |
| | | ulong hash = 0; |
| | | int bit = 0; |
| | |
| | | { |
| | | for (int u = 0; u < LOW_FREQ_SIZE; u++) |
| | | { |
| | | if (dct[v * LOW_FREQ_SIZE + u] > avg) |
| | | if (u == 0 && v == 0) |
| | | continue; |
| | | if (dct[v * LOW_FREQ_SIZE + u] > median) |
| | | hash |= (1UL << bit); |
| | | bit++; |
| | | } |
| | |
| | | pixels = pixels, |
| | | width = width, |
| | | height = height, |
| | | integral = new float[(width + 1) * (height + 1)], |
| | | cropX = 0, |
| | | cropY = 0, |
| | | cropW = 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 (c.a >= VISIBLE_ALPHA_THRESHOLD) |
| | | { |
| | | 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; |
| | | } |
| | | } |
| | | |
| | |
| | | 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; |
| | | ctx.shapeThresholdSq = SHAPE_COLOR_DISTANCE_SQ; |
| | | |
| | | int fgCount = 0; |
| | | for (int y = 0; y < height; y++) |
| | |
| | | if (sampleW > width) sampleW = width; |
| | | if (sampleH > height) sampleH = height; |
| | | |
| | | int r = 0; |
| | | int g = 0; |
| | | int b = 0; |
| | | int a = 0; |
| | | int[] red = new int[64]; |
| | | int[] green = new int[64]; |
| | | int[] blue = new int[64]; |
| | | int[] alpha = new int[64]; |
| | | int count = 0; |
| | | for (int cy = 0; cy < 2; cy++) |
| | | { |
| | |
| | | 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; |
| | | red[count] = c.r; |
| | | green[count] = c.g; |
| | | blue[count] = c.b; |
| | | alpha[count] = c.a; |
| | | count++; |
| | | } |
| | | } |
| | |
| | | return; |
| | | } |
| | | |
| | | bgR = r / count; |
| | | bgG = g / count; |
| | | bgB = b / count; |
| | | bgA = a / count; |
| | | Array.Sort(red, 0, count); |
| | | Array.Sort(green, 0, count); |
| | | Array.Sort(blue, 0, count); |
| | | Array.Sort(alpha, 0, count); |
| | | int median = count / 2; |
| | | bgR = red[median]; |
| | | bgG = green[median]; |
| | | bgB = blue[median]; |
| | | bgA = alpha[median]; |
| | | } |
| | | |
| | | static int ColorDistanceSq32(Color32 c, int bgR, int bgG, int bgB) |
| | |
| | | static bool IsShapeForeground32(FingerprintContext ctx, int index) |
| | | { |
| | | Color32 c = ctx.pixels[index]; |
| | | if (c.a < 128) |
| | | if (c.a < VISIBLE_ALPHA_THRESHOLD) |
| | | return false; |
| | | if (ctx.bgA < 128) |
| | | if (ctx.bgA < VISIBLE_ALPHA_THRESHOLD) |
| | | 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; |
| | | return (77f * c.r + 150f * c.g + 29f * c.b) * c.a / (65280f * 255f); |
| | | } |
| | | |
| | | 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) |
| | | static void BuildSmallGray(FingerprintContext ctx, int regionX, int regionY, int regionWidth, int regionHeight, double[] small) |
| | | { |
| | | if (regionWidth <= 0 || regionHeight <= 0) |
| | | { |
| | |
| | | |
| | | for (int by = 0; by < HASH_SIZE; by++) |
| | | { |
| | | int y0 = regionY + by * regionHeight / HASH_SIZE; |
| | | int y1 = regionY + (by + 1) * regionHeight / HASH_SIZE; |
| | | double y0 = regionY + (double)by * regionHeight / HASH_SIZE; |
| | | double y1 = regionY + (double)(by + 1) * regionHeight / HASH_SIZE; |
| | | int startY = (int)System.Math.Floor(y0); |
| | | int endY = (int)System.Math.Ceiling(y1); |
| | | 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; |
| | | double x0 = regionX + (double)bx * regionWidth / HASH_SIZE; |
| | | double x1 = regionX + (double)(bx + 1) * regionWidth / HASH_SIZE; |
| | | int startX = (int)System.Math.Floor(x0); |
| | | int endX = (int)System.Math.Ceiling(x1); |
| | | double sum = 0.0; |
| | | for (int y = startY; y < endY; y++) |
| | | { |
| | | double weightY = System.Math.Min(y1, y + 1.0) - System.Math.Max(y0, y); |
| | | int rowBase = y * ctx.width; |
| | | for (int x = startX; x < endX; x++) |
| | | { |
| | | double weightX = System.Math.Min(x1, x + 1.0) - System.Math.Max(x0, x); |
| | | sum += Gray01(ctx.pixels[rowBase + x]) * weightX * weightY; |
| | | } |
| | | } |
| | | small[by * HASH_SIZE + bx] = sum / ((x1 - x0) * (y1 - y0)); |
| | | } |
| | | } |
| | | } |
| | | |
| | | static bool HasVisualDetail(double[] small) |
| | | { |
| | | double sum = 0.0; |
| | | for (int i = 0; i < small.Length; i++) |
| | | sum += small[i]; |
| | | double average = sum / small.Length; |
| | | double variance = 0.0; |
| | | for (int i = 0; i < small.Length; i++) |
| | | { |
| | | double delta = small[i] - average; |
| | | variance += delta * delta; |
| | | } |
| | | return variance / small.Length >= DETAIL_VARIANCE_THRESHOLD; |
| | | } |
| | | |
| | | static void BuildTransformedSmall(double[] source, int transform, double[] dest) |
| | |
| | | { |
| | | FingerprintContext ctx = CreateFingerprintContext(pixels, width, height); |
| | | PHashWorkspace workspace = new PHashWorkspace(); |
| | | int averageR; |
| | | int averageG; |
| | | int averageB; |
| | | int averageA; |
| | | ComputeAverageColor(pixels, out averageR, out averageG, out averageB, out averageA); |
| | | |
| | | var fp = new FingerPrint |
| | | { |
| | |
| | | mtime = mtime, |
| | | width = width, |
| | | height = height, |
| | | hasShape = ctx.hasShape, |
| | | averageR = averageR, |
| | | averageG = averageG, |
| | | averageB = averageB, |
| | | averageA = averageA, |
| | | md5 = md5, |
| | | pixelMd5 = ComputePixelMD5(pixels, width, height), |
| | | ver = ALGO_VER, |
| | | transformHashLo = new long[TRANSFORM_COUNT], |
| | | transformHashHi = new long[TRANSFORM_COUNT], |
| | |
| | | EnsureCosTable(); |
| | | |
| | | double[] fullSmall = workspace.fullSmall; |
| | | BuildSmallGrayFromIntegral(ctx, 0, 0, width, height, fullSmall); |
| | | BuildSmallGray(ctx, 0, 0, width, height, fullSmall); |
| | | |
| | | fp.hasDetail = HasVisualDetail(fullSmall); |
| | | fp.hasCrop = ctx.cropX != 0 || ctx.cropY != 0 || ctx.cropW != width || ctx.cropH != height; |
| | | 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 cropHash; |
| | | if (fp.hasCrop) |
| | | cropHash = ComputePHashFromRegion(ctx, ctx.cropX, ctx.cropY, ctx.cropW, ctx.cropH, workspace, out fp.hasCropDetail); |
| | | else |
| | | { |
| | | cropHash = hash; |
| | | fp.hasCropDetail = fp.hasDetail; |
| | | } |
| | | ulong edgeHash = ComputeEdgeHashFromSmall(fullSmall, workspace); |
| | | ulong shapeHash = ComputeShapeHash(ctx); |
| | | ulong nineSliceHash = ComputeNineSliceHash(ctx); |
| | |
| | | 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)); |
| | | bool patchHasDetail; |
| | | ulong patchHash = ComputePHashFromRegion(ctx, patchX0, patchY0, patchX1 - patchX0, patchY1 - patchY0, workspace, out patchHasDetail); |
| | | fp.SetPatchHash(index, patchHasDetail ? patchHash : 0UL); |
| | | } |
| | | } |
| | | return fp; |
| | | } |
| | | |
| | | static ulong ComputePHashFromIntegral(FingerprintContext ctx, int regionX, int regionY, int regionWidth, int regionHeight, PHashWorkspace workspace) |
| | | static ulong ComputePHashFromRegion(FingerprintContext ctx, int regionX, int regionY, int regionWidth, int regionHeight, PHashWorkspace workspace, out bool hasDetail) |
| | | { |
| | | double[] small = workspace.small; |
| | | BuildSmallGrayFromIntegral(ctx, regionX, regionY, regionWidth, regionHeight, small); |
| | | BuildSmallGray(ctx, regionX, regionY, regionWidth, regionHeight, small); |
| | | hasDetail = HasVisualDetail(small); |
| | | return ComputePHashFromSmall(small, workspace); |
| | | } |
| | | |
| | | static void ComputeAverageColor(Color32[] pixels, out int averageR, out int averageG, out int averageB, out int averageA) |
| | | { |
| | | long red = 0; |
| | | long green = 0; |
| | | long blue = 0; |
| | | long alpha = 0; |
| | | for (int i = 0; i < pixels.Length; i++) |
| | | { |
| | | Color32 c = pixels[i]; |
| | | red += c.r * c.a; |
| | | green += c.g * c.a; |
| | | blue += c.b * c.a; |
| | | alpha += c.a; |
| | | } |
| | | |
| | | averageA = pixels.Length > 0 ? (int)(alpha / pixels.Length) : 0; |
| | | if (alpha <= 0) |
| | | { |
| | | averageR = 0; |
| | | averageG = 0; |
| | | averageB = 0; |
| | | return; |
| | | } |
| | | averageR = (int)(red / alpha); |
| | | averageG = (int)(green / alpha); |
| | | averageB = (int)(blue / alpha); |
| | | } |
| | | |
| | | static string ComputePixelMD5(Color32[] pixels, int width, int height) |
| | | { |
| | | byte[] bytes = new byte[pixels.Length * 4 + 8]; |
| | | bytes[0] = (byte)width; |
| | | bytes[1] = (byte)(width >> 8); |
| | | bytes[2] = (byte)(width >> 16); |
| | | bytes[3] = (byte)(width >> 24); |
| | | bytes[4] = (byte)height; |
| | | bytes[5] = (byte)(height >> 8); |
| | | bytes[6] = (byte)(height >> 16); |
| | | bytes[7] = (byte)(height >> 24); |
| | | int byteIndex = 8; |
| | | for (int i = 0; i < pixels.Length; i++) |
| | | { |
| | | Color32 c = pixels[i]; |
| | | bytes[byteIndex] = (byte)((c.r * c.a + 127) / 255); |
| | | bytes[byteIndex + 1] = (byte)((c.g * c.a + 127) / 255); |
| | | bytes[byteIndex + 2] = (byte)((c.b * c.a + 127) / 255); |
| | | bytes[byteIndex + 3] = c.a; |
| | | byteIndex += 4; |
| | | } |
| | | return ComputeMD5(bytes); |
| | | } |
| | | |
| | | static string ComputeMD5(string filePath) |
| | |
| | | static Texture2D LoadPngAsTexture(byte[] bytes) |
| | | { |
| | | var tex = new Texture2D(2, 2, TextureFormat.RGBA32, false); |
| | | tex.hideFlags = HideFlags.HideAndDontSave; |
| | | if (ImageConversion.LoadImage(tex, bytes, false)) |
| | | return tex; |
| | | DestroyImmediate(tex); |
| | |
| | | return Application.dataPath.Substring(0, Application.dataPath.Length - "Assets".Length) + assetPath; |
| | | } |
| | | |
| | | static string FormatMetric(int value) |
| | | static bool IsSameAssetPath(string a, string b) |
| | | { |
| | | return value >= 0 ? value.ToString() : "-"; |
| | | if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) |
| | | return false; |
| | | return string.Equals(a.Replace('\\', '/'), b.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase); |
| | | } |
| | | |
| | | static GUIStyle CreateResultNameStyle() |
| | |
| | | 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 contentRect = new Rect(rowRect.x, rowRect.y + 9f, rowRect.width - 8f, 64f); |
| | | Rect thumbRect = new Rect(contentRect.x, contentRect.y, 64f, 64f); |
| | | |
| | | Texture2D thumb; |
| | |
| | | |
| | | 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); |
| | | Rect replaceRect = new Rect(thumbRect.xMax + 8f, contentRect.y + 38f, buttonWidth, buttonHeight); |
| | | |
| | | float textX = thumbRect.xMax + 8f; |
| | | float textWidth = locateRect.x - textX - 8f; |
| | | float textWidth = contentRect.xMax - textX; |
| | | if (textWidth > 20f) |
| | | { |
| | | GUI.Label(new Rect(textX, contentRect.y, textWidth, 18f), Path.GetFileName(r.path), resultNameStyle); |
| | | Rect nameRect = new Rect(textX, contentRect.y, textWidth, 18f); |
| | | EditorGUIUtility.AddCursorRect(nameRect, MouseCursor.Link); |
| | | if (GUI.Button(nameRect, new GUIContent(Path.GetFileNameWithoutExtension(r.path), "点击复制图片名(不含扩展名)"), resultNameStyle)) |
| | | GUIUtility.systemCopyBuffer = Path.GetFileNameWithoutExtension(r.path); |
| | | |
| | | 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); |
| | | GUI.Label(new Rect(textX, contentRect.y + 19f, textWidth, 18f), "图集: " + r.dirName, resultMetaStyle); |
| | | } |
| | | |
| | | if (GUI.Button(locateRect, "定位")) |
| | | EditorGUIUtility.AddCursorRect(thumbRect, MouseCursor.Link); |
| | | if (GUI.Button(thumbRect, GUIContent.none, GUIStyle.none)) |
| | | { |
| | | var asset = AssetDatabase.LoadAssetAtPath(r.path, typeof(UnityEngine.Object)); |
| | | if (asset != null && Selection.activeObject != asset) |
| | |
| | | EditorGUIUtility.PingObject(asset); |
| | | Selection.activeObject = asset; |
| | | } |
| | | if (GUI.Button(copyRect, "复制")) |
| | | |
| | | using (new EditorGUI.DisabledScope(_replacementTarget == null)) |
| | | if (GUI.Button(replaceRect, "替换")) |
| | | ReplaceSourceImage(r.path); |
| | | } |
| | | |
| | | public static void FindFromImage(UnityEngine.UI.Image target) |
| | | { |
| | | if (target == null || target.sprite == null) |
| | | return; |
| | | string path = AssetDatabase.GetAssetPath(target.sprite); |
| | | var window = GetWindow<SpriteSimilarityFinder>("相似图片查找"); |
| | | window._replacementTarget = target; |
| | | window.Show(); |
| | | window.QueryImage(path); |
| | | } |
| | | |
| | | void ReplaceSourceImage(string path) |
| | | { |
| | | if (_replacementTarget == null) |
| | | return; |
| | | TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; |
| | | Sprite sprite = AssetDatabase.LoadAssetAtPath<Sprite>(path); |
| | | if (importer == null || importer.spriteImportMode != SpriteImportMode.Single || sprite == null) |
| | | { |
| | | GUIUtility.systemCopyBuffer = Path.GetFileNameWithoutExtension(r.path); |
| | | EditorUtility.DisplayDialog("无法替换", "候选图片必须是 Single 模式的 Sprite。", "确定"); |
| | | return; |
| | | } |
| | | ApplySourceImage(_replacementTarget, sprite); |
| | | } |
| | | |
| | | static void ApplySourceImage(UnityEngine.UI.Image target, Sprite sprite) |
| | | { |
| | | using (var serialized = new SerializedObject(target)) |
| | | { |
| | | serialized.FindProperty("m_Sprite").objectReferenceValue = sprite; |
| | | if (sprite.border != Vector4.zero) |
| | | serialized.FindProperty("m_Type").enumValueIndex = (int)UnityEngine.UI.Image.Type.Sliced; |
| | | serialized.ApplyModifiedProperties(); |
| | | } |
| | | } |
| | | |
| | | public static void RunReplacementSelfCheck() |
| | | { |
| | | Sprite original = AssetDatabase.LoadAssetAtPath<Sprite>("Assets/ResourcesOut/Sprite/Common/GuildSys_img_177.png"); |
| | | Sprite replacement = AssetDatabase.LoadAssetAtPath<Sprite>("Assets/ResourcesOut/Sprite/GoldRush/GoldRush_img_107.png"); |
| | | AssertSelfCheck(original != null && replacement != null && replacement.border != Vector4.zero, "Replacement source assets"); |
| | | var go = new GameObject("Sprite replacement self check", typeof(RectTransform), typeof(UnityEngine.UI.Image)); |
| | | go.hideFlags = HideFlags.HideAndDontSave; |
| | | var image = go.GetComponent<UnityEngine.UI.Image>(); |
| | | image.sprite = original; |
| | | image.type = UnityEngine.UI.Image.Type.Simple; |
| | | image.color = Color.gray; |
| | | image.rectTransform.sizeDelta = new Vector2(660f, 273f); |
| | | Undo.IncrementCurrentGroup(); |
| | | int group = Undo.GetCurrentGroup(); |
| | | try |
| | | { |
| | | ApplySourceImage(image, replacement); |
| | | AssertSelfCheck(image.sprite == replacement, "Replace source sprite"); |
| | | AssertSelfCheck(image.type == UnityEngine.UI.Image.Type.Sliced && image.color == Color.gray && image.rectTransform.sizeDelta == new Vector2(660f, 273f), "Set sliced type and preserve other image properties"); |
| | | Undo.FlushUndoRecordObjects(); |
| | | Undo.RevertAllDownToGroup(group); |
| | | AssertSelfCheck(image.sprite == original && image.type == UnityEngine.UI.Image.Type.Simple, "Undo source sprite and image type replacement"); |
| | | } |
| | | finally |
| | | { |
| | | Undo.ClearUndo(image); |
| | | DestroyImmediate(go); |
| | | } |
| | | } |
| | | |
| | |
| | | return (maxDistance - distance) * weight / maxDistance; |
| | | } |
| | | |
| | | static int WeightedHitScore(int hits, int maxHits, int weight) |
| | | static int WeightedSimilarityScore(int similarity, int maxSimilarity, int weight) |
| | | { |
| | | if (hits <= 0) |
| | | if (similarity <= 0) |
| | | return 0; |
| | | if (hits >= maxHits) |
| | | if (similarity >= maxSimilarity) |
| | | return weight; |
| | | return hits * weight / maxHits; |
| | | return similarity * weight / maxSimilarity; |
| | | } |
| | | |
| | | static int ComputeSimilarityScore(int dist, int cropDist, int transformDist, int edgeDist, int localHits, int shapeDist, int nineSliceDist, int nineSliceColorDist, bool nineSliceSizeRelation) |
| | | static int ComputeSimilarityScore(FingerPrint a, FingerPrint b, int dist, int cropDist, int transformDist, int edgeDist, int shapeDist, int colorDist, int alphaDist, int aspectSimilarity) |
| | | { |
| | | if (!a.hasDetail && !b.hasDetail) |
| | | { |
| | | if (colorDist > LOW_DETAIL_COLOR_MAX || alphaDist > LOW_DETAIL_ALPHA_MAX || aspectSimilarity < LOW_DETAIL_ASPECT_MIN) |
| | | return 0; |
| | | int lowDetailScore = WeightedDistanceScore(colorDist, LOW_DETAIL_COLOR_MAX, LOW_DETAIL_SCORE_WEIGHT_COLOR); |
| | | lowDetailScore += WeightedDistanceScore(alphaDist, LOW_DETAIL_ALPHA_MAX, LOW_DETAIL_SCORE_WEIGHT_ALPHA); |
| | | lowDetailScore += WeightedSimilarityScore(aspectSimilarity, 100, LOW_DETAIL_SCORE_WEIGHT_ASPECT); |
| | | return lowDetailScore; |
| | | } |
| | | |
| | | int structureDist = dist < transformDist ? dist : transformDist; |
| | | 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(structureDist, SCORE_DIST_MAX, SCORE_WEIGHT_STRUCTURE); |
| | | 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; |
| | | score += WeightedDistanceScore(colorDist, SCORE_COLOR_MAX, SCORE_WEIGHT_COLOR); |
| | | score += WeightedDistanceScore(alphaDist, SCORE_ALPHA_MAX, SCORE_WEIGHT_ALPHA); |
| | | score += WeightedSimilarityScore(aspectSimilarity, 100, SCORE_WEIGHT_ASPECT); |
| | | if (a.hasCropDetail && b.hasCropDetail && (a.hasCrop || b.hasCrop)) |
| | | score += WeightedDistanceScore(cropDist, SCORE_DIST_MAX, SCORE_WEIGHT_CROP); |
| | | if (a.hasShape && b.hasShape) |
| | | score += WeightedDistanceScore(shapeDist, SCORE_SHAPE_MAX, SCORE_WEIGHT_SHAPE); |
| | | 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) |
| | | static int ComputeColorDistance(FingerPrint a, FingerPrint b) |
| | | { |
| | | 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; |
| | | int red = a.averageR - b.averageR; |
| | | int green = a.averageG - b.averageG; |
| | | int blue = a.averageB - b.averageB; |
| | | return (int)System.Math.Sqrt(red * red + green * green + blue * blue); |
| | | } |
| | | |
| | | static bool IsBalancedSize(int width, int height) |
| | | static int ComputeAspectSimilarity(FingerPrint a, FingerPrint b) |
| | | { |
| | | if (width <= 0 || height <= 0) |
| | | return false; |
| | | int small = width < height ? width : height; |
| | | int large = width < height ? height : width; |
| | | return large <= small * 2; |
| | | long crossA = (long)a.width * b.height; |
| | | long crossB = (long)b.width * a.height; |
| | | long small = crossA < crossB ? crossA : crossB; |
| | | long large = crossA < crossB ? crossB : crossA; |
| | | if (large <= 0) |
| | | return 0; |
| | | return (int)(small * 100L / large); |
| | | } |
| | | |
| | | static bool IsSameAssetDirectory(string a, string b) |
| | | static bool IsNineSliceMatch(FingerPrint a, FingerPrint b, int shapeDist, int nineSliceDist, int nineSliceColorDist) |
| | | { |
| | | if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) |
| | | if (!a.hasShape || !b.hasShape || !HasNineSliceSizeRelation(a, 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; |
| | | return shapeDist <= NINE_SLICE_SHAPE_THRESHOLD && nineSliceDist <= NINE_SLICE_THRESHOLD && nineSliceColorDist <= NINE_SLICE_COLOR_THRESHOLD; |
| | | } |
| | | |
| | | static void EnsureBitCountTable() |
| | |
| | | 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; |
| | | int bits = Hamming(hash, 0UL); |
| | | return bits <= 2 || bits >= 61; |
| | | } |
| | | |
| | | 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 count = a.patchCount < b.patchCount ? a.patchCount : b.patchCount; |
| | | if (count > PATCH_COUNT) count = PATCH_COUNT; |
| | | |
| | | int hits = 0; |
| | | for (int i = 0; i < aCount; i++) |
| | | for (int i = 0; i < count; i++) |
| | | { |
| | | ulong ah = a.GetPatchHash(i); |
| | | if (IsWeakPatchHash(ah)) |
| | | ulong bh = b.GetPatchHash(i); |
| | | if (IsWeakPatchHash(ah) || IsWeakPatchHash(bh)) |
| | | 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) |
| | | if (HammingLimited(ah, bh, patchThreshold) <= patchThreshold) |
| | | hits++; |
| | | } |
| | | return hits; |
| | |
| | | |
| | | void LoadCache() |
| | | { |
| | | InvalidateNineSliceCache(); |
| | | _cacheByPath = new Dictionary<string, FingerPrint>(); |
| | | string cacheFile = ToAbsolutePath(CACHE_PATH); |
| | | if (!File.Exists(cacheFile)) |
| | |
| | | |
| | | void BuildCache(bool forceRebuild) |
| | | { |
| | | InvalidateNineSliceCache(); |
| | | string absRoot = ToAbsolutePath(SEARCH_ROOT); |
| | | var dirInfo = new DirectoryInfo(absRoot); |
| | | if (!dirInfo.Exists) |
| | |
| | | } |
| | | |
| | | FingerPrint[] fingerprints = new FingerPrint[files.Length]; |
| | | var nineSliceCandidates = new List<NineSliceCandidate>(); |
| | | string nineSliceStamp = ComputeNineSliceCacheStamp(); |
| | | var pendingTasks = new List<PendingFingerprintTask>(); |
| | | int recomputeCount = 0; |
| | | bool cancelled = false; |
| | |
| | | long size = fi.Length; |
| | | long mtime = fi.LastWriteTimeUtc.Ticks; |
| | | |
| | | TextureImporter importer = AssetImporter.GetAtPath(assetPath) as TextureImporter; |
| | | Vector4 border = importer != null && importer.spriteImportMode == SpriteImportMode.Single ? importer.spriteBorder : Vector4.zero; |
| | | FingerPrint fp = null; |
| | | |
| | | if (!forceRebuild && _cacheByPath.TryGetValue(assetPath, out fp)) |
| | | if (!forceRebuild && _cacheByPath.TryGetValue(assetPath, out fp) && fp.size == size && fp.mtime == mtime && fp.ver == ALGO_VER && HasPatchFingerprint(fp)) |
| | | { |
| | | if (fp.size == size && fp.mtime == mtime && fp.ver == ALGO_VER && HasPatchFingerprint(fp)) |
| | | { |
| | | fingerprints[i] = fp; |
| | | fingerprints[i] = fp; |
| | | if (border == Vector4.zero) |
| | | continue; |
| | | } |
| | | fp = null; |
| | | } |
| | | else |
| | | fp = null; |
| | | |
| | | byte[] bytes; |
| | | var readWatch = System.Diagnostics.Stopwatch.StartNew(); |
| | |
| | | } |
| | | decodeWatch.Stop(); |
| | | decodeTicks += decodeWatch.ElapsedTicks; |
| | | |
| | | if (border != Vector4.zero) |
| | | { |
| | | nineSliceCandidates.Add(new NineSliceCandidate |
| | | { |
| | | path = assetPath, |
| | | width = width, |
| | | height = height, |
| | | border = border, |
| | | samples = CreateNineSliceSamples(pixels, width, height, border) |
| | | }); |
| | | } |
| | | if (fp != null) |
| | | continue; |
| | | |
| | | long estimatedBytes = EstimateFingerprintTaskBytes(bytes.Length); |
| | | for (int guard = 0; pendingTasks.Count > 0 && (pendingTasks.Count >= maxBuildJobs || pendingBytes + estimatedBytes > BUILD_MEMORY_BUDGET_BYTES); guard++) |
| | |
| | | _cacheByPath = newCache; |
| | | var saveWatch = System.Diagnostics.Stopwatch.StartNew(); |
| | | SaveCache(); |
| | | for (int i = nineSliceCandidates.Count - 1; i >= 0; i--) |
| | | if (!newCache.ContainsKey(nineSliceCandidates[i].path)) |
| | | nineSliceCandidates.RemoveAt(i); |
| | | SaveNineSliceCache(nineSliceCandidates, nineSliceStamp); |
| | | _nineSliceCandidates = nineSliceCandidates; |
| | | saveWatch.Stop(); |
| | | saveTicks = saveWatch.ElapsedTicks; |
| | | totalWatch.Stop(); |
| | |
| | | Repaint(); |
| | | return; |
| | | } |
| | | string queryDirectory = Path.GetDirectoryName(queryAssetPath.Replace('\\', '/')); |
| | | string queryGroup = GetSpriteGroupDirectory(queryDirectory); |
| | | foreach (var kvp in _cacheByPath) |
| | | { |
| | | if (kvp.Key == queryAssetPath) continue; |
| | | if (IsSameAssetPath(kvp.Key, queryAssetPath)) |
| | | continue; |
| | | var fp = kvp.Value; |
| | | int dist = Hamming(_queryFingerprint.GetHash(), fp.GetHash()); |
| | | int cropDist = Hamming(_queryFingerprint.GetCropHash(), fp.GetCropHash()); |
| | |
| | | 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; |
| | | } |
| | | bool pixelSame = !string.IsNullOrEmpty(fp.pixelMd5) && fp.pixelMd5 == _queryFingerprint.pixelMd5; |
| | | int colorDist = ComputeColorDistance(_queryFingerprint, fp); |
| | | int alphaDist = System.Math.Abs(_queryFingerprint.averageA - fp.averageA); |
| | | int aspectSimilarity = ComputeAspectSimilarity(_queryFingerprint, fp); |
| | | bool nineSliceMatch = IsNineSliceMatch(_queryFingerprint, fp, shapeDist, nineSliceDist, nineSliceColorDist); |
| | | int generalScore = ComputeSimilarityScore(_queryFingerprint, fp, dist, cropDist, transformDist, edgeDist, shapeDist, colorDist, alphaDist, aspectSimilarity); |
| | | int stretchScore; |
| | | _nineSliceScores.TryGetValue(fp.path, out stretchScore); |
| | | int score = md5Same || pixelSame ? 100 : System.Math.Max(generalScore, stretchScore); |
| | | |
| | | SimilarityMatchType matchType = SimilarityMatchType.Similar; |
| | | if (md5Same) |
| | | { |
| | | matchType = SimilarityMatchType.Exact; |
| | | } |
| | | else if (dist == 0) |
| | | else if (pixelSame) |
| | | { |
| | | matchType = SimilarityMatchType.VisualSame; |
| | | } |
| | | else if (dist <= _threshold) |
| | | else if (stretchScore > generalScore) |
| | | { |
| | | matchType = SimilarityMatchType.Similar; |
| | | matchType = SimilarityMatchType.NineSliceStretched; |
| | | } |
| | | else if (nineSliceBonus > 0) |
| | | else if (nineSliceMatch) |
| | | { |
| | | matchType = SimilarityMatchType.NineSliceSimilar; |
| | | } |
| | | else if (_queryFingerprint.hasDetail && fp.hasDetail && dist <= _threshold) |
| | | { |
| | | matchType = SimilarityMatchType.Similar; |
| | | } |
| | | else if (localHits >= _localMinHits) |
| | | { |
| | | matchType = SimilarityMatchType.LocalSimilar; |
| | | } |
| | | else if (cropDist <= _threshold) |
| | | else if (_queryFingerprint.hasCropDetail && fp.hasCropDetail && (_queryFingerprint.hasCrop || fp.hasCrop) && cropDist <= _threshold) |
| | | { |
| | | matchType = SimilarityMatchType.SubjectSimilar; |
| | | } |
| | | else if (transformDist <= _threshold) |
| | | else if (_queryFingerprint.hasDetail && fp.hasDetail && transformDist <= _threshold) |
| | | { |
| | | matchType = SimilarityMatchType.TransformSimilar; |
| | | } |
| | | else if (edgeDist <= _edgeThreshold) |
| | | else if (_queryFingerprint.hasDetail && fp.hasDetail && edgeDist <= _edgeThreshold) |
| | | { |
| | | matchType = SimilarityMatchType.EdgeSimilar; |
| | | } |
| | |
| | | matchType = SimilarityMatchType.Similar; |
| | | } |
| | | |
| | | if (!md5Same && score < _scoreThreshold) |
| | | if (!md5Same && !pixelSame && score < _scoreThreshold) |
| | | continue; |
| | | |
| | | string dirFull = Path.GetDirectoryName(fp.path); |
| | | string dirFull = Path.GetDirectoryName(fp.path.Replace('\\', '/')); |
| | | string dirName = Path.GetFileName(dirFull); |
| | | _results.Add(new SearchResult |
| | | { |
| | |
| | | cropDist = cropDist, |
| | | transformDist = transformDist, |
| | | edgeDist = edgeDist, |
| | | shapeDist = shapeDist, |
| | | nineSliceDist = nineSliceDist, |
| | | nineSliceColorDist = nineSliceColorDist, |
| | | nineSliceSizeScore = nineSliceSizeScore, |
| | | shapeDist = _queryFingerprint.hasShape && fp.hasShape ? shapeDist : -1, |
| | | nineSliceDist = _queryFingerprint.hasShape && fp.hasShape ? nineSliceDist : -1, |
| | | nineSliceColorDist = _queryFingerprint.hasShape && fp.hasShape ? nineSliceColorDist : -1, |
| | | localHits = localHits, |
| | | colorDist = colorDist, |
| | | alphaDist = alphaDist, |
| | | aspectSimilarity = aspectSimilarity, |
| | | score = score, |
| | | md5Same = md5Same, |
| | | pixelSame = pixelSame, |
| | | dirName = dirName, |
| | | isQueryDirectory = IsSameAssetPath(dirFull, queryDirectory), |
| | | isQueryGroup = IsSameAssetPath(GetSpriteGroupDirectory(dirFull), queryGroup), |
| | | 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); |
| | | }); |
| | | _results.Sort(CompareResults); |
| | | Repaint(); |
| | | } |
| | | |
| | | void BuildNineSliceScores(Texture2D query, string queryAssetPath) |
| | | { |
| | | _nineSliceScores.Clear(); |
| | | Color32[] queryPixels = query.GetPixels32(); |
| | | foreach (var candidate in _nineSliceCandidates) |
| | | { |
| | | if (!IsSameAssetPath(candidate.path, queryAssetPath)) |
| | | _nineSliceScores[candidate.path] = ComputeNineSliceScore(queryPixels, query.width, query.height, null, candidate.width, candidate.height, candidate.border, candidate.samples); |
| | | } |
| | | } |
| | | |
| | | void InvalidateNineSliceCache() |
| | | { |
| | | EditorApplication.update -= UpdateNineSliceCache; |
| | | if (_nineSliceCacheBuild != null) |
| | | _nineSliceCacheBuild.Dispose(); |
| | | _nineSliceCacheBuild = null; |
| | | _nineSliceCandidates = null; |
| | | _queryFpValid = false; |
| | | } |
| | | |
| | | string ComputeNineSliceCacheStamp() |
| | | { |
| | | FileInfo[] files = new DirectoryInfo(ToAbsolutePath(SEARCH_ROOT)).GetFiles("*.png", SearchOption.AllDirectories); |
| | | Array.Sort(files, (a, b) => string.CompareOrdinal(a.FullName, b.FullName)); |
| | | using (var stream = new MemoryStream()) |
| | | { |
| | | using (var writer = new BinaryWriter(stream, System.Text.Encoding.UTF8, true)) |
| | | { |
| | | foreach (var file in files) |
| | | { |
| | | writer.Write(file.FullName); |
| | | writer.Write(file.Length); |
| | | writer.Write(file.LastWriteTimeUtc.Ticks); |
| | | var meta = new FileInfo(file.FullName + ".meta"); |
| | | writer.Write(meta.Exists); |
| | | if (meta.Exists) |
| | | { |
| | | writer.Write(meta.Length); |
| | | writer.Write(meta.LastWriteTimeUtc.Ticks); |
| | | } |
| | | } |
| | | } |
| | | return ComputeMD5(stream.ToArray()); |
| | | } |
| | | } |
| | | |
| | | void SaveNineSliceCache(List<NineSliceCandidate> candidates, string stamp) |
| | | { |
| | | if (stamp != ComputeNineSliceCacheStamp()) |
| | | throw new InvalidOperationException("构建期间图片或导入设置发生变化,请重新刷新指纹库。"); |
| | | string path = ToAbsolutePath(NINE_SLICE_CACHE_PATH); |
| | | string temporary = path + ".tmp"; |
| | | using (var stream = File.Create(temporary)) |
| | | using (var writer = new BinaryWriter(stream)) |
| | | { |
| | | writer.Write(NINE_SLICE_CACHE_VER); |
| | | writer.Write(stamp); |
| | | WriteNineSliceCandidates(writer, candidates); |
| | | } |
| | | if (File.Exists(path)) |
| | | File.Replace(temporary, path, null); |
| | | else |
| | | File.Move(temporary, path); |
| | | } |
| | | |
| | | bool LoadNineSliceCache() |
| | | { |
| | | string path = ToAbsolutePath(NINE_SLICE_CACHE_PATH); |
| | | if (!File.Exists(path)) |
| | | return false; |
| | | using (var stream = File.OpenRead(path)) |
| | | using (var reader = new BinaryReader(stream)) |
| | | { |
| | | if (reader.ReadInt32() != NINE_SLICE_CACHE_VER || reader.ReadString() != ComputeNineSliceCacheStamp()) |
| | | return false; |
| | | var candidates = ReadNineSliceCandidates(reader, _cacheByPath.Count); |
| | | foreach (var candidate in candidates) |
| | | if (!_cacheByPath.ContainsKey(candidate.path)) |
| | | return false; |
| | | _nineSliceCandidates = candidates; |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | static void WriteNineSliceCandidates(BinaryWriter writer, List<NineSliceCandidate> candidates) |
| | | { |
| | | writer.Write(candidates.Count); |
| | | foreach (var candidate in candidates) |
| | | { |
| | | if (candidate.samples == null || candidate.samples.Length != 9 * 32 * 32) |
| | | throw new InvalidDataException("Invalid nine slice sample count"); |
| | | writer.Write(candidate.path); |
| | | writer.Write(candidate.width); |
| | | writer.Write(candidate.height); |
| | | writer.Write(candidate.border.x); |
| | | writer.Write(candidate.border.y); |
| | | writer.Write(candidate.border.z); |
| | | writer.Write(candidate.border.w); |
| | | byte[] bytes = new byte[9 * 32 * 32 * 4]; |
| | | for (int i = 0; i < candidate.samples.Length; i++) |
| | | { |
| | | Color32 pixel = candidate.samples[i]; |
| | | bytes[i * 4] = pixel.r; |
| | | bytes[i * 4 + 1] = pixel.g; |
| | | bytes[i * 4 + 2] = pixel.b; |
| | | bytes[i * 4 + 3] = pixel.a; |
| | | } |
| | | writer.Write(bytes); |
| | | } |
| | | } |
| | | |
| | | static List<NineSliceCandidate> ReadNineSliceCandidates(BinaryReader reader, int maxCount) |
| | | { |
| | | int count = reader.ReadInt32(); |
| | | if (count < 0 || count > maxCount) |
| | | throw new InvalidDataException("Invalid nine slice cache count"); |
| | | var candidates = new List<NineSliceCandidate>(count); |
| | | for (int i = 0; i < count; i++) |
| | | { |
| | | var candidate = new NineSliceCandidate |
| | | { |
| | | path = reader.ReadString(), |
| | | width = reader.ReadInt32(), |
| | | height = reader.ReadInt32(), |
| | | border = new Vector4(reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()), |
| | | samples = new Color32[9 * 32 * 32] |
| | | }; |
| | | if (candidate.width <= 0 || candidate.height <= 0) |
| | | throw new InvalidDataException("Invalid nine slice cache dimensions"); |
| | | byte[] bytes = reader.ReadBytes(candidate.samples.Length * 4); |
| | | if (bytes.Length != candidate.samples.Length * 4) |
| | | throw new EndOfStreamException("Incomplete nine slice cache"); |
| | | for (int sample = 0; sample < candidate.samples.Length; sample++) |
| | | candidate.samples[sample] = new Color32(bytes[sample * 4], bytes[sample * 4 + 1], bytes[sample * 4 + 2], bytes[sample * 4 + 3]); |
| | | candidates.Add(candidate); |
| | | } |
| | | if (reader.BaseStream.Position != reader.BaseStream.Length) |
| | | throw new InvalidDataException("Unexpected nine slice cache data"); |
| | | return candidates; |
| | | } |
| | | |
| | | void StartNineSliceCache() |
| | | { |
| | | if (_nineSliceCacheBuild != null || _nineSliceCandidates != null) |
| | | return; |
| | | if (LoadNineSliceCache()) |
| | | return; |
| | | _nineSliceCacheScanned = 0; |
| | | _nineSliceCacheBuild = CacheNineSliceCandidates(); |
| | | EditorApplication.update += UpdateNineSliceCache; |
| | | Repaint(); |
| | | } |
| | | |
| | | void UpdateNineSliceCache() |
| | | { |
| | | bool pending = false; |
| | | try |
| | | { |
| | | pending = _nineSliceCacheBuild.MoveNext(); |
| | | } |
| | | finally |
| | | { |
| | | if (!pending) |
| | | { |
| | | EditorApplication.update -= UpdateNineSliceCache; |
| | | _nineSliceCacheBuild.Dispose(); |
| | | _nineSliceCacheBuild = null; |
| | | } |
| | | } |
| | | Repaint(); |
| | | if (!pending && !string.IsNullOrEmpty(_lastQueryPath)) |
| | | RunSearch(_lastQueryPath); |
| | | } |
| | | |
| | | IEnumerator<object> CacheNineSliceCandidates() |
| | | { |
| | | var candidates = new List<NineSliceCandidate>(); |
| | | string stamp = ComputeNineSliceCacheStamp(); |
| | | double sliceStart = EditorApplication.timeSinceStartup; |
| | | foreach (var entry in _cacheByPath) |
| | | { |
| | | if (EditorApplication.timeSinceStartup - sliceStart >= 0.004) |
| | | { |
| | | yield return null; |
| | | sliceStart = EditorApplication.timeSinceStartup; |
| | | } |
| | | _nineSliceCacheScanned++; |
| | | TextureImporter importer = AssetImporter.GetAtPath(entry.Key) as TextureImporter; |
| | | if (importer == null || importer.spriteImportMode != SpriteImportMode.Single || importer.spriteBorder == Vector4.zero) |
| | | continue; |
| | | Texture2D texture = LoadPngAsTexture(ToAbsolutePath(entry.Key)); |
| | | if (texture == null) |
| | | throw new InvalidOperationException(entry.Key); |
| | | try |
| | | { |
| | | candidates.Add(new NineSliceCandidate |
| | | { |
| | | path = entry.Key, |
| | | width = texture.width, |
| | | height = texture.height, |
| | | border = importer.spriteBorder, |
| | | samples = CreateNineSliceSamples(texture.GetPixels32(), texture.width, texture.height, importer.spriteBorder) |
| | | }); |
| | | } |
| | | finally |
| | | { |
| | | DestroyImmediate(texture); |
| | | } |
| | | } |
| | | SaveNineSliceCache(candidates, stamp); |
| | | _nineSliceCandidates = candidates; |
| | | } |
| | | |
| | | static Color32[] CreateNineSliceSamples(Color32[] pixels, int width, int height, Vector4 border) |
| | | { |
| | | float[] sourceX = GetSliceCuts(width, border.x, border.z); |
| | | float[] sourceY = GetSliceCuts(height, border.y, border.w); |
| | | Color32[] samples = new Color32[9 * 32 * 32]; |
| | | for (int row = 0; row < 3; row++) |
| | | { |
| | | for (int column = 0; column < 3; column++) |
| | | { |
| | | for (int y = 0; y < 32; y++) |
| | | { |
| | | int sy = System.Math.Min(height - 1, (int)(sourceY[row] + (sourceY[row + 1] - sourceY[row]) * (y + 0.5f) / 32f)); |
| | | for (int x = 0; x < 32; x++) |
| | | { |
| | | int sx = System.Math.Min(width - 1, (int)(sourceX[column] + (sourceX[column + 1] - sourceX[column]) * (x + 0.5f) / 32f)); |
| | | samples[(row * 3 + column) * 32 * 32 + y * 32 + x] = pixels[sy * width + sx]; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | return samples; |
| | | } |
| | | |
| | | static float[] GetSliceCuts(int size, float first, float last) |
| | | { |
| | | float scale = first + last > size ? size / (first + last) : 1f; |
| | | return new float[] { 0f, first * scale, size - last * scale, size }; |
| | | } |
| | | |
| | | static int ComputeNineSliceScore(Color32[] query, int queryWidth, int queryHeight, Color32[] candidate, int width, int height, Vector4 border, Color32[] samples = null) |
| | | { |
| | | if (queryWidth <= 0 || queryHeight <= 0 || width <= 0 || height <= 0 || border == Vector4.zero || border.x < 0 || border.y < 0 || border.z < 0 || border.w < 0 || border.x + border.z >= width || border.y + border.w >= height) |
| | | return 0; |
| | | if (samples == null) |
| | | samples = CreateNineSliceSamples(candidate, width, height, border); |
| | | float[] targetX = GetSliceCuts(queryWidth, border.x, border.z); |
| | | float[] targetY = GetSliceCuts(queryHeight, border.y, border.w); |
| | | double error = 0; |
| | | int totalWeight = 0; |
| | | for (int row = 0; row < 3; row++) |
| | | { |
| | | for (int column = 0; column < 3; column++) |
| | | { |
| | | if (targetX[column + 1] <= targetX[column] || targetY[row + 1] <= targetY[row]) |
| | | continue; |
| | | double regionError = 0; |
| | | for (int y = 0; y < 32; y++) |
| | | { |
| | | int ty = (int)(targetY[row] + (targetY[row + 1] - targetY[row]) * (y + 0.5f) / 32f); |
| | | for (int x = 0; x < 32; x++) |
| | | { |
| | | int tx = (int)(targetX[column] + (targetX[column + 1] - targetX[column]) * (x + 0.5f) / 32f); |
| | | Color32 a = query[ty * queryWidth + tx]; |
| | | Color32 b = samples[(row * 3 + column) * 32 * 32 + y * 32 + x]; |
| | | regionError += (System.Math.Abs(a.r * a.a - b.r * b.a) + System.Math.Abs(a.g * a.a - b.g * b.a) + System.Math.Abs(a.b * a.a - b.b * b.a)) / 255.0 + System.Math.Abs(a.a - b.a); |
| | | } |
| | | } |
| | | int weight = row == 1 && column == 1 ? 2 : 1; |
| | | error += regionError / (32 * 32 * 4) * weight; |
| | | totalWeight += weight; |
| | | } |
| | | } |
| | | if (totalWeight == 0) |
| | | return 0; |
| | | // 64 级平均像素误差记为零分;分区独立加权,四角/四边/中心占 40%/40%/20%。 |
| | | return System.Math.Max(0, System.Math.Min(99, (int)(100 - error / totalWeight * 100 / 64))); |
| | | } |
| | | |
| | | static string GetSpriteGroupDirectory(string directory) |
| | | { |
| | | directory = directory.Replace('\\', '/').TrimEnd('/'); |
| | | if (!directory.StartsWith(SEARCH_ROOT + "/", StringComparison.OrdinalIgnoreCase)) |
| | | return directory; |
| | | int end = directory.IndexOf('/', SEARCH_ROOT.Length + 1); |
| | | return end < 0 ? directory : directory.Substring(0, end); |
| | | } |
| | | |
| | | static int CompareResults(SearchResult a, SearchResult b) |
| | | { |
| | | if (a.isQueryGroup != b.isQueryGroup) |
| | | return a.isQueryGroup ? 1 : -1; |
| | | if (a.isQueryDirectory != b.isQueryDirectory) |
| | | return a.isQueryDirectory ? 1 : -1; |
| | | if (a.md5Same != b.md5Same) |
| | | return a.md5Same ? -1 : 1; |
| | | if (a.pixelSame != b.pixelSame) |
| | | return a.pixelSame ? -1 : 1; |
| | | if (a.score != b.score) |
| | | return b.score.CompareTo(a.score); |
| | | if (a.colorDist != b.colorDist) |
| | | return a.colorDist.CompareTo(b.colorDist); |
| | | if (a.alphaDist != b.alphaDist) |
| | | return a.alphaDist.CompareTo(b.alphaDist); |
| | | if (a.aspectSimilarity != b.aspectSimilarity) |
| | | return b.aspectSimilarity.CompareTo(a.aspectSimilarity); |
| | | if (a.transformDist != b.transformDist) |
| | | return a.transformDist.CompareTo(b.transformDist); |
| | | 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); |
| | | } |
| | | |
| | | bool EnsureQueryFingerprint(string queryAssetPath, Texture2D reuseTexture) |
| | |
| | | return false; |
| | | } |
| | | |
| | | if (_nineSliceCandidates == null) |
| | | { |
| | | StartNineSliceCache(); |
| | | if (_nineSliceCandidates == null) |
| | | return false; |
| | | } |
| | | |
| | | string absPath = ToAbsolutePath(queryAssetPath); |
| | | Texture2D tex; |
| | | bool ownsTex = false; |
| | |
| | | { |
| | | _queryMD5 = ComputeMD5(absPath); |
| | | _queryFingerprint = CreateFingerprint(tex, queryAssetPath, 0, 0, _queryMD5, true); |
| | | BuildNineSliceScores(tex, queryAssetPath); |
| | | _queryIncludesPatch = true; |
| | | } |
| | | finally |
| | |
| | | } |
| | | } |
| | | |
| | | public static void RunSelfCheck() |
| | | { |
| | | RunResultColumnsSelfCheck(); |
| | | AssertSelfCheck(SpriteSimilarityAssetPostprocessor.ContainsSearchAsset(new string[] { "assets\\resourcesout\\sprite\\Common\\test.png" }), "Sprite change invalidates cache"); |
| | | AssertSelfCheck(!SpriteSimilarityAssetPostprocessor.ContainsSearchAsset(new string[] { CACHE_PATH, "Assets/ResourcesOut/SpriteOther/test.png" }), "Unrelated changes preserve cache"); |
| | | RunNineSliceSelfCheck(); |
| | | AssertSelfCheck(IsSameAssetPath("Assets/ResourcesOut/Sprite/Test.png", "assets\\resourcesout\\sprite\\TEST.PNG"), nameof(IsSameAssetPath)); |
| | | |
| | | RunDirectorySortSelfCheck(); |
| | | |
| | | Color32[] grayPixels = CreateSolidPixels(32, 32, new Color32(220, 220, 220, 255)); |
| | | Color32[] purplePixels = CreateSolidPixels(32, 32, new Color32(160, 140, 240, 255)); |
| | | FingerPrint gray = CreateFingerprint(grayPixels, 32, 32, string.Empty, 0, 0, string.Empty, true); |
| | | FingerPrint purple = CreateFingerprint(purplePixels, 32, 32, string.Empty, 0, 0, string.Empty, true); |
| | | AssertSelfCheck(!gray.hasDetail && !purple.hasDetail, nameof(FingerPrint.hasDetail)); |
| | | AssertSelfCheck(!gray.hasShape && !purple.hasShape, nameof(FingerPrint.hasShape)); |
| | | AssertSelfCheck(ComputeSelfCheckScore(gray, purple) < DEFAULT_SCORE_THRESHOLD, nameof(LOW_DETAIL_COLOR_MAX)); |
| | | |
| | | Color32[] smallPixels = CreatePatternPixels(16, 16); |
| | | Color32[] largePixels = CreatePatternPixels(32, 32); |
| | | FingerPrint small = CreateFingerprint(smallPixels, 16, 16, string.Empty, 0, 0, string.Empty, true); |
| | | FingerPrint large = CreateFingerprint(largePixels, 32, 32, string.Empty, 0, 0, string.Empty, true); |
| | | AssertSelfCheck(small.hasDetail && large.hasDetail, nameof(DETAIL_VARIANCE_THRESHOLD)); |
| | | AssertSelfCheck(ComputeSelfCheckScore(small, large) >= DEFAULT_SCORE_THRESHOLD, nameof(BuildSmallGray)); |
| | | AssertSelfCheck(CountLocalPatchHits(small, large, 3) == CountLocalPatchHits(large, small, 3), nameof(CountLocalPatchHits)); |
| | | |
| | | FingerPrint smallCopy = CreateFingerprint((Color32[])smallPixels.Clone(), 16, 16, string.Empty, 0, 0, "different-file", true); |
| | | AssertSelfCheck(small.pixelMd5 == smallCopy.pixelMd5, nameof(FingerPrint.pixelMd5)); |
| | | FingerPrint panel = CreateSelfCheckFingerprint("Assets/ResourcesOut/Sprite/FairySiege/攻城战_通用切图.psd-assets/FairySiege_img_133.png"); |
| | | FingerPrint border = CreateSelfCheckFingerprint("Assets/ResourcesOut/Sprite/4Interactable/mainboss_img_152.png"); |
| | | AssertSelfCheck(panel.hasDetail != border.hasDetail, "Panel detail classification"); |
| | | AssertSelfCheck(ComputeSelfCheckScore(panel, border) >= DEFAULT_SCORE_THRESHOLD, "Panel border similarity"); |
| | | AssertSelfCheck(ComputeSelfCheckScore(panel, border) == ComputeSelfCheckScore(border, panel), "Panel border symmetry"); |
| | | } |
| | | |
| | | static void RunDirectorySortSelfCheck() |
| | | { |
| | | SearchResult sameDirectory = new SearchResult { isQueryDirectory = true, isQueryGroup = true, md5Same = true, score = 100 }; |
| | | SearchResult otherDirectory = new SearchResult { score = DEFAULT_SCORE_THRESHOLD }; |
| | | SearchResult otherDirectoryExact = new SearchResult { md5Same = true, score = 100 }; |
| | | SearchResult otherDirectoryHighScore = new SearchResult { score = 99 }; |
| | | AssertSelfCheck(CompareResults(otherDirectory, sameDirectory) < 0 && CompareResults(sameDirectory, otherDirectory) > 0, "Other directory priority"); |
| | | AssertSelfCheck(CompareResults(otherDirectoryExact, otherDirectoryHighScore) < 0, "Exact match priority within directory group"); |
| | | AssertSelfCheck(CompareResults(otherDirectoryHighScore, otherDirectory) < 0, "Score priority within directory group"); |
| | | SearchResult sameGroup = new SearchResult { isQueryGroup = true, md5Same = true, score = 100 }; |
| | | AssertSelfCheck(CompareResults(otherDirectory, sameGroup) < 0 && CompareResults(sameGroup, otherDirectory) > 0, "External group before internal exact match"); |
| | | AssertSelfCheck(CompareResults(sameGroup, sameDirectory) < 0 && CompareResults(sameDirectory, sameGroup) > 0, "Sibling directory before query directory"); |
| | | AssertSelfCheck(IsSameAssetPath(GetSpriteGroupDirectory("Assets/ResourcesOut/Sprite/FairySiege/Battle.psd-assets"), GetSpriteGroupDirectory(@"assets\resourcesout\sprite\FairySiege\Common.psd-assets")), "Sibling folders share sprite group"); |
| | | AssertSelfCheck(!IsSameAssetPath(GetSpriteGroupDirectory("Assets/ResourcesOut/Sprite/FairySiege/Battle.psd-assets"), GetSpriteGroupDirectory("Assets/ResourcesOut/Sprite/FairySiegeExtra/Battle.psd-assets")), "Group prefix is not group identity"); |
| | | AssertSelfCheck(IsSameAssetPath(GetSpriteGroupDirectory("Assets/ResourcesOut/Sprite/Guild"), "Assets/ResourcesOut/Sprite/Guild"), "Direct child group"); |
| | | AssertSelfCheck(IsSameAssetPath(GetSpriteGroupDirectory(SEARCH_ROOT), SEARCH_ROOT), "Search root directory"); |
| | | } |
| | | |
| | | static void RunNineSliceSelfCheck() |
| | | { |
| | | const string panelPath = "Assets/ResourcesOut/Sprite/FairySiege/攻城战_通用切图.psd-assets/FairySiege_img_546.png"; |
| | | const string candidatePath = "Assets/ResourcesOut/Sprite/Common/GuildSys_img_177.png"; |
| | | Texture2D panel = LoadPngAsTexture(ToAbsolutePath(panelPath)); |
| | | Texture2D candidate = null; |
| | | try |
| | | { |
| | | candidate = LoadPngAsTexture(ToAbsolutePath(candidatePath)); |
| | | AssertSelfCheck(panel != null && candidate != null, "Nine slice source images"); |
| | | TextureImporter importer = AssetImporter.GetAtPath(candidatePath) as TextureImporter; |
| | | AssertSelfCheck(importer != null && importer.spriteBorder != Vector4.zero, "Nine slice source border"); |
| | | Color32[] panelPixels = panel.GetPixels32(); |
| | | Color32[] candidatePixels = candidate.GetPixels32(); |
| | | int score = ComputeNineSliceScore(panelPixels, panel.width, panel.height, candidatePixels, candidate.width, candidate.height, importer.spriteBorder); |
| | | AssertSelfCheck(score >= 90, "Guild panel stretched similarity"); |
| | | Color32[] samples = CreateNineSliceSamples(candidatePixels, candidate.width, candidate.height, importer.spriteBorder); |
| | | AssertSelfCheck(ComputeNineSliceScore(panelPixels, panel.width, panel.height, null, candidate.width, candidate.height, importer.spriteBorder, samples) == score, "Cached nine slice score"); |
| | | using (var stream = new MemoryStream()) |
| | | { |
| | | var stored = new NineSliceCandidate { path = candidatePath, width = candidate.width, height = candidate.height, border = importer.spriteBorder, samples = samples }; |
| | | using (var writer = new BinaryWriter(stream, System.Text.Encoding.UTF8, true)) |
| | | WriteNineSliceCandidates(writer, new List<NineSliceCandidate> { stored }); |
| | | stream.Position = 0; |
| | | using (var reader = new BinaryReader(stream, System.Text.Encoding.UTF8, true)) |
| | | { |
| | | var restored = ReadNineSliceCandidates(reader, 1)[0]; |
| | | AssertSelfCheck(restored.path == stored.path && restored.width == stored.width && restored.height == stored.height && restored.border == stored.border, "Persistent nine slice metadata"); |
| | | for (int i = 0; i < samples.Length; i++) |
| | | AssertSelfCheck(samples[i].Equals(restored.samples[i]), "Persistent nine slice pixels"); |
| | | AssertSelfCheck(ComputeNineSliceScore(panelPixels, panel.width, panel.height, null, restored.width, restored.height, restored.border, restored.samples) == score, "Persistent nine slice score"); |
| | | } |
| | | } |
| | | AssertSelfCheck(ComputeNineSliceScore(candidatePixels, candidate.width, candidate.height, candidatePixels, candidate.width, candidate.height, importer.spriteBorder) == 99, "Nine slice identity"); |
| | | AssertSelfCheck(ComputeNineSliceScore(panelPixels, panel.width, panel.height, candidatePixels, candidate.width, candidate.height, Vector4.zero) == 0, "Nine slice border required"); |
| | | } |
| | | finally |
| | | { |
| | | DestroyImmediate(panel); |
| | | DestroyImmediate(candidate); |
| | | } |
| | | } |
| | | |
| | | static FingerPrint CreateSelfCheckFingerprint(string assetPath) |
| | | { |
| | | Texture2D texture = LoadPngAsTexture(ToAbsolutePath(assetPath)); |
| | | AssertSelfCheck(texture != null, assetPath); |
| | | try |
| | | { |
| | | return CreateFingerprint(texture, assetPath, 0, 0, string.Empty, true); |
| | | } |
| | | finally |
| | | { |
| | | DestroyImmediate(texture); |
| | | } |
| | | } |
| | | |
| | | static Color32[] CreateSolidPixels(int width, int height, Color32 color) |
| | | { |
| | | Color32[] pixels = new Color32[width * height]; |
| | | for (int i = 0; i < pixels.Length; i++) |
| | | pixels[i] = color; |
| | | return pixels; |
| | | } |
| | | |
| | | static Color32[] CreatePatternPixels(int width, int height) |
| | | { |
| | | Color32[] pixels = new Color32[width * height]; |
| | | for (int y = 0; y < height; y++) |
| | | { |
| | | for (int x = 0; x < width; x++) |
| | | { |
| | | byte value = ((x * 8 / width + y * 8 / height) & 1) == 0 ? (byte)32 : (byte)224; |
| | | pixels[y * width + x] = new Color32(value, value, value, 255); |
| | | } |
| | | } |
| | | return pixels; |
| | | } |
| | | |
| | | static int ComputeSelfCheckScore(FingerPrint a, FingerPrint b) |
| | | { |
| | | int dist = Hamming(a.GetHash(), b.GetHash()); |
| | | int cropDist = Hamming(a.GetCropHash(), b.GetCropHash()); |
| | | int transformDist = MinTransformDistance(a, b); |
| | | int edgeDist = Hamming(a.GetEdgeHash(), b.GetEdgeHash()); |
| | | int shapeDist = Hamming(a.GetShapeHash(), b.GetShapeHash()); |
| | | int colorDist = ComputeColorDistance(a, b); |
| | | int alphaDist = System.Math.Abs(a.averageA - b.averageA); |
| | | int aspectSimilarity = ComputeAspectSimilarity(a, b); |
| | | return ComputeSimilarityScore(a, b, dist, cropDist, transformDist, edgeDist, shapeDist, colorDist, alphaDist, aspectSimilarity); |
| | | } |
| | | |
| | | static void AssertSelfCheck(bool condition, string check) |
| | | { |
| | | if (!condition) |
| | | throw new InvalidOperationException(check); |
| | | } |
| | | |
| | | public void OnQueryFromSelection() |
| | | { |
| | | _replacementTarget = null; |
| | | var obj = Selection.activeObject; |
| | | if (obj == null) return; |
| | | string path = AssetDatabase.GetAssetPath(obj); |
| | | QueryImage(path); |
| | | } |
| | | |
| | | void QueryImage(string path) |
| | | { |
| | | if (string.IsNullOrEmpty(path)) return; |
| | | if (!path.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && |
| | | !path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) && |
| | | !path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)) |
| | | return; |
| | | |
| | | _lastQueryPath = path; |
| | | _resultScroll = Vector2.zero; |
| | | |
| | | if (_queryTexture != null) |
| | | { |
| | |
| | | |
| | | void OnEnable() |
| | | { |
| | | SpriteSimilarityAssetPostprocessor.Changed += OnSpriteAssetsChanged; |
| | | LoadCache(); |
| | | if (_thumbCache == null) |
| | | _thumbCache = new Dictionary<string, Texture2D>(); |
| | |
| | | |
| | | void OnDisable() |
| | | { |
| | | SpriteSimilarityAssetPostprocessor.Changed -= OnSpriteAssetsChanged; |
| | | InvalidateNineSliceCache(); |
| | | if (_queryTexture != null) |
| | | { |
| | | DestroyImmediate(_queryTexture); |
| | |
| | | DestroyImmediate(_queryTexture); |
| | | _queryTexture = null; |
| | | } |
| | | } |
| | | |
| | | void OnSpriteAssetsChanged() |
| | | { |
| | | bool pending = _nineSliceCacheBuild != null; |
| | | InvalidateNineSliceCache(); |
| | | if (pending) |
| | | StartNineSliceCache(); |
| | | } |
| | | |
| | | void OnSelectionChange() |
| | |
| | | } |
| | | } |
| | | |
| | | void OnGUI() |
| | | static int CalculateResultColumns(float availableWidth, int maxColumns) |
| | | { |
| | | GUILayout.Label("选中一张图片,自动在 ResourcesOut/Sprite 下查找相同/相似 PNG"); |
| | | return System.Math.Max(1, System.Math.Min(maxColumns, (int)(availableWidth / RESULT_MIN_COLUMN_WIDTH))); |
| | | } |
| | | |
| | | static void RunResultColumnsSelfCheck() |
| | | { |
| | | AssertSelfCheck(CalculateResultColumns(0, 4) == 1, "Narrow window keeps one column"); |
| | | AssertSelfCheck(CalculateResultColumns(299, 4) == 1 && CalculateResultColumns(300, 4) == 2, "Column width boundary"); |
| | | AssertSelfCheck(CalculateResultColumns(450, 4) == 3, "Three column window"); |
| | | AssertSelfCheck(CalculateResultColumns(1800, 8) == 8, "Default column cap"); |
| | | AssertSelfCheck(CalculateResultColumns(1800, 6) == 6, "Custom column cap"); |
| | | AssertSelfCheck(CalculateResultColumns(1800, 1) == 1 && CalculateResultColumns(1800, 0) == 1, "Minimum column cap"); |
| | | } |
| | | |
| | | internal void DrawSettings() |
| | | { |
| | | EditorGUI.BeginChangeCheck(); |
| | | bool newFollow = EditorGUILayout.Toggle("跟随选择自动查询", _followSelection); |
| | | if (newFollow != _followSelection) |
| | | { |
| | |
| | | if (_followSelection) |
| | | OnQueryFromSelection(); |
| | | } |
| | | |
| | | _maxResultColumns = System.Math.Max(1, EditorGUILayout.IntField("最大列数", _maxResultColumns)); |
| | | |
| | | int prevThreshold = _threshold; |
| | | int prevEdgeThreshold = _edgeThreshold; |
| | |
| | | if ((prevScoreThreshold != _scoreThreshold || prevThreshold != _threshold || prevEdgeThreshold != _edgeThreshold || prevLocalPatchThreshold != _localPatchThreshold || prevLocalMinHits != _localMinHits) && !string.IsNullOrEmpty(_lastQueryPath)) |
| | | RunSearch(_lastQueryPath); |
| | | |
| | | if (EditorGUI.EndChangeCheck()) |
| | | Repaint(); |
| | | } |
| | | |
| | | void OnGUI() |
| | | { |
| | | EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); |
| | | if (GUILayout.Button("开始查找", EditorStyles.toolbarButton)) |
| | | OnQueryFromSelection(); |
| | | if (GUILayout.Button("刷新指纹库", EditorStyles.toolbarButton)) |
| | | BuildCache(false); |
| | | if (GUILayout.Button("重建指纹库", EditorStyles.toolbarButton)) |
| | | BuildCache(true); |
| | | GUILayout.FlexibleSpace(); |
| | | if (GUILayout.Button("设置", EditorStyles.toolbarButton)) |
| | | SpriteSimilaritySettingsWindow.ShowFor(this); |
| | | if (GUILayout.Button("说明", EditorStyles.toolbarButton)) |
| | | SpriteSimilarityHelpWindow.Open(); |
| | | EditorGUILayout.EndHorizontal(); |
| | | |
| | | if (_replacementTarget != null) |
| | | using (new EditorGUI.DisabledScope(true)) |
| | | EditorGUILayout.ObjectField("替换目标", _replacementTarget, typeof(UnityEngine.UI.Image), true); |
| | | |
| | | if (_queryTexture != null) |
| | | { |
| | | EditorGUILayout.BeginHorizontal(); |
| | | GUILayout.Label(_queryTexture, GUILayout.Width(96), GUILayout.Height(96)); |
| | | GUILayout.Label(string.IsNullOrEmpty(_lastQueryPath) ? "(未知)" : Path.GetFileName(_lastQueryPath)); |
| | | GUILayout.Label(_queryTexture, GUILayout.Width(64), GUILayout.Height(64)); |
| | | GUILayout.Label(string.IsNullOrEmpty(_lastQueryPath) ? "(未知)" : Path.GetFileNameWithoutExtension(_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.Separator(); |
| | | |
| | | string countText; |
| | | if (_results != null) |
| | | if (_nineSliceCacheBuild != null) |
| | | countText = $"正在初始化九宫格缓存:{_nineSliceCacheScanned}/{_cacheByPath.Count}"; |
| | | else if (_results != null) |
| | | { |
| | | countText = $"找到 {_results.Count} 个相同/相似图片"; |
| | | } |
| | |
| | | GUIStyle resultNameStyle = CreateResultNameStyle(); |
| | | GUIStyle resultMetaStyle = CreateResultMetaStyle(); |
| | | _resultScroll = EditorGUILayout.BeginScrollView(_resultScroll); |
| | | int resultRows = (_results.Count + RESULT_COLUMNS - 1) / RESULT_COLUMNS; |
| | | int resultColumns = CalculateResultColumns(position.width - GUI.skin.verticalScrollbar.fixedWidth - 8f, _maxResultColumns); |
| | | int resultRows = (_results.Count + resultColumns - 1) / resultColumns; |
| | | 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 (lastVisible > resultRows) |
| | | lastVisible = resultRows; |
| | | |
| | | float columnWidth = contentRect.width / RESULT_COLUMNS; |
| | | float columnWidth = contentRect.width / resultColumns; |
| | | |
| | | for (int row = firstVisible; row < lastVisible; row++) |
| | | { |
| | | for (int column = 0; column < RESULT_COLUMNS; column++) |
| | | for (int column = 0; column < resultColumns; column++) |
| | | { |
| | | int resultIndex = row * RESULT_COLUMNS + column; |
| | | int resultIndex = row * resultColumns + column; |
| | | if (resultIndex >= _results.Count) |
| | | continue; |
| | | Rect rowRect = new Rect(contentRect.x + column * columnWidth, contentRect.y + row * RESULT_ROW_HEIGHT, columnWidth, RESULT_ROW_HEIGHT); |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | class SpriteSimilarityAssetPostprocessor : AssetPostprocessor |
| | | { |
| | | internal static event Action Changed; |
| | | |
| | | static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) |
| | | { |
| | | if (ContainsSearchAsset(importedAssets) || ContainsSearchAsset(deletedAssets) || ContainsSearchAsset(movedAssets) || ContainsSearchAsset(movedFromAssetPaths)) |
| | | Changed?.Invoke(); |
| | | } |
| | | |
| | | internal static bool ContainsSearchAsset(string[] paths) |
| | | { |
| | | foreach (string path in paths) |
| | | { |
| | | string normalized = path.Replace('\\', '/'); |
| | | if (string.Equals(normalized, "Assets/ResourcesOut/Sprite", StringComparison.OrdinalIgnoreCase) || normalized.StartsWith("Assets/ResourcesOut/Sprite/", StringComparison.OrdinalIgnoreCase)) |
| | | return true; |
| | | } |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | class SpriteSimilaritySettingsWindow : EditorWindow |
| | | { |
| | | [SerializeField] SpriteSimilarityFinder _owner; |
| | | |
| | | internal static void ShowFor(SpriteSimilarityFinder owner) |
| | | { |
| | | var window = GetWindow<SpriteSimilaritySettingsWindow>(true, "相似图片查找设置", true); |
| | | window._owner = owner; |
| | | window.minSize = new Vector2(420f, 300f); |
| | | window.ShowUtility(); |
| | | } |
| | | |
| | | void OnGUI() |
| | | { |
| | | if (_owner == null) |
| | | { |
| | | Close(); |
| | | return; |
| | | } |
| | | _owner.DrawSettings(); |
| | | } |
| | | } |
| | | |
| | | class SpriteSimilarityHelpWindow : EditorWindow |
| | | { |
| | | Vector2 _scroll; |
| | | |
| | | internal static void Open() |
| | | { |
| | | var window = GetWindow<SpriteSimilarityHelpWindow>(true, "相似图片查找使用流程", true); |
| | | window.minSize = new Vector2(420f, 360f); |
| | | window.ShowUtility(); |
| | | } |
| | | |
| | | void OnGUI() |
| | | { |
| | | _scroll = EditorGUILayout.BeginScrollView(_scroll); |
| | | EditorGUILayout.BeginVertical(EditorStyles.inspectorDefaultMargins); |
| | | GUILayout.Label("相似图片查找 · 使用流程", EditorStyles.boldLabel); |
| | | GUILayout.Label("用于寻找可以复用的图片,并将候选图替换到指定的 ImageEx 组件。建议按以下步骤操作。", EditorStyles.wordWrappedLabel); |
| | | EditorGUILayout.Space(); |
| | | GUILayout.Label("1. 首次准备", EditorStyles.boldLabel); |
| | | GUILayout.Label("从 Unity 菜单“程序 → 相似图片查找”打开工具。首次使用点击“重建指纹库”,等待构建完成。工具会扫描 Assets/ResourcesOut/Sprite 下的 PNG,并一起生成指纹与九宫格缓存。已有有效缓存时可直接开始查询。", EditorStyles.wordWrappedLabel); |
| | | EditorGUILayout.Space(); |
| | | GUILayout.Label("2. 选择要查找的原图", EditorStyles.boldLabel); |
| | | GUILayout.Label("想替换界面中的图片:选中场景或预制体编辑模式中的对象,在 Inspector 中左键点击 ImageEx 的 Source Image 资源名,工具会查询该图片,并显示“替换目标”。\n只想找相似资源:在 Project 中选中图片,点击“开始查找”。开启设置中的“跟随选择自动查询”后,切换图片会自动查询,结果列表回到顶部。", EditorStyles.wordWrappedLabel); |
| | | EditorGUILayout.Space(); |
| | | GUILayout.Label("3. 挑选候选图片", EditorStyles.boldLabel); |
| | | GUILayout.Label("对照顶部原图,查看结果的预览、图片名和图集目录。工具优先展示当前图片所属顶层目录之外的候选,例如 FairySiege 下的原图会优先展示 Common、Guild 等其他目录的图片。\n点击候选预览图可在 Project 中定位资源;点击图片名可复制名称,不含扩展名。定位候选后仍可继续为原来的目标选择替换图。", EditorStyles.wordWrappedLabel); |
| | | EditorGUILayout.Space(); |
| | | GUILayout.Label("4. 替换并检查效果", EditorStyles.boldLabel); |
| | | GUILayout.Label("确认主界面的“替换目标”是要修改的组件,再点击候选的“替换”。会修改该组件的 Source Image;候选图片设有九宫格边距时,会同时将 Image Type 设为 Sliced。无九宫格边距时保留原类型,其他属性保持不变,图片和类型可一起撤销。\n回到界面检查外观与拉伸效果,不合适可撤销,再选择其他候选。确认效果后,按项目原有流程保存场景或预制体。\n如果“替换”不可用,说明当前没有组件替换目标,请回到步骤 2,从 ImageEx 的 Source Image 资源名发起查询。", EditorStyles.wordWrappedLabel); |
| | | EditorGUILayout.Space(); |
| | | GUILayout.Label("5. 找不到合适的图时", EditorStyles.boldLabel); |
| | | GUILayout.Label("打开“设置”,降低总分阈值以扩大候选范围;候选太多时则提高阈值。总分虽不显示,仍参与筛选与排序。其他标签参数通常保持默认即可。\n长条、面板等可拉伸图片会参考 Sprite Border 进行九宫格匹配,选择后仍需检查实际显示效果。\n可按阅读习惯调整最大列数,默认 8、最小 1,实际列数随窗口宽度变化。", EditorStyles.wordWrappedLabel); |
| | | EditorGUILayout.Space(); |
| | | GUILayout.Label("6. 后续日常使用", EditorStyles.boldLabel); |
| | | GUILayout.Label("平时直接选择原图查询,无需每次重建。新增、删除、移动或修改图片,以及调整九宫格边距后,点击“刷新指纹库”更新;需要完整重新计算时点击“重建指纹库”。\n刷新会复用未变化的指纹,重建会重新计算全部指纹,两者都会生成九宫格采样。缓存保存在 Assets/Editor/Tool,后续切换图片和重启可直接复用。", EditorStyles.wordWrappedLabel); |
| | | EditorGUILayout.EndVertical(); |
| | | EditorGUILayout.EndScrollView(); |
| | | } |
| | | } |