diff --git a/.justfile b/.justfile
index e62b769..1f663ae 100644
--- a/.justfile
+++ b/.justfile
@@ -1,19 +1,62 @@
-# Scry build recipes
+# Scry development commands
+# Android SDK paths
-# Default recipe - show available commands
-default:
- @just --list
+android_sdk := env_var('LOCALAPPDATA') / "Android/Sdk"
+adb := android_sdk / "platform-tools/adb.exe"
+emulator := android_sdk / "emulator/emulator.exe"
+camera_virtual := "-camera-back virtualscene -virtualscene-poster wall=\"" + (justfile_directory() / "TestImages/reference_alpha/serra_angel.jpg") + "\""
+camera_webcam := "-camera-back webcam0 -camera-front webcam0"
-# Build both standard and embedded versions for all platforms
-build apk="delver.apk":
- rm -rf dist
- dotnet publish -c Release -r win-x64 -o dist/win-x64/standard
- dotnet publish -c Release -r win-x64 -p:EmbeddedApk={{apk}} -o dist/win-x64/embedded
- dotnet publish -c Release -r linux-x64 -o dist/linux-x64/standard
- dotnet publish -c Release -r linux-x64 -p:EmbeddedApk={{apk}} -o dist/linux-x64/embedded
- dotnet publish -c Release -r osx-x64 -o dist/osx-x64/standard
- dotnet publish -c Release -r osx-x64 -p:EmbeddedApk={{apk}} -o dist/osx-x64/embedded
+emu camera="virtual":
+ {{ emulator }} -avd Pixel_6 {{ if camera == "virtual" { camera_virtual } else { camera_webcam } }} -gpu host &
-# Clean build artifacts
-clean:
- rm -rf bin obj dist
+# Kill the running emulator
+emu-kill:
+ {{ adb }} emu kill
+
+# Wait for emulator to fully boot
+emu-wait:
+ @echo "Waiting for emulator to boot..."
+ @while [ "$({{ adb }} shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do sleep 1; done
+ @echo "Emulator ready"
+
+# Build a project
+build project="src/Scry.App" target="net10.0-android":
+ @echo "Building {{ project }}..."
+ dotnet build {{ project }} -f {{ target }} -c Debug
+ @echo "Build complete"
+
+# Publish a project (creates distributable)
+publish project="src/Scry.App" target="net10.0-android":
+ @echo "Publishing {{ project }} (this takes a while)..."
+ dotnet publish {{ project }} -f {{ target }} -c Release
+ @echo "Publish complete"
+
+# Install APK to emulator/device
+install:
+ {{ adb }} install -r src/Scry.App/bin/Release/net10.0-android/publish/land.charm.scry-Signed.apk
+
+# Launch the app on emulator/device
+launch:
+ {{ adb }} shell am start -n land.charm.scry/crc64fb23cc0d511b0157.MainActivity
+
+# Publish, install, and launch
+run: (publish "src/Scry.App") install launch
+
+# View app crash logs
+logs:
+ {{ adb }} logcat -d | grep -iE "land.charm.scry|scry|mono|dotnet" | tail -80
+
+# Run tests
+test:
+ dotnet test test/Scry.Tests
+
+# Generate the card hash database from Scryfall
+gen-db *args: (build "tools/DbGenerator" "net10.0")
+ @echo "Running Database generator (this takes a while)..."
+ dotnet run --project tools/DbGenerator --no-build -- src/Scry.App/Resources/Raw/card_hashes.db {{ args }}
+ @echo "Completed generating the database"
+
+# Full workflow: start emulator, wait, run with hot reload
+dev: emu emu-wait
+ dotnet watch --project src/Scry.App -f net10.0-android
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..64bdd14
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,30 @@
+# Agent Instructions
+
+## Build and Task Commands
+
+Prefer using `just` commands over raw dotnet/build commands:
+
+| Task | Command |
+|------|---------|
+| Build project | `just build` |
+| Run tests | `just test` |
+| Generate card database | `just gen-db` |
+| Publish app | `just publish` |
+| Run full dev workflow | `just dev` |
+
+## Project Structure
+
+- `src/Scry.App` - MAUI mobile app
+- `src/Scry.Core` - Core library (recognition, hashing, database)
+- `test/Scry.Tests` - Unit tests
+- `tools/DbGenerator` - Card hash database generator
+
+## Database Generator
+
+The `just gen-db` command:
+- Builds the DbGenerator tool
+- Runs it against `src/Scry.App/Resources/Raw/card_hashes.db`
+- Supports incremental updates (only downloads missing cards)
+- Prefers LEA/LEB (Alpha/Beta) sets for reference_alpha test cards
+
+Use `--force` flag to rebuild from scratch if needed.
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..b9cdb07
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,10 @@
+
+
+ net10.0
+ enable
+ enable
+ true
+ latest
+ true
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
new file mode 100644
index 0000000..880d82a
--- /dev/null
+++ b/Directory.Packages.props
@@ -0,0 +1,26 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Program.cs b/Program.cs
deleted file mode 100644
index 06ed5ea..0000000
--- a/Program.cs
+++ /dev/null
@@ -1,369 +0,0 @@
-using System.CommandLine;
-using System.Reflection;
-using System.Text;
-using ICSharpCode.SharpZipLib.Zip;
-using Microsoft.Data.Sqlite;
-using Spectre.Console;
-
-// Ensure UTF-8 output for Unicode characters
-Console.OutputEncoding = Encoding.UTF8;
-
-var dlensArgument = new Argument("dlens");
-dlensArgument.Description = "Path to the .dlens database file";
-
-var outputOption = new Option("--output", "-o");
-outputOption.Description = "Output CSV file path (defaults to collection.csv)";
-
-var showTableOption = new Option("--show-table", "-t");
-showTableOption.Description = "Display the card collection as a table";
-showTableOption.DefaultValueFactory = _ => false;
-
-#if EMBEDDED_APK
-var rootCommand = new RootCommand("Extract and display card data from Delver Lens")
-{
- dlensArgument,
- outputOption,
- showTableOption
-};
-
-rootCommand.SetAction(async (parseResult, cancellationToken) =>
-{
- var dlensFile = parseResult.GetValue(dlensArgument)!;
- var outputFile = parseResult.GetValue(outputOption) ?? new FileInfo("collection.csv");
- var showTable = parseResult.GetValue(showTableOption);
- await ProcessFiles(null, dlensFile, outputFile, showTable);
-});
-#else
-var apkArgument = new Argument("apk");
-apkArgument.Description = "Path to the Delver Lens APK file";
-
-var rootCommand = new RootCommand("Extract and display card data from Delver Lens")
-{
- apkArgument,
- dlensArgument,
- outputOption,
- showTableOption
-};
-
-rootCommand.SetAction(async (parseResult, cancellationToken) =>
-{
- var apkFile = parseResult.GetValue(apkArgument)!;
- var dlensFile = parseResult.GetValue(dlensArgument)!;
- var outputFile = parseResult.GetValue(outputOption) ?? new FileInfo("collection.csv");
- var showTable = parseResult.GetValue(showTableOption);
- await ProcessFiles(apkFile, dlensFile, outputFile, showTable);
-});
-#endif
-
-return await rootCommand.Parse(args).InvokeAsync();
-
-async Task ProcessFiles(FileInfo? apkFile, FileInfo dlensFile, FileInfo outputFile, bool showTable)
-{
-#if !EMBEDDED_APK
- if (apkFile == null || !apkFile.Exists)
- {
- AnsiConsole.MarkupLine($"[red]APK file not found:[/] {apkFile?.FullName}");
- return;
- }
-#endif
-
- if (!dlensFile.Exists)
- {
- AnsiConsole.MarkupLine($"[red]dlens file not found:[/] {dlensFile.FullName}");
- return;
- }
-
- List? scannedCards = null;
- List? collection = null;
- var steps = new[] { false, false, false };
-
- Panel BuildPanel()
- {
- var content = $"""
- [bold yellow]Progress[/]
-
- {Step(0, "Read scanned cards from dlens")}
- {Step(1, "Resolve card data from APK")}
- {Step(2, "Export collection to CSV")}
- """;
-
- if (steps[2])
- {
- content += $"""
-
-
- [bold yellow]Summary[/]
-
- [blue]Your collection:[/] {collection!.Count} unique cards, {collection.Sum(c => c.Quantity)} total
- [green]Exported to:[/] {outputFile.FullName}
-
- [bold yellow]How to import into Archidekt[/]
-
- 1. Go to [link]https://archidekt.com/collection[/]
- 2. Click [yellow]Import[/]
- 3. Click [yellow]Add manual column[/] [blue]6 times[/]
- 4. Set the columns in order:
- • Quantity → [blue]Quantity[/]
- • Scryfall ID → [blue]Scryfall ID[/]
- • Foil → [blue]Foil[/]
- • Card Name → [blue]Ignore[/]
- • Set Code → [blue]Ignore[/]
- • Collector Number → [blue]Ignore[/]
- 5. Set [yellow]Skip first row[/] to [blue]true[/] [grey](the CSV has a header)[/]
- 6. Set the csv file by either dragging and dropping it, or clicking the upload box
- 7. Click [yellow]Upload[/]
- """;
- }
-
- return new Panel(content)
- {
- Header = new PanelHeader(" Delver Lens → Archidekt "),
- Border = BoxBorder.Rounded,
- Padding = new Padding(2, 1)
- };
- }
-
- var spinnerFrames = new[] { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" };
- var spinnerIndex = 0;
- var currentStep = 0;
-
- string Step(int index, string text)
- {
- if (steps[index])
- return $"[green][[✓]][/] {text}";
- if (index == currentStep)
- return $"[blue][[{spinnerFrames[spinnerIndex]}]][/] {text}";
- return $"[grey][[○]][/] [grey]{text}[/]";
- }
-
- // When piped, output CSV to stdout for composability
- if (Console.IsOutputRedirected)
- {
- scannedCards = await GetScannedCards(dlensFile);
- collection = await ResolveCollection(apkFile, scannedCards);
- WriteCsvToStdout(collection);
- return;
- }
-
- // Interactive: use live display with progress panel
- using var cts = new CancellationTokenSource();
-
- await AnsiConsole.Live(BuildPanel())
- .StartAsync(async ctx =>
- {
- // Spinner animation task
- var spinnerTask = Task.Run(async () =>
- {
- while (!cts.Token.IsCancellationRequested)
- {
- await Task.Delay(80, cts.Token).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
- spinnerIndex = (spinnerIndex + 1) % spinnerFrames.Length;
- ctx.UpdateTarget(BuildPanel());
- }
- });
-
- scannedCards = await GetScannedCards(dlensFile);
- steps[0] = true;
- currentStep = 1;
- ctx.UpdateTarget(BuildPanel());
-
- collection = await ResolveCollection(apkFile, scannedCards);
- steps[1] = true;
- currentStep = 2;
- ctx.UpdateTarget(BuildPanel());
-
- await ExportCsv(collection, outputFile);
- steps[2] = true;
- ctx.UpdateTarget(BuildPanel());
-
- cts.Cancel();
- await spinnerTask;
- });
-
- // Display table if requested (after live panel completes)
- if (showTable)
- {
- DisplayCollection(collection!);
- }
-}
-
-async Task> ResolveCollection(FileInfo? apkFile, List scannedCards)
-{
- var tempDbPath = Path.GetTempFileName();
- var cardIds = scannedCards.Select(c => c.CardId).ToHashSet();
-
- try
- {
- // Get APK stream from embedded resource or file
-#if EMBEDDED_APK
- var assembly = Assembly.GetExecutingAssembly();
- await using var apkStream = assembly.GetManifestResourceStream("delver.apk")
- ?? throw new Exception("Embedded APK resource not found");
-#else
- await using var apkStream = apkFile!.OpenRead();
-#endif
-
- using (var zipFile = new ZipFile(apkStream))
- {
- var entry = zipFile.GetEntry("res/raw/data.db");
- if (entry == null)
- {
- throw new Exception("Could not find res/raw/data.db in APK");
- }
-
- await using var zipStream = zipFile.GetInputStream(entry);
- await using var outputStream = File.Create(tempDbPath);
- await zipStream.CopyToAsync(outputStream);
- }
-
- var cardData = new Dictionary();
-
- await using (var connection = new SqliteConnection($"Data Source={tempDbPath};Mode=ReadOnly"))
- {
- await connection.OpenAsync();
-
- await using var cmd = connection.CreateCommand();
- cmd.CommandText = @"
- SELECT
- c._id,
- n.name,
- e.tl_abb,
- c.number,
- c.scryfall_id
- FROM cards c
- JOIN names n ON c.name = n._id
- JOIN editions e ON c.edition = e._id;";
-
- await using var reader = await cmd.ExecuteReaderAsync();
- while (await reader.ReadAsync())
- {
- var id = reader.GetInt32(0);
- if (cardIds.Contains(id))
- {
- cardData[id] = (
- reader.GetString(1),
- reader.GetString(2),
- reader.IsDBNull(3) ? "" : reader.GetString(3),
- reader.IsDBNull(4) ? "" : reader.GetString(4)
- );
- }
- }
- }
-
- var collection = new List();
- foreach (var scanned in scannedCards)
- {
- if (cardData.TryGetValue(scanned.CardId, out var data))
- {
- collection.Add(new CollectionCard(
- scanned.Quantity,
- data.Name,
- data.SetCode,
- data.CollectorNumber,
- data.ScryfallId,
- scanned.Foil
- ));
- }
- else
- {
- collection.Add(new CollectionCard(
- scanned.Quantity,
- $"Unknown (ID: {scanned.CardId})",
- "",
- "",
- "",
- scanned.Foil
- ));
- }
- }
-
- return collection;
- }
- finally
- {
- SqliteConnection.ClearAllPools();
- if (File.Exists(tempDbPath))
- {
- File.Delete(tempDbPath);
- }
- }
-}
-
-void DisplayCollection(List collection)
-{
- var table = new Table();
- table.Border = TableBorder.Rounded;
- table.AddColumn("Qty");
- table.AddColumn("Name");
- table.AddColumn("Set");
- table.AddColumn("#");
- table.AddColumn("Foil");
- table.AddColumn("Scryfall ID");
-
- foreach (var card in collection.OrderBy(c => c.Name).ThenBy(c => c.SetCode))
- {
- table.AddRow(
- card.Quantity.ToString(),
- card.Name.Length > 30 ? card.Name[..27] + "..." : card.Name,
- card.SetCode,
- card.CollectorNumber,
- card.Foil ? "[yellow]Yes[/]" : "",
- card.ScryfallId.Length > 8 ? card.ScryfallId[..8] + "..." : card.ScryfallId
- );
- }
-
- AnsiConsole.Write(table);
-}
-
-async Task ExportCsv(List collection, FileInfo outputFile)
-{
- var sb = new StringBuilder();
- sb.AppendLine("Quantity,Scryfall ID,Foil,Card Name,Set Code,Collector Number");
-
- foreach (var card in collection.OrderBy(c => c.Name).ThenBy(c => c.SetCode))
- {
- var foilStr = card.Foil ? "Foil" : "Normal";
- var name = card.Name.Contains(',') ? $"\"{card.Name}\"" : card.Name;
- sb.AppendLine($"{card.Quantity},{card.ScryfallId},{foilStr},{name},{card.SetCode},{card.CollectorNumber}");
- }
-
- await File.WriteAllTextAsync(outputFile.FullName, sb.ToString());
-}
-
-void WriteCsvToStdout(List collection)
-{
- Console.WriteLine("Quantity,Scryfall ID,Foil,Card Name,Set Code,Collector Number");
-
- foreach (var card in collection.OrderBy(c => c.Name).ThenBy(c => c.SetCode))
- {
- var foilStr = card.Foil ? "Foil" : "Normal";
- var name = card.Name.Contains(',') ? $"\"{card.Name}\"" : card.Name;
- Console.WriteLine($"{card.Quantity},{card.ScryfallId},{foilStr},{name},{card.SetCode},{card.CollectorNumber}");
- }
-}
-
-async Task> GetScannedCards(FileInfo dlensFile)
-{
- var cards = new List();
-
- await using var connection = new SqliteConnection($"Data Source={dlensFile.FullName};Mode=ReadOnly");
- await connection.OpenAsync();
-
- await using var command = connection.CreateCommand();
- command.CommandText = "SELECT * FROM cards";
-
- await using var reader = await command.ExecuteReaderAsync();
- while (await reader.ReadAsync())
- {
- var cardId = reader.GetInt32(reader.GetOrdinal("card"));
- var quantity = reader.GetInt32(reader.GetOrdinal("quantity"));
- var foil = reader.GetInt32(reader.GetOrdinal("foil")) == 1;
-
- cards.Add(new ScannedCard(cardId, quantity, foil));
- }
-
- return cards;
-}
-
-record ScannedCard(int CardId, int Quantity, bool Foil);
-record CollectionCard(int Quantity, string Name, string SetCode, string CollectorNumber, string ScryfallId, bool Foil);
diff --git a/Scry.csproj b/Scry.csproj
deleted file mode 100644
index b3f1ef6..0000000
--- a/Scry.csproj
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
- Exe
- net10.0
- enable
- enable
- true
- true
- true
- true
- none
- false
-
-
-
- $(DefineConstants);EMBEDDED_APK
-
-
-
-
- delver.apk
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Scry.slnx b/Scry.slnx
new file mode 100644
index 0000000..8a2e845
--- /dev/null
+++ b/Scry.slnx
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/TestImages/README.md b/TestImages/README.md
new file mode 100644
index 0000000..799ba29
--- /dev/null
+++ b/TestImages/README.md
@@ -0,0 +1,151 @@
+# Test Images
+
+This directory contains **225 reference images** for testing card recognition algorithms without requiring actual hardware.
+
+## Directory Structure
+
+| Category | Count | Description |
+|----------|-------|-------------|
+| `reference_alpha/` | 47 | Alpha edition reference cards (old frame) |
+| `varying_quality/` | 38 | Different lighting, blur, exposure, angles |
+| `single_cards/` | 19 | Individual card photos |
+| `real_photos/` | 18 | Phone camera photos from Visions project |
+| `foreign/` | 16 | Non-English cards (Japanese, German, French, etc.) |
+| `worn/` | 15 | Heavily played, damaged, worn cards |
+| `foil/` | 14 | Foil cards with holographic glare/reflections |
+| `low_light/` | 14 | Poor lighting, glare, shadows, amateur photos |
+| `tokens/` | 13 | Tokens and planeswalker emblems |
+| `hands/` | 11 | Cards held in hand (partial visibility) |
+| `ocr_test/` | 10 | Images optimized for OCR testing |
+| `reference/` | 9 | High-quality reference scans |
+| `multiple_cards/` | 6 | Multiple cards in frame |
+| `augmented/` | 4 | Augmented training examples |
+| `training_examples/` | 3 | ML training set samples |
+| `angled/` | 2 | Perspective distortion |
+
+## Image Sources
+
+Images from open-source MIT-licensed projects:
+
+- [hj3yoo/mtg_card_detector](https://github.com/hj3yoo/mtg_card_detector)
+- [tmikonen/magic_card_detector](https://github.com/tmikonen/magic_card_detector)
+- [fortierq/mtgscan](https://github.com/fortierq/mtgscan)
+- [LauriHursti/visions](https://github.com/LauriHursti/visions)
+- [KLuml/CardScanner](https://github.com/KLuml/CardScanner)
+- [dills122/MTG-Card-Analyzer](https://github.com/dills122/MTG-Card-Analyzer)
+- [ryanlin/Turtle](https://github.com/ryanlin/Turtle)
+
+Additional images from:
+- Reddit r/magicTCG (user-submitted photos)
+- Flickr (Creative Commons)
+- Card Kingdom / Face to Face Games grading guides
+- Scryfall (foreign language card scans)
+
+## Usage
+
+```csharp
+[Theory]
+[InlineData("varying_quality/test1.jpg")]
+[InlineData("angled/tilted_card_1.jpg")]
+[InlineData("hands/hand_of_card_1.png")]
+[InlineData("foil/rainbow_foil_secret_lair.jpg")]
+[InlineData("worn/hp_shuffle_crease.webp")]
+[InlineData("foreign/japanese_aang.jpg")]
+public async Task RecognizeCard_VaryingConditions(string imagePath)
+{
+ using var stream = File.OpenRead(Path.Combine("TestImages", imagePath));
+ var result = await _recognitionService.RecognizeCardAsync(stream);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Card);
+ Assert.True(result.Confidence >= 0.7f);
+}
+```
+
+## Category Details
+
+### foil/
+Foil cards showing holographic effects that challenge recognition:
+- Rainbow foils with color-shifting (`rainbow_foil_secret_lair.jpg`)
+- Surge foils with holo stickers (`surge_foils_holo.jpeg`)
+- Old-style foils (`old_foil_yawgmoth.jpg`)
+- Textured/dragonscale foils (`dragonscale_foil.jpg`)
+- Foil curling examples showing warping
+
+### worn/
+Heavily played and damaged cards:
+- Edge whitening (`edge_white.png`, `very_good_*.jpg`)
+- Scratches and scuffs (`scratch.png`, `hp_scratches.png`)
+- Creases and bends (`hp_shuffle_crease.webp`, `bent_creased.jpg`)
+- Binder damage (`hp_binder_bite_*.webp`)
+- Water damage (`hp_water_warping.png`)
+- Corner damage (`hp_compromised_corner.webp`)
+
+### low_light/
+Poor lighting and amateur photography conditions:
+- Glare from toploaders/sleeves (`glare_toploader.png`)
+- Direct light causing hotspots (`glare_straight_down.jpg`)
+- Depth of field blur (`dof_blur_amateur.jpg`)
+- Amateur condition photos with shadows
+- Flickr collection shots with mixed lighting
+
+### foreign/
+Non-English cards (8 languages):
+- Japanese (日本語)
+- German (Deutsch)
+- French (Français)
+- Italian (Italiano)
+- Spanish (Español)
+- Russian (Русский)
+- Simplified Chinese (简体中文)
+- Korean (한국어)
+
+### tokens/
+Tokens and planeswalker emblems:
+- Official WotC tokens
+- Custom/altered tokens
+- Planeswalker emblems (Elspeth, Gideon, Narset)
+- Token collections and gameplay shots
+
+### varying_quality/
+Images with various real-world challenges:
+- Different camera exposures
+- BGS graded cases (`counterspell_bgs.jpg`)
+- Cards in plastic sleeves (`card_in_plastic_case.jpg`)
+- Various lighting conditions
+- 28 numbered test images (`test1.jpg` - `test27.jpg`)
+
+### reference_alpha/
+47 Limited Edition Alpha cards for old-frame recognition:
+- Power Nine (Black Lotus, Ancestral Recall, Moxen, etc.)
+- Dual lands (Underground Sea, Volcanic Island, etc.)
+- Classic staples (Lightning Bolt, Counterspell, Sol Ring)
+
+### hands/
+Cards held in hand - partial visibility, stacked:
+- Various deck archetypes (Tron, Green, Red)
+- New and old frame cards
+- Different lighting conditions
+
+### real_photos/
+Phone camera photos from Visions project:
+- Real-world scanning conditions
+- Various resolutions and crops
+- Includes processed result images
+
+### ocr_test/
+From CardScanner project, graded by difficulty:
+- `card0-4.jpg`: Easier recognition
+- `card10-13.jpg`: Harder recognition (noted ~less accuracy)
+
+## TODO: Additional Categories Needed
+
+- [ ] **double_faced/** - Transform/MDFC cards (both sides)
+- [ ] **art_cards/** - Art series cards without text boxes
+- [ ] **promos/** - Extended art, borderless, showcase frames
+- [ ] **very_low_light/** - Near-dark conditions
+- [ ] **motion_blur/** - Cards in motion during scanning
+
+## License
+
+Card artwork is property of Wizards of the Coast. Images used for testing/research purposes only.
diff --git a/TestImages/angled/tilted_card_1.jpg b/TestImages/angled/tilted_card_1.jpg
new file mode 100644
index 0000000..e973651
Binary files /dev/null and b/TestImages/angled/tilted_card_1.jpg differ
diff --git a/TestImages/angled/tilted_card_2.jpg b/TestImages/angled/tilted_card_2.jpg
new file mode 100644
index 0000000..d1edf41
Binary files /dev/null and b/TestImages/angled/tilted_card_2.jpg differ
diff --git a/TestImages/augmented/augmented_1.jpg b/TestImages/augmented/augmented_1.jpg
new file mode 100644
index 0000000..1f75ac5
Binary files /dev/null and b/TestImages/augmented/augmented_1.jpg differ
diff --git a/TestImages/augmented/augmented_2.jpg b/TestImages/augmented/augmented_2.jpg
new file mode 100644
index 0000000..5eb1c4e
Binary files /dev/null and b/TestImages/augmented/augmented_2.jpg differ
diff --git a/TestImages/augmented/augmented_3.jpg b/TestImages/augmented/augmented_3.jpg
new file mode 100644
index 0000000..9d41972
Binary files /dev/null and b/TestImages/augmented/augmented_3.jpg differ
diff --git a/TestImages/augmented/augmented_4.jpg b/TestImages/augmented/augmented_4.jpg
new file mode 100644
index 0000000..d4897ae
Binary files /dev/null and b/TestImages/augmented/augmented_4.jpg differ
diff --git a/TestImages/foil/dragonscale_foil.jpg b/TestImages/foil/dragonscale_foil.jpg
new file mode 100644
index 0000000..ce1bbfc
Binary files /dev/null and b/TestImages/foil/dragonscale_foil.jpg differ
diff --git a/TestImages/foil/foil_curling_1.jpg b/TestImages/foil/foil_curling_1.jpg
new file mode 100644
index 0000000..6d20039
Binary files /dev/null and b/TestImages/foil/foil_curling_1.jpg differ
diff --git a/TestImages/foil/foil_curling_2.jpg b/TestImages/foil/foil_curling_2.jpg
new file mode 100644
index 0000000..a37dfcd
Binary files /dev/null and b/TestImages/foil/foil_curling_2.jpg differ
diff --git a/TestImages/foil/foil_jpn_mystical_archives.jpg b/TestImages/foil/foil_jpn_mystical_archives.jpg
new file mode 100644
index 0000000..436d844
Binary files /dev/null and b/TestImages/foil/foil_jpn_mystical_archives.jpg differ
diff --git a/TestImages/foil/foil_peel_holo_layer.jpg b/TestImages/foil/foil_peel_holo_layer.jpg
new file mode 100644
index 0000000..f19ee43
Binary files /dev/null and b/TestImages/foil/foil_peel_holo_layer.jpg differ
diff --git a/TestImages/foil/foil_quality_comparison.jpeg b/TestImages/foil/foil_quality_comparison.jpeg
new file mode 100644
index 0000000..7899964
Binary files /dev/null and b/TestImages/foil/foil_quality_comparison.jpeg differ
diff --git a/TestImages/foil/foil_swamp_collection.jpg b/TestImages/foil/foil_swamp_collection.jpg
new file mode 100644
index 0000000..3111393
Binary files /dev/null and b/TestImages/foil/foil_swamp_collection.jpg differ
diff --git a/TestImages/foil/modern_vs_og_foils.jpg b/TestImages/foil/modern_vs_og_foils.jpg
new file mode 100644
index 0000000..7a04366
Binary files /dev/null and b/TestImages/foil/modern_vs_og_foils.jpg differ
diff --git a/TestImages/foil/old_foil_yawgmoth.jpg b/TestImages/foil/old_foil_yawgmoth.jpg
new file mode 100644
index 0000000..5c72d87
Binary files /dev/null and b/TestImages/foil/old_foil_yawgmoth.jpg differ
diff --git a/TestImages/foil/rainbow_foil_secret_lair.jpg b/TestImages/foil/rainbow_foil_secret_lair.jpg
new file mode 100644
index 0000000..11aa32a
Binary files /dev/null and b/TestImages/foil/rainbow_foil_secret_lair.jpg differ
diff --git a/TestImages/foil/rainbow_foil_sheldons.jpg b/TestImages/foil/rainbow_foil_sheldons.jpg
new file mode 100644
index 0000000..e4e3072
Binary files /dev/null and b/TestImages/foil/rainbow_foil_sheldons.jpg differ
diff --git a/TestImages/foil/surge_foil_rhino.jpeg b/TestImages/foil/surge_foil_rhino.jpeg
new file mode 100644
index 0000000..c9c48ea
Binary files /dev/null and b/TestImages/foil/surge_foil_rhino.jpeg differ
diff --git a/TestImages/foil/surge_foils_holo.jpeg b/TestImages/foil/surge_foils_holo.jpeg
new file mode 100644
index 0000000..fd3e806
Binary files /dev/null and b/TestImages/foil/surge_foils_holo.jpeg differ
diff --git a/TestImages/foil/textured_foils.jpg b/TestImages/foil/textured_foils.jpg
new file mode 100644
index 0000000..5e204dd
Binary files /dev/null and b/TestImages/foil/textured_foils.jpg differ
diff --git a/TestImages/foreign/chinese_aarakocra.jpg b/TestImages/foreign/chinese_aarakocra.jpg
new file mode 100644
index 0000000..6d092a3
Binary files /dev/null and b/TestImages/foreign/chinese_aarakocra.jpg differ
diff --git a/TestImages/foreign/chinese_abattoir_ghoul.jpg b/TestImages/foreign/chinese_abattoir_ghoul.jpg
new file mode 100644
index 0000000..1abb3e0
Binary files /dev/null and b/TestImages/foreign/chinese_abattoir_ghoul.jpg differ
diff --git a/TestImages/foreign/french_aang.jpg b/TestImages/foreign/french_aang.jpg
new file mode 100644
index 0000000..e4955a1
Binary files /dev/null and b/TestImages/foreign/french_aang.jpg differ
diff --git a/TestImages/foreign/french_abattoir_ghoul.jpg b/TestImages/foreign/french_abattoir_ghoul.jpg
new file mode 100644
index 0000000..16090fc
Binary files /dev/null and b/TestImages/foreign/french_abattoir_ghoul.jpg differ
diff --git a/TestImages/foreign/german_aang.jpg b/TestImages/foreign/german_aang.jpg
new file mode 100644
index 0000000..d0615f9
Binary files /dev/null and b/TestImages/foreign/german_aang.jpg differ
diff --git a/TestImages/foreign/german_abattoir_ghoul.jpg b/TestImages/foreign/german_abattoir_ghoul.jpg
new file mode 100644
index 0000000..382f59c
Binary files /dev/null and b/TestImages/foreign/german_abattoir_ghoul.jpg differ
diff --git a/TestImages/foreign/italian_aang.jpg b/TestImages/foreign/italian_aang.jpg
new file mode 100644
index 0000000..c18f85a
Binary files /dev/null and b/TestImages/foreign/italian_aang.jpg differ
diff --git a/TestImages/foreign/japanese_aang.jpg b/TestImages/foreign/japanese_aang.jpg
new file mode 100644
index 0000000..2a3fec1
Binary files /dev/null and b/TestImages/foreign/japanese_aang.jpg differ
diff --git a/TestImages/foreign/japanese_abduction.jpg b/TestImages/foreign/japanese_abduction.jpg
new file mode 100644
index 0000000..0f7dc2d
Binary files /dev/null and b/TestImages/foreign/japanese_abduction.jpg differ
diff --git a/TestImages/foreign/japanese_aberrant_researcher.jpg b/TestImages/foreign/japanese_aberrant_researcher.jpg
new file mode 100644
index 0000000..9906fd1
Binary files /dev/null and b/TestImages/foreign/japanese_aberrant_researcher.jpg differ
diff --git a/TestImages/foreign/japanese_abhorrent_overlord.jpg b/TestImages/foreign/japanese_abhorrent_overlord.jpg
new file mode 100644
index 0000000..f81b500
Binary files /dev/null and b/TestImages/foreign/japanese_abhorrent_overlord.jpg differ
diff --git a/TestImages/foreign/korean_aarakocra.jpg b/TestImages/foreign/korean_aarakocra.jpg
new file mode 100644
index 0000000..0239a26
Binary files /dev/null and b/TestImages/foreign/korean_aarakocra.jpg differ
diff --git a/TestImages/foreign/korean_abattoir_ghoul.jpg b/TestImages/foreign/korean_abattoir_ghoul.jpg
new file mode 100644
index 0000000..d764f62
Binary files /dev/null and b/TestImages/foreign/korean_abattoir_ghoul.jpg differ
diff --git a/TestImages/foreign/russian_aarakocra.jpg b/TestImages/foreign/russian_aarakocra.jpg
new file mode 100644
index 0000000..6470a93
Binary files /dev/null and b/TestImages/foreign/russian_aarakocra.jpg differ
diff --git a/TestImages/foreign/russian_abattoir_ghoul.jpg b/TestImages/foreign/russian_abattoir_ghoul.jpg
new file mode 100644
index 0000000..b43afff
Binary files /dev/null and b/TestImages/foreign/russian_abattoir_ghoul.jpg differ
diff --git a/TestImages/foreign/spanish_aang.jpg b/TestImages/foreign/spanish_aang.jpg
new file mode 100644
index 0000000..b222c29
Binary files /dev/null and b/TestImages/foreign/spanish_aang.jpg differ
diff --git a/TestImages/hands/handOfCards.jpg b/TestImages/hands/handOfCards.jpg
new file mode 100644
index 0000000..8f8f53e
Binary files /dev/null and b/TestImages/hands/handOfCards.jpg differ
diff --git a/TestImages/hands/hand_of_card_1.png b/TestImages/hands/hand_of_card_1.png
new file mode 100644
index 0000000..8323d5c
Binary files /dev/null and b/TestImages/hands/hand_of_card_1.png differ
diff --git a/TestImages/hands/hand_of_card_green_1.jpg b/TestImages/hands/hand_of_card_green_1.jpg
new file mode 100644
index 0000000..13f5b75
Binary files /dev/null and b/TestImages/hands/hand_of_card_green_1.jpg differ
diff --git a/TestImages/hands/hand_of_card_green_2.jpeg b/TestImages/hands/hand_of_card_green_2.jpeg
new file mode 100644
index 0000000..86109fa
Binary files /dev/null and b/TestImages/hands/hand_of_card_green_2.jpeg differ
diff --git a/TestImages/hands/hand_of_card_ktk.png b/TestImages/hands/hand_of_card_ktk.png
new file mode 100644
index 0000000..456ab69
Binary files /dev/null and b/TestImages/hands/hand_of_card_ktk.png differ
diff --git a/TestImages/hands/hand_of_card_new_frame.webp b/TestImages/hands/hand_of_card_new_frame.webp
new file mode 100644
index 0000000..1eb5b04
Binary files /dev/null and b/TestImages/hands/hand_of_card_new_frame.webp differ
diff --git a/TestImages/hands/hand_of_card_one_hand.jpg b/TestImages/hands/hand_of_card_one_hand.jpg
new file mode 100644
index 0000000..bae5d8d
Binary files /dev/null and b/TestImages/hands/hand_of_card_one_hand.jpg differ
diff --git a/TestImages/hands/hand_of_card_red.jpeg b/TestImages/hands/hand_of_card_red.jpeg
new file mode 100644
index 0000000..4469e9f
Binary files /dev/null and b/TestImages/hands/hand_of_card_red.jpeg differ
diff --git a/TestImages/hands/hand_of_card_tron.png b/TestImages/hands/hand_of_card_tron.png
new file mode 100644
index 0000000..b2f569c
Binary files /dev/null and b/TestImages/hands/hand_of_card_tron.png differ
diff --git a/TestImages/hands/klomparens_hand.png b/TestImages/hands/klomparens_hand.png
new file mode 100644
index 0000000..09cc0b3
Binary files /dev/null and b/TestImages/hands/klomparens_hand.png differ
diff --git a/TestImages/hands/li38_handOfCards.jpg b/TestImages/hands/li38_handOfCards.jpg
new file mode 100644
index 0000000..e7e91be
Binary files /dev/null and b/TestImages/hands/li38_handOfCards.jpg differ
diff --git a/TestImages/low_light/authenticity_check.jpg b/TestImages/low_light/authenticity_check.jpg
new file mode 100644
index 0000000..7618852
Binary files /dev/null and b/TestImages/low_light/authenticity_check.jpg differ
diff --git a/TestImages/low_light/basic_lands_amateur.jpg b/TestImages/low_light/basic_lands_amateur.jpg
new file mode 100644
index 0000000..f95979b
Binary files /dev/null and b/TestImages/low_light/basic_lands_amateur.jpg differ
diff --git a/TestImages/low_light/condition_amateur_1.jpg b/TestImages/low_light/condition_amateur_1.jpg
new file mode 100644
index 0000000..46b0d27
Binary files /dev/null and b/TestImages/low_light/condition_amateur_1.jpg differ
diff --git a/TestImages/low_light/condition_amateur_2.jpg b/TestImages/low_light/condition_amateur_2.jpg
new file mode 100644
index 0000000..49d0e2b
Binary files /dev/null and b/TestImages/low_light/condition_amateur_2.jpg differ
diff --git a/TestImages/low_light/diy_lighting_rig.jpg b/TestImages/low_light/diy_lighting_rig.jpg
new file mode 100644
index 0000000..e49fb06
Binary files /dev/null and b/TestImages/low_light/diy_lighting_rig.jpg differ
diff --git a/TestImages/low_light/dof_blur_amateur.jpg b/TestImages/low_light/dof_blur_amateur.jpg
new file mode 100644
index 0000000..9e3a974
Binary files /dev/null and b/TestImages/low_light/dof_blur_amateur.jpg differ
diff --git a/TestImages/low_light/fake_detection.jpg b/TestImages/low_light/fake_detection.jpg
new file mode 100644
index 0000000..54f1bdd
Binary files /dev/null and b/TestImages/low_light/fake_detection.jpg differ
diff --git a/TestImages/low_light/flickr_collection_1.jpg b/TestImages/low_light/flickr_collection_1.jpg
new file mode 100644
index 0000000..057b426
Binary files /dev/null and b/TestImages/low_light/flickr_collection_1.jpg differ
diff --git a/TestImages/low_light/flickr_collection_2.jpg b/TestImages/low_light/flickr_collection_2.jpg
new file mode 100644
index 0000000..6764c6e
Binary files /dev/null and b/TestImages/low_light/flickr_collection_2.jpg differ
diff --git a/TestImages/low_light/flickr_collection_3.jpg b/TestImages/low_light/flickr_collection_3.jpg
new file mode 100644
index 0000000..f7e6483
Binary files /dev/null and b/TestImages/low_light/flickr_collection_3.jpg differ
diff --git a/TestImages/low_light/glare_straight_down.jpg b/TestImages/low_light/glare_straight_down.jpg
new file mode 100644
index 0000000..fdf5838
Binary files /dev/null and b/TestImages/low_light/glare_straight_down.jpg differ
diff --git a/TestImages/low_light/glare_toploader.png b/TestImages/low_light/glare_toploader.png
new file mode 100644
index 0000000..5a3f6b2
Binary files /dev/null and b/TestImages/low_light/glare_toploader.png differ
diff --git a/TestImages/low_light/grading_amateur.jpg b/TestImages/low_light/grading_amateur.jpg
new file mode 100644
index 0000000..8a7a040
Binary files /dev/null and b/TestImages/low_light/grading_amateur.jpg differ
diff --git a/TestImages/low_light/macro_monday_shadows.jpg b/TestImages/low_light/macro_monday_shadows.jpg
new file mode 100644
index 0000000..bf47519
Binary files /dev/null and b/TestImages/low_light/macro_monday_shadows.jpg differ
diff --git a/TestImages/multiple_cards/alpha_deck.jpg b/TestImages/multiple_cards/alpha_deck.jpg
new file mode 100644
index 0000000..281ff42
Binary files /dev/null and b/TestImages/multiple_cards/alpha_deck.jpg differ
diff --git a/TestImages/multiple_cards/geyser_twister_fireball.jpg b/TestImages/multiple_cards/geyser_twister_fireball.jpg
new file mode 100644
index 0000000..47263a3
Binary files /dev/null and b/TestImages/multiple_cards/geyser_twister_fireball.jpg differ
diff --git a/TestImages/multiple_cards/lands_and_fatties.jpg b/TestImages/multiple_cards/lands_and_fatties.jpg
new file mode 100644
index 0000000..344b26b
Binary files /dev/null and b/TestImages/multiple_cards/lands_and_fatties.jpg differ
diff --git a/TestImages/multiple_cards/magic1.png b/TestImages/multiple_cards/magic1.png
new file mode 100644
index 0000000..a6480fb
Binary files /dev/null and b/TestImages/multiple_cards/magic1.png differ
diff --git a/TestImages/multiple_cards/pro_tour_side.png b/TestImages/multiple_cards/pro_tour_side.png
new file mode 100644
index 0000000..759ddf3
Binary files /dev/null and b/TestImages/multiple_cards/pro_tour_side.png differ
diff --git a/TestImages/multiple_cards/pro_tour_table.png b/TestImages/multiple_cards/pro_tour_table.png
new file mode 100644
index 0000000..e02960b
Binary files /dev/null and b/TestImages/multiple_cards/pro_tour_table.png differ
diff --git a/TestImages/ocr_test/card.jpg b/TestImages/ocr_test/card.jpg
new file mode 100644
index 0000000..ff57b28
Binary files /dev/null and b/TestImages/ocr_test/card.jpg differ
diff --git a/TestImages/ocr_test/card0.jpg b/TestImages/ocr_test/card0.jpg
new file mode 100644
index 0000000..5a5f5d8
Binary files /dev/null and b/TestImages/ocr_test/card0.jpg differ
diff --git a/TestImages/ocr_test/card1.jpg b/TestImages/ocr_test/card1.jpg
new file mode 100644
index 0000000..151d89f
Binary files /dev/null and b/TestImages/ocr_test/card1.jpg differ
diff --git a/TestImages/ocr_test/card10.jpg b/TestImages/ocr_test/card10.jpg
new file mode 100644
index 0000000..1d25cf2
Binary files /dev/null and b/TestImages/ocr_test/card10.jpg differ
diff --git a/TestImages/ocr_test/card11.jpg b/TestImages/ocr_test/card11.jpg
new file mode 100644
index 0000000..339fc0c
Binary files /dev/null and b/TestImages/ocr_test/card11.jpg differ
diff --git a/TestImages/ocr_test/card12.jpg b/TestImages/ocr_test/card12.jpg
new file mode 100644
index 0000000..4de7f50
Binary files /dev/null and b/TestImages/ocr_test/card12.jpg differ
diff --git a/TestImages/ocr_test/card13.jpg b/TestImages/ocr_test/card13.jpg
new file mode 100644
index 0000000..3b96f8d
Binary files /dev/null and b/TestImages/ocr_test/card13.jpg differ
diff --git a/TestImages/ocr_test/card2.jpg b/TestImages/ocr_test/card2.jpg
new file mode 100644
index 0000000..b974812
Binary files /dev/null and b/TestImages/ocr_test/card2.jpg differ
diff --git a/TestImages/ocr_test/card3.jpg b/TestImages/ocr_test/card3.jpg
new file mode 100644
index 0000000..56347eb
Binary files /dev/null and b/TestImages/ocr_test/card3.jpg differ
diff --git a/TestImages/ocr_test/card4.jpg b/TestImages/ocr_test/card4.jpg
new file mode 100644
index 0000000..4e73d9c
Binary files /dev/null and b/TestImages/ocr_test/card4.jpg differ
diff --git a/TestImages/real_photos/visions_1.jpg b/TestImages/real_photos/visions_1.jpg
new file mode 100644
index 0000000..9408b6d
Binary files /dev/null and b/TestImages/real_photos/visions_1.jpg differ
diff --git a/TestImages/real_photos/visions_1_square.jpg b/TestImages/real_photos/visions_1_square.jpg
new file mode 100644
index 0000000..a15da3e
Binary files /dev/null and b/TestImages/real_photos/visions_1_square.jpg differ
diff --git a/TestImages/real_photos/visions_2.jpg b/TestImages/real_photos/visions_2.jpg
new file mode 100644
index 0000000..04878b2
Binary files /dev/null and b/TestImages/real_photos/visions_2.jpg differ
diff --git a/TestImages/real_photos/visions_2_square.jpg b/TestImages/real_photos/visions_2_square.jpg
new file mode 100644
index 0000000..389a603
Binary files /dev/null and b/TestImages/real_photos/visions_2_square.jpg differ
diff --git a/TestImages/real_photos/visions_3.jpg b/TestImages/real_photos/visions_3.jpg
new file mode 100644
index 0000000..5fcc36b
Binary files /dev/null and b/TestImages/real_photos/visions_3.jpg differ
diff --git a/TestImages/real_photos/visions_4.jpg b/TestImages/real_photos/visions_4.jpg
new file mode 100644
index 0000000..2664cca
Binary files /dev/null and b/TestImages/real_photos/visions_4.jpg differ
diff --git a/TestImages/real_photos/visions_5.jpg b/TestImages/real_photos/visions_5.jpg
new file mode 100644
index 0000000..67ef2f0
Binary files /dev/null and b/TestImages/real_photos/visions_5.jpg differ
diff --git a/TestImages/real_photos/visions_6.jpg b/TestImages/real_photos/visions_6.jpg
new file mode 100644
index 0000000..39b27fd
Binary files /dev/null and b/TestImages/real_photos/visions_6.jpg differ
diff --git a/TestImages/real_photos/visions_6_square.jpg b/TestImages/real_photos/visions_6_square.jpg
new file mode 100644
index 0000000..15bd9bc
Binary files /dev/null and b/TestImages/real_photos/visions_6_square.jpg differ
diff --git a/TestImages/real_photos/visions_7.jpg b/TestImages/real_photos/visions_7.jpg
new file mode 100644
index 0000000..4a5525b
Binary files /dev/null and b/TestImages/real_photos/visions_7.jpg differ
diff --git a/TestImages/real_photos/visions_8.jpg b/TestImages/real_photos/visions_8.jpg
new file mode 100644
index 0000000..5205411
Binary files /dev/null and b/TestImages/real_photos/visions_8.jpg differ
diff --git a/TestImages/real_photos/visions_8_big.jpg b/TestImages/real_photos/visions_8_big.jpg
new file mode 100644
index 0000000..aacdb0a
Binary files /dev/null and b/TestImages/real_photos/visions_8_big.jpg differ
diff --git a/TestImages/real_photos/visions_9.jpg b/TestImages/real_photos/visions_9.jpg
new file mode 100644
index 0000000..04cb000
Binary files /dev/null and b/TestImages/real_photos/visions_9.jpg differ
diff --git a/TestImages/real_photos/visions_9_small.jpg b/TestImages/real_photos/visions_9_small.jpg
new file mode 100644
index 0000000..230f5b2
Binary files /dev/null and b/TestImages/real_photos/visions_9_small.jpg differ
diff --git a/TestImages/real_photos/visions_result_1.jpg b/TestImages/real_photos/visions_result_1.jpg
new file mode 100644
index 0000000..a669ee2
Binary files /dev/null and b/TestImages/real_photos/visions_result_1.jpg differ
diff --git a/TestImages/real_photos/visions_result_2.jpg b/TestImages/real_photos/visions_result_2.jpg
new file mode 100644
index 0000000..abd29ed
Binary files /dev/null and b/TestImages/real_photos/visions_result_2.jpg differ
diff --git a/TestImages/real_photos/visions_result_3.jpg b/TestImages/real_photos/visions_result_3.jpg
new file mode 100644
index 0000000..988e068
Binary files /dev/null and b/TestImages/real_photos/visions_result_3.jpg differ
diff --git a/TestImages/real_photos/visions_result_4.jpg b/TestImages/real_photos/visions_result_4.jpg
new file mode 100644
index 0000000..a28fd0a
Binary files /dev/null and b/TestImages/real_photos/visions_result_4.jpg differ
diff --git a/TestImages/reference/brainstorm.png b/TestImages/reference/brainstorm.png
new file mode 100644
index 0000000..bf7f8f5
Binary files /dev/null and b/TestImages/reference/brainstorm.png differ
diff --git a/TestImages/reference/force_of_will.png b/TestImages/reference/force_of_will.png
new file mode 100644
index 0000000..6ec00e5
Binary files /dev/null and b/TestImages/reference/force_of_will.png differ
diff --git a/TestImages/reference/griselbrand.png b/TestImages/reference/griselbrand.png
new file mode 100644
index 0000000..e73c642
Binary files /dev/null and b/TestImages/reference/griselbrand.png differ
diff --git a/TestImages/reference/lotus_petal.png b/TestImages/reference/lotus_petal.png
new file mode 100644
index 0000000..d048c9f
Binary files /dev/null and b/TestImages/reference/lotus_petal.png differ
diff --git a/TestImages/reference/ponder.png b/TestImages/reference/ponder.png
new file mode 100644
index 0000000..48ae59d
Binary files /dev/null and b/TestImages/reference/ponder.png differ
diff --git a/TestImages/reference/show_and_tell.png b/TestImages/reference/show_and_tell.png
new file mode 100644
index 0000000..9dee849
Binary files /dev/null and b/TestImages/reference/show_and_tell.png differ
diff --git a/TestImages/reference/tropical_island.png b/TestImages/reference/tropical_island.png
new file mode 100644
index 0000000..5ddb71f
Binary files /dev/null and b/TestImages/reference/tropical_island.png differ
diff --git a/TestImages/reference/volcanic_island.png b/TestImages/reference/volcanic_island.png
new file mode 100644
index 0000000..d14eb98
Binary files /dev/null and b/TestImages/reference/volcanic_island.png differ
diff --git a/TestImages/reference/wasteland.png b/TestImages/reference/wasteland.png
new file mode 100644
index 0000000..54b12ab
Binary files /dev/null and b/TestImages/reference/wasteland.png differ
diff --git a/TestImages/reference_alpha/ancestral_recall.jpg b/TestImages/reference_alpha/ancestral_recall.jpg
new file mode 100644
index 0000000..273d451
Binary files /dev/null and b/TestImages/reference_alpha/ancestral_recall.jpg differ
diff --git a/TestImages/reference_alpha/badlands.jpg b/TestImages/reference_alpha/badlands.jpg
new file mode 100644
index 0000000..34b8f20
Binary files /dev/null and b/TestImages/reference_alpha/badlands.jpg differ
diff --git a/TestImages/reference_alpha/balance.jpg b/TestImages/reference_alpha/balance.jpg
new file mode 100644
index 0000000..004e76b
Binary files /dev/null and b/TestImages/reference_alpha/balance.jpg differ
diff --git a/TestImages/reference_alpha/bayou.jpg b/TestImages/reference_alpha/bayou.jpg
new file mode 100644
index 0000000..77ed6ab
Binary files /dev/null and b/TestImages/reference_alpha/bayou.jpg differ
diff --git a/TestImages/reference_alpha/birds_of_paradise.jpg b/TestImages/reference_alpha/birds_of_paradise.jpg
new file mode 100644
index 0000000..83407e2
Binary files /dev/null and b/TestImages/reference_alpha/birds_of_paradise.jpg differ
diff --git a/TestImages/reference_alpha/black_lotus.jpg b/TestImages/reference_alpha/black_lotus.jpg
new file mode 100644
index 0000000..b529a2b
Binary files /dev/null and b/TestImages/reference_alpha/black_lotus.jpg differ
diff --git a/TestImages/reference_alpha/channel.jpg b/TestImages/reference_alpha/channel.jpg
new file mode 100644
index 0000000..ea61345
Binary files /dev/null and b/TestImages/reference_alpha/channel.jpg differ
diff --git a/TestImages/reference_alpha/chaos_orb.jpg b/TestImages/reference_alpha/chaos_orb.jpg
new file mode 100644
index 0000000..d67b23a
Binary files /dev/null and b/TestImages/reference_alpha/chaos_orb.jpg differ
diff --git a/TestImages/reference_alpha/clone.jpg b/TestImages/reference_alpha/clone.jpg
new file mode 100644
index 0000000..937461a
Binary files /dev/null and b/TestImages/reference_alpha/clone.jpg differ
diff --git a/TestImages/reference_alpha/control_magic.jpg b/TestImages/reference_alpha/control_magic.jpg
new file mode 100644
index 0000000..51f94d9
Binary files /dev/null and b/TestImages/reference_alpha/control_magic.jpg differ
diff --git a/TestImages/reference_alpha/counterspell.jpg b/TestImages/reference_alpha/counterspell.jpg
new file mode 100644
index 0000000..44a134c
Binary files /dev/null and b/TestImages/reference_alpha/counterspell.jpg differ
diff --git a/TestImages/reference_alpha/dark_ritual.jpg b/TestImages/reference_alpha/dark_ritual.jpg
new file mode 100644
index 0000000..92829be
Binary files /dev/null and b/TestImages/reference_alpha/dark_ritual.jpg differ
diff --git a/TestImages/reference_alpha/demonic_tutor.jpg b/TestImages/reference_alpha/demonic_tutor.jpg
new file mode 100644
index 0000000..bf0375d
Binary files /dev/null and b/TestImages/reference_alpha/demonic_tutor.jpg differ
diff --git a/TestImages/reference_alpha/disenchant.jpg b/TestImages/reference_alpha/disenchant.jpg
new file mode 100644
index 0000000..a159c61
Binary files /dev/null and b/TestImages/reference_alpha/disenchant.jpg differ
diff --git a/TestImages/reference_alpha/fireball.jpg b/TestImages/reference_alpha/fireball.jpg
new file mode 100644
index 0000000..a683353
Binary files /dev/null and b/TestImages/reference_alpha/fireball.jpg differ
diff --git a/TestImages/reference_alpha/force_of_nature.jpg b/TestImages/reference_alpha/force_of_nature.jpg
new file mode 100644
index 0000000..497c7c5
Binary files /dev/null and b/TestImages/reference_alpha/force_of_nature.jpg differ
diff --git a/TestImages/reference_alpha/fork.jpg b/TestImages/reference_alpha/fork.jpg
new file mode 100644
index 0000000..40ac20d
Binary files /dev/null and b/TestImages/reference_alpha/fork.jpg differ
diff --git a/TestImages/reference_alpha/giant_growth.jpg b/TestImages/reference_alpha/giant_growth.jpg
new file mode 100644
index 0000000..45bc473
Binary files /dev/null and b/TestImages/reference_alpha/giant_growth.jpg differ
diff --git a/TestImages/reference_alpha/hypnotic_specter.jpg b/TestImages/reference_alpha/hypnotic_specter.jpg
new file mode 100644
index 0000000..11ebb95
Binary files /dev/null and b/TestImages/reference_alpha/hypnotic_specter.jpg differ
diff --git a/TestImages/reference_alpha/lightning_bolt.jpg b/TestImages/reference_alpha/lightning_bolt.jpg
new file mode 100644
index 0000000..710b69a
Binary files /dev/null and b/TestImages/reference_alpha/lightning_bolt.jpg differ
diff --git a/TestImages/reference_alpha/llanowar_elves.jpg b/TestImages/reference_alpha/llanowar_elves.jpg
new file mode 100644
index 0000000..bdfbfc1
Binary files /dev/null and b/TestImages/reference_alpha/llanowar_elves.jpg differ
diff --git a/TestImages/reference_alpha/mahamoti_djinn.jpg b/TestImages/reference_alpha/mahamoti_djinn.jpg
new file mode 100644
index 0000000..5265950
Binary files /dev/null and b/TestImages/reference_alpha/mahamoti_djinn.jpg differ
diff --git a/TestImages/reference_alpha/mind_twist.jpg b/TestImages/reference_alpha/mind_twist.jpg
new file mode 100644
index 0000000..6ee690b
Binary files /dev/null and b/TestImages/reference_alpha/mind_twist.jpg differ
diff --git a/TestImages/reference_alpha/mox_emerald.jpg b/TestImages/reference_alpha/mox_emerald.jpg
new file mode 100644
index 0000000..25c0e11
Binary files /dev/null and b/TestImages/reference_alpha/mox_emerald.jpg differ
diff --git a/TestImages/reference_alpha/mox_jet.jpg b/TestImages/reference_alpha/mox_jet.jpg
new file mode 100644
index 0000000..a3e18bf
Binary files /dev/null and b/TestImages/reference_alpha/mox_jet.jpg differ
diff --git a/TestImages/reference_alpha/mox_pearl.jpg b/TestImages/reference_alpha/mox_pearl.jpg
new file mode 100644
index 0000000..97d12ee
Binary files /dev/null and b/TestImages/reference_alpha/mox_pearl.jpg differ
diff --git a/TestImages/reference_alpha/mox_ruby.jpg b/TestImages/reference_alpha/mox_ruby.jpg
new file mode 100644
index 0000000..c2d1d3b
Binary files /dev/null and b/TestImages/reference_alpha/mox_ruby.jpg differ
diff --git a/TestImages/reference_alpha/mox_sapphire.jpg b/TestImages/reference_alpha/mox_sapphire.jpg
new file mode 100644
index 0000000..ed7e87e
Binary files /dev/null and b/TestImages/reference_alpha/mox_sapphire.jpg differ
diff --git a/TestImages/reference_alpha/nightmare.jpg b/TestImages/reference_alpha/nightmare.jpg
new file mode 100644
index 0000000..d1a0a15
Binary files /dev/null and b/TestImages/reference_alpha/nightmare.jpg differ
diff --git a/TestImages/reference_alpha/plateau.jpg b/TestImages/reference_alpha/plateau.jpg
new file mode 100644
index 0000000..0d5ccd5
Binary files /dev/null and b/TestImages/reference_alpha/plateau.jpg differ
diff --git a/TestImages/reference_alpha/regrowth.jpg b/TestImages/reference_alpha/regrowth.jpg
new file mode 100644
index 0000000..97fd879
Binary files /dev/null and b/TestImages/reference_alpha/regrowth.jpg differ
diff --git a/TestImages/reference_alpha/rock_hydra.jpg b/TestImages/reference_alpha/rock_hydra.jpg
new file mode 100644
index 0000000..b88b8c5
Binary files /dev/null and b/TestImages/reference_alpha/rock_hydra.jpg differ
diff --git a/TestImages/reference_alpha/royal_assassin.jpg b/TestImages/reference_alpha/royal_assassin.jpg
new file mode 100644
index 0000000..fa23a71
Binary files /dev/null and b/TestImages/reference_alpha/royal_assassin.jpg differ
diff --git a/TestImages/reference_alpha/savannah.jpg b/TestImages/reference_alpha/savannah.jpg
new file mode 100644
index 0000000..2ef8dd9
Binary files /dev/null and b/TestImages/reference_alpha/savannah.jpg differ
diff --git a/TestImages/reference_alpha/scrubland.jpg b/TestImages/reference_alpha/scrubland.jpg
new file mode 100644
index 0000000..bfaf8b8
Binary files /dev/null and b/TestImages/reference_alpha/scrubland.jpg differ
diff --git a/TestImages/reference_alpha/serra_angel.jpg b/TestImages/reference_alpha/serra_angel.jpg
new file mode 100644
index 0000000..7bc59cf
Binary files /dev/null and b/TestImages/reference_alpha/serra_angel.jpg differ
diff --git a/TestImages/reference_alpha/shivan_dragon.jpg b/TestImages/reference_alpha/shivan_dragon.jpg
new file mode 100644
index 0000000..3126461
Binary files /dev/null and b/TestImages/reference_alpha/shivan_dragon.jpg differ
diff --git a/TestImages/reference_alpha/sol_ring.jpg b/TestImages/reference_alpha/sol_ring.jpg
new file mode 100644
index 0000000..a754249
Binary files /dev/null and b/TestImages/reference_alpha/sol_ring.jpg differ
diff --git a/TestImages/reference_alpha/swords_to_plowshares.jpg b/TestImages/reference_alpha/swords_to_plowshares.jpg
new file mode 100644
index 0000000..964667e
Binary files /dev/null and b/TestImages/reference_alpha/swords_to_plowshares.jpg differ
diff --git a/TestImages/reference_alpha/taiga.jpg b/TestImages/reference_alpha/taiga.jpg
new file mode 100644
index 0000000..a9465b7
Binary files /dev/null and b/TestImages/reference_alpha/taiga.jpg differ
diff --git a/TestImages/reference_alpha/time_walk.jpg b/TestImages/reference_alpha/time_walk.jpg
new file mode 100644
index 0000000..0807e9a
Binary files /dev/null and b/TestImages/reference_alpha/time_walk.jpg differ
diff --git a/TestImages/reference_alpha/timetwister.jpg b/TestImages/reference_alpha/timetwister.jpg
new file mode 100644
index 0000000..aa95c55
Binary files /dev/null and b/TestImages/reference_alpha/timetwister.jpg differ
diff --git a/TestImages/reference_alpha/tropical_island.jpg b/TestImages/reference_alpha/tropical_island.jpg
new file mode 100644
index 0000000..186a951
Binary files /dev/null and b/TestImages/reference_alpha/tropical_island.jpg differ
diff --git a/TestImages/reference_alpha/tundra.jpg b/TestImages/reference_alpha/tundra.jpg
new file mode 100644
index 0000000..d2769bc
Binary files /dev/null and b/TestImages/reference_alpha/tundra.jpg differ
diff --git a/TestImages/reference_alpha/underground_sea.jpg b/TestImages/reference_alpha/underground_sea.jpg
new file mode 100644
index 0000000..6824628
Binary files /dev/null and b/TestImages/reference_alpha/underground_sea.jpg differ
diff --git a/TestImages/reference_alpha/wheel_of_fortune.jpg b/TestImages/reference_alpha/wheel_of_fortune.jpg
new file mode 100644
index 0000000..603136f
Binary files /dev/null and b/TestImages/reference_alpha/wheel_of_fortune.jpg differ
diff --git a/TestImages/reference_alpha/wrath_of_god.jpg b/TestImages/reference_alpha/wrath_of_god.jpg
new file mode 100644
index 0000000..9339812
Binary files /dev/null and b/TestImages/reference_alpha/wrath_of_god.jpg differ
diff --git a/TestImages/single_cards/adanto_vanguard.png b/TestImages/single_cards/adanto_vanguard.png
new file mode 100644
index 0000000..a7d27c2
Binary files /dev/null and b/TestImages/single_cards/adanto_vanguard.png differ
diff --git a/TestImages/single_cards/angel_of_sanctions.png b/TestImages/single_cards/angel_of_sanctions.png
new file mode 100644
index 0000000..181ed0b
Binary files /dev/null and b/TestImages/single_cards/angel_of_sanctions.png differ
diff --git a/TestImages/single_cards/attunement.jpg b/TestImages/single_cards/attunement.jpg
new file mode 100644
index 0000000..5994502
Binary files /dev/null and b/TestImages/single_cards/attunement.jpg differ
diff --git a/TestImages/single_cards/avaricious_dragon.jpg b/TestImages/single_cards/avaricious_dragon.jpg
new file mode 100644
index 0000000..396fa6c
Binary files /dev/null and b/TestImages/single_cards/avaricious_dragon.jpg differ
diff --git a/TestImages/single_cards/burgeoning.png b/TestImages/single_cards/burgeoning.png
new file mode 100644
index 0000000..0a5baba
Binary files /dev/null and b/TestImages/single_cards/burgeoning.png differ
diff --git a/TestImages/single_cards/fireball.jpg b/TestImages/single_cards/fireball.jpg
new file mode 100644
index 0000000..1a6a56f
Binary files /dev/null and b/TestImages/single_cards/fireball.jpg differ
diff --git a/TestImages/single_cards/jarad_golgari.jpg b/TestImages/single_cards/jarad_golgari.jpg
new file mode 100644
index 0000000..ee26e77
Binary files /dev/null and b/TestImages/single_cards/jarad_golgari.jpg differ
diff --git a/TestImages/single_cards/llanowar_elves.jpg b/TestImages/single_cards/llanowar_elves.jpg
new file mode 100644
index 0000000..33adb4b
Binary files /dev/null and b/TestImages/single_cards/llanowar_elves.jpg differ
diff --git a/TestImages/single_cards/meletis_charlatan.jpg b/TestImages/single_cards/meletis_charlatan.jpg
new file mode 100644
index 0000000..8c736f7
Binary files /dev/null and b/TestImages/single_cards/meletis_charlatan.jpg differ
diff --git a/TestImages/single_cards/mindstab_thrull.jpeg b/TestImages/single_cards/mindstab_thrull.jpeg
new file mode 100644
index 0000000..95b1c61
Binary files /dev/null and b/TestImages/single_cards/mindstab_thrull.jpeg differ
diff --git a/TestImages/single_cards/pacifism.jpg b/TestImages/single_cards/pacifism.jpg
new file mode 100644
index 0000000..7ed4f88
Binary files /dev/null and b/TestImages/single_cards/pacifism.jpg differ
diff --git a/TestImages/single_cards/platinum_angel.jpg b/TestImages/single_cards/platinum_angel.jpg
new file mode 100644
index 0000000..b971461
Binary files /dev/null and b/TestImages/single_cards/platinum_angel.jpg differ
diff --git a/TestImages/single_cards/queen_marchesa.png b/TestImages/single_cards/queen_marchesa.png
new file mode 100644
index 0000000..aa2b3f7
Binary files /dev/null and b/TestImages/single_cards/queen_marchesa.png differ
diff --git a/TestImages/single_cards/queen_marchesa_analyzer.png b/TestImages/single_cards/queen_marchesa_analyzer.png
new file mode 100644
index 0000000..aa2b3f7
Binary files /dev/null and b/TestImages/single_cards/queen_marchesa_analyzer.png differ
diff --git a/TestImages/single_cards/shivan_dragon.jpg b/TestImages/single_cards/shivan_dragon.jpg
new file mode 100644
index 0000000..50276a1
Binary files /dev/null and b/TestImages/single_cards/shivan_dragon.jpg differ
diff --git a/TestImages/single_cards/spellseeker.png b/TestImages/single_cards/spellseeker.png
new file mode 100644
index 0000000..0a3cb75
Binary files /dev/null and b/TestImages/single_cards/spellseeker.png differ
diff --git a/TestImages/single_cards/tarmogoyf.jpg b/TestImages/single_cards/tarmogoyf.jpg
new file mode 100644
index 0000000..e547a94
Binary files /dev/null and b/TestImages/single_cards/tarmogoyf.jpg differ
diff --git a/TestImages/single_cards/thought_reflection.jpg b/TestImages/single_cards/thought_reflection.jpg
new file mode 100644
index 0000000..e1c7ba5
Binary files /dev/null and b/TestImages/single_cards/thought_reflection.jpg differ
diff --git a/TestImages/single_cards/unsummon.jpg b/TestImages/single_cards/unsummon.jpg
new file mode 100644
index 0000000..a44be04
Binary files /dev/null and b/TestImages/single_cards/unsummon.jpg differ
diff --git a/TestImages/tokens/angel_token_alter.jpg b/TestImages/tokens/angel_token_alter.jpg
new file mode 100644
index 0000000..8a94cae
Binary files /dev/null and b/TestImages/tokens/angel_token_alter.jpg differ
diff --git a/TestImages/tokens/brothers_tokens.jpg b/TestImages/tokens/brothers_tokens.jpg
new file mode 100644
index 0000000..f3363d3
Binary files /dev/null and b/TestImages/tokens/brothers_tokens.jpg differ
diff --git a/TestImages/tokens/christopher_rush_tokens.jpg b/TestImages/tokens/christopher_rush_tokens.jpg
new file mode 100644
index 0000000..bc93444
Binary files /dev/null and b/TestImages/tokens/christopher_rush_tokens.jpg differ
diff --git a/TestImages/tokens/custom_tokens.jpg b/TestImages/tokens/custom_tokens.jpg
new file mode 100644
index 0000000..89d4dda
Binary files /dev/null and b/TestImages/tokens/custom_tokens.jpg differ
diff --git a/TestImages/tokens/elspeth_emblem.jpg b/TestImages/tokens/elspeth_emblem.jpg
new file mode 100644
index 0000000..78be8d9
Binary files /dev/null and b/TestImages/tokens/elspeth_emblem.jpg differ
diff --git a/TestImages/tokens/elspeth_starwars_emblem.jpg b/TestImages/tokens/elspeth_starwars_emblem.jpg
new file mode 100644
index 0000000..d37ba4d
Binary files /dev/null and b/TestImages/tokens/elspeth_starwars_emblem.jpg differ
diff --git a/TestImages/tokens/gideon_emblem.jpg b/TestImages/tokens/gideon_emblem.jpg
new file mode 100644
index 0000000..a9292d3
Binary files /dev/null and b/TestImages/tokens/gideon_emblem.jpg differ
diff --git a/TestImages/tokens/narset_emblem.jpg b/TestImages/tokens/narset_emblem.jpg
new file mode 100644
index 0000000..5b2c0fc
Binary files /dev/null and b/TestImages/tokens/narset_emblem.jpg differ
diff --git a/TestImages/tokens/ratadrabik_token.jpg b/TestImages/tokens/ratadrabik_token.jpg
new file mode 100644
index 0000000..9a10a4f
Binary files /dev/null and b/TestImages/tokens/ratadrabik_token.jpg differ
diff --git a/TestImages/tokens/rkpost_rhino_tokens.jpg b/TestImages/tokens/rkpost_rhino_tokens.jpg
new file mode 100644
index 0000000..34ccd1b
Binary files /dev/null and b/TestImages/tokens/rkpost_rhino_tokens.jpg differ
diff --git a/TestImages/tokens/token_collection_pucatrade.jpg b/TestImages/tokens/token_collection_pucatrade.jpg
new file mode 100644
index 0000000..4297869
Binary files /dev/null and b/TestImages/tokens/token_collection_pucatrade.jpg differ
diff --git a/TestImages/tokens/tokens_foils_lands.jpg b/TestImages/tokens/tokens_foils_lands.jpg
new file mode 100644
index 0000000..850bd60
Binary files /dev/null and b/TestImages/tokens/tokens_foils_lands.jpg differ
diff --git a/TestImages/tokens/vampire_knight_token.jpg b/TestImages/tokens/vampire_knight_token.jpg
new file mode 100644
index 0000000..5649e7e
Binary files /dev/null and b/TestImages/tokens/vampire_knight_token.jpg differ
diff --git a/TestImages/training_examples/training_set_1.jpg b/TestImages/training_examples/training_set_1.jpg
new file mode 100644
index 0000000..b3d4ffe
Binary files /dev/null and b/TestImages/training_examples/training_set_1.jpg differ
diff --git a/TestImages/training_examples/training_set_2.jpg b/TestImages/training_examples/training_set_2.jpg
new file mode 100644
index 0000000..32bd556
Binary files /dev/null and b/TestImages/training_examples/training_set_2.jpg differ
diff --git a/TestImages/training_examples/training_set_3.jpg b/TestImages/training_examples/training_set_3.jpg
new file mode 100644
index 0000000..8467af5
Binary files /dev/null and b/TestImages/training_examples/training_set_3.jpg differ
diff --git a/TestImages/varying_quality/black.jpg b/TestImages/varying_quality/black.jpg
new file mode 100644
index 0000000..dc90cae
Binary files /dev/null and b/TestImages/varying_quality/black.jpg differ
diff --git a/TestImages/varying_quality/card_in_plastic_case.jpg b/TestImages/varying_quality/card_in_plastic_case.jpg
new file mode 100644
index 0000000..e771a5c
Binary files /dev/null and b/TestImages/varying_quality/card_in_plastic_case.jpg differ
diff --git a/TestImages/varying_quality/counterspell_bgs.jpg b/TestImages/varying_quality/counterspell_bgs.jpg
new file mode 100644
index 0000000..25a8e1c
Binary files /dev/null and b/TestImages/varying_quality/counterspell_bgs.jpg differ
diff --git a/TestImages/varying_quality/dragon_whelp.jpg b/TestImages/varying_quality/dragon_whelp.jpg
new file mode 100644
index 0000000..effdde6
Binary files /dev/null and b/TestImages/varying_quality/dragon_whelp.jpg differ
diff --git a/TestImages/varying_quality/evil_eye.jpg b/TestImages/varying_quality/evil_eye.jpg
new file mode 100644
index 0000000..faad74e
Binary files /dev/null and b/TestImages/varying_quality/evil_eye.jpg differ
diff --git a/TestImages/varying_quality/frilly.jpg b/TestImages/varying_quality/frilly.jpg
new file mode 100644
index 0000000..5ab39fd
Binary files /dev/null and b/TestImages/varying_quality/frilly.jpg differ
diff --git a/TestImages/varying_quality/image_orig.jpg b/TestImages/varying_quality/image_orig.jpg
new file mode 100644
index 0000000..440ad18
Binary files /dev/null and b/TestImages/varying_quality/image_orig.jpg differ
diff --git a/TestImages/varying_quality/instill_energy.jpg b/TestImages/varying_quality/instill_energy.jpg
new file mode 100644
index 0000000..c443961
Binary files /dev/null and b/TestImages/varying_quality/instill_energy.jpg differ
diff --git a/TestImages/varying_quality/ruby.jpg b/TestImages/varying_quality/ruby.jpg
new file mode 100644
index 0000000..a343232
Binary files /dev/null and b/TestImages/varying_quality/ruby.jpg differ
diff --git a/TestImages/varying_quality/s-l300.jpg b/TestImages/varying_quality/s-l300.jpg
new file mode 100644
index 0000000..819daca
Binary files /dev/null and b/TestImages/varying_quality/s-l300.jpg differ
diff --git a/TestImages/varying_quality/test.jpg b/TestImages/varying_quality/test.jpg
new file mode 100644
index 0000000..233ffa8
Binary files /dev/null and b/TestImages/varying_quality/test.jpg differ
diff --git a/TestImages/varying_quality/test1.jpg b/TestImages/varying_quality/test1.jpg
new file mode 100644
index 0000000..a75278e
Binary files /dev/null and b/TestImages/varying_quality/test1.jpg differ
diff --git a/TestImages/varying_quality/test10.jpg b/TestImages/varying_quality/test10.jpg
new file mode 100644
index 0000000..8e9062b
Binary files /dev/null and b/TestImages/varying_quality/test10.jpg differ
diff --git a/TestImages/varying_quality/test11.jpg b/TestImages/varying_quality/test11.jpg
new file mode 100644
index 0000000..b0795f4
Binary files /dev/null and b/TestImages/varying_quality/test11.jpg differ
diff --git a/TestImages/varying_quality/test12.jpg b/TestImages/varying_quality/test12.jpg
new file mode 100644
index 0000000..c2f5de6
Binary files /dev/null and b/TestImages/varying_quality/test12.jpg differ
diff --git a/TestImages/varying_quality/test13.jpg b/TestImages/varying_quality/test13.jpg
new file mode 100644
index 0000000..878cbad
Binary files /dev/null and b/TestImages/varying_quality/test13.jpg differ
diff --git a/TestImages/varying_quality/test14.jpg b/TestImages/varying_quality/test14.jpg
new file mode 100644
index 0000000..bf5094a
Binary files /dev/null and b/TestImages/varying_quality/test14.jpg differ
diff --git a/TestImages/varying_quality/test15.jpg b/TestImages/varying_quality/test15.jpg
new file mode 100644
index 0000000..39f1dd4
Binary files /dev/null and b/TestImages/varying_quality/test15.jpg differ
diff --git a/TestImages/varying_quality/test16.jpg b/TestImages/varying_quality/test16.jpg
new file mode 100644
index 0000000..c514771
Binary files /dev/null and b/TestImages/varying_quality/test16.jpg differ
diff --git a/TestImages/varying_quality/test17.jpg b/TestImages/varying_quality/test17.jpg
new file mode 100644
index 0000000..4ad12f7
Binary files /dev/null and b/TestImages/varying_quality/test17.jpg differ
diff --git a/TestImages/varying_quality/test18.jpg b/TestImages/varying_quality/test18.jpg
new file mode 100644
index 0000000..a0f9390
Binary files /dev/null and b/TestImages/varying_quality/test18.jpg differ
diff --git a/TestImages/varying_quality/test19.jpg b/TestImages/varying_quality/test19.jpg
new file mode 100644
index 0000000..8f3c5a6
Binary files /dev/null and b/TestImages/varying_quality/test19.jpg differ
diff --git a/TestImages/varying_quality/test2.jpg b/TestImages/varying_quality/test2.jpg
new file mode 100644
index 0000000..1fceb1f
Binary files /dev/null and b/TestImages/varying_quality/test2.jpg differ
diff --git a/TestImages/varying_quality/test20.jpg b/TestImages/varying_quality/test20.jpg
new file mode 100644
index 0000000..8717d5f
Binary files /dev/null and b/TestImages/varying_quality/test20.jpg differ
diff --git a/TestImages/varying_quality/test21.jpg b/TestImages/varying_quality/test21.jpg
new file mode 100644
index 0000000..342577c
Binary files /dev/null and b/TestImages/varying_quality/test21.jpg differ
diff --git a/TestImages/varying_quality/test22.png b/TestImages/varying_quality/test22.png
new file mode 100644
index 0000000..179f188
Binary files /dev/null and b/TestImages/varying_quality/test22.png differ
diff --git a/TestImages/varying_quality/test23.jpg b/TestImages/varying_quality/test23.jpg
new file mode 100644
index 0000000..af79a6f
Binary files /dev/null and b/TestImages/varying_quality/test23.jpg differ
diff --git a/TestImages/varying_quality/test24.jpg b/TestImages/varying_quality/test24.jpg
new file mode 100644
index 0000000..937354c
Binary files /dev/null and b/TestImages/varying_quality/test24.jpg differ
diff --git a/TestImages/varying_quality/test25.jpg b/TestImages/varying_quality/test25.jpg
new file mode 100644
index 0000000..6e39077
Binary files /dev/null and b/TestImages/varying_quality/test25.jpg differ
diff --git a/TestImages/varying_quality/test26.jpg b/TestImages/varying_quality/test26.jpg
new file mode 100644
index 0000000..ee83759
Binary files /dev/null and b/TestImages/varying_quality/test26.jpg differ
diff --git a/TestImages/varying_quality/test27.jpg b/TestImages/varying_quality/test27.jpg
new file mode 100644
index 0000000..0ee79be
Binary files /dev/null and b/TestImages/varying_quality/test27.jpg differ
diff --git a/TestImages/varying_quality/test3.jpg b/TestImages/varying_quality/test3.jpg
new file mode 100644
index 0000000..fd1f2cb
Binary files /dev/null and b/TestImages/varying_quality/test3.jpg differ
diff --git a/TestImages/varying_quality/test4.jpg b/TestImages/varying_quality/test4.jpg
new file mode 100644
index 0000000..1f2ffc6
Binary files /dev/null and b/TestImages/varying_quality/test4.jpg differ
diff --git a/TestImages/varying_quality/test5.jpg b/TestImages/varying_quality/test5.jpg
new file mode 100644
index 0000000..f9e8a1f
Binary files /dev/null and b/TestImages/varying_quality/test5.jpg differ
diff --git a/TestImages/varying_quality/test6.jpg b/TestImages/varying_quality/test6.jpg
new file mode 100644
index 0000000..1454673
Binary files /dev/null and b/TestImages/varying_quality/test6.jpg differ
diff --git a/TestImages/varying_quality/test7.jpg b/TestImages/varying_quality/test7.jpg
new file mode 100644
index 0000000..82dfb3c
Binary files /dev/null and b/TestImages/varying_quality/test7.jpg differ
diff --git a/TestImages/varying_quality/test8.jpg b/TestImages/varying_quality/test8.jpg
new file mode 100644
index 0000000..2d480ce
Binary files /dev/null and b/TestImages/varying_quality/test8.jpg differ
diff --git a/TestImages/varying_quality/test9.jpg b/TestImages/varying_quality/test9.jpg
new file mode 100644
index 0000000..c8b0f53
Binary files /dev/null and b/TestImages/varying_quality/test9.jpg differ
diff --git a/TestImages/worn/bent_creased.jpg b/TestImages/worn/bent_creased.jpg
new file mode 100644
index 0000000..18c948a
Binary files /dev/null and b/TestImages/worn/bent_creased.jpg differ
diff --git a/TestImages/worn/edge_nick.png b/TestImages/worn/edge_nick.png
new file mode 100644
index 0000000..68a7251
Binary files /dev/null and b/TestImages/worn/edge_nick.png differ
diff --git a/TestImages/worn/edge_white.png b/TestImages/worn/edge_white.png
new file mode 100644
index 0000000..1c91723
Binary files /dev/null and b/TestImages/worn/edge_white.png differ
diff --git a/TestImages/worn/good_1.jpg b/TestImages/worn/good_1.jpg
new file mode 100644
index 0000000..cd0007e
Binary files /dev/null and b/TestImages/worn/good_1.jpg differ
diff --git a/TestImages/worn/good_2.jpg b/TestImages/worn/good_2.jpg
new file mode 100644
index 0000000..bd6e04e
Binary files /dev/null and b/TestImages/worn/good_2.jpg differ
diff --git a/TestImages/worn/hp_binder_bite_back.webp b/TestImages/worn/hp_binder_bite_back.webp
new file mode 100644
index 0000000..727f380
Binary files /dev/null and b/TestImages/worn/hp_binder_bite_back.webp differ
diff --git a/TestImages/worn/hp_binder_bite_front.webp b/TestImages/worn/hp_binder_bite_front.webp
new file mode 100644
index 0000000..936ce8d
Binary files /dev/null and b/TestImages/worn/hp_binder_bite_front.webp differ
diff --git a/TestImages/worn/hp_compromised_corner.webp b/TestImages/worn/hp_compromised_corner.webp
new file mode 100644
index 0000000..8665a6d
Binary files /dev/null and b/TestImages/worn/hp_compromised_corner.webp differ
diff --git a/TestImages/worn/hp_scratches.png b/TestImages/worn/hp_scratches.png
new file mode 100644
index 0000000..b179f72
Binary files /dev/null and b/TestImages/worn/hp_scratches.png differ
diff --git a/TestImages/worn/hp_shuffle_crease.webp b/TestImages/worn/hp_shuffle_crease.webp
new file mode 100644
index 0000000..6ad1542
Binary files /dev/null and b/TestImages/worn/hp_shuffle_crease.webp differ
diff --git a/TestImages/worn/hp_water_warping.png b/TestImages/worn/hp_water_warping.png
new file mode 100644
index 0000000..590dfc0
Binary files /dev/null and b/TestImages/worn/hp_water_warping.png differ
diff --git a/TestImages/worn/scratch.png b/TestImages/worn/scratch.png
new file mode 100644
index 0000000..d7830d6
Binary files /dev/null and b/TestImages/worn/scratch.png differ
diff --git a/TestImages/worn/spotting.png b/TestImages/worn/spotting.png
new file mode 100644
index 0000000..f559c42
Binary files /dev/null and b/TestImages/worn/spotting.png differ
diff --git a/TestImages/worn/very_good_1.jpg b/TestImages/worn/very_good_1.jpg
new file mode 100644
index 0000000..938cd43
Binary files /dev/null and b/TestImages/worn/very_good_1.jpg differ
diff --git a/TestImages/worn/very_good_2.jpg b/TestImages/worn/very_good_2.jpg
new file mode 100644
index 0000000..2431c08
Binary files /dev/null and b/TestImages/worn/very_good_2.jpg differ
diff --git a/docs/CARD_RECOGNITION.md b/docs/CARD_RECOGNITION.md
new file mode 100644
index 0000000..591293f
--- /dev/null
+++ b/docs/CARD_RECOGNITION.md
@@ -0,0 +1,425 @@
+# Card Recognition Architecture
+
+This document explores approaches for implementing robust MTG card recognition in Scry.
+
+## Goals
+
+1. **Robustness** - Work reliably across varying lighting, angles, and card conditions
+2. **Speed** - Fast enough for real-time scanning (<500ms per card)
+3. **Accuracy** - High precision to avoid misidentifying valuable cards
+4. **Offline-capable** - Core recognition should work without network
+
+## Data Sources
+
+### Scryfall API
+
+Scryfall is the de-facto source of truth for MTG card data.
+
+**Key endpoints:**
+
+| Endpoint | Purpose |
+|----------|---------|
+| `GET /cards/named?fuzzy={name}` | Fuzzy name lookup |
+| `GET /cards/{scryfall_id}` | Get card by ID |
+| `GET /cards/search?q={query}` | Full-text search |
+| `GET /bulk-data` | Daily JSON exports |
+
+**Rate limits:** 50-100ms between requests (~10/sec). Images at `*.scryfall.io` have no rate limit.
+
+**Bulk data options:**
+
+| File | Size | Use Case |
+|------|------|----------|
+| Oracle Cards | ~161 MB | One card per Oracle ID (recognition) |
+| Unique Artwork | ~233 MB | One per unique art (art-based matching) |
+| Default Cards | ~501 MB | Every English printing |
+| All Cards | ~2.3 GB | Every card, every language |
+
+**Recommended approach:** Download "Unique Artwork" bulk data, extract image URLs and hashes for all cards. Update weekly or after new set releases.
+
+### Card Image Fields
+
+```json
+{
+ "id": "uuid",
+ "oracle_id": "uuid",
+ "name": "Lightning Bolt",
+ "set": "2xm",
+ "collector_number": "129",
+ "illustration_id": "uuid",
+ "image_uris": {
+ "small": "https://cards.scryfall.io/.../small/...",
+ "normal": "https://cards.scryfall.io/.../normal/...",
+ "large": "https://cards.scryfall.io/.../large/...",
+ "art_crop": "https://cards.scryfall.io/.../art_crop/..."
+ }
+}
+```
+
+Key identifiers:
+- `id` - Unique per printing
+- `oracle_id` - Same across reprints (same card conceptually)
+- `illustration_id` - Same across reprints with identical artwork
+
+---
+
+## Recognition Approaches
+
+### 1. Perceptual Hashing (Recommended Primary)
+
+**How it works:** Convert image to fixed-size fingerprint resistant to minor transformations.
+
+**Algorithm:**
+1. Resize image to small size (e.g., 32x32)
+2. Convert to grayscale (or keep RGB for color-aware variant)
+3. Apply DCT (Discrete Cosine Transform)
+4. Keep low-frequency components
+5. Compute hash from median comparison
+
+**Variants:**
+
+| Type | Description | Use Case |
+|------|-------------|----------|
+| aHash | Average hash | Fast, less accurate |
+| pHash | Perceptual hash | Good balance |
+| dHash | Difference hash | Edge-focused |
+| wHash | Wavelet hash | Most robust |
+| Color pHash | Separate RGB channel hashes | Best for colorful art |
+
+**Performance (from MTG Card Detector project):**
+- Hash size 16 (256-bit with RGB): ~16ms per comparison
+- Hash size 64: ~65ms per comparison
+- Database of 30k+ cards: still feasible with proper indexing
+
+**Implementation:**
+```csharp
+// Pseudo-code for color-aware pHash
+public byte[] ComputeColorHash(Image image)
+{
+ var resized = Resize(image, 32, 32);
+ var rHash = ComputePHash(resized.RedChannel);
+ var gHash = ComputePHash(resized.GreenChannel);
+ var bHash = ComputePHash(resized.BlueChannel);
+ return Concat(rHash, gHash, bHash); // 768-bit hash
+}
+
+public int HammingDistance(byte[] a, byte[] b)
+{
+ int distance = 0;
+ for (int i = 0; i < a.Length; i++)
+ distance += PopCount(a[i] ^ b[i]);
+ return distance;
+}
+```
+
+**Matching strategy:**
+```
+confidence = (mean_distance - best_match_distance) / (4 * std_deviation)
+```
+Accept match if best match is >4 standard deviations better than average.
+
+### 2. OCR-Based Recognition (Fallback)
+
+**When to use:** Stacked/overlapping cards where only name is visible.
+
+**Approach:**
+1. Detect text regions in image
+2. Run OCR on card name area
+3. Fuzzy match against card database using SymSpell (edit distance ≤6)
+
+**Libraries:**
+- Azure Computer Vision / Google Cloud Vision (best accuracy)
+- Tesseract (open source, but poor on stylized MTG fonts)
+- ML Kit (on-device, good for mobile)
+
+**Accuracy:** ~90% on test sets with cloud OCR.
+
+### 3. Art-Only Matching
+
+**When to use:** Cards with same name but different art (reprints).
+
+**Approach:**
+1. Detect card boundaries
+2. Crop to art box only (known position relative to card frame)
+3. Compute hash of art region
+4. Match against art-specific hash database
+
+**Benefits:**
+- More robust to frame changes between editions
+- Smaller hash database (unique artwork only)
+- Less affected by card condition (art usually best preserved)
+
+### 4. Neural Network (Future Enhancement)
+
+**Potential approaches:**
+
+| Method | Pros | Cons |
+|--------|------|------|
+| YOLO detection | Finds cards in complex scenes | Slow (~50-60ms/frame) |
+| CNN classification | High accuracy | Needs training per card |
+| CNN embeddings | Similarity search | Requires pre-trained model |
+| Siamese networks | Few-shot learning | Complex training |
+
+**Recommendation:** Start with pHash, add neural detection for card localization only if contour detection proves insufficient.
+
+---
+
+## Robustness Strategies
+
+### Pre-processing Pipeline
+
+```
+Input Image
+ │
+ ▼
+┌─────────────────┐
+│ Resize (max 1000px) │
+└─────────────────┘
+ │
+ ▼
+┌─────────────────┐
+│ CLAHE Normalization │ ← Fixes uneven lighting
+│ (LAB color space) │
+└─────────────────┘
+ │
+ ▼
+┌─────────────────┐
+│ Card Detection │ ← Contour or ML-based
+│ (find boundaries) │
+└─────────────────┘
+ │
+ ▼
+┌─────────────────┐
+│ Perspective Warp │ ← Normalize to rectangle
+└─────────────────┘
+ │
+ ▼
+┌─────────────────┐
+│ Hash Computation │
+└─────────────────┘
+ │
+ ▼
+┌─────────────────┐
+│ Database Matching │
+└─────────────────┘
+```
+
+### CLAHE (Contrast Limited Adaptive Histogram Equalization)
+
+Critical for handling varying lighting:
+
+```csharp
+// Convert to LAB, apply CLAHE to L channel, convert back
+var lab = ConvertToLab(image);
+lab.L = ApplyCLAHE(lab.L, clipLimit: 2.0, tileSize: 8);
+var normalized = ConvertToRgb(lab);
+```
+
+### Multi-Threshold Card Detection
+
+Use multiple thresholding approaches in parallel:
+1. Adaptive threshold on grayscale
+2. Separate thresholds on R, G, B channels
+3. Canny edge detection
+
+Combine results to find card contours that appear in multiple methods.
+
+### Confidence Scoring
+
+```csharp
+public class MatchResult
+{
+ public Card Card { get; set; }
+ public float Confidence { get; set; }
+ public int HashDistance { get; set; }
+ public MatchMethod Method { get; set; }
+}
+
+public MatchResult Match(byte[] queryHash, CardDatabase db)
+{
+ var distances = db.Cards
+ .Select(c => (Card: c, Distance: HammingDistance(queryHash, c.Hash)))
+ .OrderBy(x => x.Distance)
+ .ToList();
+
+ var best = distances[0];
+ var mean = distances.Average(x => x.Distance);
+ var stdDev = StandardDeviation(distances.Select(x => x.Distance));
+
+ // Z-score: how many std devs better than mean
+ var zScore = (mean - best.Distance) / stdDev;
+
+ return new MatchResult
+ {
+ Card = best.Card,
+ Confidence = Math.Min(zScore / 4f, 1f), // Normalize to 0-1
+ HashDistance = best.Distance
+ };
+}
+```
+
+### Edge Cases
+
+| Scenario | Strategy |
+|----------|----------|
+| Foil cards | Pre-process to reduce glare; may need separate foil hash DB |
+| Worn/played | Lower confidence threshold, flag for manual review |
+| Foreign language | Match by art hash (language-independent) |
+| Tokens/emblems | Include in database with separate type flag |
+| Partial visibility | Fall back to OCR on visible portion |
+| Similar cards | Color-aware hashing helps; art-only match as tiebreaker |
+
+---
+
+## Recommended Architecture
+
+### Phase 1: MVP (pHash + Scryfall)
+
+```
+┌─────────────────────────────────────────────────────┐
+│ Scry App │
+├─────────────────────────────────────────────────────┤
+│ ┌─────────────┐ ┌──────────────────┐ │
+│ │ CameraView │───▶│ CardRecognition │ │
+│ └─────────────┘ │ Service │ │
+│ ├──────────────────┤ │
+│ │ • PreProcess() │ │
+│ │ • DetectCard() │ │
+│ │ • ComputeHash() │ │
+│ │ • MatchCard() │ │
+│ └────────┬─────────┘ │
+│ │ │
+│ ┌────────▼─────────┐ │
+│ │ CardHashDatabase │ │
+│ │ (SQLite) │ │
+│ └────────┬─────────┘ │
+│ │ │
+└──────────────────────────────┼──────────────────────┘
+ │ Weekly sync
+ ┌─────────▼─────────┐
+ │ Scryfall Bulk │
+ │ Data API │
+ └───────────────────┘
+```
+
+### Components
+
+1. **CardHashDatabase** - SQLite with pre-computed hashes for all cards
+2. **ImagePreprocessor** - CLAHE, resize, normalize
+3. **CardDetector** - Contour detection, perspective correction
+4. **HashComputer** - Color-aware pHash implementation
+5. **CardMatcher** - Hamming distance search with confidence scoring
+6. **ScryfallSyncService** - Downloads bulk data, computes hashes, updates DB
+
+### Database Schema
+
+```sql
+CREATE TABLE cards (
+ id TEXT PRIMARY KEY, -- Scryfall ID
+ oracle_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ set_code TEXT NOT NULL,
+ collector_number TEXT,
+ illustration_id TEXT,
+ image_url TEXT,
+ art_hash BLOB, -- 256-bit hash of art region
+ full_hash BLOB, -- 256-bit hash of full card
+ color_hash BLOB -- 768-bit color-aware hash
+);
+
+CREATE INDEX idx_cards_oracle ON cards(oracle_id);
+CREATE INDEX idx_cards_illustration ON cards(illustration_id);
+```
+
+### Phase 2: Enhanced (Add OCR Fallback)
+
+Add ML Kit or Tesseract for OCR when hash matching confidence is low.
+
+### Phase 3: Advanced (Neural Detection)
+
+Replace contour-based card detection with YOLO or similar for complex scenes (multiple overlapping cards, cluttered backgrounds).
+
+---
+
+## Libraries & Tools
+
+### .NET/MAUI Compatible
+
+| Library | Purpose | Platform |
+|---------|---------|----------|
+| SkiaSharp | Image processing | All |
+| OpenCvSharp4 | Advanced CV | Android/iOS/Windows |
+| ImageSharp | Image manipulation | All |
+| Emgu.CV | OpenCV wrapper | All |
+| ML.NET | Machine learning | All |
+| Plugin.Maui.OCR | On-device OCR | Android/iOS |
+
+### Recommended Stack
+
+```xml
+
+
+
+
+```
+
+For perceptual hashing in C#, we'll need to implement it using SkiaSharp (no direct port of Python's imagehash exists).
+
+---
+
+## Test Image Categories
+
+The `TestImages/` directory contains reference images for testing:
+
+```
+TestImages/
+├── varying_quality/ # Different lighting, blur, exposure
+│ ├── black.jpg
+│ ├── counterspell_bgs.jpg
+│ ├── dragon_whelp.jpg
+│ ├── evil_eye.jpg
+│ ├── instill_energy.jpg
+│ ├── ruby.jpg
+│ ├── card_in_plastic_case.jpg
+│ ├── test1.jpg
+│ ├── test2.jpg
+│ └── test3.jpg
+├── hands/ # Cards held in hand (partial visibility)
+│ ├── hand_of_card_1.png
+│ ├── hand_of_card_green_1.jpg
+│ ├── hand_of_card_green_2.jpeg
+│ ├── hand_of_card_ktk.png
+│ ├── hand_of_card_red.jpeg
+│ └── hand_of_card_tron.png
+├── angled/ # Perspective distortion
+│ ├── tilted_card_1.jpg
+│ └── tilted_card_2.jpg
+└── multiple_cards/ # Multiple cards in frame
+ ├── alpha_deck.jpg
+ ├── geyser_twister_fireball.jpg
+ ├── lands_and_fatties.jpg
+ ├── pro_tour_table.png
+ └── pro_tour_side.png
+```
+
+### Test Scenarios to Add
+
+- [ ] Foil cards with glare
+- [ ] Heavily played/worn cards
+- [ ] Cards under glass/sleeve
+- [ ] Low-light conditions
+- [ ] Overexposed images
+- [ ] Cards with shadows across them
+- [ ] Non-English cards
+- [ ] Tokens and emblems
+- [ ] Old frame vs new frame cards
+
+---
+
+## References
+
+- [Scryfall API Docs](https://scryfall.com/docs/api)
+- [MTG Card Detector (Python)](https://github.com/hj3yoo/mtg_card_detector)
+- [Magic Card Detector Blog](https://tmikonen.github.io/quantitatively/2020-01-01-magic-card-detector/)
+- [mtgscan (OCR approach)](https://pypi.org/project/mtgscan/)
+- [Moss Machine (pHash + sorting)](https://github.com/KairiCollections/Moss-Machine---Magic-the-Gathering-recognition-and-sorting-machine)
diff --git a/screen.png b/screen.png
new file mode 100644
index 0000000..3d9ea9a
Binary files /dev/null and b/screen.png differ
diff --git a/src/Scry.App/App.xaml b/src/Scry.App/App.xaml
new file mode 100644
index 0000000..311b5d9
--- /dev/null
+++ b/src/Scry.App/App.xaml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Scry.App/App.xaml.cs b/src/Scry.App/App.xaml.cs
new file mode 100644
index 0000000..5f51db0
--- /dev/null
+++ b/src/Scry.App/App.xaml.cs
@@ -0,0 +1,14 @@
+namespace Scry;
+
+public partial class App : Application
+{
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected override Window CreateWindow(IActivationState? activationState)
+ {
+ return new Window(new AppShell());
+ }
+}
diff --git a/src/Scry.App/AppShell.xaml b/src/Scry.App/AppShell.xaml
new file mode 100644
index 0000000..f516875
--- /dev/null
+++ b/src/Scry.App/AppShell.xaml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Scry.App/AppShell.xaml.cs b/src/Scry.App/AppShell.xaml.cs
new file mode 100644
index 0000000..c0d57ff
--- /dev/null
+++ b/src/Scry.App/AppShell.xaml.cs
@@ -0,0 +1,13 @@
+using Scry.Views;
+
+namespace Scry;
+
+public partial class AppShell : Shell
+{
+ public AppShell()
+ {
+ InitializeComponent();
+
+ Routing.RegisterRoute(nameof(CardDetailPage), typeof(CardDetailPage));
+ }
+}
diff --git a/src/Scry.App/Converters/BoolToScanTextConverter.cs b/src/Scry.App/Converters/BoolToScanTextConverter.cs
new file mode 100644
index 0000000..de0eec1
--- /dev/null
+++ b/src/Scry.App/Converters/BoolToScanTextConverter.cs
@@ -0,0 +1,18 @@
+using System.Globalization;
+
+namespace Scry.Converters;
+
+public class BoolToScanTextConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is bool isProcessing)
+ return isProcessing ? "Scanning..." : "Scan Card";
+ return "Scan Card";
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/src/Scry.App/Converters/InverseBoolConverter.cs b/src/Scry.App/Converters/InverseBoolConverter.cs
new file mode 100644
index 0000000..9d32a3a
--- /dev/null
+++ b/src/Scry.App/Converters/InverseBoolConverter.cs
@@ -0,0 +1,20 @@
+using System.Globalization;
+
+namespace Scry.Converters;
+
+public class InverseBoolConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is bool b)
+ return !b;
+ return value;
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value is bool b)
+ return !b;
+ return value;
+ }
+}
diff --git a/src/Scry.App/Converters/StringNotEmptyConverter.cs b/src/Scry.App/Converters/StringNotEmptyConverter.cs
new file mode 100644
index 0000000..d549f8f
--- /dev/null
+++ b/src/Scry.App/Converters/StringNotEmptyConverter.cs
@@ -0,0 +1,16 @@
+using System.Globalization;
+
+namespace Scry.Converters;
+
+public class StringNotEmptyConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ return !string.IsNullOrWhiteSpace(value as string);
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/src/Scry.App/MauiProgram.cs b/src/Scry.App/MauiProgram.cs
new file mode 100644
index 0000000..0eb375e
--- /dev/null
+++ b/src/Scry.App/MauiProgram.cs
@@ -0,0 +1,74 @@
+using CommunityToolkit.Maui;
+using Microsoft.Extensions.Logging;
+using Scry.Core.Data;
+using Scry.Core.Recognition;
+using Scry.Services;
+using Scry.ViewModels;
+using Scry.Views;
+
+namespace Scry;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder
+ .UseMauiApp()
+ .UseMauiCommunityToolkit()
+ .UseMauiCommunityToolkitCamera()
+ .ConfigureFonts(fonts =>
+ {
+ fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
+ fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
+ });
+
+ // Core Services (from Scry.Core)
+ builder.Services.AddSingleton(sp =>
+ {
+ var dbPath = Path.Combine(FileSystem.AppDataDirectory, "card_hashes.db");
+ EnsureDatabaseCopied(dbPath);
+ return new CardHashDatabase(dbPath);
+ });
+ builder.Services.AddSingleton();
+
+ // App Services
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+
+ // ViewModels
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+
+ // Views
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+
+#if DEBUG
+ builder.Logging.AddDebug();
+#endif
+
+ return builder.Build();
+ }
+
+ private static void EnsureDatabaseCopied(string targetPath)
+ {
+ if (File.Exists(targetPath))
+ return;
+
+ try
+ {
+ using var stream = FileSystem.OpenAppPackageFileAsync("card_hashes.db").GetAwaiter().GetResult();
+ using var fileStream = File.Create(targetPath);
+ stream.CopyTo(fileStream);
+ }
+ catch
+ {
+ // Database not bundled, will be empty
+ }
+ }
+}
diff --git a/src/Scry.App/Models/CollectionEntry.cs b/src/Scry.App/Models/CollectionEntry.cs
new file mode 100644
index 0000000..4b523d2
--- /dev/null
+++ b/src/Scry.App/Models/CollectionEntry.cs
@@ -0,0 +1,12 @@
+using Scry.Core.Models;
+
+namespace Scry.Models;
+
+public class CollectionEntry
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public Card Card { get; set; } = null!;
+ public int Quantity { get; set; } = 1;
+ public bool IsFoil { get; set; }
+ public DateTime AddedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/src/Scry.App/Platforms/Android/AndroidManifest.xml b/src/Scry.App/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 0000000..a3ec4f5
--- /dev/null
+++ b/src/Scry.App/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Scry.App/Platforms/Android/MainActivity.cs b/src/Scry.App/Platforms/Android/MainActivity.cs
new file mode 100644
index 0000000..e569cf3
--- /dev/null
+++ b/src/Scry.App/Platforms/Android/MainActivity.cs
@@ -0,0 +1,12 @@
+using Android.App;
+using Android.Content.PM;
+using Android.OS;
+
+namespace Scry;
+
+[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop,
+ ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode |
+ ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
+public class MainActivity : MauiAppCompatActivity
+{
+}
diff --git a/src/Scry.App/Platforms/Android/MainApplication.cs b/src/Scry.App/Platforms/Android/MainApplication.cs
new file mode 100644
index 0000000..0e985c5
--- /dev/null
+++ b/src/Scry.App/Platforms/Android/MainApplication.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Runtime;
+
+namespace Scry;
+
+[Application]
+public class MainApplication : MauiApplication
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership)
+ {
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/src/Scry.App/Platforms/Android/Resources/values/colors.xml b/src/Scry.App/Platforms/Android/Resources/values/colors.xml
new file mode 100644
index 0000000..614ac41
--- /dev/null
+++ b/src/Scry.App/Platforms/Android/Resources/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #512BD4
+ #3B1F9E
+ #512BD4
+
diff --git a/src/Scry.App/Resources/AppIcon/appicon.svg b/src/Scry.App/Resources/AppIcon/appicon.svg
new file mode 100644
index 0000000..86e49b4
--- /dev/null
+++ b/src/Scry.App/Resources/AppIcon/appicon.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/src/Scry.App/Resources/AppIcon/appiconfg.svg b/src/Scry.App/Resources/AppIcon/appiconfg.svg
new file mode 100644
index 0000000..76d01d6
--- /dev/null
+++ b/src/Scry.App/Resources/AppIcon/appiconfg.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/src/Scry.App/Resources/Fonts/.gitkeep b/src/Scry.App/Resources/Fonts/.gitkeep
new file mode 100644
index 0000000..dcf2c80
--- /dev/null
+++ b/src/Scry.App/Resources/Fonts/.gitkeep
@@ -0,0 +1 @@
+# Placeholder
diff --git a/src/Scry.App/Resources/Raw/.gitkeep b/src/Scry.App/Resources/Raw/.gitkeep
new file mode 100644
index 0000000..dcf2c80
--- /dev/null
+++ b/src/Scry.App/Resources/Raw/.gitkeep
@@ -0,0 +1 @@
+# Placeholder
diff --git a/src/Scry.App/Resources/Raw/card_hashes.db b/src/Scry.App/Resources/Raw/card_hashes.db
new file mode 100644
index 0000000..27cc003
Binary files /dev/null and b/src/Scry.App/Resources/Raw/card_hashes.db differ
diff --git a/src/Scry.App/Resources/Splash/splash.svg b/src/Scry.App/Resources/Splash/splash.svg
new file mode 100644
index 0000000..be886b2
--- /dev/null
+++ b/src/Scry.App/Resources/Splash/splash.svg
@@ -0,0 +1,12 @@
+
+
diff --git a/src/Scry.App/Resources/Styles/Colors.xaml b/src/Scry.App/Resources/Styles/Colors.xaml
new file mode 100644
index 0000000..9f295a7
--- /dev/null
+++ b/src/Scry.App/Resources/Styles/Colors.xaml
@@ -0,0 +1,47 @@
+
+
+
+
+ #512BD4
+ #3B1F9E
+ White
+ #DFD8F7
+ #3B1F9E
+ #2B0B98
+
+ White
+ Black
+ #E1E1E1
+ #C8C8C8
+ #ACACAC
+ #919191
+ #6E6E6E
+ #404040
+ #2A2A2A
+ #1A1A1A
+ #141414
+
+ #F7B548
+ #FFD590
+ #FFE5B9
+ #28C2D1
+ #7BDDEF
+ #C3F2F4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Scry.App/Resources/Styles/Styles.xaml b/src/Scry.App/Resources/Styles/Styles.xaml
new file mode 100644
index 0000000..a1c5607
--- /dev/null
+++ b/src/Scry.App/Resources/Styles/Styles.xaml
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Scry.App/Scry.App.csproj b/src/Scry.App/Scry.App.csproj
new file mode 100644
index 0000000..b0ae247
--- /dev/null
+++ b/src/Scry.App/Scry.App.csproj
@@ -0,0 +1,98 @@
+
+
+
+
+ $(LOCALAPPDATA)\Android\Sdk
+ C:\Program Files\Microsoft\jdk-21.0.10.7-hotspot
+
+
+
+ net10.0-android
+ android-arm64;android-x64
+ true
+
+ Exe
+ Scry
+ true
+ true
+ enable
+ enable
+
+
+
+
+ true
+ true
+ true
+
+
+
+
+ Scry
+
+
+ land.charm.scry
+
+
+ 1.0
+ 1
+
+ 21.0
+ 15.0
+ 15.0
+ 10.0.17763.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Scry.App/Services/ICardRecognitionService.cs b/src/Scry.App/Services/ICardRecognitionService.cs
new file mode 100644
index 0000000..c481e11
--- /dev/null
+++ b/src/Scry.App/Services/ICardRecognitionService.cs
@@ -0,0 +1,8 @@
+using Scry.Core.Models;
+
+namespace Scry.Services;
+
+public interface ICardRecognitionService
+{
+ Task RecognizeCardAsync(Stream imageStream, CancellationToken cancellationToken = default);
+}
diff --git a/src/Scry.App/Services/ICardRepository.cs b/src/Scry.App/Services/ICardRepository.cs
new file mode 100644
index 0000000..2eaa3fa
--- /dev/null
+++ b/src/Scry.App/Services/ICardRepository.cs
@@ -0,0 +1,15 @@
+using Scry.Core.Models;
+using Scry.Models;
+
+namespace Scry.Services;
+
+public interface ICardRepository
+{
+ IReadOnlyList GetAll();
+ CollectionEntry? GetById(string id);
+ void Add(Card card, int quantity = 1, bool isFoil = false);
+ void UpdateQuantity(string entryId, int newQuantity);
+ void Remove(string entryId);
+ int TotalCards { get; }
+ int UniqueCards { get; }
+}
diff --git a/src/Scry.App/Services/InMemoryCardRepository.cs b/src/Scry.App/Services/InMemoryCardRepository.cs
new file mode 100644
index 0000000..e56f8d2
--- /dev/null
+++ b/src/Scry.App/Services/InMemoryCardRepository.cs
@@ -0,0 +1,103 @@
+using Scry.Core.Models;
+using Scry.Models;
+
+namespace Scry.Services;
+
+public class InMemoryCardRepository : ICardRepository
+{
+ private readonly List _entries = [];
+ private readonly object _lock = new();
+
+ public IReadOnlyList GetAll()
+ {
+ lock (_lock)
+ {
+ return _entries.ToList();
+ }
+ }
+
+ public CollectionEntry? GetById(string id)
+ {
+ lock (_lock)
+ {
+ return _entries.FirstOrDefault(e => e.Id == id);
+ }
+ }
+
+ public void Add(Card card, int quantity = 1, bool isFoil = false)
+ {
+ lock (_lock)
+ {
+ // Check if we already have this exact card (same id + foil status)
+ var existing = _entries.FirstOrDefault(e =>
+ e.Card.Id == card.Id && e.IsFoil == isFoil);
+
+ if (existing != null)
+ {
+ existing.Quantity += quantity;
+ }
+ else
+ {
+ _entries.Add(new CollectionEntry
+ {
+ Card = card,
+ Quantity = quantity,
+ IsFoil = isFoil
+ });
+ }
+ }
+ }
+
+ public void UpdateQuantity(string entryId, int newQuantity)
+ {
+ lock (_lock)
+ {
+ var entry = _entries.FirstOrDefault(e => e.Id == entryId);
+ if (entry != null)
+ {
+ if (newQuantity <= 0)
+ {
+ _entries.Remove(entry);
+ }
+ else
+ {
+ entry.Quantity = newQuantity;
+ }
+ }
+ }
+ }
+
+ public void Remove(string entryId)
+ {
+ lock (_lock)
+ {
+ var entry = _entries.FirstOrDefault(e => e.Id == entryId);
+ if (entry != null)
+ {
+ _entries.Remove(entry);
+ }
+ }
+ }
+
+ public int TotalCards
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _entries.Sum(e => e.Quantity);
+ }
+ }
+ }
+
+ public int UniqueCards
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _entries.Count;
+ }
+ }
+ }
+}
diff --git a/src/Scry.App/Services/MockCardRecognitionService.cs b/src/Scry.App/Services/MockCardRecognitionService.cs
new file mode 100644
index 0000000..16102ca
--- /dev/null
+++ b/src/Scry.App/Services/MockCardRecognitionService.cs
@@ -0,0 +1,123 @@
+using Scry.Core.Models;
+
+namespace Scry.Services;
+
+///
+/// Mock implementation that returns random MTG cards for testing.
+/// Replace with RealCardRecognitionService for production use.
+///
+public class MockCardRecognitionService : ICardRecognitionService
+{
+ private static readonly Card[] SampleCards =
+ [
+ new Card
+ {
+ Id = "sol-ring-c21",
+ Name = "Sol Ring",
+ SetCode = "C21",
+ SetName = "Commander 2021",
+ CollectorNumber = "263",
+ ScryfallId = "4cbc6901-6a4a-4d0a-83ea-7eefa3b35021",
+ ImageUri = "https://cards.scryfall.io/normal/front/4/c/4cbc6901-6a4a-4d0a-83ea-7eefa3b35021.jpg",
+ ManaCost = "{1}",
+ TypeLine = "Artifact",
+ OracleText = "{T}: Add {C}{C}.",
+ Rarity = "uncommon",
+ PriceUsd = 1.50m
+ },
+ new Card
+ {
+ Id = "lightning-bolt-2xm",
+ Name = "Lightning Bolt",
+ SetCode = "2XM",
+ SetName = "Double Masters",
+ CollectorNumber = "129",
+ ScryfallId = "e3285e6b-3e79-4d7c-bf96-d920f973b122",
+ ImageUri = "https://cards.scryfall.io/normal/front/e/3/e3285e6b-3e79-4d7c-bf96-d920f973b122.jpg",
+ ManaCost = "{R}",
+ TypeLine = "Instant",
+ OracleText = "Lightning Bolt deals 3 damage to any target.",
+ Rarity = "uncommon",
+ PriceUsd = 2.00m
+ },
+ new Card
+ {
+ Id = "counterspell-cmr",
+ Name = "Counterspell",
+ SetCode = "CMR",
+ SetName = "Commander Legends",
+ CollectorNumber = "395",
+ ScryfallId = "ce30f926-bc06-46ee-9f35-0c32659a1b1c",
+ ImageUri = "https://cards.scryfall.io/normal/front/c/e/ce30f926-bc06-46ee-9f35-0c32659a1b1c.jpg",
+ ManaCost = "{U}{U}",
+ TypeLine = "Instant",
+ OracleText = "Counter target spell.",
+ Rarity = "uncommon",
+ PriceUsd = 1.25m
+ },
+ new Card
+ {
+ Id = "llanowar-elves-m19",
+ Name = "Llanowar Elves",
+ SetCode = "M19",
+ SetName = "Core Set 2019",
+ CollectorNumber = "314",
+ ScryfallId = "73542c66-eb3a-46e8-a8f6-5f02087b28cf",
+ ImageUri = "https://cards.scryfall.io/normal/front/7/3/73542c66-eb3a-46e8-a8f6-5f02087b28cf.jpg",
+ ManaCost = "{G}",
+ TypeLine = "Creature — Elf Druid",
+ OracleText = "{T}: Add {G}.",
+ Rarity = "common",
+ PriceUsd = 0.25m
+ },
+ new Card
+ {
+ Id = "swords-to-plowshares-cmr",
+ Name = "Swords to Plowshares",
+ SetCode = "CMR",
+ SetName = "Commander Legends",
+ CollectorNumber = "387",
+ ScryfallId = "b8dcd4a6-5c0b-4e1e-8e1d-2e2e5c1d6a1e",
+ ImageUri = "https://cards.scryfall.io/normal/front/b/8/b8dcd4a6-5c0b-4e1e-8e1d-2e2e5c1d6a1e.jpg",
+ ManaCost = "{W}",
+ TypeLine = "Instant",
+ OracleText = "Exile target creature. Its controller gains life equal to its power.",
+ Rarity = "uncommon",
+ PriceUsd = 3.50m
+ },
+ new Card
+ {
+ Id = "black-lotus-lea",
+ Name = "Black Lotus",
+ SetCode = "LEA",
+ SetName = "Limited Edition Alpha",
+ CollectorNumber = "232",
+ ScryfallId = "bd8fa327-dd41-4737-8f19-2cf5eb1f7c2e",
+ ImageUri = "https://cards.scryfall.io/normal/front/b/d/bd8fa327-dd41-4737-8f19-2cf5eb1f7c2e.jpg",
+ ManaCost = "{0}",
+ TypeLine = "Artifact",
+ OracleText = "{T}, Sacrifice Black Lotus: Add three mana of any one color.",
+ Rarity = "rare",
+ PriceUsd = 500000.00m
+ }
+ ];
+
+ private readonly Random _random = new();
+
+ public async Task RecognizeCardAsync(Stream imageStream, CancellationToken cancellationToken = default)
+ {
+ // Simulate processing delay
+ await Task.Delay(500 + _random.Next(500), cancellationToken);
+
+ // 90% success rate
+ if (_random.NextDouble() < 0.1)
+ {
+ return ScanResult.Failed("Could not recognize card. Please try again with better lighting.");
+ }
+
+ var card = SampleCards[_random.Next(SampleCards.Length)];
+ var confidence = 0.75f + (float)_random.NextDouble() * 0.24f; // 75-99% confidence
+
+ return ScanResult.Matched(card, confidence, 10, TimeSpan.FromMilliseconds(500));
+ }
+}
diff --git a/src/Scry.App/Services/RealCardRecognitionService.cs b/src/Scry.App/Services/RealCardRecognitionService.cs
new file mode 100644
index 0000000..1afb57f
--- /dev/null
+++ b/src/Scry.App/Services/RealCardRecognitionService.cs
@@ -0,0 +1,22 @@
+using Scry.Core.Models;
+using Scry.Core.Recognition;
+
+namespace Scry.Services;
+
+///
+/// Real implementation that uses Scry.Core's perceptual hash-based card recognition.
+///
+public class RealCardRecognitionService : ICardRecognitionService
+{
+ private readonly CardRecognitionService _recognitionService;
+
+ public RealCardRecognitionService(CardRecognitionService recognitionService)
+ {
+ _recognitionService = recognitionService;
+ }
+
+ public async Task RecognizeCardAsync(Stream imageStream, CancellationToken cancellationToken = default)
+ {
+ return await _recognitionService.RecognizeAsync(imageStream, cancellationToken);
+ }
+}
diff --git a/src/Scry.App/ViewModels/CardDetailViewModel.cs b/src/Scry.App/ViewModels/CardDetailViewModel.cs
new file mode 100644
index 0000000..f50c2ff
--- /dev/null
+++ b/src/Scry.App/ViewModels/CardDetailViewModel.cs
@@ -0,0 +1,86 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Scry.Core.Models;
+using Scry.Models;
+using Scry.Services;
+
+namespace Scry.ViewModels;
+
+[QueryProperty(nameof(CardId), "cardId")]
+[QueryProperty(nameof(EntryId), "entryId")]
+public partial class CardDetailViewModel : ObservableObject
+{
+ private readonly ICardRepository _cardRepository;
+
+ [ObservableProperty]
+ private Card? _card;
+
+ [ObservableProperty]
+ private CollectionEntry? _entry;
+
+ [ObservableProperty]
+ private bool _isInCollection;
+
+ [ObservableProperty]
+ private string? _cardId;
+
+ [ObservableProperty]
+ private string? _entryId;
+
+ public CardDetailViewModel(ICardRepository cardRepository)
+ {
+ _cardRepository = cardRepository;
+ }
+
+ partial void OnEntryIdChanged(string? value)
+ {
+ if (string.IsNullOrEmpty(value)) return;
+
+ Entry = _cardRepository.GetById(value);
+ if (Entry != null)
+ {
+ Card = Entry.Card;
+ IsInCollection = true;
+ }
+ }
+
+ partial void OnCardIdChanged(string? value)
+ {
+ if (string.IsNullOrEmpty(value)) return;
+
+ // For now, find by looking through collection
+ var entries = _cardRepository.GetAll();
+ Entry = entries.FirstOrDefault(e => e.Card.Id == value);
+ if (Entry != null)
+ {
+ Card = Entry.Card;
+ IsInCollection = true;
+ }
+ }
+
+ [RelayCommand]
+ private void IncrementQuantity()
+ {
+ if (Entry == null) return;
+ _cardRepository.UpdateQuantity(Entry.Id, Entry.Quantity + 1);
+ Entry = _cardRepository.GetById(Entry.Id);
+ }
+
+ [RelayCommand]
+ private void DecrementQuantity()
+ {
+ if (Entry == null) return;
+
+ if (Entry.Quantity > 1)
+ {
+ _cardRepository.UpdateQuantity(Entry.Id, Entry.Quantity - 1);
+ Entry = _cardRepository.GetById(Entry.Id);
+ }
+ }
+
+ [RelayCommand]
+ private async Task GoBackAsync()
+ {
+ await Shell.Current.GoToAsync("..");
+ }
+}
diff --git a/src/Scry.App/ViewModels/CollectionViewModel.cs b/src/Scry.App/ViewModels/CollectionViewModel.cs
new file mode 100644
index 0000000..677e921
--- /dev/null
+++ b/src/Scry.App/ViewModels/CollectionViewModel.cs
@@ -0,0 +1,84 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Scry.Core.Models;
+using Scry.Models;
+using Scry.Services;
+using Scry.Views;
+using System.Collections.ObjectModel;
+
+namespace Scry.ViewModels;
+
+public partial class CollectionViewModel : ObservableObject
+{
+ private readonly ICardRepository _cardRepository;
+
+ [ObservableProperty]
+ private ObservableCollection _entries = [];
+
+ [ObservableProperty]
+ private int _totalCards;
+
+ [ObservableProperty]
+ private int _uniqueCards;
+
+ [ObservableProperty]
+ private bool _isEmpty = true;
+
+ public CollectionViewModel(ICardRepository cardRepository)
+ {
+ _cardRepository = cardRepository;
+ }
+
+ [RelayCommand]
+ private void LoadCollection()
+ {
+ var allEntries = _cardRepository.GetAll();
+ Entries = new ObservableCollection(allEntries.OrderByDescending(e => e.AddedAt));
+ TotalCards = _cardRepository.TotalCards;
+ UniqueCards = _cardRepository.UniqueCards;
+ IsEmpty = Entries.Count == 0;
+ }
+
+ [RelayCommand]
+ private async Task ViewCardAsync(CollectionEntry entry)
+ {
+ await Shell.Current.GoToAsync($"{nameof(CardDetailPage)}?entryId={entry.Id}");
+ }
+
+ [RelayCommand]
+ private void IncrementQuantity(CollectionEntry entry)
+ {
+ _cardRepository.UpdateQuantity(entry.Id, entry.Quantity + 1);
+ LoadCollection();
+ }
+
+ [RelayCommand]
+ private void DecrementQuantity(CollectionEntry entry)
+ {
+ if (entry.Quantity > 1)
+ {
+ _cardRepository.UpdateQuantity(entry.Id, entry.Quantity - 1);
+ }
+ else
+ {
+ _cardRepository.Remove(entry.Id);
+ }
+ LoadCollection();
+ }
+
+ [RelayCommand]
+ private async Task RemoveEntryAsync(CollectionEntry entry)
+ {
+ var result = await Shell.Current.DisplayAlertAsync(
+ "Remove Card",
+ $"Remove all {entry.Quantity}x {entry.Card.Name} from collection?",
+ "Remove",
+ "Cancel");
+
+ if (result)
+ {
+ _cardRepository.Remove(entry.Id);
+ LoadCollection();
+ }
+ }
+}
diff --git a/src/Scry.App/ViewModels/ScanViewModel.cs b/src/Scry.App/ViewModels/ScanViewModel.cs
new file mode 100644
index 0000000..8d1951a
--- /dev/null
+++ b/src/Scry.App/ViewModels/ScanViewModel.cs
@@ -0,0 +1,130 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using CommunityToolkit.Maui.Views;
+using Scry.Core.Models;
+using Scry.Services;
+using Scry.Views;
+
+namespace Scry.ViewModels;
+
+public partial class ScanViewModel : ObservableObject
+{
+ private readonly ICardRecognitionService _recognitionService;
+ private readonly ICardRepository _cardRepository;
+
+ [ObservableProperty]
+ private bool _isScanning;
+
+ [ObservableProperty]
+ private bool _isProcessing;
+
+ [ObservableProperty]
+ private string? _statusMessage;
+
+ [ObservableProperty]
+ private Card? _lastScannedCard;
+
+ [ObservableProperty]
+ private float _lastConfidence;
+
+ [ObservableProperty]
+ private bool _hasResult;
+
+ [ObservableProperty]
+ private bool _isFoil;
+
+ public ScanViewModel(
+ ICardRecognitionService recognitionService,
+ ICardRepository cardRepository)
+ {
+ _recognitionService = recognitionService;
+ _cardRepository = cardRepository;
+ StatusMessage = "Point camera at a card and tap Scan";
+ }
+
+ [RelayCommand]
+ private async Task CaptureAndRecognizeAsync(CameraView cameraView)
+ {
+ if (IsProcessing) return;
+
+ try
+ {
+ IsProcessing = true;
+ StatusMessage = "Capturing...";
+
+ // Capture image from camera
+ using var imageStream = await cameraView.CaptureImage(CancellationToken.None);
+ if (imageStream == null)
+ {
+ StatusMessage = "Failed to capture image";
+ return;
+ }
+
+ StatusMessage = "Recognizing card...";
+
+ // Copy to memory stream since the camera stream might not be seekable
+ using var memoryStream = new MemoryStream();
+ await imageStream.CopyToAsync(memoryStream);
+ memoryStream.Position = 0;
+
+ var result = await _recognitionService.RecognizeCardAsync(memoryStream);
+
+ if (result.Success && result.Card != null)
+ {
+ LastScannedCard = result.Card;
+ LastConfidence = result.Confidence;
+ HasResult = true;
+ StatusMessage = $"Found: {result.Card.Name} ({result.Confidence:P0})";
+ }
+ else
+ {
+ HasResult = false;
+ StatusMessage = result.ErrorMessage ?? "Recognition failed";
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Error: {ex.Message}";
+ HasResult = false;
+ }
+ finally
+ {
+ IsProcessing = false;
+ }
+ }
+
+ [RelayCommand]
+ private async Task AddToCollectionAsync()
+ {
+ if (LastScannedCard == null) return;
+
+ _cardRepository.Add(LastScannedCard, 1, IsFoil);
+
+ var foilText = IsFoil ? " (Foil)" : "";
+ StatusMessage = $"Added {LastScannedCard.Name}{foilText} to collection!";
+
+ // Reset for next scan
+ await Task.Delay(1500);
+ HasResult = false;
+ LastScannedCard = null;
+ IsFoil = false;
+ StatusMessage = "Point camera at a card and tap Scan";
+ }
+
+ [RelayCommand]
+ private void CancelResult()
+ {
+ HasResult = false;
+ LastScannedCard = null;
+ IsFoil = false;
+ StatusMessage = "Point camera at a card and tap Scan";
+ }
+
+ [RelayCommand]
+ private async Task ViewCardDetailsAsync()
+ {
+ if (LastScannedCard == null) return;
+
+ await Shell.Current.GoToAsync($"{nameof(CardDetailPage)}?cardId={LastScannedCard.Id}");
+ }
+}
diff --git a/src/Scry.App/ViewModels/SettingsViewModel.cs b/src/Scry.App/ViewModels/SettingsViewModel.cs
new file mode 100644
index 0000000..c84bc3c
--- /dev/null
+++ b/src/Scry.App/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,28 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Scry.Core.Data;
+
+namespace Scry.ViewModels;
+
+public partial class SettingsViewModel : ObservableObject
+{
+ private readonly CardHashDatabase _database;
+
+ [ObservableProperty]
+ private int _cardCount;
+
+ [ObservableProperty]
+ private string? _statusMessage;
+
+ public SettingsViewModel(CardHashDatabase database)
+ {
+ _database = database;
+ }
+
+ [RelayCommand]
+ private async Task LoadAsync()
+ {
+ CardCount = await _database.GetHashCountAsync();
+ StatusMessage = $"Database ready with {CardCount:N0} cards";
+ }
+}
diff --git a/src/Scry.App/Views/CardDetailPage.xaml b/src/Scry.App/Views/CardDetailPage.xaml
new file mode 100644
index 0000000..4432868
--- /dev/null
+++ b/src/Scry.App/Views/CardDetailPage.xaml
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Scry.App/Views/CardDetailPage.xaml.cs b/src/Scry.App/Views/CardDetailPage.xaml.cs
new file mode 100644
index 0000000..9690709
--- /dev/null
+++ b/src/Scry.App/Views/CardDetailPage.xaml.cs
@@ -0,0 +1,12 @@
+using Scry.ViewModels;
+
+namespace Scry.Views;
+
+public partial class CardDetailPage : ContentPage
+{
+ public CardDetailPage(CardDetailViewModel viewModel)
+ {
+ InitializeComponent();
+ BindingContext = viewModel;
+ }
+}
diff --git a/src/Scry.App/Views/CollectionPage.xaml b/src/Scry.App/Views/CollectionPage.xaml
new file mode 100644
index 0000000..70c7e3e
--- /dev/null
+++ b/src/Scry.App/Views/CollectionPage.xaml
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Scry.App/Views/CollectionPage.xaml.cs b/src/Scry.App/Views/CollectionPage.xaml.cs
new file mode 100644
index 0000000..aa5e479
--- /dev/null
+++ b/src/Scry.App/Views/CollectionPage.xaml.cs
@@ -0,0 +1,20 @@
+using Scry.ViewModels;
+
+namespace Scry.Views;
+
+public partial class CollectionPage : ContentPage
+{
+ private readonly CollectionViewModel _viewModel;
+
+ public CollectionPage(CollectionViewModel viewModel)
+ {
+ InitializeComponent();
+ BindingContext = _viewModel = viewModel;
+ }
+
+ protected override void OnAppearing()
+ {
+ base.OnAppearing();
+ _viewModel.LoadCollectionCommand.Execute(null);
+ }
+}
diff --git a/src/Scry.App/Views/ScanPage.xaml b/src/Scry.App/Views/ScanPage.xaml
new file mode 100644
index 0000000..b00bd94
--- /dev/null
+++ b/src/Scry.App/Views/ScanPage.xaml
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Scry.App/Views/ScanPage.xaml.cs b/src/Scry.App/Views/ScanPage.xaml.cs
new file mode 100644
index 0000000..9122e6f
--- /dev/null
+++ b/src/Scry.App/Views/ScanPage.xaml.cs
@@ -0,0 +1,54 @@
+using Scry.ViewModels;
+
+namespace Scry.Views;
+
+public partial class ScanPage : ContentPage
+{
+ public ScanPage(ScanViewModel viewModel)
+ {
+ InitializeComponent();
+ BindingContext = viewModel;
+ }
+
+ protected override async void OnAppearing()
+ {
+ base.OnAppearing();
+
+ // Request camera permission
+ var status = await Permissions.CheckStatusAsync();
+ if (status != PermissionStatus.Granted)
+ {
+ status = await Permissions.RequestAsync();
+ if (status != PermissionStatus.Granted)
+ {
+ await DisplayAlertAsync("Permission Denied", "Camera permission is required to scan cards.", "OK");
+ return;
+ }
+ }
+
+ // Start camera when page appears
+ try
+ {
+ await cameraView.StartCameraPreview(CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ await DisplayAlertAsync("Camera Error", $"Could not start camera: {ex.Message}", "OK");
+ }
+ }
+
+ protected override void OnDisappearing()
+ {
+ base.OnDisappearing();
+
+ // Stop camera when leaving page
+ try
+ {
+ cameraView.StopCameraPreview();
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+}
diff --git a/src/Scry.App/Views/SettingsPage.xaml b/src/Scry.App/Views/SettingsPage.xaml
new file mode 100644
index 0000000..aa27e91
--- /dev/null
+++ b/src/Scry.App/Views/SettingsPage.xaml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Scry.App/Views/SettingsPage.xaml.cs b/src/Scry.App/Views/SettingsPage.xaml.cs
new file mode 100644
index 0000000..64f843d
--- /dev/null
+++ b/src/Scry.App/Views/SettingsPage.xaml.cs
@@ -0,0 +1,22 @@
+using Scry.ViewModels;
+
+namespace Scry.Views;
+
+public partial class SettingsPage : ContentPage
+{
+ public SettingsPage(SettingsViewModel viewModel)
+ {
+ InitializeComponent();
+ BindingContext = viewModel;
+ }
+
+ protected override async void OnAppearing()
+ {
+ base.OnAppearing();
+
+ if (BindingContext is SettingsViewModel vm)
+ {
+ await vm.LoadCommand.ExecuteAsync(null);
+ }
+ }
+}
diff --git a/src/Scry.App/global.json b/src/Scry.App/global.json
new file mode 100644
index 0000000..bb62055
--- /dev/null
+++ b/src/Scry.App/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.102",
+ "rollForward": "latestMinor"
+ }
+}
diff --git a/src/Scry.Core/Data/CardHashDatabase.cs b/src/Scry.Core/Data/CardHashDatabase.cs
new file mode 100644
index 0000000..cc2629a
--- /dev/null
+++ b/src/Scry.Core/Data/CardHashDatabase.cs
@@ -0,0 +1,234 @@
+using Microsoft.Data.Sqlite;
+using Scry.Core.Models;
+
+namespace Scry.Core.Data;
+
+public class CardHashDatabase : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly string _dbPath;
+
+ public CardHashDatabase(string dbPath)
+ {
+ _dbPath = dbPath;
+ _connection = new SqliteConnection($"Data Source={dbPath}");
+ _connection.Open();
+ Initialize();
+ }
+
+ private void Initialize()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = """
+ CREATE TABLE IF NOT EXISTS card_hashes (
+ card_id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ set_code TEXT NOT NULL,
+ collector_number TEXT,
+ hash BLOB NOT NULL,
+ image_uri TEXT
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_card_hashes_name ON card_hashes(name);
+ CREATE INDEX IF NOT EXISTS idx_card_hashes_set ON card_hashes(set_code);
+
+ CREATE TABLE IF NOT EXISTS metadata (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ );
+ """;
+ cmd.ExecuteNonQuery();
+ }
+
+ public async Task GetMetadataAsync(string key, CancellationToken ct = default)
+ {
+ await using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT value FROM metadata WHERE key = $key";
+ cmd.Parameters.AddWithValue("$key", key);
+
+ var result = await cmd.ExecuteScalarAsync(ct);
+ return result as string;
+ }
+
+ public async Task SetMetadataAsync(string key, string value, CancellationToken ct = default)
+ {
+ await using var cmd = _connection.CreateCommand();
+ cmd.CommandText = """
+ INSERT OR REPLACE INTO metadata (key, value) VALUES ($key, $value)
+ """;
+ cmd.Parameters.AddWithValue("$key", key);
+ cmd.Parameters.AddWithValue("$value", value);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ public async Task InsertHashAsync(CardHash hash, CancellationToken ct = default)
+ {
+ await using var cmd = _connection.CreateCommand();
+ cmd.CommandText = """
+ INSERT OR REPLACE INTO card_hashes
+ (card_id, name, set_code, collector_number, hash, image_uri)
+ VALUES ($card_id, $name, $set_code, $collector_number, $hash, $image_uri)
+ """;
+ cmd.Parameters.AddWithValue("$card_id", hash.CardId);
+ cmd.Parameters.AddWithValue("$name", hash.Name);
+ cmd.Parameters.AddWithValue("$set_code", hash.SetCode);
+ cmd.Parameters.AddWithValue("$collector_number", hash.CollectorNumber ?? (object)DBNull.Value);
+ cmd.Parameters.AddWithValue("$hash", hash.Hash);
+ cmd.Parameters.AddWithValue("$image_uri", hash.ImageUri ?? (object)DBNull.Value);
+
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ public async Task InsertHashBatchAsync(IEnumerable hashes, CancellationToken ct = default)
+ {
+ await using var transaction = await _connection.BeginTransactionAsync(ct);
+
+ try
+ {
+ await using var cmd = _connection.CreateCommand();
+ cmd.CommandText = """
+ INSERT OR REPLACE INTO card_hashes
+ (card_id, name, set_code, collector_number, hash, image_uri)
+ VALUES ($card_id, $name, $set_code, $collector_number, $hash, $image_uri)
+ """;
+
+ var cardIdParam = cmd.Parameters.Add("$card_id", SqliteType.Text);
+ var nameParam = cmd.Parameters.Add("$name", SqliteType.Text);
+ var setCodeParam = cmd.Parameters.Add("$set_code", SqliteType.Text);
+ var collectorNumberParam = cmd.Parameters.Add("$collector_number", SqliteType.Text);
+ var hashParam = cmd.Parameters.Add("$hash", SqliteType.Blob);
+ var imageUriParam = cmd.Parameters.Add("$image_uri", SqliteType.Text);
+
+ foreach (var hash in hashes)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ cardIdParam.Value = hash.CardId;
+ nameParam.Value = hash.Name;
+ setCodeParam.Value = hash.SetCode;
+ collectorNumberParam.Value = hash.CollectorNumber ?? (object)DBNull.Value;
+ hashParam.Value = hash.Hash;
+ imageUriParam.Value = hash.ImageUri ?? (object)DBNull.Value;
+
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ await transaction.CommitAsync(ct);
+ }
+ catch
+ {
+ await transaction.RollbackAsync(ct);
+ throw;
+ }
+ }
+
+ public async Task> GetAllHashesAsync(CancellationToken ct = default)
+ {
+ var hashes = new List();
+
+ await using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT card_id, name, set_code, collector_number, hash, image_uri FROM card_hashes";
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ hashes.Add(new CardHash
+ {
+ CardId = reader.GetString(0),
+ Name = reader.GetString(1),
+ SetCode = reader.GetString(2),
+ CollectorNumber = reader.IsDBNull(3) ? null : reader.GetString(3),
+ Hash = (byte[])reader.GetValue(4),
+ ImageUri = reader.IsDBNull(5) ? null : reader.GetString(5)
+ });
+ }
+
+ return hashes;
+ }
+
+ public async Task GetHashCountAsync(CancellationToken ct = default)
+ {
+ await using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT COUNT(*) FROM card_hashes";
+ var result = await cmd.ExecuteScalarAsync(ct);
+ return Convert.ToInt32(result);
+ }
+
+ public async Task GetHashByIdAsync(string cardId, CancellationToken ct = default)
+ {
+ await using var cmd = _connection.CreateCommand();
+ cmd.CommandText = """
+ SELECT card_id, name, set_code, collector_number, hash, image_uri
+ FROM card_hashes WHERE card_id = $card_id
+ """;
+ cmd.Parameters.AddWithValue("$card_id", cardId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ if (await reader.ReadAsync(ct))
+ {
+ return new CardHash
+ {
+ CardId = reader.GetString(0),
+ Name = reader.GetString(1),
+ SetCode = reader.GetString(2),
+ CollectorNumber = reader.IsDBNull(3) ? null : reader.GetString(3),
+ Hash = (byte[])reader.GetValue(4),
+ ImageUri = reader.IsDBNull(5) ? null : reader.GetString(5)
+ };
+ }
+
+ return null;
+ }
+
+ public async Task> GetExistingCardIdsAsync(CancellationToken ct = default)
+ {
+ var ids = new HashSet();
+
+ await using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT card_id FROM card_hashes";
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ ids.Add(reader.GetString(0));
+ }
+
+ return ids;
+ }
+
+ public async Task> GetExistingCardNamesAsync(CancellationToken ct = default)
+ {
+ var names = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ await using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "SELECT DISTINCT name FROM card_hashes";
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ while (await reader.ReadAsync(ct))
+ {
+ names.Add(reader.GetString(0));
+ }
+
+ return names;
+ }
+
+ public async Task DeleteByCardIdAsync(string cardId, CancellationToken ct = default)
+ {
+ await using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM card_hashes WHERE card_id = $card_id";
+ cmd.Parameters.AddWithValue("$card_id", cardId);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ public async Task ClearAsync(CancellationToken ct = default)
+ {
+ await using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "DELETE FROM card_hashes";
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+}
diff --git a/src/Scry.Core/Imaging/CardDetector.cs b/src/Scry.Core/Imaging/CardDetector.cs
new file mode 100644
index 0000000..5b147eb
--- /dev/null
+++ b/src/Scry.Core/Imaging/CardDetector.cs
@@ -0,0 +1,625 @@
+using SkiaSharp;
+
+namespace Scry.Core.Imaging;
+
+///
+/// Detects card boundaries in images using edge detection and contour analysis.
+///
+public static class CardDetector
+{
+ ///
+ /// Standard MTG card aspect ratio (height / width).
+ /// Cards are 63mm x 88mm = 1.397 aspect ratio.
+ ///
+ private const float CardAspectRatio = 88f / 63f; // ~1.397
+ private const float AspectRatioTolerance = 0.25f; // Allow 25% deviation
+
+ ///
+ /// Minimum area of detected card relative to image area.
+ ///
+ private const float MinCardAreaRatio = 0.05f; // Card must be at least 5% of image
+
+ ///
+ /// Maximum area of detected card relative to image area.
+ ///
+ private const float MaxCardAreaRatio = 0.98f; // Card can be at most 98% of image
+
+ ///
+ /// Result of card detection.
+ ///
+ public record CardDetectionResult(
+ bool Found,
+ SKPoint[] Corners,
+ float Confidence,
+ string? DebugMessage = null
+ )
+ {
+ public static CardDetectionResult NotFound(string reason) =>
+ new(false, Array.Empty(), 0, reason);
+
+ public static CardDetectionResult Success(SKPoint[] corners, float confidence) =>
+ new(true, corners, confidence);
+ }
+
+ ///
+ /// Detect a card in the image and return its corner points.
+ ///
+ public static CardDetectionResult DetectCard(SKBitmap image)
+ {
+ // Step 1: Convert to grayscale
+ using var grayscale = ToGrayscale(image);
+
+ // Step 2: Apply Gaussian blur to reduce noise
+ using var blurred = ApplyGaussianBlur(grayscale, 5);
+
+ // Step 3: Apply Canny edge detection
+ using var edges = ApplyCannyEdgeDetection(blurred, 50, 150);
+
+ // Step 4: Find contours
+ var contours = FindContours(edges);
+
+ if (contours.Count == 0)
+ return CardDetectionResult.NotFound("No contours found");
+
+ // Step 5: Find the best card-like quadrilateral
+ var imageArea = image.Width * image.Height;
+ var bestQuad = FindBestCardQuadrilateral(contours, imageArea);
+
+ if (bestQuad == null)
+ return CardDetectionResult.NotFound("No card-like quadrilateral found");
+
+ // Step 6: Order corners consistently (top-left, top-right, bottom-right, bottom-left)
+ var orderedCorners = OrderCorners(bestQuad);
+
+ // Calculate confidence based on how well the shape matches a card
+ var confidence = CalculateConfidence(orderedCorners, imageArea);
+
+ return CardDetectionResult.Success(orderedCorners, confidence);
+ }
+
+ ///
+ /// Convert image to grayscale.
+ ///
+ private static SKBitmap ToGrayscale(SKBitmap source)
+ {
+ var result = new SKBitmap(source.Width, source.Height, SKColorType.Gray8, SKAlphaType.Opaque);
+
+ for (var y = 0; y < source.Height; y++)
+ {
+ for (var x = 0; x < source.Width; x++)
+ {
+ var pixel = source.GetPixel(x, y);
+ var gray = (byte)(0.299 * pixel.Red + 0.587 * pixel.Green + 0.114 * pixel.Blue);
+ result.SetPixel(x, y, new SKColor(gray, gray, gray));
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Apply Gaussian blur using a simple box blur approximation.
+ ///
+ private static SKBitmap ApplyGaussianBlur(SKBitmap source, int radius)
+ {
+ var result = new SKBitmap(source.Width, source.Height, source.ColorType, source.AlphaType);
+
+ // Use SkiaSharp's built-in blur
+ using var surface = SKSurface.Create(new SKImageInfo(source.Width, source.Height));
+ var canvas = surface.Canvas;
+
+ using var paint = new SKPaint
+ {
+ ImageFilter = SKImageFilter.CreateBlur(radius, radius)
+ };
+
+ canvas.DrawBitmap(source, 0, 0, paint);
+
+ using var image = surface.Snapshot();
+ image.ReadPixels(result.Info, result.GetPixels(), result.RowBytes, 0, 0);
+
+ return result;
+ }
+
+ ///
+ /// Apply Canny edge detection.
+ ///
+ private static SKBitmap ApplyCannyEdgeDetection(SKBitmap source, int lowThreshold, int highThreshold)
+ {
+ var width = source.Width;
+ var height = source.Height;
+
+ // Step 1: Compute gradients using Sobel operators
+ var gradientX = new float[height, width];
+ var gradientY = new float[height, width];
+ var magnitude = new float[height, width];
+ var direction = new float[height, width];
+
+ // Sobel kernels
+ int[,] sobelX = { { -1, 0, 1 }, { -2, 0, 2 }, { -1, 0, 1 } };
+ int[,] sobelY = { { -1, -2, -1 }, { 0, 0, 0 }, { 1, 2, 1 } };
+
+ for (var y = 1; y < height - 1; y++)
+ {
+ for (var x = 1; x < width - 1; x++)
+ {
+ float gx = 0, gy = 0;
+
+ for (var ky = -1; ky <= 1; ky++)
+ {
+ for (var kx = -1; kx <= 1; kx++)
+ {
+ var pixel = source.GetPixel(x + kx, y + ky).Red;
+ gx += pixel * sobelX[ky + 1, kx + 1];
+ gy += pixel * sobelY[ky + 1, kx + 1];
+ }
+ }
+
+ gradientX[y, x] = gx;
+ gradientY[y, x] = gy;
+ magnitude[y, x] = MathF.Sqrt(gx * gx + gy * gy);
+ direction[y, x] = MathF.Atan2(gy, gx);
+ }
+ }
+
+ // Step 2: Non-maximum suppression
+ var suppressed = new float[height, width];
+
+ for (var y = 1; y < height - 1; y++)
+ {
+ for (var x = 1; x < width - 1; x++)
+ {
+ var angle = direction[y, x] * 180 / MathF.PI;
+ if (angle < 0) angle += 180;
+
+ float neighbor1, neighbor2;
+
+ if (angle is < 22.5f or >= 157.5f)
+ {
+ neighbor1 = magnitude[y, x - 1];
+ neighbor2 = magnitude[y, x + 1];
+ }
+ else if (angle is >= 22.5f and < 67.5f)
+ {
+ neighbor1 = magnitude[y - 1, x + 1];
+ neighbor2 = magnitude[y + 1, x - 1];
+ }
+ else if (angle is >= 67.5f and < 112.5f)
+ {
+ neighbor1 = magnitude[y - 1, x];
+ neighbor2 = magnitude[y + 1, x];
+ }
+ else
+ {
+ neighbor1 = magnitude[y - 1, x - 1];
+ neighbor2 = magnitude[y + 1, x + 1];
+ }
+
+ if (magnitude[y, x] >= neighbor1 && magnitude[y, x] >= neighbor2)
+ suppressed[y, x] = magnitude[y, x];
+ }
+ }
+
+ // Step 3: Double thresholding and edge tracking
+ var result = new SKBitmap(width, height, SKColorType.Gray8, SKAlphaType.Opaque);
+ var strong = new bool[height, width];
+ var weak = new bool[height, width];
+
+ for (var y = 0; y < height; y++)
+ {
+ for (var x = 0; x < width; x++)
+ {
+ if (suppressed[y, x] >= highThreshold)
+ strong[y, x] = true;
+ else if (suppressed[y, x] >= lowThreshold)
+ weak[y, x] = true;
+ }
+ }
+
+ // Edge tracking by hysteresis
+ for (var y = 1; y < height - 1; y++)
+ {
+ for (var x = 1; x < width - 1; x++)
+ {
+ byte value = 0;
+
+ if (strong[y, x])
+ {
+ value = 255;
+ }
+ else if (weak[y, x])
+ {
+ // Check if connected to strong edge
+ for (var dy = -1; dy <= 1; dy++)
+ {
+ for (var dx = -1; dx <= 1; dx++)
+ {
+ if (strong[y + dy, x + dx])
+ {
+ value = 255;
+ break;
+ }
+ }
+ if (value == 255) break;
+ }
+ }
+
+ result.SetPixel(x, y, new SKColor(value, value, value));
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Find contours in a binary edge image.
+ ///
+ private static List> FindContours(SKBitmap edges)
+ {
+ var width = edges.Width;
+ var height = edges.Height;
+ var visited = new bool[height, width];
+ var contours = new List>();
+
+ // Simple contour tracing
+ for (var y = 1; y < height - 1; y++)
+ {
+ for (var x = 1; x < width - 1; x++)
+ {
+ if (visited[y, x]) continue;
+ if (edges.GetPixel(x, y).Red < 128) continue;
+
+ var contour = TraceContour(edges, x, y, visited);
+ if (contour.Count >= 4)
+ {
+ contours.Add(contour);
+ }
+ }
+ }
+
+ return contours;
+ }
+
+ ///
+ /// Trace a contour starting from a point.
+ ///
+ private static List TraceContour(SKBitmap edges, int startX, int startY, bool[,] visited)
+ {
+ var contour = new List();
+ var queue = new Queue<(int x, int y)>();
+ queue.Enqueue((startX, startY));
+
+ // 8-directional neighbors
+ int[] dx = { -1, 0, 1, 1, 1, 0, -1, -1 };
+ int[] dy = { -1, -1, -1, 0, 1, 1, 1, 0 };
+
+ while (queue.Count > 0 && contour.Count < 10000) // Limit to prevent runaway
+ {
+ var (x, y) = queue.Dequeue();
+
+ if (x < 0 || x >= edges.Width || y < 0 || y >= edges.Height)
+ continue;
+ if (visited[y, x])
+ continue;
+ if (edges.GetPixel(x, y).Red < 128)
+ continue;
+
+ visited[y, x] = true;
+ contour.Add(new SKPoint(x, y));
+
+ for (var i = 0; i < 8; i++)
+ {
+ queue.Enqueue((x + dx[i], y + dy[i]));
+ }
+ }
+
+ return contour;
+ }
+
+ ///
+ /// Find the best quadrilateral that matches a card shape.
+ ///
+ private static SKPoint[]? FindBestCardQuadrilateral(List> contours, float imageArea)
+ {
+ SKPoint[]? bestQuad = null;
+ var bestScore = float.MinValue;
+
+ foreach (var contour in contours)
+ {
+ // Simplify contour using Douglas-Peucker algorithm
+ var simplified = SimplifyContour(contour, contour.Count * 0.02f);
+
+ // Try to approximate as quadrilateral
+ var quad = ApproximateQuadrilateral(simplified);
+ if (quad == null) continue;
+
+ // Check if it's a valid card shape
+ var area = CalculateQuadArea(quad);
+ var areaRatio = area / imageArea;
+
+ if (areaRatio < MinCardAreaRatio || areaRatio > MaxCardAreaRatio)
+ continue;
+
+ // Check aspect ratio
+ var aspectScore = CalculateAspectRatioScore(quad);
+ if (aspectScore < 0.5f) continue;
+
+ // Check if it's convex
+ if (!IsConvex(quad)) continue;
+
+ // Score based on area (prefer larger) and aspect ratio match
+ var score = areaRatio * aspectScore;
+
+ if (score > bestScore)
+ {
+ bestScore = score;
+ bestQuad = quad;
+ }
+ }
+
+ return bestQuad;
+ }
+
+ ///
+ /// Simplify a contour using Douglas-Peucker algorithm.
+ ///
+ private static List SimplifyContour(List contour, float epsilon)
+ {
+ if (contour.Count < 3) return contour;
+
+ // Find the point farthest from line between first and last
+ var first = contour[0];
+ var last = contour[^1];
+
+ var maxDist = 0f;
+ var maxIndex = 0;
+
+ for (var i = 1; i < contour.Count - 1; i++)
+ {
+ var dist = PointToLineDistance(contour[i], first, last);
+ if (dist > maxDist)
+ {
+ maxDist = dist;
+ maxIndex = i;
+ }
+ }
+
+ if (maxDist > epsilon)
+ {
+ var left = SimplifyContour(contour.Take(maxIndex + 1).ToList(), epsilon);
+ var right = SimplifyContour(contour.Skip(maxIndex).ToList(), epsilon);
+
+ return left.Take(left.Count - 1).Concat(right).ToList();
+ }
+
+ return new List { first, last };
+ }
+
+ ///
+ /// Calculate distance from point to line.
+ ///
+ private static float PointToLineDistance(SKPoint point, SKPoint lineStart, SKPoint lineEnd)
+ {
+ var dx = lineEnd.X - lineStart.X;
+ var dy = lineEnd.Y - lineStart.Y;
+ var lengthSquared = dx * dx + dy * dy;
+
+ if (lengthSquared == 0)
+ return Distance(point, lineStart);
+
+ var t = ((point.X - lineStart.X) * dx + (point.Y - lineStart.Y) * dy) / lengthSquared;
+ t = Math.Clamp(t, 0, 1);
+
+ var projection = new SKPoint(lineStart.X + t * dx, lineStart.Y + t * dy);
+ return Distance(point, projection);
+ }
+
+ ///
+ /// Try to approximate a contour as a quadrilateral.
+ ///
+ private static SKPoint[]? ApproximateQuadrilateral(List contour)
+ {
+ if (contour.Count < 4) return null;
+
+ // If already 4 points, use them
+ if (contour.Count == 4)
+ return contour.ToArray();
+
+ // Find convex hull
+ var hull = ConvexHull(contour);
+ if (hull.Count < 4) return null;
+
+ // Find 4 corners by finding the 4 points that form the largest quadrilateral
+ if (hull.Count == 4)
+ return hull.ToArray();
+
+ // Find the 4 most extreme points
+ var corners = FindExtremePoints(hull);
+ return corners.Length == 4 ? corners : null;
+ }
+
+ ///
+ /// Calculate convex hull using Graham scan.
+ ///
+ private static List ConvexHull(List points)
+ {
+ if (points.Count < 3) return points;
+
+ // Find bottom-most point (or left-most in case of tie)
+ var start = points.OrderBy(p => p.Y).ThenBy(p => p.X).First();
+
+ // Sort by polar angle
+ var sorted = points
+ .Where(p => p != start)
+ .OrderBy(p => MathF.Atan2(p.Y - start.Y, p.X - start.X))
+ .ThenBy(p => Distance(p, start))
+ .ToList();
+
+ var hull = new List { start };
+
+ foreach (var point in sorted)
+ {
+ while (hull.Count > 1 && CrossProduct(hull[^2], hull[^1], point) <= 0)
+ {
+ hull.RemoveAt(hull.Count - 1);
+ }
+ hull.Add(point);
+ }
+
+ return hull;
+ }
+
+ ///
+ /// Find the 4 most extreme points of a convex hull.
+ ///
+ private static SKPoint[] FindExtremePoints(List hull)
+ {
+ if (hull.Count <= 4) return hull.ToArray();
+
+ // Find points with min/max X and Y
+ var minX = hull.OrderBy(p => p.X).First();
+ var maxX = hull.OrderByDescending(p => p.X).First();
+ var minY = hull.OrderBy(p => p.Y).First();
+ var maxY = hull.OrderByDescending(p => p.Y).First();
+
+ var extremes = new HashSet { minX, maxX, minY, maxY };
+
+ if (extremes.Count == 4)
+ return extremes.ToArray();
+
+ // If we have duplicates, add more points
+ var sorted = hull.OrderBy(p => MathF.Atan2(p.Y - hull.Average(h => h.Y), p.X - hull.Average(h => h.X))).ToList();
+
+ var step = sorted.Count / 4;
+ return new[]
+ {
+ sorted[0],
+ sorted[step],
+ sorted[step * 2],
+ sorted[step * 3]
+ };
+ }
+
+ ///
+ /// Calculate cross product of vectors (b-a) and (c-b).
+ ///
+ private static float CrossProduct(SKPoint a, SKPoint b, SKPoint c)
+ {
+ return (b.X - a.X) * (c.Y - a.Y) - (b.Y - a.Y) * (c.X - a.X);
+ }
+
+ ///
+ /// Calculate distance between two points.
+ ///
+ private static float Distance(SKPoint a, SKPoint b)
+ {
+ var dx = b.X - a.X;
+ var dy = b.Y - a.Y;
+ return MathF.Sqrt(dx * dx + dy * dy);
+ }
+
+ ///
+ /// Calculate the area of a quadrilateral.
+ ///
+ private static float CalculateQuadArea(SKPoint[] quad)
+ {
+ // Shoelace formula
+ var area = 0f;
+ for (var i = 0; i < 4; i++)
+ {
+ var j = (i + 1) % 4;
+ area += quad[i].X * quad[j].Y;
+ area -= quad[j].X * quad[i].Y;
+ }
+ return Math.Abs(area) / 2;
+ }
+
+ ///
+ /// Calculate how well the aspect ratio matches a card.
+ ///
+ private static float CalculateAspectRatioScore(SKPoint[] quad)
+ {
+ // Calculate width and height of the quadrilateral
+ var width1 = Distance(quad[0], quad[1]);
+ var width2 = Distance(quad[2], quad[3]);
+ var height1 = Distance(quad[1], quad[2]);
+ var height2 = Distance(quad[3], quad[0]);
+
+ var avgWidth = (width1 + width2) / 2;
+ var avgHeight = (height1 + height2) / 2;
+
+ // Ensure height > width (portrait orientation)
+ var aspectRatio = avgWidth > avgHeight
+ ? avgWidth / avgHeight
+ : avgHeight / avgWidth;
+
+ var expectedRatio = CardAspectRatio;
+ var deviation = Math.Abs(aspectRatio - expectedRatio) / expectedRatio;
+
+ // Score from 0 to 1 based on how close to expected ratio
+ return Math.Max(0, 1 - deviation / AspectRatioTolerance);
+ }
+
+ ///
+ /// Check if a quadrilateral is convex.
+ ///
+ private static bool IsConvex(SKPoint[] quad)
+ {
+ var sign = 0;
+
+ for (var i = 0; i < 4; i++)
+ {
+ var cross = CrossProduct(quad[i], quad[(i + 1) % 4], quad[(i + 2) % 4]);
+
+ if (Math.Abs(cross) < 0.0001f) continue;
+
+ var currentSign = cross > 0 ? 1 : -1;
+
+ if (sign == 0)
+ sign = currentSign;
+ else if (sign != currentSign)
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Order corners consistently: top-left, top-right, bottom-right, bottom-left.
+ ///
+ private static SKPoint[] OrderCorners(SKPoint[] corners)
+ {
+ // Find center
+ var centerX = corners.Average(c => c.X);
+ var centerY = corners.Average(c => c.Y);
+
+ // Classify each corner
+ var topLeft = corners.Where(c => c.X < centerX && c.Y < centerY).OrderBy(c => c.X + c.Y).FirstOrDefault();
+ var topRight = corners.Where(c => c.X >= centerX && c.Y < centerY).OrderBy(c => c.Y - c.X).FirstOrDefault();
+ var bottomRight = corners.Where(c => c.X >= centerX && c.Y >= centerY).OrderByDescending(c => c.X + c.Y).FirstOrDefault();
+ var bottomLeft = corners.Where(c => c.X < centerX && c.Y >= centerY).OrderByDescending(c => c.Y - c.X).FirstOrDefault();
+
+ // Handle edge cases by sorting by angle from center
+ if (topLeft == default || topRight == default || bottomRight == default || bottomLeft == default)
+ {
+ var sorted = corners.OrderBy(c => MathF.Atan2(c.Y - centerY, c.X - centerX)).ToArray();
+ // Rotate to start with top-left
+ var minSum = sorted.Select((c, i) => (c.X + c.Y, i)).Min().i;
+ return sorted.Skip(minSum).Concat(sorted.Take(minSum)).ToArray();
+ }
+
+ return new[] { topLeft, topRight, bottomRight, bottomLeft };
+ }
+
+ ///
+ /// Calculate confidence of the detection.
+ ///
+ private static float CalculateConfidence(SKPoint[] corners, float imageArea)
+ {
+ var area = CalculateQuadArea(corners);
+ var areaScore = Math.Min(area / imageArea / 0.5f, 1f); // Prefer larger cards
+ var aspectScore = CalculateAspectRatioScore(corners);
+
+ return areaScore * 0.4f + aspectScore * 0.6f;
+ }
+}
diff --git a/src/Scry.Core/Imaging/ImagePreprocessor.cs b/src/Scry.Core/Imaging/ImagePreprocessor.cs
new file mode 100644
index 0000000..6eb0429
--- /dev/null
+++ b/src/Scry.Core/Imaging/ImagePreprocessor.cs
@@ -0,0 +1,228 @@
+using SkiaSharp;
+
+namespace Scry.Core.Imaging;
+
+public static class ImagePreprocessor
+{
+ public static SKBitmap ApplyClahe(SKBitmap source, int tileSize = 8, float clipLimit = 2.0f)
+ {
+ var width = source.Width;
+ var height = source.Height;
+ var result = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
+
+ var labImage = ConvertToLab(source);
+
+ var tilesX = (width + tileSize - 1) / tileSize;
+ var tilesY = (height + tileSize - 1) / tileSize;
+
+ var tileMappings = new byte[tilesY, tilesX, 256];
+
+ for (var ty = 0; ty < tilesY; ty++)
+ {
+ for (var tx = 0; tx < tilesX; tx++)
+ {
+ var startX = tx * tileSize;
+ var startY = ty * tileSize;
+ var endX = Math.Min(startX + tileSize, width);
+ var endY = Math.Min(startY + tileSize, height);
+
+ var histogram = new int[256];
+ var pixelCount = 0;
+
+ for (var y = startY; y < endY; y++)
+ {
+ for (var x = startX; x < endX; x++)
+ {
+ var l = (int)labImage[y, x, 0];
+ histogram[l]++;
+ pixelCount++;
+ }
+ }
+
+ var clipCount = (int)(clipLimit * pixelCount / 256);
+ var excess = 0;
+
+ for (var i = 0; i < 256; i++)
+ {
+ if (histogram[i] > clipCount)
+ {
+ excess += histogram[i] - clipCount;
+ histogram[i] = clipCount;
+ }
+ }
+
+ var redistribution = excess / 256;
+ for (var i = 0; i < 256; i++)
+ {
+ histogram[i] += redistribution;
+ }
+
+ var cdf = new int[256];
+ cdf[0] = histogram[0];
+ for (var i = 1; i < 256; i++)
+ {
+ cdf[i] = cdf[i - 1] + histogram[i];
+ }
+
+ var cdfMin = cdf.FirstOrDefault(c => c > 0);
+ if (cdfMin == 0) cdfMin = 1;
+ var denominator = Math.Max(1, pixelCount - cdfMin);
+
+ for (var i = 0; i < 256; i++)
+ {
+ tileMappings[ty, tx, i] = (byte)Math.Clamp((cdf[i] - cdfMin) * 255 / denominator, 0, 255);
+ }
+ }
+ }
+
+ for (var y = 0; y < height; y++)
+ {
+ for (var x = 0; x < width; x++)
+ {
+ var l = (int)labImage[y, x, 0];
+ var a = labImage[y, x, 1];
+ var b = labImage[y, x, 2];
+
+ var txFloat = (float)x / tileSize - 0.5f;
+ var tyFloat = (float)y / tileSize - 0.5f;
+
+ var tx1 = Math.Clamp((int)Math.Floor(txFloat), 0, tilesX - 1);
+ var ty1 = Math.Clamp((int)Math.Floor(tyFloat), 0, tilesY - 1);
+ var tx2 = Math.Clamp(tx1 + 1, 0, tilesX - 1);
+ var ty2 = Math.Clamp(ty1 + 1, 0, tilesY - 1);
+
+ var xRatio = txFloat - tx1;
+ var yRatio = tyFloat - ty1;
+ xRatio = Math.Clamp(xRatio, 0, 1);
+ yRatio = Math.Clamp(yRatio, 0, 1);
+
+ var v1 = tileMappings[ty1, tx1, l];
+ var v2 = tileMappings[ty1, tx2, l];
+ var v3 = tileMappings[ty2, tx1, l];
+ var v4 = tileMappings[ty2, tx2, l];
+
+ var top = v1 * (1 - xRatio) + v2 * xRatio;
+ var bottom = v3 * (1 - xRatio) + v4 * xRatio;
+ var newL = top * (1 - yRatio) + bottom * yRatio;
+
+ var (r, g, bl) = LabToRgb(newL, a, b);
+ result.SetPixel(x, y, new SKColor(r, g, bl));
+ }
+ }
+
+ return result;
+ }
+
+ private static float[,,] ConvertToLab(SKBitmap bitmap)
+ {
+ var result = new float[bitmap.Height, bitmap.Width, 3];
+
+ for (var y = 0; y < bitmap.Height; y++)
+ {
+ for (var x = 0; x < bitmap.Width; x++)
+ {
+ var pixel = bitmap.GetPixel(x, y);
+ var (l, a, b) = RgbToLab(pixel.Red, pixel.Green, pixel.Blue);
+ result[y, x, 0] = l;
+ result[y, x, 1] = a;
+ result[y, x, 2] = b;
+ }
+ }
+
+ return result;
+ }
+
+ private static (float L, float A, float B) RgbToLab(byte r, byte g, byte b)
+ {
+ var rf = PivotRgb(r / 255.0);
+ var gf = PivotRgb(g / 255.0);
+ var bf = PivotRgb(b / 255.0);
+
+ var x = rf * 0.4124564 + gf * 0.3575761 + bf * 0.1804375;
+ var y = rf * 0.2126729 + gf * 0.7151522 + bf * 0.0721750;
+ var z = rf * 0.0193339 + gf * 0.1191920 + bf * 0.9503041;
+
+ x /= 0.95047;
+ z /= 1.08883;
+
+ x = PivotXyz(x);
+ y = PivotXyz(y);
+ z = PivotXyz(z);
+
+ var l = (float)(116 * y - 16);
+ var a = (float)(500 * (x - y));
+ var bVal = (float)(200 * (y - z));
+
+ l = l * 255 / 100;
+
+ return (l, a, bVal);
+ }
+
+ private static (byte R, byte G, byte B) LabToRgb(double l, double a, double b)
+ {
+ l = l * 100 / 255;
+
+ var y = (l + 16) / 116;
+ var x = a / 500 + y;
+ var z = y - b / 200;
+
+ var x3 = x * x * x;
+ var y3 = y * y * y;
+ var z3 = z * z * z;
+
+ x = x3 > 0.008856 ? x3 : (x - 16.0 / 116) / 7.787;
+ y = y3 > 0.008856 ? y3 : (y - 16.0 / 116) / 7.787;
+ z = z3 > 0.008856 ? z3 : (z - 16.0 / 116) / 7.787;
+
+ x *= 0.95047;
+ z *= 1.08883;
+
+ var rf = x * 3.2404542 + y * -1.5371385 + z * -0.4985314;
+ var gf = x * -0.9692660 + y * 1.8760108 + z * 0.0415560;
+ var bf = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;
+
+ rf = rf > 0.0031308 ? 1.055 * Math.Pow(rf, 1 / 2.4) - 0.055 : 12.92 * rf;
+ gf = gf > 0.0031308 ? 1.055 * Math.Pow(gf, 1 / 2.4) - 0.055 : 12.92 * gf;
+ bf = bf > 0.0031308 ? 1.055 * Math.Pow(bf, 1 / 2.4) - 0.055 : 12.92 * bf;
+
+ return (
+ (byte)Math.Clamp(rf * 255, 0, 255),
+ (byte)Math.Clamp(gf * 255, 0, 255),
+ (byte)Math.Clamp(bf * 255, 0, 255)
+ );
+ }
+
+ private static double PivotRgb(double n)
+ {
+ return n > 0.04045 ? Math.Pow((n + 0.055) / 1.055, 2.4) : n / 12.92;
+ }
+
+ private static double PivotXyz(double n)
+ {
+ return n > 0.008856 ? Math.Pow(n, 1.0 / 3) : 7.787 * n + 16.0 / 116;
+ }
+
+ public static SKBitmap Resize(SKBitmap source, int width, int height)
+ {
+ var info = new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
+ var sampling = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
+ return source.Resize(info, sampling);
+ }
+
+ public static SKBitmap ToGrayscale(SKBitmap source)
+ {
+ var result = new SKBitmap(source.Width, source.Height, SKColorType.Gray8, SKAlphaType.Opaque);
+
+ for (var y = 0; y < source.Height; y++)
+ {
+ for (var x = 0; x < source.Width; x++)
+ {
+ var pixel = source.GetPixel(x, y);
+ var gray = (byte)(0.299 * pixel.Red + 0.587 * pixel.Green + 0.114 * pixel.Blue);
+ result.SetPixel(x, y, new SKColor(gray, gray, gray));
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/src/Scry.Core/Imaging/PerceptualHash.cs b/src/Scry.Core/Imaging/PerceptualHash.cs
new file mode 100644
index 0000000..7d90c87
--- /dev/null
+++ b/src/Scry.Core/Imaging/PerceptualHash.cs
@@ -0,0 +1,208 @@
+using SkiaSharp;
+
+namespace Scry.Core.Imaging;
+
+public static class PerceptualHash
+{
+ private const int HashSize = 32;
+ private const int DctSize = 32;
+
+ public static byte[] ComputeHash(SKBitmap image)
+ {
+ using var resized = ImagePreprocessor.Resize(image, DctSize, DctSize);
+ using var grayscale = ImagePreprocessor.ToGrayscale(resized);
+
+ var pixels = new double[DctSize, DctSize];
+ for (var y = 0; y < DctSize; y++)
+ {
+ for (var x = 0; x < DctSize; x++)
+ {
+ var pixel = grayscale.GetPixel(x, y);
+ pixels[y, x] = pixel.Red;
+ }
+ }
+
+ var dct = ComputeDct(pixels);
+
+ var lowFreq = new double[8 * 8];
+ var idx = 0;
+ for (var y = 0; y < 8; y++)
+ {
+ for (var x = 0; x < 8; x++)
+ {
+ if (x == 0 && y == 0) continue;
+ lowFreq[idx++] = dct[y, x];
+ }
+ }
+
+ var median = GetMedian(lowFreq.Take(63).ToArray());
+
+ var hash = new byte[8];
+ idx = 0;
+ for (var y = 0; y < 8; y++)
+ {
+ for (var x = 0; x < 8; x++)
+ {
+ if (x == 0 && y == 0) continue;
+ if (dct[y, x] > median)
+ {
+ hash[idx / 8] |= (byte)(1 << (7 - idx % 8));
+ }
+ idx++;
+ }
+ }
+
+ return hash;
+ }
+
+ public static byte[] ComputeColorHash(SKBitmap image)
+ {
+ using var resized = ImagePreprocessor.Resize(image, DctSize, DctSize);
+
+ var redPixels = new double[DctSize, DctSize];
+ var greenPixels = new double[DctSize, DctSize];
+ var bluePixels = new double[DctSize, DctSize];
+
+ for (var y = 0; y < DctSize; y++)
+ {
+ for (var x = 0; x < DctSize; x++)
+ {
+ var pixel = resized.GetPixel(x, y);
+ redPixels[y, x] = pixel.Red;
+ greenPixels[y, x] = pixel.Green;
+ bluePixels[y, x] = pixel.Blue;
+ }
+ }
+
+ var redHash = ComputeChannelHash(redPixels);
+ var greenHash = ComputeChannelHash(greenPixels);
+ var blueHash = ComputeChannelHash(bluePixels);
+
+ var combined = new byte[redHash.Length + greenHash.Length + blueHash.Length];
+ Array.Copy(redHash, 0, combined, 0, redHash.Length);
+ Array.Copy(greenHash, 0, combined, redHash.Length, greenHash.Length);
+ Array.Copy(blueHash, 0, combined, redHash.Length + greenHash.Length, blueHash.Length);
+
+ return combined;
+ }
+
+ private static byte[] ComputeChannelHash(double[,] pixels)
+ {
+ var dct = ComputeDct(pixels);
+
+ var lowFreq = new double[63];
+ var idx = 0;
+ for (var y = 0; y < 8; y++)
+ {
+ for (var x = 0; x < 8; x++)
+ {
+ if (x == 0 && y == 0) continue;
+ lowFreq[idx++] = dct[y, x];
+ }
+ }
+
+ var median = GetMedian(lowFreq);
+
+ var hash = new byte[8];
+ idx = 0;
+ for (var y = 0; y < 8; y++)
+ {
+ for (var x = 0; x < 8; x++)
+ {
+ if (x == 0 && y == 0) continue;
+ if (dct[y, x] > median)
+ {
+ hash[idx / 8] |= (byte)(1 << (7 - idx % 8));
+ }
+ idx++;
+ }
+ }
+
+ return hash;
+ }
+
+ private static double[,] ComputeDct(double[,] input)
+ {
+ var n = input.GetLength(0);
+ var output = new double[n, n];
+
+ var coefficients = new double[n];
+ coefficients[0] = 1.0 / Math.Sqrt(n);
+ for (var i = 1; i < n; i++)
+ {
+ coefficients[i] = Math.Sqrt(2.0 / n);
+ }
+
+ var cosTable = new double[n, n];
+ for (var i = 0; i < n; i++)
+ {
+ for (var j = 0; j < n; j++)
+ {
+ cosTable[i, j] = Math.Cos((2 * j + 1) * i * Math.PI / (2 * n));
+ }
+ }
+
+ var temp = new double[n, n];
+ for (var u = 0; u < n; u++)
+ {
+ for (var y = 0; y < n; y++)
+ {
+ var sum = 0.0;
+ for (var x = 0; x < n; x++)
+ {
+ sum += input[y, x] * cosTable[u, x];
+ }
+ temp[u, y] = sum * coefficients[u];
+ }
+ }
+
+ for (var u = 0; u < n; u++)
+ {
+ for (var v = 0; v < n; v++)
+ {
+ var sum = 0.0;
+ for (var y = 0; y < n; y++)
+ {
+ sum += temp[u, y] * cosTable[v, y];
+ }
+ output[v, u] = sum * coefficients[v];
+ }
+ }
+
+ return output;
+ }
+
+ private static double GetMedian(double[] values)
+ {
+ var sorted = values.OrderBy(v => v).ToArray();
+ var mid = sorted.Length / 2;
+ return sorted.Length % 2 == 0
+ ? (sorted[mid - 1] + sorted[mid]) / 2
+ : sorted[mid];
+ }
+
+ public static int HammingDistance(byte[] hash1, byte[] hash2)
+ {
+ if (hash1.Length != hash2.Length)
+ throw new ArgumentException("Hash lengths must match");
+
+ var distance = 0;
+ for (var i = 0; i < hash1.Length; i++)
+ {
+ var xor = hash1[i] ^ hash2[i];
+ while (xor != 0)
+ {
+ distance += xor & 1;
+ xor >>= 1;
+ }
+ }
+
+ return distance;
+ }
+
+ public static float CalculateConfidence(int hammingDistance, int hashBits)
+ {
+ var similarity = 1.0f - (float)hammingDistance / hashBits;
+ return similarity;
+ }
+}
diff --git a/src/Scry.Core/Imaging/PerspectiveCorrection.cs b/src/Scry.Core/Imaging/PerspectiveCorrection.cs
new file mode 100644
index 0000000..7f5f02f
--- /dev/null
+++ b/src/Scry.Core/Imaging/PerspectiveCorrection.cs
@@ -0,0 +1,295 @@
+using SkiaSharp;
+
+namespace Scry.Core.Imaging;
+
+///
+/// Performs perspective correction to transform a quadrilateral region into a rectangle.
+///
+public static class PerspectiveCorrection
+{
+ ///
+ /// Standard output size for corrected card images.
+ /// Using 480x670 to maintain card aspect ratio (63:88).
+ ///
+ public const int OutputWidth = 480;
+ public const int OutputHeight = 670;
+
+ ///
+ /// Apply perspective correction to extract and normalize a card from an image.
+ ///
+ /// Source image containing the card.
+ /// Four corners of the card in order: top-left, top-right, bottom-right, bottom-left.
+ /// Width of the output image.
+ /// Height of the output image.
+ /// A new bitmap with the perspective-corrected card.
+ public static SKBitmap WarpPerspective(
+ SKBitmap source,
+ SKPoint[] corners,
+ int outputWidth = OutputWidth,
+ int outputHeight = OutputHeight)
+ {
+ if (corners.Length != 4)
+ throw new ArgumentException("Exactly 4 corners required", nameof(corners));
+
+ // Determine if the card is in landscape orientation (rotated 90°)
+ var width1 = Distance(corners[0], corners[1]);
+ var height1 = Distance(corners[1], corners[2]);
+
+ // If width > height, card is landscape - swap dimensions and reorder corners
+ SKPoint[] orderedCorners;
+ int targetWidth, targetHeight;
+
+ if (width1 > height1)
+ {
+ // Card is landscape - rotate corners to portrait
+ orderedCorners = new[] { corners[1], corners[2], corners[3], corners[0] };
+ targetWidth = outputWidth;
+ targetHeight = outputHeight;
+ }
+ else
+ {
+ orderedCorners = corners;
+ targetWidth = outputWidth;
+ targetHeight = outputHeight;
+ }
+
+ // Compute the perspective transform matrix
+ var matrix = ComputePerspectiveTransform(orderedCorners, targetWidth, targetHeight);
+
+ // Apply the transform
+ var result = new SKBitmap(targetWidth, targetHeight, SKColorType.Rgba8888, SKAlphaType.Premul);
+
+ for (var y = 0; y < targetHeight; y++)
+ {
+ for (var x = 0; x < targetWidth; x++)
+ {
+ // Apply inverse transform to find source coordinates
+ var srcPoint = ApplyInverseTransform(matrix, x, y);
+
+ // Bilinear interpolation for smooth sampling
+ var color = SampleBilinear(source, srcPoint.X, srcPoint.Y);
+ result.SetPixel(x, y, color);
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Compute a perspective transform matrix from quad corners to rectangle.
+ /// Uses the Direct Linear Transform (DLT) algorithm.
+ ///
+ private static float[] ComputePerspectiveTransform(SKPoint[] src, int dstWidth, int dstHeight)
+ {
+ // Destination corners (rectangle)
+ var dst = new SKPoint[]
+ {
+ new(0, 0),
+ new(dstWidth - 1, 0),
+ new(dstWidth - 1, dstHeight - 1),
+ new(0, dstHeight - 1)
+ };
+
+ // Build the 8x8 matrix for solving the homography
+ // We're solving for the 8 parameters of the 3x3 perspective matrix (h33 = 1)
+ var A = new double[8, 8];
+ var b = new double[8];
+
+ for (var i = 0; i < 4; i++)
+ {
+ var sx = src[i].X;
+ var sy = src[i].Y;
+ var dx = dst[i].X;
+ var dy = dst[i].Y;
+
+ A[i * 2, 0] = sx;
+ A[i * 2, 1] = sy;
+ A[i * 2, 2] = 1;
+ A[i * 2, 3] = 0;
+ A[i * 2, 4] = 0;
+ A[i * 2, 5] = 0;
+ A[i * 2, 6] = -dx * sx;
+ A[i * 2, 7] = -dx * sy;
+ b[i * 2] = dx;
+
+ A[i * 2 + 1, 0] = 0;
+ A[i * 2 + 1, 1] = 0;
+ A[i * 2 + 1, 2] = 0;
+ A[i * 2 + 1, 3] = sx;
+ A[i * 2 + 1, 4] = sy;
+ A[i * 2 + 1, 5] = 1;
+ A[i * 2 + 1, 6] = -dy * sx;
+ A[i * 2 + 1, 7] = -dy * sy;
+ b[i * 2 + 1] = dy;
+ }
+
+ // Solve using Gaussian elimination
+ var h = SolveLinearSystem(A, b);
+
+ // Return the 3x3 matrix as a flat array [h11, h12, h13, h21, h22, h23, h31, h32, h33]
+ return new float[]
+ {
+ (float)h[0], (float)h[1], (float)h[2],
+ (float)h[3], (float)h[4], (float)h[5],
+ (float)h[6], (float)h[7], 1f
+ };
+ }
+
+ ///
+ /// Solve a linear system Ax = b using Gaussian elimination with partial pivoting.
+ ///
+ private static double[] SolveLinearSystem(double[,] A, double[] b)
+ {
+ var n = b.Length;
+ var augmented = new double[n, n + 1];
+
+ // Create augmented matrix
+ for (var i = 0; i < n; i++)
+ {
+ for (var j = 0; j < n; j++)
+ augmented[i, j] = A[i, j];
+ augmented[i, n] = b[i];
+ }
+
+ // Forward elimination with partial pivoting
+ for (var col = 0; col < n; col++)
+ {
+ // Find pivot
+ var maxRow = col;
+ for (var row = col + 1; row < n; row++)
+ {
+ if (Math.Abs(augmented[row, col]) > Math.Abs(augmented[maxRow, col]))
+ maxRow = row;
+ }
+
+ // Swap rows
+ for (var j = 0; j <= n; j++)
+ {
+ (augmented[col, j], augmented[maxRow, j]) = (augmented[maxRow, j], augmented[col, j]);
+ }
+
+ // Eliminate
+ for (var row = col + 1; row < n; row++)
+ {
+ if (Math.Abs(augmented[col, col]) < 1e-10) continue;
+
+ var factor = augmented[row, col] / augmented[col, col];
+ for (var j = col; j <= n; j++)
+ {
+ augmented[row, j] -= factor * augmented[col, j];
+ }
+ }
+ }
+
+ // Back substitution
+ var x = new double[n];
+ for (var i = n - 1; i >= 0; i--)
+ {
+ x[i] = augmented[i, n];
+ for (var j = i + 1; j < n; j++)
+ {
+ x[i] -= augmented[i, j] * x[j];
+ }
+ if (Math.Abs(augmented[i, i]) > 1e-10)
+ x[i] /= augmented[i, i];
+ }
+
+ return x;
+ }
+
+ ///
+ /// Apply the inverse of the perspective transform to map destination to source coordinates.
+ ///
+ private static SKPoint ApplyInverseTransform(float[] H, float x, float y)
+ {
+ // H maps src -> dst, we need dst -> src
+ // Compute inverse of H
+ var inv = InvertMatrix3x3(H);
+
+ // Apply inverse transform
+ var w = inv[6] * x + inv[7] * y + inv[8];
+ if (Math.Abs(w) < 1e-10) w = 1e-10f;
+
+ var srcX = (inv[0] * x + inv[1] * y + inv[2]) / w;
+ var srcY = (inv[3] * x + inv[4] * y + inv[5]) / w;
+
+ return new SKPoint(srcX, srcY);
+ }
+
+ ///
+ /// Invert a 3x3 matrix.
+ ///
+ private static float[] InvertMatrix3x3(float[] m)
+ {
+ var det = m[0] * (m[4] * m[8] - m[5] * m[7])
+ - m[1] * (m[3] * m[8] - m[5] * m[6])
+ + m[2] * (m[3] * m[7] - m[4] * m[6]);
+
+ if (Math.Abs(det) < 1e-10f)
+ det = 1e-10f;
+
+ var invDet = 1f / det;
+
+ return new float[]
+ {
+ (m[4] * m[8] - m[5] * m[7]) * invDet,
+ (m[2] * m[7] - m[1] * m[8]) * invDet,
+ (m[1] * m[5] - m[2] * m[4]) * invDet,
+ (m[5] * m[6] - m[3] * m[8]) * invDet,
+ (m[0] * m[8] - m[2] * m[6]) * invDet,
+ (m[2] * m[3] - m[0] * m[5]) * invDet,
+ (m[3] * m[7] - m[4] * m[6]) * invDet,
+ (m[1] * m[6] - m[0] * m[7]) * invDet,
+ (m[0] * m[4] - m[1] * m[3]) * invDet
+ };
+ }
+
+ ///
+ /// Sample a pixel using bilinear interpolation.
+ ///
+ private static SKColor SampleBilinear(SKBitmap source, float x, float y)
+ {
+ // Clamp to valid range
+ x = Math.Clamp(x, 0, source.Width - 1);
+ y = Math.Clamp(y, 0, source.Height - 1);
+
+ var x0 = (int)Math.Floor(x);
+ var y0 = (int)Math.Floor(y);
+ var x1 = Math.Min(x0 + 1, source.Width - 1);
+ var y1 = Math.Min(y0 + 1, source.Height - 1);
+
+ var xFrac = x - x0;
+ var yFrac = y - y0;
+
+ var c00 = source.GetPixel(x0, y0);
+ var c10 = source.GetPixel(x1, y0);
+ var c01 = source.GetPixel(x0, y1);
+ var c11 = source.GetPixel(x1, y1);
+
+ // Interpolate
+ var r = Lerp(Lerp(c00.Red, c10.Red, xFrac), Lerp(c01.Red, c11.Red, xFrac), yFrac);
+ var g = Lerp(Lerp(c00.Green, c10.Green, xFrac), Lerp(c01.Green, c11.Green, xFrac), yFrac);
+ var b = Lerp(Lerp(c00.Blue, c10.Blue, xFrac), Lerp(c01.Blue, c11.Blue, xFrac), yFrac);
+ var a = Lerp(Lerp(c00.Alpha, c10.Alpha, xFrac), Lerp(c01.Alpha, c11.Alpha, xFrac), yFrac);
+
+ return new SKColor((byte)r, (byte)g, (byte)b, (byte)a);
+ }
+
+ ///
+ /// Linear interpolation.
+ ///
+ private static float Lerp(float a, float b, float t)
+ {
+ return a + (b - a) * t;
+ }
+
+ ///
+ /// Calculate distance between two points.
+ ///
+ private static float Distance(SKPoint a, SKPoint b)
+ {
+ var dx = b.X - a.X;
+ var dy = b.Y - a.Y;
+ return MathF.Sqrt(dx * dx + dy * dy);
+ }
+}
diff --git a/src/Scry.Core/Models/Card.cs b/src/Scry.Core/Models/Card.cs
new file mode 100644
index 0000000..42944c6
--- /dev/null
+++ b/src/Scry.Core/Models/Card.cs
@@ -0,0 +1,32 @@
+namespace Scry.Core.Models;
+
+public record Card
+{
+ public required string Id { get; init; }
+ public required string Name { get; init; }
+ public string? SetCode { get; init; }
+ public string? SetName { get; init; }
+ public string? CollectorNumber { get; init; }
+ public string? ScryfallId { get; init; }
+ public string? Rarity { get; init; }
+ public string? ManaCost { get; init; }
+ public string? TypeLine { get; init; }
+ public string? OracleText { get; init; }
+ public string? ImageUri { get; init; }
+ public string? ImageUriSmall { get; init; }
+ public string? ImageUriLarge { get; init; }
+ public string? Artist { get; init; }
+ public string? Lang { get; init; }
+ public decimal? PriceUsd { get; init; }
+ public decimal? PriceUsdFoil { get; init; }
+
+ ///
+ /// Alias for ImageUri for compatibility with App layer
+ ///
+ public string? ImageUrl => ImageUri;
+
+ ///
+ /// Alias for PriceUsd for compatibility with App layer
+ ///
+ public decimal? Price => PriceUsd;
+}
diff --git a/src/Scry.Core/Models/CardHash.cs b/src/Scry.Core/Models/CardHash.cs
new file mode 100644
index 0000000..e0724a5
--- /dev/null
+++ b/src/Scry.Core/Models/CardHash.cs
@@ -0,0 +1,11 @@
+namespace Scry.Core.Models;
+
+public record CardHash
+{
+ public required string CardId { get; init; }
+ public required string Name { get; init; }
+ public required string SetCode { get; init; }
+ public string? CollectorNumber { get; init; }
+ public required byte[] Hash { get; init; }
+ public string? ImageUri { get; init; }
+}
diff --git a/src/Scry.Core/Models/ScanResult.cs b/src/Scry.Core/Models/ScanResult.cs
new file mode 100644
index 0000000..61feb0b
--- /dev/null
+++ b/src/Scry.Core/Models/ScanResult.cs
@@ -0,0 +1,27 @@
+namespace Scry.Core.Models;
+
+public record ScanResult
+{
+ public bool Success { get; init; }
+ public Card? Card { get; init; }
+ public float Confidence { get; init; }
+ public string? ErrorMessage { get; init; }
+ public int HammingDistance { get; init; }
+ public TimeSpan ProcessingTime { get; init; }
+
+ public static ScanResult Failed(string message) => new()
+ {
+ Success = false,
+ ErrorMessage = message,
+ Confidence = 0
+ };
+
+ public static ScanResult Matched(Card card, float confidence, int hammingDistance, TimeSpan processingTime) => new()
+ {
+ Success = true,
+ Card = card,
+ Confidence = confidence,
+ HammingDistance = hammingDistance,
+ ProcessingTime = processingTime
+ };
+}
diff --git a/src/Scry.Core/Recognition/CardRecognitionService.cs b/src/Scry.Core/Recognition/CardRecognitionService.cs
new file mode 100644
index 0000000..a417aad
--- /dev/null
+++ b/src/Scry.Core/Recognition/CardRecognitionService.cs
@@ -0,0 +1,324 @@
+using System.Diagnostics;
+using Scry.Core.Data;
+using Scry.Core.Imaging;
+using Scry.Core.Models;
+using SkiaSharp;
+
+namespace Scry.Core.Recognition;
+
+public class CardRecognitionService : IDisposable
+{
+ private readonly CardHashDatabase _database;
+ private List? _hashCache;
+ private readonly SemaphoreSlim _cacheLock = new(1, 1);
+
+ private const int ColorHashBits = 192;
+ private const int MatchThreshold = 25;
+ private const float MinConfidence = 0.85f;
+
+ ///
+ /// Enable card detection and perspective correction.
+ /// When disabled, assumes the input image is already a cropped card.
+ ///
+ public bool EnableCardDetection { get; set; } = true;
+
+ ///
+ /// Try multiple rotations (0°, 90°, 180°, 270°) when matching.
+ /// Useful when card orientation is unknown.
+ ///
+ public bool EnableRotationMatching { get; set; } = true;
+
+ public CardRecognitionService(CardHashDatabase database)
+ {
+ _database = database;
+ }
+
+ public async Task RecognizeAsync(Stream imageStream, CancellationToken ct = default)
+ {
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ using var bitmap = SKBitmap.Decode(imageStream);
+ if (bitmap == null)
+ {
+ return ScanResult.Failed("Could not decode image");
+ }
+
+ return await RecognizeAsync(bitmap, ct);
+ }
+ catch (Exception ex)
+ {
+ return ScanResult.Failed($"Recognition error: {ex.Message}");
+ }
+ finally
+ {
+ stopwatch.Stop();
+ }
+ }
+
+ public async Task RecognizeAsync(SKBitmap bitmap, CancellationToken ct = default)
+ {
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ var hashes = await GetHashCacheAsync(ct);
+ Console.WriteLine($"[Scry] Database has {hashes.Count} hashes");
+
+ if (hashes.Count == 0)
+ {
+ return ScanResult.Failed("No card hashes in database. Run sync first.");
+ }
+
+ // Step 1: Detect and extract card from image (if enabled)
+ SKBitmap cardImage;
+ bool cardDetected = false;
+
+ if (EnableCardDetection)
+ {
+ var detection = CardDetector.DetectCard(bitmap);
+ if (detection.Found)
+ {
+ cardImage = PerspectiveCorrection.WarpPerspective(bitmap, detection.Corners);
+ cardDetected = true;
+ Console.WriteLine($"[Scry] Card detected with confidence {detection.Confidence:P0}");
+ }
+ else
+ {
+ // Fall back to using the whole image
+ Console.WriteLine($"[Scry] Card detection failed: {detection.DebugMessage}, using full image");
+ cardImage = bitmap;
+ }
+ }
+ else
+ {
+ cardImage = bitmap;
+ }
+
+ try
+ {
+ // Step 2: Try matching with rotation variants (if enabled)
+ var bestMatch = EnableRotationMatching
+ ? await FindBestMatchWithRotationsAsync(cardImage, hashes, ct)
+ : FindBestMatchSingle(cardImage, hashes);
+
+ stopwatch.Stop();
+
+ if (bestMatch == null)
+ {
+ return ScanResult.Failed($"No match found (detection={cardDetected})");
+ }
+
+ var (cardHash, distance, rotation) = bestMatch.Value;
+ var confidence = PerceptualHash.CalculateConfidence(distance, ColorHashBits);
+ Console.WriteLine($"[Scry] Best match: {cardHash.Name}, distance={distance}, confidence={confidence:P0}, rotation={rotation}°");
+
+ if (confidence < MinConfidence)
+ {
+ return ScanResult.Failed($"Match confidence too low: {confidence:P0}");
+ }
+
+ var card = new Card
+ {
+ Id = cardHash.CardId,
+ Name = cardHash.Name,
+ SetCode = cardHash.SetCode,
+ CollectorNumber = cardHash.CollectorNumber,
+ ImageUri = cardHash.ImageUri
+ };
+
+ return ScanResult.Matched(card, confidence, distance, stopwatch.Elapsed);
+ }
+ finally
+ {
+ // Dispose card image only if we created a new one
+ if (cardDetected && cardImage != bitmap)
+ {
+ cardImage.Dispose();
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ stopwatch.Stop();
+ return ScanResult.Failed($"Recognition error: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Compute hash for a card image with the full preprocessing pipeline.
+ ///
+ public byte[] ComputeHash(SKBitmap bitmap, bool applyPreprocessing = true)
+ {
+ if (applyPreprocessing)
+ {
+ // CLAHE is applied as the last step before hashing
+ using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
+ return PerceptualHash.ComputeColorHash(preprocessed);
+ }
+
+ return PerceptualHash.ComputeColorHash(bitmap);
+ }
+
+ ///
+ /// Compute hash for a reference image (used when building the database).
+ /// Reference images are assumed to be already cropped and oriented correctly.
+ ///
+ public byte[] ComputeReferenceHash(SKBitmap bitmap)
+ {
+ using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
+ return PerceptualHash.ComputeColorHash(preprocessed);
+ }
+
+ public async Task InvalidateCacheAsync()
+ {
+ await _cacheLock.WaitAsync();
+ try
+ {
+ _hashCache = null;
+ }
+ finally
+ {
+ _cacheLock.Release();
+ }
+ }
+
+ private async Task> GetHashCacheAsync(CancellationToken ct)
+ {
+ if (_hashCache != null)
+ return _hashCache;
+
+ await _cacheLock.WaitAsync(ct);
+ try
+ {
+ _hashCache ??= await _database.GetAllHashesAsync(ct);
+ return _hashCache;
+ }
+ finally
+ {
+ _cacheLock.Release();
+ }
+ }
+
+ ///
+ /// Find best match trying all 4 rotations (0°, 90°, 180°, 270°).
+ ///
+ private Task<(CardHash Hash, int Distance, int Rotation)?> FindBestMatchWithRotationsAsync(
+ SKBitmap cardImage,
+ List candidates,
+ CancellationToken ct)
+ {
+ return Task.Run(() =>
+ {
+ CardHash? bestMatch = null;
+ var bestDistance = int.MaxValue;
+ var bestRotation = 0;
+
+ var rotations = new[] { 0, 90, 180, 270 };
+
+ foreach (var rotation in rotations)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ using var rotated = rotation == 0 ? null : RotateImage(cardImage, rotation);
+ var imageToHash = rotated ?? cardImage;
+
+ // Apply CLAHE and compute hash
+ using var preprocessed = ImagePreprocessor.ApplyClahe(imageToHash);
+ var queryHash = PerceptualHash.ComputeColorHash(preprocessed);
+
+ // Find best match for this rotation
+ foreach (var candidate in candidates)
+ {
+ if (candidate.Hash.Length != queryHash.Length)
+ continue;
+
+ var distance = PerceptualHash.HammingDistance(queryHash, candidate.Hash);
+
+ if (distance < bestDistance)
+ {
+ bestDistance = distance;
+ bestMatch = candidate;
+ bestRotation = rotation;
+ }
+
+ // Early exit on perfect match
+ if (distance == 0 && bestMatch != null)
+ return (bestMatch, bestDistance, bestRotation);
+ }
+ }
+
+ if (bestMatch == null || bestDistance > MatchThreshold)
+ return null;
+
+ return ((CardHash Hash, int Distance, int Rotation)?)(bestMatch, bestDistance, bestRotation);
+ }, ct);
+ }
+
+ ///
+ /// Find best match without rotation (single orientation).
+ ///
+ private (CardHash Hash, int Distance, int Rotation)? FindBestMatchSingle(
+ SKBitmap cardImage,
+ List candidates)
+ {
+ // Apply CLAHE and compute hash
+ using var preprocessed = ImagePreprocessor.ApplyClahe(cardImage);
+ var queryHash = PerceptualHash.ComputeColorHash(preprocessed);
+
+ CardHash? bestMatch = null;
+ var bestDistance = int.MaxValue;
+
+ foreach (var candidate in candidates)
+ {
+ if (candidate.Hash.Length != queryHash.Length)
+ continue;
+
+ var distance = PerceptualHash.HammingDistance(queryHash, candidate.Hash);
+
+ if (distance < bestDistance)
+ {
+ bestDistance = distance;
+ bestMatch = candidate;
+ }
+
+ if (distance == 0)
+ break;
+ }
+
+ if (bestMatch == null || bestDistance > MatchThreshold)
+ return null;
+
+ return (bestMatch, bestDistance, 0);
+ }
+
+ ///
+ /// Rotate an image by the specified degrees (90, 180, or 270).
+ ///
+ private static SKBitmap RotateImage(SKBitmap source, int degrees)
+ {
+ var (newWidth, newHeight) = degrees switch
+ {
+ 90 or 270 => (source.Height, source.Width),
+ _ => (source.Width, source.Height)
+ };
+
+ var rotated = new SKBitmap(newWidth, newHeight, source.ColorType, source.AlphaType);
+
+ using var canvas = new SKCanvas(rotated);
+ canvas.Clear(SKColors.Black);
+
+ canvas.Translate(newWidth / 2f, newHeight / 2f);
+ canvas.RotateDegrees(degrees);
+ canvas.Translate(-source.Width / 2f, -source.Height / 2f);
+ canvas.DrawBitmap(source, 0, 0);
+
+ return rotated;
+ }
+
+ public void Dispose()
+ {
+ _cacheLock.Dispose();
+ }
+}
diff --git a/src/Scry.Core/Recognition/HashDatabaseSyncService.cs b/src/Scry.Core/Recognition/HashDatabaseSyncService.cs
new file mode 100644
index 0000000..ee7a5c1
--- /dev/null
+++ b/src/Scry.Core/Recognition/HashDatabaseSyncService.cs
@@ -0,0 +1,227 @@
+using Scry.Core.Data;
+using Scry.Core.Imaging;
+using Scry.Core.Models;
+using Scry.Core.Scryfall;
+using SkiaSharp;
+
+namespace Scry.Core.Recognition;
+
+public class HashDatabaseSyncService
+{
+ private readonly ScryfallClient _scryfallClient;
+ private readonly CardHashDatabase _database;
+ private readonly HttpClient _imageClient;
+
+ public event Action? OnProgress;
+
+ public HashDatabaseSyncService(ScryfallClient scryfallClient, CardHashDatabase database, HttpClient? imageClient = null)
+ {
+ _scryfallClient = scryfallClient;
+ _database = database;
+ _imageClient = imageClient ?? new HttpClient();
+ }
+
+ public async Task SyncAsync(SyncOptions? options = null, CancellationToken ct = default)
+ {
+ options ??= new SyncOptions();
+ var result = new SyncResult();
+ var startTime = DateTime.UtcNow;
+
+ try
+ {
+ var bulkInfo = await _scryfallClient.GetBulkDataInfoAsync(options.BulkDataType, ct);
+ if (bulkInfo?.DownloadUri == null)
+ {
+ result.Error = "Could not get bulk data info from Scryfall";
+ return result;
+ }
+
+ var lastSync = await _database.GetMetadataAsync("last_sync_date", ct);
+ if (!options.ForceFullSync && lastSync != null)
+ {
+ if (DateTime.TryParse(lastSync, out var lastSyncDate) &&
+ bulkInfo.UpdatedAt <= lastSyncDate)
+ {
+ result.Skipped = true;
+ result.Message = "Database is up to date";
+ return result;
+ }
+ }
+
+ ReportProgress(new SyncProgress { Stage = SyncStage.Downloading, Message = "Downloading card data..." });
+
+ var batch = new List();
+ var processed = 0;
+ var errors = 0;
+
+ await foreach (var card in _scryfallClient.StreamBulkDataAsync(bulkInfo.DownloadUri, ct))
+ {
+ ct.ThrowIfCancellationRequested();
+
+ if (card.Lang != "en" && !options.IncludeNonEnglish)
+ continue;
+
+ var imageUri = card.GetImageUri(options.ImageSize);
+ if (string.IsNullOrEmpty(imageUri))
+ continue;
+
+ try
+ {
+ var imageBytes = await DownloadWithRetryAsync(imageUri, options.MaxRetries, ct);
+ if (imageBytes == null)
+ {
+ errors++;
+ continue;
+ }
+
+ using var bitmap = SKBitmap.Decode(imageBytes);
+ if (bitmap == null)
+ {
+ errors++;
+ continue;
+ }
+
+ using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
+ var hash = PerceptualHash.ComputeColorHash(preprocessed);
+
+ batch.Add(new CardHash
+ {
+ CardId = card.Id ?? Guid.NewGuid().ToString(),
+ Name = card.Name ?? "Unknown",
+ SetCode = card.Set ?? "???",
+ CollectorNumber = card.CollectorNumber,
+ Hash = hash,
+ ImageUri = imageUri
+ });
+
+ processed++;
+
+ if (batch.Count >= options.BatchSize)
+ {
+ await _database.InsertHashBatchAsync(batch, ct);
+ result.ProcessedCards += batch.Count;
+ batch.Clear();
+
+ ReportProgress(new SyncProgress
+ {
+ Stage = SyncStage.Processing,
+ ProcessedCards = result.ProcessedCards,
+ Message = $"Processed {result.ProcessedCards} cards..."
+ });
+ }
+
+ if (options.RateLimitMs > 0)
+ {
+ await Task.Delay(options.RateLimitMs, ct);
+ }
+ }
+ catch (Exception ex)
+ {
+ errors++;
+ if (options.StopOnError)
+ throw;
+
+ result.Errors.Add($"{card.Name}: {ex.Message}");
+ }
+ }
+
+ if (batch.Count > 0)
+ {
+ await _database.InsertHashBatchAsync(batch, ct);
+ result.ProcessedCards += batch.Count;
+ }
+
+ await _database.SetMetadataAsync("last_sync_date", DateTime.UtcNow.ToString("O"), ct);
+ await _database.SetMetadataAsync("bulk_data_updated", bulkInfo.UpdatedAt?.ToString("O") ?? "", ct);
+
+ result.Success = true;
+ result.Duration = DateTime.UtcNow - startTime;
+ result.ErrorCount = errors;
+ result.Message = $"Synced {result.ProcessedCards} cards in {result.Duration.TotalSeconds:F1}s";
+
+ ReportProgress(new SyncProgress
+ {
+ Stage = SyncStage.Complete,
+ ProcessedCards = result.ProcessedCards,
+ Message = result.Message
+ });
+ }
+ catch (OperationCanceledException)
+ {
+ result.Cancelled = true;
+ result.Message = "Sync cancelled";
+ }
+ catch (Exception ex)
+ {
+ result.Error = ex.Message;
+ }
+
+ return result;
+ }
+
+ private async Task DownloadWithRetryAsync(string url, int maxRetries, CancellationToken ct)
+ {
+ for (var attempt = 0; attempt <= maxRetries; attempt++)
+ {
+ try
+ {
+ return await _imageClient.GetByteArrayAsync(url, ct);
+ }
+ catch (HttpRequestException) when (attempt < maxRetries)
+ {
+ await Task.Delay(1000 * (attempt + 1), ct);
+ }
+ }
+
+ return null;
+ }
+
+ private void ReportProgress(SyncProgress progress)
+ {
+ OnProgress?.Invoke(progress);
+ }
+}
+
+public class SyncOptions
+{
+ public string BulkDataType { get; set; } = "unique_artwork";
+ public string ImageSize { get; set; } = "normal";
+ public int BatchSize { get; set; } = 100;
+ public int RateLimitMs { get; set; } = 50;
+ public int MaxRetries { get; set; } = 3;
+ public bool ForceFullSync { get; set; }
+ public bool IncludeNonEnglish { get; set; }
+ public bool StopOnError { get; set; }
+}
+
+public class SyncResult
+{
+ public bool Success { get; set; }
+ public bool Skipped { get; set; }
+ public bool Cancelled { get; set; }
+ public int ProcessedCards { get; set; }
+ public int ErrorCount { get; set; }
+ public TimeSpan Duration { get; set; }
+ public string? Message { get; set; }
+ public string? Error { get; set; }
+ public List Errors { get; set; } = new();
+}
+
+public class SyncProgress
+{
+ public SyncStage Stage { get; set; }
+ public int ProcessedCards { get; set; }
+ public int TotalCards { get; set; }
+ public string? Message { get; set; }
+
+ public float Percentage => TotalCards > 0 ? (float)ProcessedCards / TotalCards * 100 : 0;
+}
+
+public enum SyncStage
+{
+ Initializing,
+ Downloading,
+ Processing,
+ Complete,
+ Error
+}
diff --git a/src/Scry.Core/Scry.Core.csproj b/src/Scry.Core/Scry.Core.csproj
new file mode 100644
index 0000000..e2e0044
--- /dev/null
+++ b/src/Scry.Core/Scry.Core.csproj
@@ -0,0 +1,15 @@
+
+
+
+ net10.0
+ enable
+ enable
+ Scry.Core
+
+
+
+
+
+
+
+
diff --git a/src/Scry.Core/Scryfall/ScryfallClient.cs b/src/Scry.Core/Scryfall/ScryfallClient.cs
new file mode 100644
index 0000000..62e8551
--- /dev/null
+++ b/src/Scry.Core/Scryfall/ScryfallClient.cs
@@ -0,0 +1,175 @@
+using System.IO.Compression;
+using System.Text.Json;
+using Scry.Core.Models;
+
+namespace Scry.Core.Scryfall;
+
+public class ScryfallClient : IDisposable
+{
+ private readonly HttpClient _httpClient;
+ private const string BulkDataUrl = "https://api.scryfall.com/bulk-data";
+
+ public ScryfallClient(HttpClient? httpClient = null)
+ {
+ _httpClient = httpClient ?? new HttpClient();
+ _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Scry/1.0 (MTG Card Scanner)");
+ }
+
+ public async Task GetBulkDataInfoAsync(string type = "unique_artwork", CancellationToken ct = default)
+ {
+ var response = await _httpClient.GetStringAsync(BulkDataUrl, ct);
+ var bulkData = JsonSerializer.Deserialize(response, JsonOptions);
+
+ return bulkData?.Data?.FirstOrDefault(d =>
+ d.Type?.Equals(type, StringComparison.OrdinalIgnoreCase) == true);
+ }
+
+ public async IAsyncEnumerable StreamBulkDataAsync(
+ string downloadUri,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
+ {
+ using var response = await _httpClient.GetAsync(downloadUri, HttpCompletionOption.ResponseHeadersRead, ct);
+ response.EnsureSuccessStatusCode();
+
+ await using var stream = await response.Content.ReadAsStreamAsync(ct);
+
+ Stream dataStream = stream;
+ if (downloadUri.EndsWith(".gz", StringComparison.OrdinalIgnoreCase))
+ {
+ dataStream = new GZipStream(stream, CompressionMode.Decompress);
+ }
+
+ await foreach (var card in JsonSerializer.DeserializeAsyncEnumerable(dataStream, JsonOptions, ct))
+ {
+ if (card != null)
+ {
+ yield return card;
+ }
+ }
+ }
+
+ public async Task DownloadImageAsync(string imageUri, CancellationToken ct = default)
+ {
+ try
+ {
+ return await _httpClient.GetByteArrayAsync(imageUri, ct);
+ }
+ catch (HttpRequestException)
+ {
+ return null;
+ }
+ }
+
+ public void Dispose()
+ {
+ _httpClient.Dispose();
+ }
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ PropertyNameCaseInsensitive = true
+ };
+}
+
+public record BulkDataResponse
+{
+ public List? Data { get; init; }
+}
+
+public record BulkDataInfo
+{
+ public string? Id { get; init; }
+ public string? Type { get; init; }
+ public string? Name { get; init; }
+ public string? DownloadUri { get; init; }
+ public DateTime? UpdatedAt { get; init; }
+ public long? Size { get; init; }
+}
+
+public record ScryfallCard
+{
+ public string? Id { get; init; }
+ public string? Name { get; init; }
+ public string? Set { get; init; }
+ public string? SetName { get; init; }
+ public string? CollectorNumber { get; init; }
+ public string? Rarity { get; init; }
+ public string? ManaCost { get; init; }
+ public string? TypeLine { get; init; }
+ public string? OracleText { get; init; }
+ public string? Lang { get; init; }
+ public string? Artist { get; init; }
+ public ImageUris? ImageUris { get; init; }
+ public Prices? Prices { get; init; }
+ public List? CardFaces { get; init; }
+}
+
+public record ImageUris
+{
+ public string? Small { get; init; }
+ public string? Normal { get; init; }
+ public string? Large { get; init; }
+ public string? Png { get; init; }
+ public string? ArtCrop { get; init; }
+ public string? BorderCrop { get; init; }
+}
+
+public record Prices
+{
+ public string? Usd { get; init; }
+ public string? UsdFoil { get; init; }
+ public string? Eur { get; init; }
+}
+
+public record CardFace
+{
+ public string? Name { get; init; }
+ public string? ManaCost { get; init; }
+ public string? TypeLine { get; init; }
+ public string? OracleText { get; init; }
+ public ImageUris? ImageUris { get; init; }
+}
+
+public static class ScryfallCardExtensions
+{
+ public static Card ToCard(this ScryfallCard scryfall)
+ {
+ var imageUris = scryfall.ImageUris ?? scryfall.CardFaces?.FirstOrDefault()?.ImageUris;
+
+ return new Card
+ {
+ Id = scryfall.Id ?? Guid.NewGuid().ToString(),
+ Name = scryfall.Name ?? "Unknown",
+ SetCode = scryfall.Set,
+ SetName = scryfall.SetName,
+ CollectorNumber = scryfall.CollectorNumber,
+ Rarity = scryfall.Rarity,
+ ManaCost = scryfall.ManaCost,
+ TypeLine = scryfall.TypeLine,
+ OracleText = scryfall.OracleText,
+ ImageUri = imageUris?.Normal,
+ ImageUriSmall = imageUris?.Small,
+ ImageUriLarge = imageUris?.Large ?? imageUris?.Png,
+ Artist = scryfall.Artist,
+ Lang = scryfall.Lang,
+ PriceUsd = decimal.TryParse(scryfall.Prices?.Usd, out var usd) ? usd : null,
+ PriceUsdFoil = decimal.TryParse(scryfall.Prices?.UsdFoil, out var foil) ? foil : null
+ };
+ }
+
+ public static string? GetImageUri(this ScryfallCard card, string size = "normal")
+ {
+ var uris = card.ImageUris ?? card.CardFaces?.FirstOrDefault()?.ImageUris;
+
+ return size.ToLowerInvariant() switch
+ {
+ "small" => uris?.Small,
+ "large" => uris?.Large,
+ "png" => uris?.Png,
+ "art_crop" => uris?.ArtCrop,
+ "border_crop" => uris?.BorderCrop,
+ _ => uris?.Normal
+ };
+ }
+}
diff --git a/test/Scry.Tests/CardHashDatabaseTests.cs b/test/Scry.Tests/CardHashDatabaseTests.cs
new file mode 100644
index 0000000..e6d8944
--- /dev/null
+++ b/test/Scry.Tests/CardHashDatabaseTests.cs
@@ -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)
+ {
+ }
+ }
+}
diff --git a/test/Scry.Tests/CardRecognitionTests.cs b/test/Scry.Tests/CardRecognitionTests.cs
new file mode 100644
index 0000000..35130fb
--- /dev/null
+++ b/test/Scry.Tests/CardRecognitionTests.cs
@@ -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)
+ {
+ }
+ }
+}
diff --git a/test/Scry.Tests/ImagePreprocessorTests.cs b/test/Scry.Tests/ImagePreprocessorTests.cs
new file mode 100644
index 0000000..8498443
--- /dev/null
+++ b/test/Scry.Tests/ImagePreprocessorTests.cs
@@ -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);
+ }
+}
diff --git a/test/Scry.Tests/PerceptualHashTests.cs b/test/Scry.Tests/PerceptualHashTests.cs
new file mode 100644
index 0000000..6553bf5
--- /dev/null
+++ b/test/Scry.Tests/PerceptualHashTests.cs
@@ -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;
+ }
+}
diff --git a/test/Scry.Tests/RobustnessAnalysisTests.cs b/test/Scry.Tests/RobustnessAnalysisTests.cs
new file mode 100644
index 0000000..faa6810
--- /dev/null
+++ b/test/Scry.Tests/RobustnessAnalysisTests.cs
@@ -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;
+
+///
+/// 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 { }
+ }
+}
diff --git a/test/Scry.Tests/Scry.Tests.csproj b/test/Scry.Tests/Scry.Tests.csproj
new file mode 100644
index 0000000..f53b84d
--- /dev/null
+++ b/test/Scry.Tests/Scry.Tests.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/test/Scry.Tests/TestImageBenchmarks.cs b/test/Scry.Tests/TestImageBenchmarks.cs
new file mode 100644
index 0000000..a164e40
--- /dev/null
+++ b/test/Scry.Tests/TestImageBenchmarks.cs
@@ -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();
+ 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();
+ 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();
+
+ 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 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();
+ }
+}
diff --git a/tools/DbGenerator/DbGenerator.csproj b/tools/DbGenerator/DbGenerator.csproj
new file mode 100644
index 0000000..4884da9
--- /dev/null
+++ b/tools/DbGenerator/DbGenerator.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/DbGenerator/Program.cs b/tools/DbGenerator/Program.cs
new file mode 100644
index 0000000..7857845
--- /dev/null
+++ b/tools/DbGenerator/Program.cs
@@ -0,0 +1,377 @@
+using Scry.Core.Data;
+using Scry.Core.Imaging;
+using Scry.Core.Models;
+using Scry.Core.Scryfall;
+using SkiaSharp;
+
+// Generate a card hash database from Scryfall images
+// Usage: dotnet run -- [--count N] [--include-test-cards] [--force]
+
+var outputDb = args.Length > 0 ? args[0] : "card_hashes.db";
+var maxCards = 500;
+var includeTestCards = true;
+var forceRebuild = false;
+
+// Parse arguments
+for (var i = 0; i < args.Length; i++)
+{
+ if (args[i] == "--count" && i + 1 < args.Length && int.TryParse(args[i + 1], out var parsedCount))
+ {
+ maxCards = parsedCount;
+ i++;
+ }
+ else if (args[i] == "--include-test-cards")
+ {
+ includeTestCards = true;
+ }
+ else if (args[i] == "--no-test-cards")
+ {
+ includeTestCards = false;
+ }
+ else if (args[i] == "--force")
+ {
+ forceRebuild = true;
+ }
+}
+
+Console.WriteLine($"Generating hash database with up to {maxCards} cards");
+Console.WriteLine($"Output: {outputDb}");
+Console.WriteLine($"Include test cards: {includeTestCards}");
+Console.WriteLine($"Force rebuild: {forceRebuild}");
+Console.WriteLine();
+
+// Cards that should be included for testing with preferred sets
+// Key: card name, Value: preferred set codes (first match wins) or empty for any
+var priorityCardsWithSets = new Dictionary(StringComparer.OrdinalIgnoreCase)
+{
+ // From reference_alpha/ - prefer LEA (Alpha) or LEB (Beta) for classic look
+ ["Ancestral Recall"] = ["lea", "leb"],
+ ["Badlands"] = ["lea", "leb"],
+ ["Balance"] = ["lea", "leb"],
+ ["Bayou"] = ["lea", "leb"],
+ ["Birds of Paradise"] = ["lea", "leb"],
+ ["Black Lotus"] = ["lea", "leb"],
+ ["Channel"] = ["lea", "leb"],
+ ["Chaos Orb"] = ["lea", "leb"],
+ ["Clone"] = ["lea", "leb"],
+ ["Control Magic"] = ["lea", "leb"],
+ ["Counterspell"] = ["lea", "leb"],
+ ["Dark Ritual"] = ["lea", "leb"],
+ ["Demonic Tutor"] = ["lea", "leb"],
+ ["Disenchant"] = ["lea", "leb"],
+ ["Fireball"] = ["lea", "leb"],
+ ["Force of Nature"] = ["lea", "leb"],
+ ["Fork"] = ["lea", "leb"],
+ ["Giant Growth"] = ["lea", "leb"],
+ ["Hypnotic Specter"] = ["lea", "leb"],
+ ["Lightning Bolt"] = ["lea", "leb"],
+ ["Llanowar Elves"] = ["lea", "leb"],
+ ["Mahamoti Djinn"] = ["lea", "leb"],
+ ["Mind Twist"] = ["lea", "leb"],
+ ["Mox Emerald"] = ["lea", "leb"],
+ ["Mox Jet"] = ["lea", "leb"],
+ ["Mox Pearl"] = ["lea", "leb"],
+ ["Mox Ruby"] = ["lea", "leb"],
+ ["Mox Sapphire"] = ["lea", "leb"],
+ ["Nightmare"] = ["lea", "leb"],
+ ["Plateau"] = ["lea", "leb"],
+ ["Regrowth"] = ["lea", "leb"],
+ ["Rock Hydra"] = ["lea", "leb"],
+ ["Royal Assassin"] = ["lea", "leb"],
+ ["Savannah"] = ["lea", "leb"],
+ ["Scrubland"] = ["lea", "leb"],
+ ["Serra Angel"] = ["lea", "leb"],
+ ["Shivan Dragon"] = ["lea", "leb"],
+ ["Sol Ring"] = ["lea", "leb"],
+ ["Swords to Plowshares"] = ["lea", "leb"],
+ ["Taiga"] = ["lea", "leb"],
+ ["Time Walk"] = ["lea", "leb"],
+ ["Timetwister"] = ["lea", "leb"],
+ ["Tropical Island"] = ["lea", "leb"],
+ ["Tundra"] = ["lea", "leb"],
+ ["Underground Sea"] = ["lea", "leb"],
+ ["Wheel of Fortune"] = ["lea", "leb"],
+ ["Wrath of God"] = ["lea", "leb"],
+
+ // From reference/ - any set is fine
+ ["Brainstorm"] = [],
+ ["Force of Will"] = [],
+ ["Griselbrand"] = [],
+ ["Lotus Petal"] = [],
+ ["Ponder"] = [],
+ ["Show and Tell"] = [],
+ ["Volcanic Island"] = [],
+ ["Wasteland"] = [],
+
+ // From single_cards/ - any set is fine
+ ["Adanto Vanguard"] = [],
+ ["Angel of Sanctions"] = [],
+ ["Attunement"] = [],
+ ["Avaricious Dragon"] = [],
+ ["Burgeoning"] = [],
+ ["Jarad, Golgari Lich Lord"] = [],
+ ["Meletis Charlatan"] = [],
+ ["Mindstab Thrull"] = [],
+ ["Pacifism"] = [],
+ ["Platinum Angel"] = [],
+ ["Queen Marchesa"] = [],
+ ["Spellseeker"] = [],
+ ["Tarmogoyf"] = [],
+ ["Thought Reflection"] = [],
+ ["Unsummon"] = [],
+
+ // From varying_quality - prefer older sets
+ ["Dragon Whelp"] = ["lea", "leb"],
+ ["Evil Eye of Orms-by-Gore"] = [],
+ ["Instill Energy"] = ["lea", "leb"],
+
+ // Popular cards for general testing
+ ["Lightning Helix"] = [],
+ ["Path to Exile"] = [],
+ ["Thoughtseize"] = [],
+ ["Fatal Push"] = [],
+ ["Snapcaster Mage"] = [],
+ ["Jace, the Mind Sculptor"] = [],
+ ["Liliana of the Veil"] = [],
+ ["Noble Hierarch"] = [],
+ ["Goblin Guide"] = [],
+ ["Eidolon of the Great Revel"] = [],
+};
+
+var priorityCards = new HashSet(priorityCardsWithSets.Keys, StringComparer.OrdinalIgnoreCase);
+
+// Force rebuild if requested
+if (forceRebuild && File.Exists(outputDb))
+{
+ Console.WriteLine("Force rebuild requested, removing existing database...");
+ File.Delete(outputDb);
+}
+
+using var httpClient = new HttpClient();
+httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Scry/1.0 (MTG Card Scanner - Database Generator)");
+
+using var scryfallClient = new ScryfallClient(httpClient);
+using var db = new CardHashDatabase(outputDb);
+
+// Check existing database state
+var existingCardIds = await db.GetExistingCardIdsAsync();
+var existingCardNames = await db.GetExistingCardNamesAsync();
+var existingCount = await db.GetHashCountAsync();
+var storedScryfallDate = await db.GetMetadataAsync("scryfall_updated_at");
+
+Console.WriteLine($"Existing database has {existingCount} cards");
+
+Console.WriteLine("Fetching bulk data info from Scryfall...");
+var bulkInfo = await scryfallClient.GetBulkDataInfoAsync("unique_artwork");
+
+if (bulkInfo?.DownloadUri == null)
+{
+ Console.WriteLine("Failed to get bulk data info from Scryfall");
+ return 1;
+}
+
+Console.WriteLine($"Scryfall data last updated: {bulkInfo.UpdatedAt}");
+
+// Check if we need to update at all
+var scryfallDateStr = bulkInfo.UpdatedAt?.ToString("O") ?? "";
+var needsUpdate = existingCount == 0 ||
+ storedScryfallDate != scryfallDateStr ||
+ existingCount < maxCards;
+
+// Also check if all priority cards exist
+var missingPriorityCards = includeTestCards
+ ? priorityCards.Where(c => !existingCardNames.Contains(c)).ToList()
+ : new List();
+
+if (missingPriorityCards.Count > 0)
+{
+ Console.WriteLine($"Missing {missingPriorityCards.Count} priority cards");
+ needsUpdate = true;
+}
+
+if (!needsUpdate)
+{
+ Console.WriteLine("Database is up-to-date, no changes needed");
+ return 0;
+}
+
+Console.WriteLine($"Downloading card data from: {bulkInfo.DownloadUri}");
+Console.WriteLine();
+
+var newHashes = new List();
+var processed = 0;
+var errors = 0;
+var skipped = 0;
+var priorityFound = 0;
+var priorityNeeded = includeTestCards ? priorityCards.Count : 0;
+
+// Track which priority cards we've already found with their set
+// Key: card name, Value: set code
+var foundPriorityWithSet = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+// Track deferred priority cards that might get a better set later
+// Key: card name, Value: list of (cardHash, setCode) candidates
+var deferredPriority = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+
+// Helper to check if a set is preferred for a priority card
+bool IsPreferredSet(string cardName, string setCode)
+{
+ if (!priorityCardsWithSets.TryGetValue(cardName, out var preferredSets))
+ return false;
+ return preferredSets.Length == 0 || preferredSets.Contains(setCode, StringComparer.OrdinalIgnoreCase);
+}
+
+await foreach (var card in scryfallClient.StreamBulkDataAsync(bulkInfo.DownloadUri))
+{
+ // Skip non-English cards
+ if (card.Lang != "en")
+ continue;
+
+ var imageUri = card.GetImageUri("normal");
+ if (string.IsNullOrEmpty(imageUri))
+ continue;
+
+ var cardId = card.Id ?? Guid.NewGuid().ToString();
+ var cardName = card.Name ?? "Unknown";
+ var setCode = card.Set ?? "???";
+
+ // Check if this card already exists in the database
+ if (existingCardIds.Contains(cardId))
+ {
+ skipped++;
+ continue;
+ }
+
+ // Check if this is a priority card we might need
+ var isPriorityCard = includeTestCards && priorityCards.Contains(cardName);
+ var isPreferred = isPriorityCard && IsPreferredSet(cardName, setCode);
+
+ // If this priority card already found with preferred set, skip
+ if (isPriorityCard && foundPriorityWithSet.TryGetValue(cardName, out var existingSet))
+ {
+ if (IsPreferredSet(cardName, existingSet))
+ {
+ // Already have preferred version
+ continue;
+ }
+ // We have a non-preferred version; if this is preferred, we'll replace
+ if (!isPreferred)
+ {
+ continue;
+ }
+ }
+
+ // Calculate how many slots we have left
+ var totalCards = existingCount + newHashes.Count;
+ var priorityRemaining = priorityNeeded - foundPriorityWithSet.Count;
+ var slotsForNonPriority = maxCards - priorityRemaining;
+
+ // Skip if we have enough non-priority cards and this isn't priority
+ if (!isPriorityCard && totalCards >= slotsForNonPriority)
+ continue;
+
+ // Download and process image
+ try
+ {
+ Console.Write($"[{processed + 1}] {cardName}... ");
+
+ var imageBytes = await httpClient.GetByteArrayAsync(imageUri);
+ using var bitmap = SKBitmap.Decode(imageBytes);
+
+ if (bitmap == null)
+ {
+ Console.WriteLine("decode failed");
+ errors++;
+ continue;
+ }
+
+ // Apply CLAHE preprocessing and compute hash
+ using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
+ var hash = PerceptualHash.ComputeColorHash(preprocessed);
+
+ var cardHash = new CardHash
+ {
+ CardId = cardId,
+ Name = cardName,
+ SetCode = setCode,
+ CollectorNumber = card.CollectorNumber,
+ Hash = hash,
+ ImageUri = imageUri
+ };
+
+ newHashes.Add(cardHash);
+
+ if (isPriorityCard)
+ {
+ foundPriorityWithSet[cardName] = setCode;
+ priorityFound++;
+ Console.WriteLine($"OK (priority, {setCode})");
+ }
+ else
+ {
+ Console.WriteLine($"OK ({setCode})");
+ }
+
+ processed++;
+
+ // Check if we have enough cards
+ var foundAllPriority = foundPriorityWithSet.Count >= priorityNeeded;
+ if (existingCount + newHashes.Count >= maxCards && foundAllPriority)
+ {
+ Console.WriteLine($"\nReached {maxCards} cards limit with all priority cards");
+ break;
+ }
+
+ // Rate limit to be nice to Scryfall
+ await Task.Delay(50);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"error: {ex.Message}");
+ errors++;
+ }
+}
+
+Console.WriteLine();
+Console.WriteLine($"Skipped (already in DB): {skipped}");
+Console.WriteLine($"Newly processed: {processed} cards");
+Console.WriteLine($"New priority cards found: {priorityFound}");
+Console.WriteLine($"Total priority cards: {foundPriorityWithSet.Count}/{priorityNeeded}");
+Console.WriteLine($"Errors: {errors}");
+Console.WriteLine();
+
+if (newHashes.Count > 0)
+{
+ Console.WriteLine($"Inserting {newHashes.Count} new hashes into database...");
+ await db.InsertHashBatchAsync(newHashes);
+}
+
+await db.SetMetadataAsync("generated_at", DateTime.UtcNow.ToString("O"));
+await db.SetMetadataAsync("scryfall_updated_at", scryfallDateStr);
+
+var finalCount = await db.GetHashCountAsync();
+await db.SetMetadataAsync("card_count", finalCount.ToString());
+
+Console.WriteLine($"Database now has {finalCount} cards: {outputDb}");
+
+// Report missing priority cards
+if (includeTestCards)
+{
+ var missing = priorityCards.Where(c => !foundPriorityWithSet.ContainsKey(c)).ToList();
+
+ if (missing.Count > 0)
+ {
+ Console.WriteLine();
+ Console.WriteLine($"Missing priority cards ({missing.Count}):");
+ foreach (var name in missing.Take(20))
+ {
+ Console.WriteLine($" - {name}");
+ }
+ if (missing.Count > 20)
+ {
+ Console.WriteLine($" ... and {missing.Count - 20} more");
+ }
+ }
+}
+
+return 0;