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