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