This commit is contained in:
Chris Kruining 2026-02-05 09:41:07 +01:00
parent 86aa0f856c
commit 0801ceee6a
No known key found for this signature in database
GPG key ID: EB894A3560CCCAD2
310 changed files with 6712 additions and 418 deletions

View 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)
{
}
}
}

View 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)
{
}
}
}

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

View 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;
}
}

View 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 { }
}
}

View 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>

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