.
This commit is contained in:
parent
86aa0f856c
commit
0801ceee6a
310 changed files with 6712 additions and 418 deletions
146
test/Scry.Tests/CardHashDatabaseTests.cs
Normal file
146
test/Scry.Tests/CardHashDatabaseTests.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
using Microsoft.Data.Sqlite;
|
||||
using Scry.Core.Data;
|
||||
using Scry.Core.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace Scry.Tests;
|
||||
|
||||
public class CardHashDatabaseTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
private readonly CardHashDatabase _database;
|
||||
|
||||
public CardHashDatabaseTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"scry_test_{Guid.NewGuid()}.db");
|
||||
_database = new CardHashDatabase(_dbPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InsertHash_ThenRetrieve_ReturnsMatch()
|
||||
{
|
||||
var hash = new CardHash
|
||||
{
|
||||
CardId = "test-id",
|
||||
Name = "Test Card",
|
||||
SetCode = "TST",
|
||||
CollectorNumber = "1",
|
||||
Hash = new byte[] { 0x01, 0x02, 0x03 },
|
||||
ImageUri = "https://example.com/image.jpg"
|
||||
};
|
||||
|
||||
await _database.InsertHashAsync(hash);
|
||||
var retrieved = await _database.GetHashByIdAsync("test-id");
|
||||
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("Test Card", retrieved.Name);
|
||||
Assert.Equal("TST", retrieved.SetCode);
|
||||
Assert.Equal(hash.Hash, retrieved.Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InsertHashBatch_InsertsAllHashes()
|
||||
{
|
||||
var hashes = Enumerable.Range(0, 100).Select(i => new CardHash
|
||||
{
|
||||
CardId = $"card-{i}",
|
||||
Name = $"Card {i}",
|
||||
SetCode = "TST",
|
||||
Hash = new byte[] { (byte)i }
|
||||
}).ToList();
|
||||
|
||||
await _database.InsertHashBatchAsync(hashes);
|
||||
var count = await _database.GetHashCountAsync();
|
||||
|
||||
Assert.Equal(100, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAllHashes_ReturnsAllHashes()
|
||||
{
|
||||
var hashes = Enumerable.Range(0, 10).Select(i => new CardHash
|
||||
{
|
||||
CardId = $"card-{i}",
|
||||
Name = $"Card {i}",
|
||||
SetCode = "TST",
|
||||
Hash = new byte[] { (byte)i }
|
||||
}).ToList();
|
||||
|
||||
await _database.InsertHashBatchAsync(hashes);
|
||||
var all = await _database.GetAllHashesAsync();
|
||||
|
||||
Assert.Equal(10, all.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Metadata_SetAndGet()
|
||||
{
|
||||
await _database.SetMetadataAsync("test_key", "test_value");
|
||||
var value = await _database.GetMetadataAsync("test_key");
|
||||
|
||||
Assert.Equal("test_value", value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_RemovesAllHashes()
|
||||
{
|
||||
var hashes = Enumerable.Range(0, 10).Select(i => new CardHash
|
||||
{
|
||||
CardId = $"card-{i}",
|
||||
Name = $"Card {i}",
|
||||
SetCode = "TST",
|
||||
Hash = new byte[] { (byte)i }
|
||||
}).ToList();
|
||||
|
||||
await _database.InsertHashBatchAsync(hashes);
|
||||
await _database.ClearAsync();
|
||||
var count = await _database.GetHashCountAsync();
|
||||
|
||||
Assert.Equal(0, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InsertHash_DuplicateId_Updates()
|
||||
{
|
||||
var hash1 = new CardHash
|
||||
{
|
||||
CardId = "duplicate-id",
|
||||
Name = "Original Name",
|
||||
SetCode = "TST",
|
||||
Hash = new byte[] { 0x01 }
|
||||
};
|
||||
|
||||
var hash2 = new CardHash
|
||||
{
|
||||
CardId = "duplicate-id",
|
||||
Name = "Updated Name",
|
||||
SetCode = "TST",
|
||||
Hash = new byte[] { 0x02 }
|
||||
};
|
||||
|
||||
await _database.InsertHashAsync(hash1);
|
||||
await _database.InsertHashAsync(hash2);
|
||||
|
||||
var retrieved = await _database.GetHashByIdAsync("duplicate-id");
|
||||
|
||||
Assert.NotNull(retrieved);
|
||||
Assert.Equal("Updated Name", retrieved.Name);
|
||||
Assert.Equal(new byte[] { 0x02 }, retrieved.Hash);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_database.Dispose();
|
||||
SqliteConnection.ClearAllPools();
|
||||
try
|
||||
{
|
||||
if (File.Exists(_dbPath))
|
||||
{
|
||||
File.Delete(_dbPath);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
234
test/Scry.Tests/CardRecognitionTests.cs
Normal file
234
test/Scry.Tests/CardRecognitionTests.cs
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
using Microsoft.Data.Sqlite;
|
||||
using Scry.Core.Data;
|
||||
using Scry.Core.Imaging;
|
||||
using Scry.Core.Models;
|
||||
using Scry.Core.Recognition;
|
||||
using SkiaSharp;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Scry.Tests;
|
||||
|
||||
public class CardRecognitionTests : IDisposable
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
private readonly string _dbPath;
|
||||
private readonly CardHashDatabase _database;
|
||||
private readonly CardRecognitionService _recognitionService;
|
||||
|
||||
public CardRecognitionTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"scry_recognition_test_{Guid.NewGuid()}.db");
|
||||
_database = new CardHashDatabase(_dbPath);
|
||||
_recognitionService = new CardRecognitionService(_database);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecognizeAsync_EmptyDatabase_ReturnsFailed()
|
||||
{
|
||||
using var bitmap = CreateTestBitmap(100, 100);
|
||||
|
||||
var result = await _recognitionService.RecognizeAsync(bitmap);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("No card hashes", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecognizeAsync_ExactMatch_ReturnsSuccess()
|
||||
{
|
||||
using var bitmap = CreateTestBitmap(100, 100);
|
||||
var hash = _recognitionService.ComputeHash(bitmap);
|
||||
|
||||
await _database.InsertHashAsync(new CardHash
|
||||
{
|
||||
CardId = "test-card",
|
||||
Name = "Test Card",
|
||||
SetCode = "TST",
|
||||
Hash = hash,
|
||||
ImageUri = "https://example.com/test.jpg"
|
||||
});
|
||||
await _recognitionService.InvalidateCacheAsync();
|
||||
|
||||
var result = await _recognitionService.RecognizeAsync(bitmap);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("Test Card", result.Card?.Name);
|
||||
Assert.Equal(1.0f, result.Confidence);
|
||||
Assert.Equal(0, result.HammingDistance);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("reference/brainstorm.png")]
|
||||
[InlineData("reference/force_of_will.png")]
|
||||
[InlineData("single_cards/llanowar_elves.jpg")]
|
||||
public async Task RecognizeAsync_ReferenceImage_SelfMatch(string imagePath)
|
||||
{
|
||||
var fullPath = Path.Combine("TestImages", imagePath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
_output.WriteLine($"Skipping test - file not found: {fullPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var bitmap = SKBitmap.Decode(fullPath);
|
||||
Assert.NotNull(bitmap);
|
||||
|
||||
var hash = _recognitionService.ComputeHash(bitmap);
|
||||
var cardName = Path.GetFileNameWithoutExtension(imagePath);
|
||||
|
||||
await _database.InsertHashAsync(new CardHash
|
||||
{
|
||||
CardId = cardName,
|
||||
Name = cardName,
|
||||
SetCode = "REF",
|
||||
Hash = hash
|
||||
});
|
||||
await _recognitionService.InvalidateCacheAsync();
|
||||
|
||||
var result = await _recognitionService.RecognizeAsync(bitmap);
|
||||
|
||||
Assert.True(result.Success, $"Recognition failed: {result.ErrorMessage}");
|
||||
Assert.Equal(cardName, result.Card?.Name);
|
||||
Assert.True(result.Confidence >= 0.85f);
|
||||
|
||||
_output.WriteLine($"Matched: {cardName}, Confidence: {result.Confidence:P0}, Distance: {result.HammingDistance}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecognizeAsync_SerraAngelFromDatabase_Matches()
|
||||
{
|
||||
// Find the solution root (where .git is)
|
||||
var currentDir = Directory.GetCurrentDirectory();
|
||||
var rootDir = currentDir;
|
||||
while (!Directory.Exists(Path.Combine(rootDir, ".git")) && Directory.GetParent(rootDir) != null)
|
||||
{
|
||||
rootDir = Directory.GetParent(rootDir)!.FullName;
|
||||
}
|
||||
|
||||
var dbPath = Path.Combine(rootDir, "src", "Scry.App", "Resources", "Raw", "card_hashes.db");
|
||||
if (!File.Exists(dbPath))
|
||||
{
|
||||
_output.WriteLine($"Skipping - database not found at {dbPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
var imagePath = Path.Combine(rootDir, "TestImages", "reference_alpha", "serra_angel.jpg");
|
||||
if (!File.Exists(imagePath))
|
||||
{
|
||||
_output.WriteLine($"Skipping - image not found at {imagePath}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var testDb = new CardHashDatabase(dbPath);
|
||||
using var testRecognition = new CardRecognitionService(testDb);
|
||||
|
||||
using var bitmap = SKBitmap.Decode(imagePath);
|
||||
Assert.NotNull(bitmap);
|
||||
|
||||
// First, just compute hash and check distance manually
|
||||
var queryHash = testRecognition.ComputeHash(bitmap);
|
||||
var allHashes = await testDb.GetAllHashesAsync();
|
||||
|
||||
_output.WriteLine($"Query hash length: {queryHash.Length} bytes");
|
||||
_output.WriteLine($"Database has {allHashes.Count} cards");
|
||||
|
||||
// Find Serra Angel and compute distance
|
||||
var serraHash = allHashes.FirstOrDefault(h => h.Name == "Serra Angel");
|
||||
if (serraHash != null)
|
||||
{
|
||||
var distance = PerceptualHash.HammingDistance(queryHash, serraHash.Hash);
|
||||
_output.WriteLine($"Serra Angel hash length: {serraHash.Hash.Length} bytes");
|
||||
_output.WriteLine($"Distance to Serra Angel: {distance}");
|
||||
}
|
||||
|
||||
// Find the actual best match
|
||||
int bestDistance = int.MaxValue;
|
||||
string? bestName = null;
|
||||
foreach (var hash in allHashes)
|
||||
{
|
||||
if (hash.Hash.Length != queryHash.Length) continue;
|
||||
var dist = PerceptualHash.HammingDistance(queryHash, hash.Hash);
|
||||
if (dist < bestDistance)
|
||||
{
|
||||
bestDistance = dist;
|
||||
bestName = hash.Name;
|
||||
}
|
||||
}
|
||||
_output.WriteLine($"Best match: {bestName}, distance: {bestDistance}");
|
||||
|
||||
// Now try actual recognition
|
||||
var result = await testRecognition.RecognizeAsync(bitmap);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
_output.WriteLine($"Recognition succeeded: {result.Card?.Name}, confidence: {result.Confidence:P0}");
|
||||
Assert.Equal("Serra Angel", result.Card?.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_output.WriteLine($"Recognition failed: {result.ErrorMessage}");
|
||||
// For debugging - this should be 0 since we're using the exact same image
|
||||
Assert.Fail($"Expected to match Serra Angel, but got: {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecognizeAsync_MeasuresProcessingTime()
|
||||
{
|
||||
using var bitmap = CreateTestBitmap(200, 300);
|
||||
var hash = _recognitionService.ComputeHash(bitmap);
|
||||
|
||||
await _database.InsertHashAsync(new CardHash
|
||||
{
|
||||
CardId = "timing-test",
|
||||
Name = "Timing Test Card",
|
||||
SetCode = "TST",
|
||||
Hash = hash
|
||||
});
|
||||
await _recognitionService.InvalidateCacheAsync();
|
||||
|
||||
var result = await _recognitionService.RecognizeAsync(bitmap);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.ProcessingTime.TotalMilliseconds > 0);
|
||||
_output.WriteLine($"Processing time: {result.ProcessingTime.TotalMilliseconds:F2}ms");
|
||||
}
|
||||
|
||||
private static SKBitmap CreateTestBitmap(int width, int height)
|
||||
{
|
||||
var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
|
||||
var random = new Random(42);
|
||||
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var r = (byte)random.Next(256);
|
||||
var g = (byte)random.Next(256);
|
||||
var b = (byte)random.Next(256);
|
||||
bitmap.SetPixel(x, y, new SKColor(r, g, b));
|
||||
}
|
||||
}
|
||||
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_recognitionService.Dispose();
|
||||
_database.Dispose();
|
||||
SqliteConnection.ClearAllPools();
|
||||
try
|
||||
{
|
||||
if (File.Exists(_dbPath))
|
||||
{
|
||||
File.Delete(_dbPath);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
131
test/Scry.Tests/ImagePreprocessorTests.cs
Normal file
131
test/Scry.Tests/ImagePreprocessorTests.cs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
using Scry.Core.Imaging;
|
||||
using SkiaSharp;
|
||||
using Xunit;
|
||||
|
||||
namespace Scry.Tests;
|
||||
|
||||
public class ImagePreprocessorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Resize_ProducesCorrectDimensions()
|
||||
{
|
||||
using var bitmap = CreateTestBitmap(100, 100);
|
||||
|
||||
using var resized = ImagePreprocessor.Resize(bitmap, 32, 32);
|
||||
|
||||
Assert.Equal(32, resized.Width);
|
||||
Assert.Equal(32, resized.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToGrayscale_ProducesGrayscaleImage()
|
||||
{
|
||||
using var bitmap = CreateColorBitmap(10, 10);
|
||||
|
||||
using var grayscale = ImagePreprocessor.ToGrayscale(bitmap);
|
||||
|
||||
Assert.Equal(10, grayscale.Width);
|
||||
Assert.Equal(10, grayscale.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyClahe_PreservesDimensions()
|
||||
{
|
||||
using var bitmap = CreateTestBitmap(64, 64);
|
||||
|
||||
using var result = ImagePreprocessor.ApplyClahe(bitmap);
|
||||
|
||||
Assert.Equal(64, result.Width);
|
||||
Assert.Equal(64, result.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyClahe_EnhancesContrast()
|
||||
{
|
||||
using var bitmap = CreateLowContrastBitmap(64, 64);
|
||||
|
||||
using var result = ImagePreprocessor.ApplyClahe(bitmap);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(bitmap.Width, result.Width);
|
||||
Assert.Equal(bitmap.Height, result.Height);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("varying_quality/test1.jpg")]
|
||||
[InlineData("low_light/glare_toploader.png")]
|
||||
[InlineData("foil/rainbow_foil_secret_lair.jpg")]
|
||||
public void ApplyClahe_RealImages_DoesNotCrash(string imagePath)
|
||||
{
|
||||
var fullPath = Path.Combine("TestImages", imagePath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var bitmap = SKBitmap.Decode(fullPath);
|
||||
Assert.NotNull(bitmap);
|
||||
|
||||
using var result = ImagePreprocessor.ApplyClahe(bitmap);
|
||||
|
||||
Assert.Equal(bitmap.Width, result.Width);
|
||||
Assert.Equal(bitmap.Height, result.Height);
|
||||
}
|
||||
|
||||
private static SKBitmap CreateTestBitmap(int width, int height)
|
||||
{
|
||||
var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
|
||||
using var canvas = new SKCanvas(bitmap);
|
||||
canvas.Clear(SKColors.Gray);
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private static SKBitmap CreateColorBitmap(int width, int height)
|
||||
{
|
||||
var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var r = (byte)(x * 255 / width);
|
||||
var g = (byte)(y * 255 / height);
|
||||
var b = (byte)((x + y) * 127 / (width + height));
|
||||
bitmap.SetPixel(x, y, new SKColor(r, g, b));
|
||||
}
|
||||
}
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private static SKBitmap CreateLowContrastBitmap(int width, int height)
|
||||
{
|
||||
var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var gray = (byte)(120 + (x + y) % 20);
|
||||
bitmap.SetPixel(x, y, new SKColor(gray, gray, gray));
|
||||
}
|
||||
}
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private static (byte Min, byte Max) GetLuminanceRange(SKBitmap bitmap)
|
||||
{
|
||||
byte min = 255;
|
||||
byte max = 0;
|
||||
|
||||
for (var y = 0; y < bitmap.Height; y++)
|
||||
{
|
||||
for (var x = 0; x < bitmap.Width; x++)
|
||||
{
|
||||
var pixel = bitmap.GetPixel(x, y);
|
||||
var luminance = (byte)(0.299 * pixel.Red + 0.587 * pixel.Green + 0.114 * pixel.Blue);
|
||||
min = Math.Min(min, luminance);
|
||||
max = Math.Max(max, luminance);
|
||||
}
|
||||
}
|
||||
|
||||
return (min, max);
|
||||
}
|
||||
}
|
||||
148
test/Scry.Tests/PerceptualHashTests.cs
Normal file
148
test/Scry.Tests/PerceptualHashTests.cs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
using Scry.Core.Imaging;
|
||||
using SkiaSharp;
|
||||
using Xunit;
|
||||
|
||||
namespace Scry.Tests;
|
||||
|
||||
public class PerceptualHashTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeHash_ReturnsConsistentHash()
|
||||
{
|
||||
using var bitmap = CreateTestBitmap(32, 32, SKColors.Red);
|
||||
|
||||
var hash1 = PerceptualHash.ComputeHash(bitmap);
|
||||
var hash2 = PerceptualHash.ComputeHash(bitmap);
|
||||
|
||||
Assert.Equal(hash1, hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeColorHash_Returns24Bytes()
|
||||
{
|
||||
using var bitmap = CreateTestBitmap(32, 32, SKColors.Blue);
|
||||
|
||||
var hash = PerceptualHash.ComputeColorHash(bitmap);
|
||||
|
||||
Assert.Equal(24, hash.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HammingDistance_IdenticalHashes_ReturnsZero()
|
||||
{
|
||||
var hash = new byte[] { 0xFF, 0x00, 0xAB, 0xCD };
|
||||
|
||||
var distance = PerceptualHash.HammingDistance(hash, hash);
|
||||
|
||||
Assert.Equal(0, distance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HammingDistance_OppositeHashes_ReturnsMaxBits()
|
||||
{
|
||||
var hash1 = new byte[] { 0x00, 0x00 };
|
||||
var hash2 = new byte[] { 0xFF, 0xFF };
|
||||
|
||||
var distance = PerceptualHash.HammingDistance(hash1, hash2);
|
||||
|
||||
Assert.Equal(16, distance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HammingDistance_SingleBitDifference()
|
||||
{
|
||||
var hash1 = new byte[] { 0b00000000 };
|
||||
var hash2 = new byte[] { 0b00000001 };
|
||||
|
||||
var distance = PerceptualHash.HammingDistance(hash1, hash2);
|
||||
|
||||
Assert.Equal(1, distance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateConfidence_ZeroDistance_ReturnsOne()
|
||||
{
|
||||
var confidence = PerceptualHash.CalculateConfidence(0, 192);
|
||||
|
||||
Assert.Equal(1.0f, confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateConfidence_HalfDistance_ReturnsHalf()
|
||||
{
|
||||
var confidence = PerceptualHash.CalculateConfidence(96, 192);
|
||||
|
||||
Assert.Equal(0.5f, confidence);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("reference/brainstorm.png")]
|
||||
[InlineData("reference/force_of_will.png")]
|
||||
[InlineData("single_cards/llanowar_elves.jpg")]
|
||||
public void ComputeColorHash_RealImages_ProducesValidHash(string imagePath)
|
||||
{
|
||||
var fullPath = Path.Combine("TestImages", imagePath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var bitmap = SKBitmap.Decode(fullPath);
|
||||
Assert.NotNull(bitmap);
|
||||
|
||||
var hash = PerceptualHash.ComputeColorHash(bitmap);
|
||||
|
||||
Assert.Equal(24, hash.Length);
|
||||
Assert.True(hash.Any(b => b != 0), "Hash should not be all zeros");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SimilarImages_HaveLowHammingDistance()
|
||||
{
|
||||
using var bitmap1 = CreateGradientBitmap(32, 32, SKColors.Red, SKColors.Blue);
|
||||
using var bitmap2 = CreateGradientBitmap(32, 32, SKColors.Red, SKColors.Blue);
|
||||
|
||||
var hash1 = PerceptualHash.ComputeColorHash(bitmap1);
|
||||
var hash2 = PerceptualHash.ComputeColorHash(bitmap2);
|
||||
|
||||
var distance = PerceptualHash.HammingDistance(hash1, hash2);
|
||||
|
||||
Assert.Equal(0, distance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentImages_HaveHighHammingDistance()
|
||||
{
|
||||
using var bitmap1 = CreateTestBitmap(32, 32, SKColors.Red);
|
||||
using var bitmap2 = CreateTestBitmap(32, 32, SKColors.Blue);
|
||||
|
||||
var hash1 = PerceptualHash.ComputeColorHash(bitmap1);
|
||||
var hash2 = PerceptualHash.ComputeColorHash(bitmap2);
|
||||
|
||||
var distance = PerceptualHash.HammingDistance(hash1, hash2);
|
||||
|
||||
Assert.True(distance > 10, $"Expected distance > 10, got {distance}");
|
||||
}
|
||||
|
||||
private static SKBitmap CreateTestBitmap(int width, int height, SKColor color)
|
||||
{
|
||||
var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
|
||||
using var canvas = new SKCanvas(bitmap);
|
||||
canvas.Clear(color);
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private static SKBitmap CreateGradientBitmap(int width, int height, SKColor start, SKColor end)
|
||||
{
|
||||
var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
|
||||
using var canvas = new SKCanvas(bitmap);
|
||||
using var paint = new SKPaint();
|
||||
paint.Shader = SKShader.CreateLinearGradient(
|
||||
new SKPoint(0, 0),
|
||||
new SKPoint(width, height),
|
||||
new[] { start, end },
|
||||
SKShaderTileMode.Clamp);
|
||||
canvas.DrawRect(0, 0, width, height, paint);
|
||||
return bitmap;
|
||||
}
|
||||
}
|
||||
480
test/Scry.Tests/RobustnessAnalysisTests.cs
Normal file
480
test/Scry.Tests/RobustnessAnalysisTests.cs
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
using Scry.Core.Data;
|
||||
using Scry.Core.Imaging;
|
||||
using Scry.Core.Models;
|
||||
using Scry.Core.Recognition;
|
||||
using SkiaSharp;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Scry.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests to analyze robustness of perceptual hashing under various camera scanning conditions.
|
||||
/// </summary>
|
||||
public class RobustnessAnalysisTests : IDisposable
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
private readonly string _dbPath;
|
||||
private readonly CardHashDatabase _database;
|
||||
private readonly CardRecognitionService _recognitionService;
|
||||
|
||||
public RobustnessAnalysisTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"scry_robustness_test_{Guid.NewGuid()}.db");
|
||||
_database = new CardHashDatabase(_dbPath);
|
||||
_recognitionService = new CardRecognitionService(_database);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test how rotation affects hash matching.
|
||||
/// pHash uses DCT which is NOT rotation invariant - this tests the impact.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(5)] // Slight tilt
|
||||
[InlineData(15)] // Moderate tilt
|
||||
[InlineData(45)] // Significant rotation
|
||||
[InlineData(90)] // Portrait vs landscape
|
||||
[InlineData(180)] // Upside down
|
||||
public async Task Rotation_ImpactOnHashDistance(int rotationDegrees)
|
||||
{
|
||||
var imagePath = FindTestImage("reference_alpha/serra_angel.jpg");
|
||||
if (imagePath == null)
|
||||
{
|
||||
_output.WriteLine("Test image not found, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
using var original = SKBitmap.Decode(imagePath);
|
||||
Assert.NotNull(original);
|
||||
|
||||
// Compute hash of original
|
||||
var originalHash = _recognitionService.ComputeHash(original);
|
||||
|
||||
// Register original in database
|
||||
await _database.InsertHashAsync(new CardHash
|
||||
{
|
||||
CardId = "serra-angel",
|
||||
Name = "Serra Angel",
|
||||
SetCode = "LEA",
|
||||
Hash = originalHash
|
||||
});
|
||||
await _recognitionService.InvalidateCacheAsync();
|
||||
|
||||
// Rotate the image
|
||||
using var rotated = RotateImage(original, rotationDegrees);
|
||||
var rotatedHash = _recognitionService.ComputeHash(rotated);
|
||||
|
||||
var distance = PerceptualHash.HammingDistance(originalHash, rotatedHash);
|
||||
var confidence = PerceptualHash.CalculateConfidence(distance, 192);
|
||||
|
||||
// Try recognition
|
||||
var result = await _recognitionService.RecognizeAsync(rotated);
|
||||
|
||||
_output.WriteLine($"Rotation: {rotationDegrees}°");
|
||||
_output.WriteLine($" Hamming distance: {distance}/192 bits");
|
||||
_output.WriteLine($" Confidence: {confidence:P0}");
|
||||
_output.WriteLine($" Recognition: {(result.Success ? "SUCCESS" : "FAILED")}");
|
||||
_output.WriteLine($" Result: {result.Card?.Name ?? result.ErrorMessage}");
|
||||
|
||||
// Document expected behavior
|
||||
if (rotationDegrees <= 5)
|
||||
{
|
||||
// Small rotations might still work
|
||||
_output.WriteLine($" [Small rotation - may still match]");
|
||||
}
|
||||
else
|
||||
{
|
||||
// pHash is NOT rotation invariant - this is expected to fail
|
||||
_output.WriteLine($" [pHash is NOT rotation invariant - failure expected]");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test how scaling/distance affects hash matching.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0.25f)] // Very small in frame (far away)
|
||||
[InlineData(0.50f)] // Half size
|
||||
[InlineData(0.75f)] // 3/4 size
|
||||
[InlineData(1.25f)] // Slightly larger
|
||||
[InlineData(2.0f)] // Double size (close up)
|
||||
public async Task Scale_ImpactOnHashDistance(float scaleFactor)
|
||||
{
|
||||
var imagePath = FindTestImage("reference_alpha/serra_angel.jpg");
|
||||
if (imagePath == null)
|
||||
{
|
||||
_output.WriteLine("Test image not found, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
using var original = SKBitmap.Decode(imagePath);
|
||||
Assert.NotNull(original);
|
||||
|
||||
var originalHash = _recognitionService.ComputeHash(original);
|
||||
|
||||
await _database.InsertHashAsync(new CardHash
|
||||
{
|
||||
CardId = "serra-angel",
|
||||
Name = "Serra Angel",
|
||||
SetCode = "LEA",
|
||||
Hash = originalHash
|
||||
});
|
||||
await _recognitionService.InvalidateCacheAsync();
|
||||
|
||||
// Scale the image
|
||||
var newWidth = (int)(original.Width * scaleFactor);
|
||||
var newHeight = (int)(original.Height * scaleFactor);
|
||||
using var scaled = ImagePreprocessor.Resize(original, newWidth, newHeight);
|
||||
var scaledHash = _recognitionService.ComputeHash(scaled);
|
||||
|
||||
var distance = PerceptualHash.HammingDistance(originalHash, scaledHash);
|
||||
var confidence = PerceptualHash.CalculateConfidence(distance, 192);
|
||||
|
||||
var result = await _recognitionService.RecognizeAsync(scaled);
|
||||
|
||||
_output.WriteLine($"Scale: {scaleFactor:P0}");
|
||||
_output.WriteLine($" Original: {original.Width}x{original.Height}");
|
||||
_output.WriteLine($" Scaled: {newWidth}x{newHeight}");
|
||||
_output.WriteLine($" Hamming distance: {distance}/192 bits");
|
||||
_output.WriteLine($" Confidence: {confidence:P0}");
|
||||
_output.WriteLine($" Recognition: {(result.Success ? "SUCCESS" : "FAILED")}");
|
||||
|
||||
// pHash should be relatively scale-invariant since it resizes to 32x32 internally
|
||||
_output.WriteLine($" [pHash resizes internally - should be scale invariant]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test impact of card being placed on different backgrounds.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(0, 0, 0)] // Black background
|
||||
[InlineData(255, 255, 255)] // White background
|
||||
[InlineData(128, 128, 128)] // Gray background
|
||||
[InlineData(139, 69, 19)] // Brown (wood table)
|
||||
[InlineData(0, 128, 0)] // Green (playmat)
|
||||
public async Task Background_ImpactOnHashDistance(byte r, byte g, byte b)
|
||||
{
|
||||
var imagePath = FindTestImage("reference_alpha/serra_angel.jpg");
|
||||
if (imagePath == null)
|
||||
{
|
||||
_output.WriteLine("Test image not found, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
using var original = SKBitmap.Decode(imagePath);
|
||||
Assert.NotNull(original);
|
||||
|
||||
var originalHash = _recognitionService.ComputeHash(original);
|
||||
|
||||
await _database.InsertHashAsync(new CardHash
|
||||
{
|
||||
CardId = "serra-angel",
|
||||
Name = "Serra Angel",
|
||||
SetCode = "LEA",
|
||||
Hash = originalHash
|
||||
});
|
||||
await _recognitionService.InvalidateCacheAsync();
|
||||
|
||||
// Create image with card on colored background (with padding)
|
||||
using var withBackground = PlaceOnBackground(original, new SKColor(r, g, b), 100);
|
||||
var bgHash = _recognitionService.ComputeHash(withBackground);
|
||||
|
||||
var distance = PerceptualHash.HammingDistance(originalHash, bgHash);
|
||||
var confidence = PerceptualHash.CalculateConfidence(distance, 192);
|
||||
|
||||
var result = await _recognitionService.RecognizeAsync(withBackground);
|
||||
|
||||
_output.WriteLine($"Background: RGB({r},{g},{b})");
|
||||
_output.WriteLine($" Image size: {withBackground.Width}x{withBackground.Height}");
|
||||
_output.WriteLine($" Hamming distance: {distance}/192 bits");
|
||||
_output.WriteLine($" Confidence: {confidence:P0}");
|
||||
_output.WriteLine($" Recognition: {(result.Success ? "SUCCESS" : "FAILED")}");
|
||||
|
||||
// Background WILL affect hash significantly since no card detection/cropping is done
|
||||
_output.WriteLine($" [No card detection - background included in hash - CRITICAL ISSUE]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test brightness variations (simulating different lighting).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(-50)] // Darker
|
||||
[InlineData(-25)] // Slightly darker
|
||||
[InlineData(25)] // Slightly brighter
|
||||
[InlineData(50)] // Brighter
|
||||
[InlineData(100)] // Very bright (overexposed)
|
||||
public async Task Brightness_ImpactOnHashDistance(int adjustment)
|
||||
{
|
||||
var imagePath = FindTestImage("reference_alpha/serra_angel.jpg");
|
||||
if (imagePath == null)
|
||||
{
|
||||
_output.WriteLine("Test image not found, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
using var original = SKBitmap.Decode(imagePath);
|
||||
Assert.NotNull(original);
|
||||
|
||||
var originalHash = _recognitionService.ComputeHash(original);
|
||||
|
||||
await _database.InsertHashAsync(new CardHash
|
||||
{
|
||||
CardId = "serra-angel",
|
||||
Name = "Serra Angel",
|
||||
SetCode = "LEA",
|
||||
Hash = originalHash
|
||||
});
|
||||
await _recognitionService.InvalidateCacheAsync();
|
||||
|
||||
using var adjusted = AdjustBrightness(original, adjustment);
|
||||
var adjustedHash = _recognitionService.ComputeHash(adjusted);
|
||||
|
||||
var distance = PerceptualHash.HammingDistance(originalHash, adjustedHash);
|
||||
var confidence = PerceptualHash.CalculateConfidence(distance, 192);
|
||||
|
||||
var result = await _recognitionService.RecognizeAsync(adjusted);
|
||||
|
||||
_output.WriteLine($"Brightness adjustment: {adjustment:+#;-#;0}");
|
||||
_output.WriteLine($" Hamming distance: {distance}/192 bits");
|
||||
_output.WriteLine($" Confidence: {confidence:P0}");
|
||||
_output.WriteLine($" Recognition: {(result.Success ? "SUCCESS" : "FAILED")}");
|
||||
|
||||
// CLAHE should help normalize lighting
|
||||
_output.WriteLine($" [CLAHE preprocessing should help normalize lighting]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test how perspective distortion affects matching.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(5)] // Slight perspective
|
||||
[InlineData(15)] // Moderate perspective
|
||||
[InlineData(30)] // Significant perspective
|
||||
public async Task Perspective_ImpactOnHashDistance(int perspectiveDegrees)
|
||||
{
|
||||
var imagePath = FindTestImage("reference_alpha/serra_angel.jpg");
|
||||
if (imagePath == null)
|
||||
{
|
||||
_output.WriteLine("Test image not found, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
using var original = SKBitmap.Decode(imagePath);
|
||||
Assert.NotNull(original);
|
||||
|
||||
var originalHash = _recognitionService.ComputeHash(original);
|
||||
|
||||
await _database.InsertHashAsync(new CardHash
|
||||
{
|
||||
CardId = "serra-angel",
|
||||
Name = "Serra Angel",
|
||||
SetCode = "LEA",
|
||||
Hash = originalHash
|
||||
});
|
||||
await _recognitionService.InvalidateCacheAsync();
|
||||
|
||||
// Apply perspective transform (shear as approximation)
|
||||
using var perspective = ApplyPerspective(original, perspectiveDegrees);
|
||||
var perspectiveHash = _recognitionService.ComputeHash(perspective);
|
||||
|
||||
var distance = PerceptualHash.HammingDistance(originalHash, perspectiveHash);
|
||||
var confidence = PerceptualHash.CalculateConfidence(distance, 192);
|
||||
|
||||
var result = await _recognitionService.RecognizeAsync(perspective);
|
||||
|
||||
_output.WriteLine($"Perspective: {perspectiveDegrees}° tilt");
|
||||
_output.WriteLine($" Hamming distance: {distance}/192 bits");
|
||||
_output.WriteLine($" Confidence: {confidence:P0}");
|
||||
_output.WriteLine($" Recognition: {(result.Success ? "SUCCESS" : "FAILED")}");
|
||||
|
||||
_output.WriteLine($" [No perspective correction - distortion affects hash]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test real-world photos vs reference images.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RealPhotos_VsReferenceImages()
|
||||
{
|
||||
// Find the production database
|
||||
var currentDir = Directory.GetCurrentDirectory();
|
||||
var rootDir = currentDir;
|
||||
while (!Directory.Exists(Path.Combine(rootDir, ".git")) && Directory.GetParent(rootDir) != null)
|
||||
{
|
||||
rootDir = Directory.GetParent(rootDir)!.FullName;
|
||||
}
|
||||
|
||||
var dbPath = Path.Combine(rootDir, "src", "Scry.App", "Resources", "Raw", "card_hashes.db");
|
||||
if (!File.Exists(dbPath))
|
||||
{
|
||||
_output.WriteLine($"Database not found at {dbPath}");
|
||||
return;
|
||||
}
|
||||
|
||||
using var prodDb = new CardHashDatabase(dbPath);
|
||||
using var prodRecognition = new CardRecognitionService(prodDb);
|
||||
|
||||
var testImagesDir = Path.Combine(rootDir, "TestImages");
|
||||
var categoriesToTest = new[] { "real_photos", "varying_quality", "angled", "low_light" };
|
||||
|
||||
_output.WriteLine("=== Real-World Recognition Test ===");
|
||||
_output.WriteLine($"Database cards: {(await prodDb.GetAllHashesAsync()).Count}");
|
||||
_output.WriteLine("");
|
||||
|
||||
foreach (var category in categoriesToTest)
|
||||
{
|
||||
var categoryPath = Path.Combine(testImagesDir, category);
|
||||
if (!Directory.Exists(categoryPath))
|
||||
continue;
|
||||
|
||||
_output.WriteLine($"--- {category} ---");
|
||||
|
||||
var imageFiles = Directory.GetFiles(categoryPath)
|
||||
.Where(f => new[] { ".jpg", ".jpeg", ".png", ".webp" }
|
||||
.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
var successes = 0;
|
||||
var failures = 0;
|
||||
|
||||
foreach (var file in imageFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var bitmap = SKBitmap.Decode(file);
|
||||
if (bitmap == null) continue;
|
||||
|
||||
var result = await prodRecognition.RecognizeAsync(bitmap);
|
||||
var fileName = Path.GetFileName(file);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
successes++;
|
||||
_output.WriteLine($" [OK] {fileName} -> {result.Card?.Name} ({result.Confidence:P0})");
|
||||
}
|
||||
else
|
||||
{
|
||||
failures++;
|
||||
_output.WriteLine($" [FAIL] {fileName} -> {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_output.WriteLine($" [ERROR] {Path.GetFileName(file)}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine($" Results: {successes} OK, {failures} failed");
|
||||
_output.WriteLine("");
|
||||
}
|
||||
}
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static string? FindTestImage(string relativePath)
|
||||
{
|
||||
var currentDir = Directory.GetCurrentDirectory();
|
||||
var rootDir = currentDir;
|
||||
while (!Directory.Exists(Path.Combine(rootDir, ".git")) && Directory.GetParent(rootDir) != null)
|
||||
{
|
||||
rootDir = Directory.GetParent(rootDir)!.FullName;
|
||||
}
|
||||
|
||||
var fullPath = Path.Combine(rootDir, "TestImages", relativePath);
|
||||
return File.Exists(fullPath) ? fullPath : null;
|
||||
}
|
||||
|
||||
private static SKBitmap RotateImage(SKBitmap original, int degrees)
|
||||
{
|
||||
var radians = degrees * Math.PI / 180;
|
||||
var cos = Math.Abs(Math.Cos(radians));
|
||||
var sin = Math.Abs(Math.Sin(radians));
|
||||
|
||||
var newWidth = (int)(original.Width * cos + original.Height * sin);
|
||||
var newHeight = (int)(original.Width * sin + original.Height * cos);
|
||||
|
||||
var rotated = new SKBitmap(newWidth, newHeight, SKColorType.Rgba8888, SKAlphaType.Premul);
|
||||
|
||||
using var canvas = new SKCanvas(rotated);
|
||||
canvas.Clear(SKColors.White);
|
||||
canvas.Translate(newWidth / 2f, newHeight / 2f);
|
||||
canvas.RotateDegrees(degrees);
|
||||
canvas.Translate(-original.Width / 2f, -original.Height / 2f);
|
||||
canvas.DrawBitmap(original, 0, 0);
|
||||
|
||||
return rotated;
|
||||
}
|
||||
|
||||
private static SKBitmap PlaceOnBackground(SKBitmap card, SKColor bgColor, int padding)
|
||||
{
|
||||
var newWidth = card.Width + padding * 2;
|
||||
var newHeight = card.Height + padding * 2;
|
||||
|
||||
var result = new SKBitmap(newWidth, newHeight, SKColorType.Rgba8888, SKAlphaType.Premul);
|
||||
|
||||
using var canvas = new SKCanvas(result);
|
||||
canvas.Clear(bgColor);
|
||||
canvas.DrawBitmap(card, padding, padding);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static SKBitmap AdjustBrightness(SKBitmap original, int adjustment)
|
||||
{
|
||||
var result = new SKBitmap(original.Width, original.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
|
||||
|
||||
for (var y = 0; y < original.Height; y++)
|
||||
{
|
||||
for (var x = 0; x < original.Width; x++)
|
||||
{
|
||||
var pixel = original.GetPixel(x, y);
|
||||
var r = (byte)Math.Clamp(pixel.Red + adjustment, 0, 255);
|
||||
var g = (byte)Math.Clamp(pixel.Green + adjustment, 0, 255);
|
||||
var b = (byte)Math.Clamp(pixel.Blue + adjustment, 0, 255);
|
||||
result.SetPixel(x, y, new SKColor(r, g, b, pixel.Alpha));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static SKBitmap ApplyPerspective(SKBitmap original, int degrees)
|
||||
{
|
||||
// Approximate perspective with horizontal shear
|
||||
var shearFactor = (float)Math.Tan(degrees * Math.PI / 180);
|
||||
var extraWidth = (int)(original.Height * Math.Abs(shearFactor));
|
||||
|
||||
var result = new SKBitmap(original.Width + extraWidth, original.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
|
||||
|
||||
using var canvas = new SKCanvas(result);
|
||||
canvas.Clear(SKColors.White);
|
||||
|
||||
var matrix = SKMatrix.CreateSkew(shearFactor, 0);
|
||||
if (shearFactor > 0)
|
||||
{
|
||||
matrix = SKMatrix.Concat(SKMatrix.CreateTranslation(extraWidth, 0), matrix);
|
||||
}
|
||||
|
||||
canvas.SetMatrix(matrix);
|
||||
canvas.DrawBitmap(original, 0, 0);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_recognitionService.Dispose();
|
||||
_database.Dispose();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
try
|
||||
{
|
||||
if (File.Exists(_dbPath))
|
||||
File.Delete(_dbPath);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
32
test/Scry.Tests/Scry.Tests.csproj
Normal file
32
test/Scry.Tests/Scry.Tests.csproj
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SkiaSharp" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Scry.Core\Scry.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None
|
||||
Include="..\..\TestImages\**\*"
|
||||
Link="TestImages\%(RecursiveDir)%(FileName)%(Extension)"
|
||||
>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
232
test/Scry.Tests/TestImageBenchmarks.cs
Normal file
232
test/Scry.Tests/TestImageBenchmarks.cs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
using Scry.Core.Imaging;
|
||||
using SkiaSharp;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Scry.Tests;
|
||||
|
||||
public class TestImageBenchmarks
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
private static readonly string TestImagesDir = "TestImages";
|
||||
|
||||
public TestImageBenchmarks(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessAllTestImages_ComputeHashes()
|
||||
{
|
||||
if (!Directory.Exists(TestImagesDir))
|
||||
{
|
||||
_output.WriteLine("TestImages directory not found, skipping benchmark");
|
||||
return;
|
||||
}
|
||||
|
||||
var categories = Directory.GetDirectories(TestImagesDir);
|
||||
var results = new List<(string Category, int Count, double AvgTimeMs, int Failures)>();
|
||||
|
||||
foreach (var categoryPath in categories)
|
||||
{
|
||||
var category = Path.GetFileName(categoryPath);
|
||||
var imageFiles = GetImageFiles(categoryPath);
|
||||
|
||||
if (!imageFiles.Any())
|
||||
continue;
|
||||
|
||||
var times = new List<double>();
|
||||
var failures = 0;
|
||||
|
||||
foreach (var file in imageFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
using var bitmap = SKBitmap.Decode(file);
|
||||
if (bitmap == null)
|
||||
{
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
|
||||
using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
|
||||
var hash = PerceptualHash.ComputeColorHash(preprocessed);
|
||||
|
||||
sw.Stop();
|
||||
times.Add(sw.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures++;
|
||||
_output.WriteLine($" Failed: {Path.GetFileName(file)} - {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (times.Any())
|
||||
{
|
||||
var avgTime = times.Average();
|
||||
results.Add((category, times.Count, avgTime, failures));
|
||||
_output.WriteLine($"{category}: {times.Count} images, {avgTime:F1}ms avg, {failures} failures");
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine("");
|
||||
_output.WriteLine("=== Summary ===");
|
||||
var totalImages = results.Sum(r => r.Count);
|
||||
var totalFailures = results.Sum(r => r.Failures);
|
||||
var overallAvg = results.SelectMany((r, _) => Enumerable.Repeat(r.AvgTimeMs, r.Count)).Average();
|
||||
|
||||
_output.WriteLine($"Total: {totalImages} images processed");
|
||||
_output.WriteLine($"Failures: {totalFailures}");
|
||||
_output.WriteLine($"Overall avg: {overallAvg:F1}ms per image");
|
||||
|
||||
Assert.True(totalImages > 0, "Should process at least some images");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("foil")]
|
||||
[InlineData("worn")]
|
||||
[InlineData("low_light")]
|
||||
[InlineData("foreign")]
|
||||
[InlineData("tokens")]
|
||||
public void ProcessCategory_AllImagesHash(string category)
|
||||
{
|
||||
var categoryPath = Path.Combine(TestImagesDir, category);
|
||||
if (!Directory.Exists(categoryPath))
|
||||
{
|
||||
_output.WriteLine($"Category not found: {category}");
|
||||
return;
|
||||
}
|
||||
|
||||
var imageFiles = GetImageFiles(categoryPath);
|
||||
_output.WriteLine($"Processing {imageFiles.Count} images in {category}/");
|
||||
|
||||
var processed = 0;
|
||||
var failed = 0;
|
||||
|
||||
foreach (var file in imageFiles)
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
try
|
||||
{
|
||||
using var bitmap = SKBitmap.Decode(file);
|
||||
if (bitmap == null)
|
||||
{
|
||||
_output.WriteLine($" [DECODE FAIL] {fileName}");
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
|
||||
var hash = PerceptualHash.ComputeColorHash(preprocessed);
|
||||
|
||||
Assert.Equal(24, hash.Length);
|
||||
processed++;
|
||||
_output.WriteLine($" [OK] {fileName} ({bitmap.Width}x{bitmap.Height})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_output.WriteLine($" [ERROR] {fileName}: {ex.Message}");
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine($"");
|
||||
_output.WriteLine($"Results: {processed} OK, {failed} failed");
|
||||
|
||||
Assert.True(processed > 0 || !imageFiles.Any(), $"Should process at least one image in {category}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashStability_SameImageProducesSameHash()
|
||||
{
|
||||
var testFile = Path.Combine(TestImagesDir, "reference", "brainstorm.png");
|
||||
if (!File.Exists(testFile))
|
||||
{
|
||||
testFile = GetImageFiles(TestImagesDir).FirstOrDefault();
|
||||
if (testFile == null)
|
||||
{
|
||||
_output.WriteLine("No test images found");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
using var bitmap = SKBitmap.Decode(testFile);
|
||||
Assert.NotNull(bitmap);
|
||||
|
||||
var hashes = new List<byte[]>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
|
||||
hashes.Add(PerceptualHash.ComputeColorHash(preprocessed));
|
||||
}
|
||||
|
||||
for (var i = 1; i < hashes.Count; i++)
|
||||
{
|
||||
Assert.Equal(hashes[0], hashes[i]);
|
||||
}
|
||||
|
||||
_output.WriteLine($"Hash is stable across {hashes.Count} runs");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashVariance_DifferentImagesProduceDifferentHashes()
|
||||
{
|
||||
var imageFiles = GetImageFiles(TestImagesDir).Take(20).ToList();
|
||||
if (imageFiles.Count < 2)
|
||||
{
|
||||
_output.WriteLine("Not enough test images for variance test");
|
||||
return;
|
||||
}
|
||||
|
||||
var hashDict = new Dictionary<string, byte[]>();
|
||||
|
||||
foreach (var file in imageFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var bitmap = SKBitmap.Decode(file);
|
||||
if (bitmap == null) continue;
|
||||
|
||||
using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
|
||||
var hash = PerceptualHash.ComputeColorHash(preprocessed);
|
||||
hashDict[file] = hash;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
var collisions = 0;
|
||||
var comparisons = 0;
|
||||
var files = hashDict.Keys.ToList();
|
||||
|
||||
for (var i = 0; i < files.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < files.Count; j++)
|
||||
{
|
||||
var distance = PerceptualHash.HammingDistance(hashDict[files[i]], hashDict[files[j]]);
|
||||
comparisons++;
|
||||
|
||||
if (distance < 5)
|
||||
{
|
||||
collisions++;
|
||||
_output.WriteLine($"Near collision (distance={distance}): {Path.GetFileName(files[i])} vs {Path.GetFileName(files[j])}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine($"Checked {comparisons} pairs, found {collisions} near-collisions");
|
||||
}
|
||||
|
||||
private static List<string> GetImageFiles(string directory)
|
||||
{
|
||||
var extensions = new[] { ".jpg", ".jpeg", ".png", ".webp", ".bmp" };
|
||||
|
||||
return Directory.EnumerateFiles(directory, "*.*", SearchOption.AllDirectories)
|
||||
.Where(f => extensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue