三国卡牌客户端基础资源仓库
lcy
2026-09-11 135365ad662781b4bbd0cdf6abfb6d334b113326
634 相似图片搜索工具体验优化
2个文件已修改
1336 ■■■■■ 已修改文件
Assets/Editor/Tool/SpriteSimilarityFinder.cs 1323 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Assets/Editor/UIComponent/ImageExEditor.cs 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Assets/Editor/Tool/SpriteSimilarityFinder.cs
@@ -13,6 +13,14 @@
    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;
@@ -31,6 +39,7 @@
    public long[] patchHashHi;
    public int patchCount;
    public string md5;
    public string pixelMd5;
    public int ver;
    public ulong GetHash()
@@ -154,6 +163,7 @@
    TransformSimilar,
    EdgeSimilar,
    NineSliceSimilar,
    NineSliceStretched,
    LocalSimilar
}
@@ -167,11 +177,16 @@
    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
@@ -184,6 +199,7 @@
            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})";
        }
@@ -194,7 +210,9 @@
{
    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;
@@ -205,49 +223,55 @@
    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;
@@ -257,12 +281,20 @@
    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;
@@ -303,6 +335,7 @@
        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("程序/相似图片查找")]
@@ -426,11 +459,20 @@
            }
        }
        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;
@@ -438,7 +480,9 @@
        {
            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++;
            }
@@ -453,7 +497,6 @@
            pixels = pixels,
            width = width,
            height = height,
            integral = new float[(width + 1) * (height + 1)],
            cropX = 0,
            cropY = 0,
            cropW = width,
@@ -470,30 +513,19 @@
        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;
            }
        }
@@ -505,10 +537,7 @@
            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++)
@@ -537,10 +566,10 @@
        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++)
        {
@@ -554,10 +583,10 @@
                    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++;
                    }
                }
@@ -573,10 +602,15 @@
            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)
@@ -590,36 +624,19 @@
    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)
        {
@@ -629,16 +646,45 @@
        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)
@@ -866,6 +912,11 @@
    {
        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
        {
@@ -874,7 +925,13 @@
            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],
@@ -886,12 +943,19 @@
        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);
@@ -922,17 +986,72 @@
                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)
@@ -958,6 +1077,7 @@
    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);
@@ -969,9 +1089,11 @@
        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()
@@ -1000,7 +1122,7 @@
        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;
@@ -1016,30 +1138,22 @@
        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)
@@ -1047,9 +1161,75 @@
            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);
        }
    }
@@ -1090,72 +1270,67 @@
        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()
@@ -1195,32 +1370,10 @@
        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)
@@ -1228,31 +1381,17 @@
        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;
@@ -1304,6 +1443,7 @@
    void LoadCache()
    {
        InvalidateNineSliceCache();
        _cacheByPath = new Dictionary<string, FingerPrint>();
        string cacheFile = ToAbsolutePath(CACHE_PATH);
        if (!File.Exists(cacheFile))
@@ -1464,6 +1604,7 @@
    void BuildCache(bool forceRebuild)
    {
        InvalidateNineSliceCache();
        string absRoot = ToAbsolutePath(SEARCH_ROOT);
        var dirInfo = new DirectoryInfo(absRoot);
        if (!dirInfo.Exists)
@@ -1486,6 +1627,8 @@
        }
        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;
@@ -1533,17 +1676,17 @@
                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();
@@ -1571,6 +1714,20 @@
                    }
                    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++)
@@ -1625,6 +1782,11 @@
            _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();
@@ -1645,9 +1807,12 @@
            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());
@@ -1656,54 +1821,52 @@
            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;
            }
@@ -1712,10 +1875,10 @@
                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
            {
@@ -1724,42 +1887,344 @@
                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)
@@ -1789,6 +2254,13 @@
            return false;
        }
        if (_nineSliceCandidates == null)
        {
            StartNineSliceCache();
            if (_nineSliceCandidates == null)
                return false;
        }
        string absPath = ToAbsolutePath(queryAssetPath);
        Texture2D tex;
        bool ownsTex = false;
@@ -1815,6 +2287,7 @@
        {
            _queryMD5 = ComputeMD5(absPath);
            _queryFingerprint = CreateFingerprint(tex, queryAssetPath, 0, 0, _queryMD5, true);
            BuildNineSliceScores(tex, queryAssetPath);
            _queryIncludesPatch = true;
        }
        finally
@@ -1839,16 +2312,176 @@
        }
    }
    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)
        {
@@ -1873,6 +2506,7 @@
    void OnEnable()
    {
        SpriteSimilarityAssetPostprocessor.Changed += OnSpriteAssetsChanged;
        LoadCache();
        if (_thumbCache == null)
            _thumbCache = new Dictionary<string, Texture2D>();
@@ -1882,6 +2516,8 @@
    void OnDisable()
    {
        SpriteSimilarityAssetPostprocessor.Changed -= OnSpriteAssetsChanged;
        InvalidateNineSliceCache();
        if (_queryTexture != null)
        {
            DestroyImmediate(_queryTexture);
@@ -1896,6 +2532,14 @@
            DestroyImmediate(_queryTexture);
            _queryTexture = null;
        }
    }
    void OnSpriteAssetsChanged()
    {
        bool pending = _nineSliceCacheBuild != null;
        InvalidateNineSliceCache();
        if (pending)
            StartNineSliceCache();
    }
    void OnSelectionChange()
@@ -1918,10 +2562,24 @@
        }
    }
    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)
        {
@@ -1929,6 +2587,8 @@
            if (_followSelection)
                OnQueryFromSelection();
        }
        _maxResultColumns = System.Math.Max(1, EditorGUILayout.IntField("最大列数", _maxResultColumns));
        int prevThreshold = _threshold;
        int prevEdgeThreshold = _edgeThreshold;
@@ -1943,22 +2603,37 @@
        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)
        {
@@ -1968,7 +2643,9 @@
        EditorGUILayout.Separator();
        string countText;
        if (_results != null)
        if (_nineSliceCacheBuild != null)
            countText = $"正在初始化九宫格缓存:{_nineSliceCacheScanned}/{_cacheByPath.Count}";
        else if (_results != null)
        {
            countText = $"找到 {_results.Count} 个相同/相似图片";
        }
@@ -1983,7 +2660,8 @@
            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;
@@ -1996,13 +2674,13 @@
            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);
@@ -2013,3 +2691,88 @@
        }
    }
}
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();
    }
}
Assets/Editor/UIComponent/ImageExEditor.cs
@@ -17,6 +17,19 @@
    public override void OnInspectorGUI()
    {
        Rect marker = GUILayoutUtility.GetRect(0f, 0f, GUILayout.ExpandWidth(true));
        Rect spriteNameRect = new Rect(marker.x + EditorGUIUtility.labelWidth, marker.yMax + EditorGUIUtility.standardVerticalSpacing, marker.width - EditorGUIUtility.labelWidth - 20f, EditorGUIUtility.singleLineHeight);
        ImageEx image = (ImageEx)target;
        if (targets.Length == 1 && image.sprite != null)
        {
            EditorGUIUtility.AddCursorRect(spriteNameRect, MouseCursor.Link);
            Event current = Event.current;
            if (current.type == EventType.MouseDown && current.button == 0 && spriteNameRect.Contains(current.mousePosition))
            {
                current.Use();
                SpriteSimilarityFinder.FindFromImage(image);
            }
        }
        base.OnInspectorGUI();
        EditorGUILayout.Space();