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();
|
}
|
}
|
}
|