.
This commit is contained in:
parent
86aa0f856c
commit
0801ceee6a
310 changed files with 6712 additions and 418 deletions
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 { }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue