diff --git a/.gitignore b/.gitignore index 29a138d..6facb8c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ Thumbs.db *.csv *.dlens *.apk +debug/ diff --git a/.justfile b/.justfile index e62b769..055de56 100644 --- a/.justfile +++ b/.justfile @@ -1,19 +1,84 @@ -# Scry build recipes +# Scry development commands -# Default recipe - show available commands -default: - @just --list +set shell := ["C:/Program Files/Git/usr/bin/bash.exe", "-c"] +set unstable := true -# 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 +# Android SDK paths -# Clean build artifacts -clean: - rm -rf bin obj dist +android_sdk := replace(env('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" + +[private] +@default: + just --list + +# Start emulator in background +emu camera="virtual": + {{ emulator }} -avd Pixel_6 {{ if camera == "virtual" { camera_virtual } else { camera_webcam } }} -no-snapshot-load -gpu host + +# Kill the running emulator +emu-kill: + {{ adb }} emu kill + +# Wait for emulator to fully boot (timeout after 2 minutes) +[script] +emu-wait: + # Wait for Android emulator to boot with timeout + TIMEOUT=120 + + echo "Waiting for emulator to boot..." + + for ((i=TIMEOUT; i>0; i--)); do + if [ "$({{ adb }} shell getprop sys.boot_completed 2>/dev/null)" = "1" ]; then + echo "Emulator ready" + exit 0 + fi + sleep 1 + done + + echo "Emulator failed to boot within 2 minutes" + exit 1 + +# 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: (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 + @echo "Completed generating the database" + +# Full workflow: start emulator, wait, run with hot reload +dev: + dotnet watch --project src/Scry.App -f net10.0-android diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a04e7bf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,291 @@ +# Agent Instructions + +## Overview + +Scry is a Magic: The Gathering card scanner app built with .NET MAUI. It uses perceptual hashing to match photographed cards against a database of known card images from Scryfall. + +**Key components:** +- Mobile scanning app (MAUI/Android) +- Card recognition via perceptual hashing (not OCR) +- SQLite database with pre-computed hashes +- Scryfall API integration for card data + +## Build Commands + +Use `just` commands (defined in `.justfile`): + +| Task | Command | Notes | +|------|---------|-------| +| Build project | `just build` | Builds Android debug | +| Run tests | `just test` | Runs xUnit tests | +| Generate card database | `just gen-db` | Downloads from Scryfall, computes hashes | +| Publish app | `just publish` | Creates release APK | +| Hot reload dev | `just dev` | Uses `dotnet watch` | +| Start emulator | `just emu` | Virtual camera with Serra Angel | +| Install to device | `just install` | Installs release APK | + +### Database Generator Options + +```bash +just gen-db # Default: 500 cards with test images +dotnet run --project tools/DbGenerator -- -c 1000 # More cards +dotnet run --project tools/DbGenerator -- --force # Rebuild from scratch +dotnet run --project tools/DbGenerator -- --no-test-cards # Skip priority test cards +``` + +## Project Structure + +``` +src/ +├── Scry.App/ # MAUI mobile app (Android target) +│ ├── Views/ # XAML pages (ScanPage, CollectionPage, etc.) +│ ├── ViewModels/ # MVVM ViewModels using CommunityToolkit.Mvvm +│ ├── Services/ # App-layer services (ICardRecognitionService, ICardRepository) +│ ├── Converters/ # XAML value converters +│ ├── Models/ # App-specific models (CollectionEntry) +│ └── Resources/Raw/ # Bundled card_hashes.db +│ +└── Scry.Core/ # Platform-independent core library + ├── Recognition/ # CardRecognitionService, RecognitionOptions + ├── Imaging/ # PerceptualHash, ImagePreprocessor, CardDetector + ├── Data/ # CardDatabase (SQLite) + ├── Models/ # Card, Oracle, Set, ScanResult + └── Scryfall/ # ScryfallClient for API/bulk data + +test/ +└── Scry.Tests/ # xUnit tests + +tools/ +└── DbGenerator/ # CLI tool to generate card_hashes.db + +TestImages/ # Test images organized by category +├── reference_alpha/ # Alpha/Beta cards for testing +├── single_cards/ # Individual card photos +├── varying_quality/ # Different lighting/quality +├── hands/ # Cards held in hand +├── foil/ # Foil cards with glare +└── ... # More categories +``` + +## Architecture + +### Recognition Pipeline + +``` +Camera Image + │ + ▼ +┌─────────────────────┐ +│ CardDetector │ ← Edge detection, find card quad +│ (optional) │ +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ PerspectiveCorrection│ ← Warp to rectangle +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ ImagePreprocessor │ ← CLAHE for lighting normalization +│ (ApplyClahe) │ +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ PerceptualHash │ ← Compute 192-bit color hash (24 bytes) +│ (ComputeColorHash) │ +└─────────────────────┘ + │ + ▼ +┌─────────────────────┐ +│ CardRecognitionService│ ← Hamming distance match against DB +└─────────────────────┘ +``` + +### Data Model + +Three-table schema mirroring Scryfall: + +- **oracles** - Abstract game cards (one per unique card name) +- **sets** - MTG sets with metadata +- **cards** - Printings with perceptual hashes (one per unique artwork) + +The `Card` model includes denormalized Oracle fields for convenience. + +### Key Classes + +| Class | Purpose | +|-------|---------| +| `CardRecognitionService` | Main recognition logic, caches DB, handles rotation matching | +| `PerceptualHash` | DCT-based color hash (192-bit = 8 bytes × 3 RGB channels) | +| `ImagePreprocessor` | CLAHE, resize, grayscale conversions | +| `CardDetector` | Edge detection + contour analysis to find card boundaries | +| `PerspectiveCorrection` | Warp detected quad to rectangle | +| `CardDatabase` | SQLite wrapper with batch insert, queries | +| `ScryfallClient` | Bulk data streaming, image downloads | + +## Code Conventions + +### General + +- **Target**: .NET 10.0 (net10.0-android for app, net10.0 for Core/tools) +- **Nullable**: Enabled everywhere (`enable`) +- **Warnings as errors**: `true` +- **Central package management**: Versions in `Directory.Packages.props` + +### C# Style + +- Records for data models (`record Card`, `record ScanResult`) +- `required` properties for non-nullable required fields +- Extension methods for conversions (`ScryfallCard.ToCard()`) +- Static classes for pure functions (`PerceptualHash`, `ImagePreprocessor`) +- `using` declarations (not `using` blocks) for disposables +- File-scoped namespaces +- Primary constructors where appropriate +- `CancellationToken` parameter on all async methods + +### MVVM (App layer) + +- `CommunityToolkit.Mvvm` for source generators +- `[ObservableProperty]` attributes for bindable properties +- `[RelayCommand]` for commands +- ViewModels in `Scry.ViewModels` namespace + +### Naming + +- Services: `ICardRecognitionService`, `CardRecognitionService` +- Database methods: `GetCardsWithHashAsync`, `InsertCardBatchAsync` +- Hash methods: `ComputeColorHash`, `HammingDistance` +- Test methods: `RecognizeAsync_ExactMatch_ReturnsSuccess` + +## Testing + +Tests are in `test/Scry.Tests` using xUnit. + +```bash +just test # Run all tests +dotnet test --filter "FullyQualifiedName~PerceptualHash" # Filter by name +``` + +### Test Categories + +| Test Class | Tests | +|------------|-------| +| `PerceptualHashTests` | Hash computation, Hamming distance | +| `CardRecognitionTests` | End-to-end recognition | +| `CardDatabaseTests` | SQLite CRUD operations | +| `ImagePreprocessorTests` | CLAHE, resize | +| `RobustnessAnalysisTests` | Multiple image variations | + +### Test Images + +TestImages directory contains categorized reference images: +- `reference_alpha/` - Alpha/Beta cards (matching DbGenerator priority cards) +- `single_cards/` - Clean single card photos +- `varying_quality/` - Different lighting/blur conditions + +## Key Algorithms + +### Perceptual Hash (pHash) + +Color-aware 192-bit hash: +1. Resize to 32×32 +2. For each RGB channel: + - Compute 2D DCT + - Extract 8×8 low-frequency coefficients (skip DC) + - Compare each to median → 63 bits per channel +3. Concatenate R, G, B hashes → 24 bytes (192 bits) + +Matching uses Hamming distance with threshold ≤25 bits and minimum confidence 85%. + +### CLAHE (Contrast Limited Adaptive Histogram Equalization) + +Applied in LAB color space to L channel only: +- Tile-based histogram equalization (8×8 tiles) +- Clip limit prevents over-amplification of noise +- Bilinear interpolation between tiles for smooth output + +### Card Detection + +Pure SkiaSharp implementation: +1. Grayscale → Gaussian blur → Canny edge detection +2. Contour tracing via flood fill +3. Douglas-Peucker simplification → Convex hull +4. Find best quadrilateral matching MTG aspect ratio (88/63 ≈ 1.397) +5. Order corners: top-left, top-right, bottom-right, bottom-left + +## Debug Mode + +Set `RecognitionOptions.DebugOutputDirectory` to save pipeline stages: +- `01_input.png` - Original image +- `02_detection.png` - Card detection visualization +- `03_perspective_corrected.png` - Warped card +- `05_clahe_*.png` - After CLAHE preprocessing + +On Android: `/sdcard/Download/scry-debug` (pull with `adb pull`) + +## Dependencies + +### Core Library (Scry.Core) + +- **SkiaSharp** - Image processing, DCT, edge detection +- **Microsoft.Data.Sqlite** - SQLite database +- **Microsoft.Extensions.Options** - Options pattern + +### App (Scry.App) + +- **CommunityToolkit.Maui** - MAUI extensions +- **CommunityToolkit.Maui.Camera** - Camera integration +- **CommunityToolkit.Mvvm** - MVVM source generators + +### DbGenerator Tool + +- **Spectre.Console** / **Spectre.Console.Cli** - Rich terminal UI + +## Common Tasks + +### Adding a New Card to Priority Test Set + +1. Add image to `TestImages/reference_alpha/` or appropriate folder +2. Add entry to `GenerateCommand.PriorityCardsWithSets` dictionary +3. Run `just gen-db` to regenerate database + +### Debugging Recognition Failures + +1. Enable debug output in `MauiProgram.cs`: + ```csharp + options.DebugOutputDirectory = "/sdcard/Download/scry-debug"; + ``` +2. Run recognition +3. Pull debug images: `adb pull /sdcard/Download/scry-debug` +4. Compare `05_clahe_*.png` with reference images in database + +### Modifying Hash Algorithm + +1. Update `PerceptualHash.ComputeColorHash()` +2. Update `CardRecognitionService.ColorHashBits` constant +3. Regenerate database: `just gen-db --force` +4. Run tests: `just test` + +## Gotchas + +1. **Hash size is 24 bytes (192 bits)** - 3 RGB channels × 8 bytes each +2. **Confidence threshold is 85%** - Configurable in `CardRecognitionService.MinConfidence` +3. **Card detection is optional** - Controlled by `RecognitionOptions.EnableCardDetection` +4. **Rotation matching tries 4 orientations** - Controlled by `RecognitionOptions.EnableRotationMatching` +5. **Database is bundled in APK** - Copied on first run to app data directory +6. **Multi-face cards** - Only front face image is used for hashing +7. **Rate limiting** - DbGenerator uses 50ms delay between Scryfall image downloads + +## CI/CD + +Forgejo Actions workflow (`.forgejo/workflows/release.yml`): +- Builds for win-x64, linux-x64, osx-x64 +- Creates "standard" and "embedded" (with APK) variants +- Publishes to Forgejo releases + +## External Resources + +- [Scryfall API](https://scryfall.com/docs/api) - Card data source +- [CARD_RECOGNITION.md](docs/CARD_RECOGNITION.md) - Detailed architecture doc 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..baa520b --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,29 @@ + + + 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..b788b81 --- /dev/null +++ b/docs/CARD_RECOGNITION.md @@ -0,0 +1,466 @@ +# 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 + +The schema mirrors Scryfall's data model with three main tables: + +```sql +-- Abstract game cards (oracle) +CREATE TABLE oracles ( + id TEXT PRIMARY KEY, -- Scryfall oracle_id + name TEXT NOT NULL, + mana_cost TEXT, + cmc REAL, + type_line TEXT, + oracle_text TEXT, + colors TEXT, -- JSON array + color_identity TEXT, -- JSON array + keywords TEXT, -- JSON array + reserved INTEGER DEFAULT 0, + legalities TEXT, -- JSON object + power TEXT, + toughness TEXT +); + +-- MTG sets +CREATE TABLE sets ( + id TEXT PRIMARY KEY, -- Scryfall set id + code TEXT NOT NULL UNIQUE, -- e.g., "lea", "mh2" + name TEXT NOT NULL, -- e.g., "Limited Edition Alpha" + set_type TEXT, -- e.g., "expansion", "core" + released_at TEXT, + card_count INTEGER, + icon_svg_uri TEXT, + digital INTEGER DEFAULT 0, + parent_set_code TEXT, + block TEXT +); + +-- Card printings with perceptual hashes +CREATE TABLE cards ( + id TEXT PRIMARY KEY, -- Scryfall card ID (printing) + oracle_id TEXT NOT NULL, -- FK to oracles + set_id TEXT NOT NULL, -- FK to sets + set_code TEXT, + name TEXT NOT NULL, + collector_number TEXT, + rarity TEXT, + artist TEXT, + illustration_id TEXT, -- Same across printings with identical art + image_uri TEXT, + hash BLOB, -- Perceptual hash for matching + lang TEXT DEFAULT 'en', + prices_usd REAL, + prices_usd_foil REAL, + FOREIGN KEY (oracle_id) REFERENCES oracles(id), + FOREIGN KEY (set_id) REFERENCES sets(id) +); + +CREATE INDEX idx_cards_oracle_id ON cards(oracle_id); +CREATE INDEX idx_cards_set_id ON cards(set_id); +CREATE INDEX idx_cards_name ON cards(name); +``` + +### 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/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..ebeca2a --- /dev/null +++ b/src/Scry.App/MauiProgram.cs @@ -0,0 +1,96 @@ +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 CardDatabase(dbPath); + }); + + // Recognition options - configure debug output in DEBUG builds + builder.Services.Configure(options => + { +#if DEBUG && ANDROID + // Use Download folder for easy adb pull access + options.DebugOutputDirectory = "/sdcard/Download/scry-debug"; +#elif DEBUG + options.DebugOutputDirectory = "./debug"; +#endif + }); + 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) + { + try + { + using var bundledStream = FileSystem.OpenAppPackageFileAsync("card_hashes.db").GetAwaiter().GetResult(); + + if (File.Exists(targetPath)) + { + // Compare sizes - if bundled is larger, replace + var existingSize = new FileInfo(targetPath).Length; + var bundledSize = bundledStream.Length; + + if (bundledSize <= existingSize) + return; + + // Bundled db is larger, delete old and copy new + File.Delete(targetPath); + } + + using var fileStream = File.Create(targetPath); + bundledStream.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..7f18094 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 @@ + + + + + + + + + + + SCRY + 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..3a04d80 --- /dev/null +++ b/src/Scry.App/Scry.App.csproj @@ -0,0 +1,103 @@ + + + + + $(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..a2db310 --- /dev/null +++ b/src/Scry.App/Services/MockCardRecognitionService.cs @@ -0,0 +1,129 @@ +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 = "4cbc6901-6a4a-4d0a-83ea-7eefa3b35021", + OracleId = "orb-sol-ring", + SetId = "set-c21", + Name = "Sol Ring", + SetCode = "C21", + SetName = "Commander 2021", + CollectorNumber = "263", + 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", + PricesUsd = 1.50m + }, + new Card + { + Id = "e3285e6b-3e79-4d7c-bf96-d920f973b122", + OracleId = "orb-lightning-bolt", + SetId = "set-2xm", + Name = "Lightning Bolt", + SetCode = "2XM", + SetName = "Double Masters", + CollectorNumber = "129", + 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", + PricesUsd = 2.00m + }, + new Card + { + Id = "ce30f926-bc06-46ee-9f35-0c32659a1b1c", + OracleId = "orb-counterspell", + SetId = "set-cmr", + Name = "Counterspell", + SetCode = "CMR", + SetName = "Commander Legends", + CollectorNumber = "395", + 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", + PricesUsd = 1.25m + }, + new Card + { + Id = "73542c66-eb3a-46e8-a8f6-5f02087b28cf", + OracleId = "orb-llanowar-elves", + SetId = "set-m19", + Name = "Llanowar Elves", + SetCode = "M19", + SetName = "Core Set 2019", + CollectorNumber = "314", + 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", + PricesUsd = 0.25m + }, + new Card + { + Id = "b8dcd4a6-5c0b-4e1e-8e1d-2e2e5c1d6a1e", + OracleId = "orb-swords-to-plowshares", + SetId = "set-cmr", + Name = "Swords to Plowshares", + SetCode = "CMR", + SetName = "Commander Legends", + CollectorNumber = "387", + 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", + PricesUsd = 3.50m + }, + new Card + { + Id = "bd8fa327-dd41-4737-8f19-2cf5eb1f7c2e", + OracleId = "orb-black-lotus", + SetId = "set-lea", + Name = "Black Lotus", + SetCode = "LEA", + SetName = "Limited Edition Alpha", + CollectorNumber = "232", + 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", + PricesUsd = 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..407a913 --- /dev/null +++ b/src/Scry.App/ViewModels/SettingsViewModel.cs @@ -0,0 +1,36 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Scry.Core.Data; + +namespace Scry.ViewModels; + +public partial class SettingsViewModel : ObservableObject +{ + private readonly CardDatabase _database; + + [ObservableProperty] + private int _cardCount; + + [ObservableProperty] + private int _oracleCount; + + [ObservableProperty] + private int _setCount; + + [ObservableProperty] + private string? _statusMessage; + + public SettingsViewModel(CardDatabase database) + { + _database = database; + } + + [RelayCommand] + private async Task LoadAsync() + { + CardCount = await _database.GetCardCountAsync(); + OracleCount = await _database.GetOracleCountAsync(); + SetCount = await _database.GetSetCountAsync(); + StatusMessage = $"Database ready: {CardCount:N0} cards, {OracleCount:N0} oracles, {SetCount:N0} sets"; + } +} 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 @@ + + + + + + + + + + + + + + + + + + + + + +