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; /// /// Tests to analyze robustness of perceptual hashing under various camera scanning conditions. /// 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); } /// /// Test how rotation affects hash matching. /// pHash uses DCT which is NOT rotation invariant - this tests the impact. /// [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]"); } } /// /// Test how scaling/distance affects hash matching. /// [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]"); } /// /// Test impact of card being placed on different backgrounds. /// [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]"); } /// /// Test brightness variations (simulating different lighting). /// [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]"); } /// /// Test how perspective distortion affects matching. /// [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]"); } /// /// Test real-world photos vs reference images. /// [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 { } } }