diff --git a/.gitignore b/.gitignore
index 6facb8c..29a138d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,4 +21,3 @@ Thumbs.db
*.csv
*.dlens
*.apk
-debug/
diff --git a/.justfile b/.justfile
index 055de56..e62b769 100644
--- a/.justfile
+++ b/.justfile
@@ -1,84 +1,19 @@
-# Scry development commands
+# Scry build recipes
-set shell := ["C:/Program Files/Git/usr/bin/bash.exe", "-c"]
-set unstable := true
+# Default recipe - show available commands
+default:
+ @just --list
-# Android SDK paths
+# 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 := 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
+# Clean build artifacts
+clean:
+ rm -rf bin obj dist
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100644
index a04e7bf..0000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,291 +0,0 @@
-# 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
deleted file mode 100644
index b9cdb07..0000000
--- a/Directory.Build.props
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- net10.0
- enable
- enable
- true
- latest
- true
-
-
diff --git a/Directory.Packages.props b/Directory.Packages.props
deleted file mode 100644
index baa520b..0000000
--- a/Directory.Packages.props
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/Program.cs b/Program.cs
new file mode 100644
index 0000000..06ed5ea
--- /dev/null
+++ b/Program.cs
@@ -0,0 +1,369 @@
+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
new file mode 100644
index 0000000..b3f1ef6
--- /dev/null
+++ b/Scry.csproj
@@ -0,0 +1,33 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+ true
+ true
+ true
+ none
+ false
+
+
+
+ $(DefineConstants);EMBEDDED_APK
+
+
+
+
+ delver.apk
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Scry.slnx b/Scry.slnx
deleted file mode 100644
index 8a2e845..0000000
--- a/Scry.slnx
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/TestImages/README.md b/TestImages/README.md
deleted file mode 100644
index 799ba29..0000000
--- a/TestImages/README.md
+++ /dev/null
@@ -1,151 +0,0 @@
-# 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
deleted file mode 100644
index e973651..0000000
Binary files a/TestImages/angled/tilted_card_1.jpg and /dev/null differ
diff --git a/TestImages/angled/tilted_card_2.jpg b/TestImages/angled/tilted_card_2.jpg
deleted file mode 100644
index d1edf41..0000000
Binary files a/TestImages/angled/tilted_card_2.jpg and /dev/null differ
diff --git a/TestImages/augmented/augmented_1.jpg b/TestImages/augmented/augmented_1.jpg
deleted file mode 100644
index 1f75ac5..0000000
Binary files a/TestImages/augmented/augmented_1.jpg and /dev/null differ
diff --git a/TestImages/augmented/augmented_2.jpg b/TestImages/augmented/augmented_2.jpg
deleted file mode 100644
index 5eb1c4e..0000000
Binary files a/TestImages/augmented/augmented_2.jpg and /dev/null differ
diff --git a/TestImages/augmented/augmented_3.jpg b/TestImages/augmented/augmented_3.jpg
deleted file mode 100644
index 9d41972..0000000
Binary files a/TestImages/augmented/augmented_3.jpg and /dev/null differ
diff --git a/TestImages/augmented/augmented_4.jpg b/TestImages/augmented/augmented_4.jpg
deleted file mode 100644
index d4897ae..0000000
Binary files a/TestImages/augmented/augmented_4.jpg and /dev/null differ
diff --git a/TestImages/foil/dragonscale_foil.jpg b/TestImages/foil/dragonscale_foil.jpg
deleted file mode 100644
index ce1bbfc..0000000
Binary files a/TestImages/foil/dragonscale_foil.jpg and /dev/null differ
diff --git a/TestImages/foil/foil_curling_1.jpg b/TestImages/foil/foil_curling_1.jpg
deleted file mode 100644
index 6d20039..0000000
Binary files a/TestImages/foil/foil_curling_1.jpg and /dev/null differ
diff --git a/TestImages/foil/foil_curling_2.jpg b/TestImages/foil/foil_curling_2.jpg
deleted file mode 100644
index a37dfcd..0000000
Binary files a/TestImages/foil/foil_curling_2.jpg and /dev/null differ
diff --git a/TestImages/foil/foil_jpn_mystical_archives.jpg b/TestImages/foil/foil_jpn_mystical_archives.jpg
deleted file mode 100644
index 436d844..0000000
Binary files a/TestImages/foil/foil_jpn_mystical_archives.jpg and /dev/null differ
diff --git a/TestImages/foil/foil_peel_holo_layer.jpg b/TestImages/foil/foil_peel_holo_layer.jpg
deleted file mode 100644
index f19ee43..0000000
Binary files a/TestImages/foil/foil_peel_holo_layer.jpg and /dev/null differ
diff --git a/TestImages/foil/foil_quality_comparison.jpeg b/TestImages/foil/foil_quality_comparison.jpeg
deleted file mode 100644
index 7899964..0000000
Binary files a/TestImages/foil/foil_quality_comparison.jpeg and /dev/null differ
diff --git a/TestImages/foil/foil_swamp_collection.jpg b/TestImages/foil/foil_swamp_collection.jpg
deleted file mode 100644
index 3111393..0000000
Binary files a/TestImages/foil/foil_swamp_collection.jpg and /dev/null differ
diff --git a/TestImages/foil/modern_vs_og_foils.jpg b/TestImages/foil/modern_vs_og_foils.jpg
deleted file mode 100644
index 7a04366..0000000
Binary files a/TestImages/foil/modern_vs_og_foils.jpg and /dev/null differ
diff --git a/TestImages/foil/old_foil_yawgmoth.jpg b/TestImages/foil/old_foil_yawgmoth.jpg
deleted file mode 100644
index 5c72d87..0000000
Binary files a/TestImages/foil/old_foil_yawgmoth.jpg and /dev/null differ
diff --git a/TestImages/foil/rainbow_foil_secret_lair.jpg b/TestImages/foil/rainbow_foil_secret_lair.jpg
deleted file mode 100644
index 11aa32a..0000000
Binary files a/TestImages/foil/rainbow_foil_secret_lair.jpg and /dev/null differ
diff --git a/TestImages/foil/rainbow_foil_sheldons.jpg b/TestImages/foil/rainbow_foil_sheldons.jpg
deleted file mode 100644
index e4e3072..0000000
Binary files a/TestImages/foil/rainbow_foil_sheldons.jpg and /dev/null differ
diff --git a/TestImages/foil/surge_foil_rhino.jpeg b/TestImages/foil/surge_foil_rhino.jpeg
deleted file mode 100644
index c9c48ea..0000000
Binary files a/TestImages/foil/surge_foil_rhino.jpeg and /dev/null differ
diff --git a/TestImages/foil/surge_foils_holo.jpeg b/TestImages/foil/surge_foils_holo.jpeg
deleted file mode 100644
index fd3e806..0000000
Binary files a/TestImages/foil/surge_foils_holo.jpeg and /dev/null differ
diff --git a/TestImages/foil/textured_foils.jpg b/TestImages/foil/textured_foils.jpg
deleted file mode 100644
index 5e204dd..0000000
Binary files a/TestImages/foil/textured_foils.jpg and /dev/null differ
diff --git a/TestImages/foreign/chinese_aarakocra.jpg b/TestImages/foreign/chinese_aarakocra.jpg
deleted file mode 100644
index 6d092a3..0000000
Binary files a/TestImages/foreign/chinese_aarakocra.jpg and /dev/null differ
diff --git a/TestImages/foreign/chinese_abattoir_ghoul.jpg b/TestImages/foreign/chinese_abattoir_ghoul.jpg
deleted file mode 100644
index 1abb3e0..0000000
Binary files a/TestImages/foreign/chinese_abattoir_ghoul.jpg and /dev/null differ
diff --git a/TestImages/foreign/french_aang.jpg b/TestImages/foreign/french_aang.jpg
deleted file mode 100644
index e4955a1..0000000
Binary files a/TestImages/foreign/french_aang.jpg and /dev/null differ
diff --git a/TestImages/foreign/french_abattoir_ghoul.jpg b/TestImages/foreign/french_abattoir_ghoul.jpg
deleted file mode 100644
index 16090fc..0000000
Binary files a/TestImages/foreign/french_abattoir_ghoul.jpg and /dev/null differ
diff --git a/TestImages/foreign/german_aang.jpg b/TestImages/foreign/german_aang.jpg
deleted file mode 100644
index d0615f9..0000000
Binary files a/TestImages/foreign/german_aang.jpg and /dev/null differ
diff --git a/TestImages/foreign/german_abattoir_ghoul.jpg b/TestImages/foreign/german_abattoir_ghoul.jpg
deleted file mode 100644
index 382f59c..0000000
Binary files a/TestImages/foreign/german_abattoir_ghoul.jpg and /dev/null differ
diff --git a/TestImages/foreign/italian_aang.jpg b/TestImages/foreign/italian_aang.jpg
deleted file mode 100644
index c18f85a..0000000
Binary files a/TestImages/foreign/italian_aang.jpg and /dev/null differ
diff --git a/TestImages/foreign/japanese_aang.jpg b/TestImages/foreign/japanese_aang.jpg
deleted file mode 100644
index 2a3fec1..0000000
Binary files a/TestImages/foreign/japanese_aang.jpg and /dev/null differ
diff --git a/TestImages/foreign/japanese_abduction.jpg b/TestImages/foreign/japanese_abduction.jpg
deleted file mode 100644
index 0f7dc2d..0000000
Binary files a/TestImages/foreign/japanese_abduction.jpg and /dev/null differ
diff --git a/TestImages/foreign/japanese_aberrant_researcher.jpg b/TestImages/foreign/japanese_aberrant_researcher.jpg
deleted file mode 100644
index 9906fd1..0000000
Binary files a/TestImages/foreign/japanese_aberrant_researcher.jpg and /dev/null differ
diff --git a/TestImages/foreign/japanese_abhorrent_overlord.jpg b/TestImages/foreign/japanese_abhorrent_overlord.jpg
deleted file mode 100644
index f81b500..0000000
Binary files a/TestImages/foreign/japanese_abhorrent_overlord.jpg and /dev/null differ
diff --git a/TestImages/foreign/korean_aarakocra.jpg b/TestImages/foreign/korean_aarakocra.jpg
deleted file mode 100644
index 0239a26..0000000
Binary files a/TestImages/foreign/korean_aarakocra.jpg and /dev/null differ
diff --git a/TestImages/foreign/korean_abattoir_ghoul.jpg b/TestImages/foreign/korean_abattoir_ghoul.jpg
deleted file mode 100644
index d764f62..0000000
Binary files a/TestImages/foreign/korean_abattoir_ghoul.jpg and /dev/null differ
diff --git a/TestImages/foreign/russian_aarakocra.jpg b/TestImages/foreign/russian_aarakocra.jpg
deleted file mode 100644
index 6470a93..0000000
Binary files a/TestImages/foreign/russian_aarakocra.jpg and /dev/null differ
diff --git a/TestImages/foreign/russian_abattoir_ghoul.jpg b/TestImages/foreign/russian_abattoir_ghoul.jpg
deleted file mode 100644
index b43afff..0000000
Binary files a/TestImages/foreign/russian_abattoir_ghoul.jpg and /dev/null differ
diff --git a/TestImages/foreign/spanish_aang.jpg b/TestImages/foreign/spanish_aang.jpg
deleted file mode 100644
index b222c29..0000000
Binary files a/TestImages/foreign/spanish_aang.jpg and /dev/null differ
diff --git a/TestImages/hands/handOfCards.jpg b/TestImages/hands/handOfCards.jpg
deleted file mode 100644
index 8f8f53e..0000000
Binary files a/TestImages/hands/handOfCards.jpg and /dev/null differ
diff --git a/TestImages/hands/hand_of_card_1.png b/TestImages/hands/hand_of_card_1.png
deleted file mode 100644
index 8323d5c..0000000
Binary files a/TestImages/hands/hand_of_card_1.png and /dev/null differ
diff --git a/TestImages/hands/hand_of_card_green_1.jpg b/TestImages/hands/hand_of_card_green_1.jpg
deleted file mode 100644
index 13f5b75..0000000
Binary files a/TestImages/hands/hand_of_card_green_1.jpg and /dev/null differ
diff --git a/TestImages/hands/hand_of_card_green_2.jpeg b/TestImages/hands/hand_of_card_green_2.jpeg
deleted file mode 100644
index 86109fa..0000000
Binary files a/TestImages/hands/hand_of_card_green_2.jpeg and /dev/null differ
diff --git a/TestImages/hands/hand_of_card_ktk.png b/TestImages/hands/hand_of_card_ktk.png
deleted file mode 100644
index 456ab69..0000000
Binary files a/TestImages/hands/hand_of_card_ktk.png and /dev/null differ
diff --git a/TestImages/hands/hand_of_card_new_frame.webp b/TestImages/hands/hand_of_card_new_frame.webp
deleted file mode 100644
index 1eb5b04..0000000
Binary files a/TestImages/hands/hand_of_card_new_frame.webp and /dev/null differ
diff --git a/TestImages/hands/hand_of_card_one_hand.jpg b/TestImages/hands/hand_of_card_one_hand.jpg
deleted file mode 100644
index bae5d8d..0000000
Binary files a/TestImages/hands/hand_of_card_one_hand.jpg and /dev/null differ
diff --git a/TestImages/hands/hand_of_card_red.jpeg b/TestImages/hands/hand_of_card_red.jpeg
deleted file mode 100644
index 4469e9f..0000000
Binary files a/TestImages/hands/hand_of_card_red.jpeg and /dev/null differ
diff --git a/TestImages/hands/hand_of_card_tron.png b/TestImages/hands/hand_of_card_tron.png
deleted file mode 100644
index b2f569c..0000000
Binary files a/TestImages/hands/hand_of_card_tron.png and /dev/null differ
diff --git a/TestImages/hands/klomparens_hand.png b/TestImages/hands/klomparens_hand.png
deleted file mode 100644
index 09cc0b3..0000000
Binary files a/TestImages/hands/klomparens_hand.png and /dev/null differ
diff --git a/TestImages/hands/li38_handOfCards.jpg b/TestImages/hands/li38_handOfCards.jpg
deleted file mode 100644
index e7e91be..0000000
Binary files a/TestImages/hands/li38_handOfCards.jpg and /dev/null differ
diff --git a/TestImages/low_light/authenticity_check.jpg b/TestImages/low_light/authenticity_check.jpg
deleted file mode 100644
index 7618852..0000000
Binary files a/TestImages/low_light/authenticity_check.jpg and /dev/null differ
diff --git a/TestImages/low_light/basic_lands_amateur.jpg b/TestImages/low_light/basic_lands_amateur.jpg
deleted file mode 100644
index f95979b..0000000
Binary files a/TestImages/low_light/basic_lands_amateur.jpg and /dev/null differ
diff --git a/TestImages/low_light/condition_amateur_1.jpg b/TestImages/low_light/condition_amateur_1.jpg
deleted file mode 100644
index 46b0d27..0000000
Binary files a/TestImages/low_light/condition_amateur_1.jpg and /dev/null differ
diff --git a/TestImages/low_light/condition_amateur_2.jpg b/TestImages/low_light/condition_amateur_2.jpg
deleted file mode 100644
index 49d0e2b..0000000
Binary files a/TestImages/low_light/condition_amateur_2.jpg and /dev/null differ
diff --git a/TestImages/low_light/diy_lighting_rig.jpg b/TestImages/low_light/diy_lighting_rig.jpg
deleted file mode 100644
index e49fb06..0000000
Binary files a/TestImages/low_light/diy_lighting_rig.jpg and /dev/null differ
diff --git a/TestImages/low_light/dof_blur_amateur.jpg b/TestImages/low_light/dof_blur_amateur.jpg
deleted file mode 100644
index 9e3a974..0000000
Binary files a/TestImages/low_light/dof_blur_amateur.jpg and /dev/null differ
diff --git a/TestImages/low_light/fake_detection.jpg b/TestImages/low_light/fake_detection.jpg
deleted file mode 100644
index 54f1bdd..0000000
Binary files a/TestImages/low_light/fake_detection.jpg and /dev/null differ
diff --git a/TestImages/low_light/flickr_collection_1.jpg b/TestImages/low_light/flickr_collection_1.jpg
deleted file mode 100644
index 057b426..0000000
Binary files a/TestImages/low_light/flickr_collection_1.jpg and /dev/null differ
diff --git a/TestImages/low_light/flickr_collection_2.jpg b/TestImages/low_light/flickr_collection_2.jpg
deleted file mode 100644
index 6764c6e..0000000
Binary files a/TestImages/low_light/flickr_collection_2.jpg and /dev/null differ
diff --git a/TestImages/low_light/flickr_collection_3.jpg b/TestImages/low_light/flickr_collection_3.jpg
deleted file mode 100644
index f7e6483..0000000
Binary files a/TestImages/low_light/flickr_collection_3.jpg and /dev/null differ
diff --git a/TestImages/low_light/glare_straight_down.jpg b/TestImages/low_light/glare_straight_down.jpg
deleted file mode 100644
index fdf5838..0000000
Binary files a/TestImages/low_light/glare_straight_down.jpg and /dev/null differ
diff --git a/TestImages/low_light/glare_toploader.png b/TestImages/low_light/glare_toploader.png
deleted file mode 100644
index 5a3f6b2..0000000
Binary files a/TestImages/low_light/glare_toploader.png and /dev/null differ
diff --git a/TestImages/low_light/grading_amateur.jpg b/TestImages/low_light/grading_amateur.jpg
deleted file mode 100644
index 8a7a040..0000000
Binary files a/TestImages/low_light/grading_amateur.jpg and /dev/null differ
diff --git a/TestImages/low_light/macro_monday_shadows.jpg b/TestImages/low_light/macro_monday_shadows.jpg
deleted file mode 100644
index bf47519..0000000
Binary files a/TestImages/low_light/macro_monday_shadows.jpg and /dev/null differ
diff --git a/TestImages/multiple_cards/alpha_deck.jpg b/TestImages/multiple_cards/alpha_deck.jpg
deleted file mode 100644
index 281ff42..0000000
Binary files a/TestImages/multiple_cards/alpha_deck.jpg and /dev/null differ
diff --git a/TestImages/multiple_cards/geyser_twister_fireball.jpg b/TestImages/multiple_cards/geyser_twister_fireball.jpg
deleted file mode 100644
index 47263a3..0000000
Binary files a/TestImages/multiple_cards/geyser_twister_fireball.jpg and /dev/null differ
diff --git a/TestImages/multiple_cards/lands_and_fatties.jpg b/TestImages/multiple_cards/lands_and_fatties.jpg
deleted file mode 100644
index 344b26b..0000000
Binary files a/TestImages/multiple_cards/lands_and_fatties.jpg and /dev/null differ
diff --git a/TestImages/multiple_cards/magic1.png b/TestImages/multiple_cards/magic1.png
deleted file mode 100644
index a6480fb..0000000
Binary files a/TestImages/multiple_cards/magic1.png and /dev/null differ
diff --git a/TestImages/multiple_cards/pro_tour_side.png b/TestImages/multiple_cards/pro_tour_side.png
deleted file mode 100644
index 759ddf3..0000000
Binary files a/TestImages/multiple_cards/pro_tour_side.png and /dev/null differ
diff --git a/TestImages/multiple_cards/pro_tour_table.png b/TestImages/multiple_cards/pro_tour_table.png
deleted file mode 100644
index e02960b..0000000
Binary files a/TestImages/multiple_cards/pro_tour_table.png and /dev/null differ
diff --git a/TestImages/ocr_test/card.jpg b/TestImages/ocr_test/card.jpg
deleted file mode 100644
index ff57b28..0000000
Binary files a/TestImages/ocr_test/card.jpg and /dev/null differ
diff --git a/TestImages/ocr_test/card0.jpg b/TestImages/ocr_test/card0.jpg
deleted file mode 100644
index 5a5f5d8..0000000
Binary files a/TestImages/ocr_test/card0.jpg and /dev/null differ
diff --git a/TestImages/ocr_test/card1.jpg b/TestImages/ocr_test/card1.jpg
deleted file mode 100644
index 151d89f..0000000
Binary files a/TestImages/ocr_test/card1.jpg and /dev/null differ
diff --git a/TestImages/ocr_test/card10.jpg b/TestImages/ocr_test/card10.jpg
deleted file mode 100644
index 1d25cf2..0000000
Binary files a/TestImages/ocr_test/card10.jpg and /dev/null differ
diff --git a/TestImages/ocr_test/card11.jpg b/TestImages/ocr_test/card11.jpg
deleted file mode 100644
index 339fc0c..0000000
Binary files a/TestImages/ocr_test/card11.jpg and /dev/null differ
diff --git a/TestImages/ocr_test/card12.jpg b/TestImages/ocr_test/card12.jpg
deleted file mode 100644
index 4de7f50..0000000
Binary files a/TestImages/ocr_test/card12.jpg and /dev/null differ
diff --git a/TestImages/ocr_test/card13.jpg b/TestImages/ocr_test/card13.jpg
deleted file mode 100644
index 3b96f8d..0000000
Binary files a/TestImages/ocr_test/card13.jpg and /dev/null differ
diff --git a/TestImages/ocr_test/card2.jpg b/TestImages/ocr_test/card2.jpg
deleted file mode 100644
index b974812..0000000
Binary files a/TestImages/ocr_test/card2.jpg and /dev/null differ
diff --git a/TestImages/ocr_test/card3.jpg b/TestImages/ocr_test/card3.jpg
deleted file mode 100644
index 56347eb..0000000
Binary files a/TestImages/ocr_test/card3.jpg and /dev/null differ
diff --git a/TestImages/ocr_test/card4.jpg b/TestImages/ocr_test/card4.jpg
deleted file mode 100644
index 4e73d9c..0000000
Binary files a/TestImages/ocr_test/card4.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_1.jpg b/TestImages/real_photos/visions_1.jpg
deleted file mode 100644
index 9408b6d..0000000
Binary files a/TestImages/real_photos/visions_1.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_1_square.jpg b/TestImages/real_photos/visions_1_square.jpg
deleted file mode 100644
index a15da3e..0000000
Binary files a/TestImages/real_photos/visions_1_square.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_2.jpg b/TestImages/real_photos/visions_2.jpg
deleted file mode 100644
index 04878b2..0000000
Binary files a/TestImages/real_photos/visions_2.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_2_square.jpg b/TestImages/real_photos/visions_2_square.jpg
deleted file mode 100644
index 389a603..0000000
Binary files a/TestImages/real_photos/visions_2_square.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_3.jpg b/TestImages/real_photos/visions_3.jpg
deleted file mode 100644
index 5fcc36b..0000000
Binary files a/TestImages/real_photos/visions_3.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_4.jpg b/TestImages/real_photos/visions_4.jpg
deleted file mode 100644
index 2664cca..0000000
Binary files a/TestImages/real_photos/visions_4.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_5.jpg b/TestImages/real_photos/visions_5.jpg
deleted file mode 100644
index 67ef2f0..0000000
Binary files a/TestImages/real_photos/visions_5.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_6.jpg b/TestImages/real_photos/visions_6.jpg
deleted file mode 100644
index 39b27fd..0000000
Binary files a/TestImages/real_photos/visions_6.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_6_square.jpg b/TestImages/real_photos/visions_6_square.jpg
deleted file mode 100644
index 15bd9bc..0000000
Binary files a/TestImages/real_photos/visions_6_square.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_7.jpg b/TestImages/real_photos/visions_7.jpg
deleted file mode 100644
index 4a5525b..0000000
Binary files a/TestImages/real_photos/visions_7.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_8.jpg b/TestImages/real_photos/visions_8.jpg
deleted file mode 100644
index 5205411..0000000
Binary files a/TestImages/real_photos/visions_8.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_8_big.jpg b/TestImages/real_photos/visions_8_big.jpg
deleted file mode 100644
index aacdb0a..0000000
Binary files a/TestImages/real_photos/visions_8_big.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_9.jpg b/TestImages/real_photos/visions_9.jpg
deleted file mode 100644
index 04cb000..0000000
Binary files a/TestImages/real_photos/visions_9.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_9_small.jpg b/TestImages/real_photos/visions_9_small.jpg
deleted file mode 100644
index 230f5b2..0000000
Binary files a/TestImages/real_photos/visions_9_small.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_result_1.jpg b/TestImages/real_photos/visions_result_1.jpg
deleted file mode 100644
index a669ee2..0000000
Binary files a/TestImages/real_photos/visions_result_1.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_result_2.jpg b/TestImages/real_photos/visions_result_2.jpg
deleted file mode 100644
index abd29ed..0000000
Binary files a/TestImages/real_photos/visions_result_2.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_result_3.jpg b/TestImages/real_photos/visions_result_3.jpg
deleted file mode 100644
index 988e068..0000000
Binary files a/TestImages/real_photos/visions_result_3.jpg and /dev/null differ
diff --git a/TestImages/real_photos/visions_result_4.jpg b/TestImages/real_photos/visions_result_4.jpg
deleted file mode 100644
index a28fd0a..0000000
Binary files a/TestImages/real_photos/visions_result_4.jpg and /dev/null differ
diff --git a/TestImages/reference/brainstorm.png b/TestImages/reference/brainstorm.png
deleted file mode 100644
index bf7f8f5..0000000
Binary files a/TestImages/reference/brainstorm.png and /dev/null differ
diff --git a/TestImages/reference/force_of_will.png b/TestImages/reference/force_of_will.png
deleted file mode 100644
index 6ec00e5..0000000
Binary files a/TestImages/reference/force_of_will.png and /dev/null differ
diff --git a/TestImages/reference/griselbrand.png b/TestImages/reference/griselbrand.png
deleted file mode 100644
index e73c642..0000000
Binary files a/TestImages/reference/griselbrand.png and /dev/null differ
diff --git a/TestImages/reference/lotus_petal.png b/TestImages/reference/lotus_petal.png
deleted file mode 100644
index d048c9f..0000000
Binary files a/TestImages/reference/lotus_petal.png and /dev/null differ
diff --git a/TestImages/reference/ponder.png b/TestImages/reference/ponder.png
deleted file mode 100644
index 48ae59d..0000000
Binary files a/TestImages/reference/ponder.png and /dev/null differ
diff --git a/TestImages/reference/show_and_tell.png b/TestImages/reference/show_and_tell.png
deleted file mode 100644
index 9dee849..0000000
Binary files a/TestImages/reference/show_and_tell.png and /dev/null differ
diff --git a/TestImages/reference/tropical_island.png b/TestImages/reference/tropical_island.png
deleted file mode 100644
index 5ddb71f..0000000
Binary files a/TestImages/reference/tropical_island.png and /dev/null differ
diff --git a/TestImages/reference/volcanic_island.png b/TestImages/reference/volcanic_island.png
deleted file mode 100644
index d14eb98..0000000
Binary files a/TestImages/reference/volcanic_island.png and /dev/null differ
diff --git a/TestImages/reference/wasteland.png b/TestImages/reference/wasteland.png
deleted file mode 100644
index 54b12ab..0000000
Binary files a/TestImages/reference/wasteland.png and /dev/null differ
diff --git a/TestImages/reference_alpha/ancestral_recall.jpg b/TestImages/reference_alpha/ancestral_recall.jpg
deleted file mode 100644
index 273d451..0000000
Binary files a/TestImages/reference_alpha/ancestral_recall.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/badlands.jpg b/TestImages/reference_alpha/badlands.jpg
deleted file mode 100644
index 34b8f20..0000000
Binary files a/TestImages/reference_alpha/badlands.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/balance.jpg b/TestImages/reference_alpha/balance.jpg
deleted file mode 100644
index 004e76b..0000000
Binary files a/TestImages/reference_alpha/balance.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/bayou.jpg b/TestImages/reference_alpha/bayou.jpg
deleted file mode 100644
index 77ed6ab..0000000
Binary files a/TestImages/reference_alpha/bayou.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/birds_of_paradise.jpg b/TestImages/reference_alpha/birds_of_paradise.jpg
deleted file mode 100644
index 83407e2..0000000
Binary files a/TestImages/reference_alpha/birds_of_paradise.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/black_lotus.jpg b/TestImages/reference_alpha/black_lotus.jpg
deleted file mode 100644
index b529a2b..0000000
Binary files a/TestImages/reference_alpha/black_lotus.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/channel.jpg b/TestImages/reference_alpha/channel.jpg
deleted file mode 100644
index ea61345..0000000
Binary files a/TestImages/reference_alpha/channel.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/chaos_orb.jpg b/TestImages/reference_alpha/chaos_orb.jpg
deleted file mode 100644
index d67b23a..0000000
Binary files a/TestImages/reference_alpha/chaos_orb.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/clone.jpg b/TestImages/reference_alpha/clone.jpg
deleted file mode 100644
index 937461a..0000000
Binary files a/TestImages/reference_alpha/clone.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/control_magic.jpg b/TestImages/reference_alpha/control_magic.jpg
deleted file mode 100644
index 51f94d9..0000000
Binary files a/TestImages/reference_alpha/control_magic.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/counterspell.jpg b/TestImages/reference_alpha/counterspell.jpg
deleted file mode 100644
index 44a134c..0000000
Binary files a/TestImages/reference_alpha/counterspell.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/dark_ritual.jpg b/TestImages/reference_alpha/dark_ritual.jpg
deleted file mode 100644
index 92829be..0000000
Binary files a/TestImages/reference_alpha/dark_ritual.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/demonic_tutor.jpg b/TestImages/reference_alpha/demonic_tutor.jpg
deleted file mode 100644
index bf0375d..0000000
Binary files a/TestImages/reference_alpha/demonic_tutor.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/disenchant.jpg b/TestImages/reference_alpha/disenchant.jpg
deleted file mode 100644
index a159c61..0000000
Binary files a/TestImages/reference_alpha/disenchant.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/fireball.jpg b/TestImages/reference_alpha/fireball.jpg
deleted file mode 100644
index a683353..0000000
Binary files a/TestImages/reference_alpha/fireball.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/force_of_nature.jpg b/TestImages/reference_alpha/force_of_nature.jpg
deleted file mode 100644
index 497c7c5..0000000
Binary files a/TestImages/reference_alpha/force_of_nature.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/fork.jpg b/TestImages/reference_alpha/fork.jpg
deleted file mode 100644
index 40ac20d..0000000
Binary files a/TestImages/reference_alpha/fork.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/giant_growth.jpg b/TestImages/reference_alpha/giant_growth.jpg
deleted file mode 100644
index 45bc473..0000000
Binary files a/TestImages/reference_alpha/giant_growth.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/hypnotic_specter.jpg b/TestImages/reference_alpha/hypnotic_specter.jpg
deleted file mode 100644
index 11ebb95..0000000
Binary files a/TestImages/reference_alpha/hypnotic_specter.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/lightning_bolt.jpg b/TestImages/reference_alpha/lightning_bolt.jpg
deleted file mode 100644
index 710b69a..0000000
Binary files a/TestImages/reference_alpha/lightning_bolt.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/llanowar_elves.jpg b/TestImages/reference_alpha/llanowar_elves.jpg
deleted file mode 100644
index bdfbfc1..0000000
Binary files a/TestImages/reference_alpha/llanowar_elves.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/mahamoti_djinn.jpg b/TestImages/reference_alpha/mahamoti_djinn.jpg
deleted file mode 100644
index 5265950..0000000
Binary files a/TestImages/reference_alpha/mahamoti_djinn.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/mind_twist.jpg b/TestImages/reference_alpha/mind_twist.jpg
deleted file mode 100644
index 6ee690b..0000000
Binary files a/TestImages/reference_alpha/mind_twist.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/mox_emerald.jpg b/TestImages/reference_alpha/mox_emerald.jpg
deleted file mode 100644
index 25c0e11..0000000
Binary files a/TestImages/reference_alpha/mox_emerald.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/mox_jet.jpg b/TestImages/reference_alpha/mox_jet.jpg
deleted file mode 100644
index a3e18bf..0000000
Binary files a/TestImages/reference_alpha/mox_jet.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/mox_pearl.jpg b/TestImages/reference_alpha/mox_pearl.jpg
deleted file mode 100644
index 97d12ee..0000000
Binary files a/TestImages/reference_alpha/mox_pearl.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/mox_ruby.jpg b/TestImages/reference_alpha/mox_ruby.jpg
deleted file mode 100644
index c2d1d3b..0000000
Binary files a/TestImages/reference_alpha/mox_ruby.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/mox_sapphire.jpg b/TestImages/reference_alpha/mox_sapphire.jpg
deleted file mode 100644
index ed7e87e..0000000
Binary files a/TestImages/reference_alpha/mox_sapphire.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/nightmare.jpg b/TestImages/reference_alpha/nightmare.jpg
deleted file mode 100644
index d1a0a15..0000000
Binary files a/TestImages/reference_alpha/nightmare.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/plateau.jpg b/TestImages/reference_alpha/plateau.jpg
deleted file mode 100644
index 0d5ccd5..0000000
Binary files a/TestImages/reference_alpha/plateau.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/regrowth.jpg b/TestImages/reference_alpha/regrowth.jpg
deleted file mode 100644
index 97fd879..0000000
Binary files a/TestImages/reference_alpha/regrowth.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/rock_hydra.jpg b/TestImages/reference_alpha/rock_hydra.jpg
deleted file mode 100644
index b88b8c5..0000000
Binary files a/TestImages/reference_alpha/rock_hydra.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/royal_assassin.jpg b/TestImages/reference_alpha/royal_assassin.jpg
deleted file mode 100644
index fa23a71..0000000
Binary files a/TestImages/reference_alpha/royal_assassin.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/savannah.jpg b/TestImages/reference_alpha/savannah.jpg
deleted file mode 100644
index 2ef8dd9..0000000
Binary files a/TestImages/reference_alpha/savannah.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/scrubland.jpg b/TestImages/reference_alpha/scrubland.jpg
deleted file mode 100644
index bfaf8b8..0000000
Binary files a/TestImages/reference_alpha/scrubland.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/serra_angel.jpg b/TestImages/reference_alpha/serra_angel.jpg
deleted file mode 100644
index 7bc59cf..0000000
Binary files a/TestImages/reference_alpha/serra_angel.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/shivan_dragon.jpg b/TestImages/reference_alpha/shivan_dragon.jpg
deleted file mode 100644
index 3126461..0000000
Binary files a/TestImages/reference_alpha/shivan_dragon.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/sol_ring.jpg b/TestImages/reference_alpha/sol_ring.jpg
deleted file mode 100644
index a754249..0000000
Binary files a/TestImages/reference_alpha/sol_ring.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/swords_to_plowshares.jpg b/TestImages/reference_alpha/swords_to_plowshares.jpg
deleted file mode 100644
index 964667e..0000000
Binary files a/TestImages/reference_alpha/swords_to_plowshares.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/taiga.jpg b/TestImages/reference_alpha/taiga.jpg
deleted file mode 100644
index a9465b7..0000000
Binary files a/TestImages/reference_alpha/taiga.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/time_walk.jpg b/TestImages/reference_alpha/time_walk.jpg
deleted file mode 100644
index 0807e9a..0000000
Binary files a/TestImages/reference_alpha/time_walk.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/timetwister.jpg b/TestImages/reference_alpha/timetwister.jpg
deleted file mode 100644
index aa95c55..0000000
Binary files a/TestImages/reference_alpha/timetwister.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/tropical_island.jpg b/TestImages/reference_alpha/tropical_island.jpg
deleted file mode 100644
index 186a951..0000000
Binary files a/TestImages/reference_alpha/tropical_island.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/tundra.jpg b/TestImages/reference_alpha/tundra.jpg
deleted file mode 100644
index d2769bc..0000000
Binary files a/TestImages/reference_alpha/tundra.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/underground_sea.jpg b/TestImages/reference_alpha/underground_sea.jpg
deleted file mode 100644
index 6824628..0000000
Binary files a/TestImages/reference_alpha/underground_sea.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/wheel_of_fortune.jpg b/TestImages/reference_alpha/wheel_of_fortune.jpg
deleted file mode 100644
index 603136f..0000000
Binary files a/TestImages/reference_alpha/wheel_of_fortune.jpg and /dev/null differ
diff --git a/TestImages/reference_alpha/wrath_of_god.jpg b/TestImages/reference_alpha/wrath_of_god.jpg
deleted file mode 100644
index 9339812..0000000
Binary files a/TestImages/reference_alpha/wrath_of_god.jpg and /dev/null differ
diff --git a/TestImages/single_cards/adanto_vanguard.png b/TestImages/single_cards/adanto_vanguard.png
deleted file mode 100644
index a7d27c2..0000000
Binary files a/TestImages/single_cards/adanto_vanguard.png and /dev/null differ
diff --git a/TestImages/single_cards/angel_of_sanctions.png b/TestImages/single_cards/angel_of_sanctions.png
deleted file mode 100644
index 181ed0b..0000000
Binary files a/TestImages/single_cards/angel_of_sanctions.png and /dev/null differ
diff --git a/TestImages/single_cards/attunement.jpg b/TestImages/single_cards/attunement.jpg
deleted file mode 100644
index 5994502..0000000
Binary files a/TestImages/single_cards/attunement.jpg and /dev/null differ
diff --git a/TestImages/single_cards/avaricious_dragon.jpg b/TestImages/single_cards/avaricious_dragon.jpg
deleted file mode 100644
index 396fa6c..0000000
Binary files a/TestImages/single_cards/avaricious_dragon.jpg and /dev/null differ
diff --git a/TestImages/single_cards/burgeoning.png b/TestImages/single_cards/burgeoning.png
deleted file mode 100644
index 0a5baba..0000000
Binary files a/TestImages/single_cards/burgeoning.png and /dev/null differ
diff --git a/TestImages/single_cards/fireball.jpg b/TestImages/single_cards/fireball.jpg
deleted file mode 100644
index 1a6a56f..0000000
Binary files a/TestImages/single_cards/fireball.jpg and /dev/null differ
diff --git a/TestImages/single_cards/jarad_golgari.jpg b/TestImages/single_cards/jarad_golgari.jpg
deleted file mode 100644
index ee26e77..0000000
Binary files a/TestImages/single_cards/jarad_golgari.jpg and /dev/null differ
diff --git a/TestImages/single_cards/llanowar_elves.jpg b/TestImages/single_cards/llanowar_elves.jpg
deleted file mode 100644
index 33adb4b..0000000
Binary files a/TestImages/single_cards/llanowar_elves.jpg and /dev/null differ
diff --git a/TestImages/single_cards/meletis_charlatan.jpg b/TestImages/single_cards/meletis_charlatan.jpg
deleted file mode 100644
index 8c736f7..0000000
Binary files a/TestImages/single_cards/meletis_charlatan.jpg and /dev/null differ
diff --git a/TestImages/single_cards/mindstab_thrull.jpeg b/TestImages/single_cards/mindstab_thrull.jpeg
deleted file mode 100644
index 95b1c61..0000000
Binary files a/TestImages/single_cards/mindstab_thrull.jpeg and /dev/null differ
diff --git a/TestImages/single_cards/pacifism.jpg b/TestImages/single_cards/pacifism.jpg
deleted file mode 100644
index 7ed4f88..0000000
Binary files a/TestImages/single_cards/pacifism.jpg and /dev/null differ
diff --git a/TestImages/single_cards/platinum_angel.jpg b/TestImages/single_cards/platinum_angel.jpg
deleted file mode 100644
index b971461..0000000
Binary files a/TestImages/single_cards/platinum_angel.jpg and /dev/null differ
diff --git a/TestImages/single_cards/queen_marchesa.png b/TestImages/single_cards/queen_marchesa.png
deleted file mode 100644
index aa2b3f7..0000000
Binary files a/TestImages/single_cards/queen_marchesa.png and /dev/null differ
diff --git a/TestImages/single_cards/queen_marchesa_analyzer.png b/TestImages/single_cards/queen_marchesa_analyzer.png
deleted file mode 100644
index aa2b3f7..0000000
Binary files a/TestImages/single_cards/queen_marchesa_analyzer.png and /dev/null differ
diff --git a/TestImages/single_cards/shivan_dragon.jpg b/TestImages/single_cards/shivan_dragon.jpg
deleted file mode 100644
index 50276a1..0000000
Binary files a/TestImages/single_cards/shivan_dragon.jpg and /dev/null differ
diff --git a/TestImages/single_cards/spellseeker.png b/TestImages/single_cards/spellseeker.png
deleted file mode 100644
index 0a3cb75..0000000
Binary files a/TestImages/single_cards/spellseeker.png and /dev/null differ
diff --git a/TestImages/single_cards/tarmogoyf.jpg b/TestImages/single_cards/tarmogoyf.jpg
deleted file mode 100644
index e547a94..0000000
Binary files a/TestImages/single_cards/tarmogoyf.jpg and /dev/null differ
diff --git a/TestImages/single_cards/thought_reflection.jpg b/TestImages/single_cards/thought_reflection.jpg
deleted file mode 100644
index e1c7ba5..0000000
Binary files a/TestImages/single_cards/thought_reflection.jpg and /dev/null differ
diff --git a/TestImages/single_cards/unsummon.jpg b/TestImages/single_cards/unsummon.jpg
deleted file mode 100644
index a44be04..0000000
Binary files a/TestImages/single_cards/unsummon.jpg and /dev/null differ
diff --git a/TestImages/tokens/angel_token_alter.jpg b/TestImages/tokens/angel_token_alter.jpg
deleted file mode 100644
index 8a94cae..0000000
Binary files a/TestImages/tokens/angel_token_alter.jpg and /dev/null differ
diff --git a/TestImages/tokens/brothers_tokens.jpg b/TestImages/tokens/brothers_tokens.jpg
deleted file mode 100644
index f3363d3..0000000
Binary files a/TestImages/tokens/brothers_tokens.jpg and /dev/null differ
diff --git a/TestImages/tokens/christopher_rush_tokens.jpg b/TestImages/tokens/christopher_rush_tokens.jpg
deleted file mode 100644
index bc93444..0000000
Binary files a/TestImages/tokens/christopher_rush_tokens.jpg and /dev/null differ
diff --git a/TestImages/tokens/custom_tokens.jpg b/TestImages/tokens/custom_tokens.jpg
deleted file mode 100644
index 89d4dda..0000000
Binary files a/TestImages/tokens/custom_tokens.jpg and /dev/null differ
diff --git a/TestImages/tokens/elspeth_emblem.jpg b/TestImages/tokens/elspeth_emblem.jpg
deleted file mode 100644
index 78be8d9..0000000
Binary files a/TestImages/tokens/elspeth_emblem.jpg and /dev/null differ
diff --git a/TestImages/tokens/elspeth_starwars_emblem.jpg b/TestImages/tokens/elspeth_starwars_emblem.jpg
deleted file mode 100644
index d37ba4d..0000000
Binary files a/TestImages/tokens/elspeth_starwars_emblem.jpg and /dev/null differ
diff --git a/TestImages/tokens/gideon_emblem.jpg b/TestImages/tokens/gideon_emblem.jpg
deleted file mode 100644
index a9292d3..0000000
Binary files a/TestImages/tokens/gideon_emblem.jpg and /dev/null differ
diff --git a/TestImages/tokens/narset_emblem.jpg b/TestImages/tokens/narset_emblem.jpg
deleted file mode 100644
index 5b2c0fc..0000000
Binary files a/TestImages/tokens/narset_emblem.jpg and /dev/null differ
diff --git a/TestImages/tokens/ratadrabik_token.jpg b/TestImages/tokens/ratadrabik_token.jpg
deleted file mode 100644
index 9a10a4f..0000000
Binary files a/TestImages/tokens/ratadrabik_token.jpg and /dev/null differ
diff --git a/TestImages/tokens/rkpost_rhino_tokens.jpg b/TestImages/tokens/rkpost_rhino_tokens.jpg
deleted file mode 100644
index 34ccd1b..0000000
Binary files a/TestImages/tokens/rkpost_rhino_tokens.jpg and /dev/null differ
diff --git a/TestImages/tokens/token_collection_pucatrade.jpg b/TestImages/tokens/token_collection_pucatrade.jpg
deleted file mode 100644
index 4297869..0000000
Binary files a/TestImages/tokens/token_collection_pucatrade.jpg and /dev/null differ
diff --git a/TestImages/tokens/tokens_foils_lands.jpg b/TestImages/tokens/tokens_foils_lands.jpg
deleted file mode 100644
index 850bd60..0000000
Binary files a/TestImages/tokens/tokens_foils_lands.jpg and /dev/null differ
diff --git a/TestImages/tokens/vampire_knight_token.jpg b/TestImages/tokens/vampire_knight_token.jpg
deleted file mode 100644
index 5649e7e..0000000
Binary files a/TestImages/tokens/vampire_knight_token.jpg and /dev/null differ
diff --git a/TestImages/training_examples/training_set_1.jpg b/TestImages/training_examples/training_set_1.jpg
deleted file mode 100644
index b3d4ffe..0000000
Binary files a/TestImages/training_examples/training_set_1.jpg and /dev/null differ
diff --git a/TestImages/training_examples/training_set_2.jpg b/TestImages/training_examples/training_set_2.jpg
deleted file mode 100644
index 32bd556..0000000
Binary files a/TestImages/training_examples/training_set_2.jpg and /dev/null differ
diff --git a/TestImages/training_examples/training_set_3.jpg b/TestImages/training_examples/training_set_3.jpg
deleted file mode 100644
index 8467af5..0000000
Binary files a/TestImages/training_examples/training_set_3.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/black.jpg b/TestImages/varying_quality/black.jpg
deleted file mode 100644
index dc90cae..0000000
Binary files a/TestImages/varying_quality/black.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/card_in_plastic_case.jpg b/TestImages/varying_quality/card_in_plastic_case.jpg
deleted file mode 100644
index e771a5c..0000000
Binary files a/TestImages/varying_quality/card_in_plastic_case.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/counterspell_bgs.jpg b/TestImages/varying_quality/counterspell_bgs.jpg
deleted file mode 100644
index 25a8e1c..0000000
Binary files a/TestImages/varying_quality/counterspell_bgs.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/dragon_whelp.jpg b/TestImages/varying_quality/dragon_whelp.jpg
deleted file mode 100644
index effdde6..0000000
Binary files a/TestImages/varying_quality/dragon_whelp.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/evil_eye.jpg b/TestImages/varying_quality/evil_eye.jpg
deleted file mode 100644
index faad74e..0000000
Binary files a/TestImages/varying_quality/evil_eye.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/frilly.jpg b/TestImages/varying_quality/frilly.jpg
deleted file mode 100644
index 5ab39fd..0000000
Binary files a/TestImages/varying_quality/frilly.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/image_orig.jpg b/TestImages/varying_quality/image_orig.jpg
deleted file mode 100644
index 440ad18..0000000
Binary files a/TestImages/varying_quality/image_orig.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/instill_energy.jpg b/TestImages/varying_quality/instill_energy.jpg
deleted file mode 100644
index c443961..0000000
Binary files a/TestImages/varying_quality/instill_energy.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/ruby.jpg b/TestImages/varying_quality/ruby.jpg
deleted file mode 100644
index a343232..0000000
Binary files a/TestImages/varying_quality/ruby.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/s-l300.jpg b/TestImages/varying_quality/s-l300.jpg
deleted file mode 100644
index 819daca..0000000
Binary files a/TestImages/varying_quality/s-l300.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test.jpg b/TestImages/varying_quality/test.jpg
deleted file mode 100644
index 233ffa8..0000000
Binary files a/TestImages/varying_quality/test.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test1.jpg b/TestImages/varying_quality/test1.jpg
deleted file mode 100644
index a75278e..0000000
Binary files a/TestImages/varying_quality/test1.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test10.jpg b/TestImages/varying_quality/test10.jpg
deleted file mode 100644
index 8e9062b..0000000
Binary files a/TestImages/varying_quality/test10.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test11.jpg b/TestImages/varying_quality/test11.jpg
deleted file mode 100644
index b0795f4..0000000
Binary files a/TestImages/varying_quality/test11.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test12.jpg b/TestImages/varying_quality/test12.jpg
deleted file mode 100644
index c2f5de6..0000000
Binary files a/TestImages/varying_quality/test12.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test13.jpg b/TestImages/varying_quality/test13.jpg
deleted file mode 100644
index 878cbad..0000000
Binary files a/TestImages/varying_quality/test13.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test14.jpg b/TestImages/varying_quality/test14.jpg
deleted file mode 100644
index bf5094a..0000000
Binary files a/TestImages/varying_quality/test14.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test15.jpg b/TestImages/varying_quality/test15.jpg
deleted file mode 100644
index 39f1dd4..0000000
Binary files a/TestImages/varying_quality/test15.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test16.jpg b/TestImages/varying_quality/test16.jpg
deleted file mode 100644
index c514771..0000000
Binary files a/TestImages/varying_quality/test16.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test17.jpg b/TestImages/varying_quality/test17.jpg
deleted file mode 100644
index 4ad12f7..0000000
Binary files a/TestImages/varying_quality/test17.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test18.jpg b/TestImages/varying_quality/test18.jpg
deleted file mode 100644
index a0f9390..0000000
Binary files a/TestImages/varying_quality/test18.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test19.jpg b/TestImages/varying_quality/test19.jpg
deleted file mode 100644
index 8f3c5a6..0000000
Binary files a/TestImages/varying_quality/test19.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test2.jpg b/TestImages/varying_quality/test2.jpg
deleted file mode 100644
index 1fceb1f..0000000
Binary files a/TestImages/varying_quality/test2.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test20.jpg b/TestImages/varying_quality/test20.jpg
deleted file mode 100644
index 8717d5f..0000000
Binary files a/TestImages/varying_quality/test20.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test21.jpg b/TestImages/varying_quality/test21.jpg
deleted file mode 100644
index 342577c..0000000
Binary files a/TestImages/varying_quality/test21.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test22.png b/TestImages/varying_quality/test22.png
deleted file mode 100644
index 179f188..0000000
Binary files a/TestImages/varying_quality/test22.png and /dev/null differ
diff --git a/TestImages/varying_quality/test23.jpg b/TestImages/varying_quality/test23.jpg
deleted file mode 100644
index af79a6f..0000000
Binary files a/TestImages/varying_quality/test23.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test24.jpg b/TestImages/varying_quality/test24.jpg
deleted file mode 100644
index 937354c..0000000
Binary files a/TestImages/varying_quality/test24.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test25.jpg b/TestImages/varying_quality/test25.jpg
deleted file mode 100644
index 6e39077..0000000
Binary files a/TestImages/varying_quality/test25.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test26.jpg b/TestImages/varying_quality/test26.jpg
deleted file mode 100644
index ee83759..0000000
Binary files a/TestImages/varying_quality/test26.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test27.jpg b/TestImages/varying_quality/test27.jpg
deleted file mode 100644
index 0ee79be..0000000
Binary files a/TestImages/varying_quality/test27.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test3.jpg b/TestImages/varying_quality/test3.jpg
deleted file mode 100644
index fd1f2cb..0000000
Binary files a/TestImages/varying_quality/test3.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test4.jpg b/TestImages/varying_quality/test4.jpg
deleted file mode 100644
index 1f2ffc6..0000000
Binary files a/TestImages/varying_quality/test4.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test5.jpg b/TestImages/varying_quality/test5.jpg
deleted file mode 100644
index f9e8a1f..0000000
Binary files a/TestImages/varying_quality/test5.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test6.jpg b/TestImages/varying_quality/test6.jpg
deleted file mode 100644
index 1454673..0000000
Binary files a/TestImages/varying_quality/test6.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test7.jpg b/TestImages/varying_quality/test7.jpg
deleted file mode 100644
index 82dfb3c..0000000
Binary files a/TestImages/varying_quality/test7.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test8.jpg b/TestImages/varying_quality/test8.jpg
deleted file mode 100644
index 2d480ce..0000000
Binary files a/TestImages/varying_quality/test8.jpg and /dev/null differ
diff --git a/TestImages/varying_quality/test9.jpg b/TestImages/varying_quality/test9.jpg
deleted file mode 100644
index c8b0f53..0000000
Binary files a/TestImages/varying_quality/test9.jpg and /dev/null differ
diff --git a/TestImages/worn/bent_creased.jpg b/TestImages/worn/bent_creased.jpg
deleted file mode 100644
index 18c948a..0000000
Binary files a/TestImages/worn/bent_creased.jpg and /dev/null differ
diff --git a/TestImages/worn/edge_nick.png b/TestImages/worn/edge_nick.png
deleted file mode 100644
index 68a7251..0000000
Binary files a/TestImages/worn/edge_nick.png and /dev/null differ
diff --git a/TestImages/worn/edge_white.png b/TestImages/worn/edge_white.png
deleted file mode 100644
index 1c91723..0000000
Binary files a/TestImages/worn/edge_white.png and /dev/null differ
diff --git a/TestImages/worn/good_1.jpg b/TestImages/worn/good_1.jpg
deleted file mode 100644
index cd0007e..0000000
Binary files a/TestImages/worn/good_1.jpg and /dev/null differ
diff --git a/TestImages/worn/good_2.jpg b/TestImages/worn/good_2.jpg
deleted file mode 100644
index bd6e04e..0000000
Binary files a/TestImages/worn/good_2.jpg and /dev/null differ
diff --git a/TestImages/worn/hp_binder_bite_back.webp b/TestImages/worn/hp_binder_bite_back.webp
deleted file mode 100644
index 727f380..0000000
Binary files a/TestImages/worn/hp_binder_bite_back.webp and /dev/null differ
diff --git a/TestImages/worn/hp_binder_bite_front.webp b/TestImages/worn/hp_binder_bite_front.webp
deleted file mode 100644
index 936ce8d..0000000
Binary files a/TestImages/worn/hp_binder_bite_front.webp and /dev/null differ
diff --git a/TestImages/worn/hp_compromised_corner.webp b/TestImages/worn/hp_compromised_corner.webp
deleted file mode 100644
index 8665a6d..0000000
Binary files a/TestImages/worn/hp_compromised_corner.webp and /dev/null differ
diff --git a/TestImages/worn/hp_scratches.png b/TestImages/worn/hp_scratches.png
deleted file mode 100644
index b179f72..0000000
Binary files a/TestImages/worn/hp_scratches.png and /dev/null differ
diff --git a/TestImages/worn/hp_shuffle_crease.webp b/TestImages/worn/hp_shuffle_crease.webp
deleted file mode 100644
index 6ad1542..0000000
Binary files a/TestImages/worn/hp_shuffle_crease.webp and /dev/null differ
diff --git a/TestImages/worn/hp_water_warping.png b/TestImages/worn/hp_water_warping.png
deleted file mode 100644
index 590dfc0..0000000
Binary files a/TestImages/worn/hp_water_warping.png and /dev/null differ
diff --git a/TestImages/worn/scratch.png b/TestImages/worn/scratch.png
deleted file mode 100644
index d7830d6..0000000
Binary files a/TestImages/worn/scratch.png and /dev/null differ
diff --git a/TestImages/worn/spotting.png b/TestImages/worn/spotting.png
deleted file mode 100644
index f559c42..0000000
Binary files a/TestImages/worn/spotting.png and /dev/null differ
diff --git a/TestImages/worn/very_good_1.jpg b/TestImages/worn/very_good_1.jpg
deleted file mode 100644
index 938cd43..0000000
Binary files a/TestImages/worn/very_good_1.jpg and /dev/null differ
diff --git a/TestImages/worn/very_good_2.jpg b/TestImages/worn/very_good_2.jpg
deleted file mode 100644
index 2431c08..0000000
Binary files a/TestImages/worn/very_good_2.jpg and /dev/null differ
diff --git a/docs/CARD_RECOGNITION.md b/docs/CARD_RECOGNITION.md
deleted file mode 100644
index b788b81..0000000
--- a/docs/CARD_RECOGNITION.md
+++ /dev/null
@@ -1,466 +0,0 @@
-# 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
deleted file mode 100644
index 311b5d9..0000000
--- a/src/Scry.App/App.xaml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Scry.App/App.xaml.cs b/src/Scry.App/App.xaml.cs
deleted file mode 100644
index 5f51db0..0000000
--- a/src/Scry.App/App.xaml.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-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
deleted file mode 100644
index f516875..0000000
--- a/src/Scry.App/AppShell.xaml
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Scry.App/AppShell.xaml.cs b/src/Scry.App/AppShell.xaml.cs
deleted file mode 100644
index c0d57ff..0000000
--- a/src/Scry.App/AppShell.xaml.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-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
deleted file mode 100644
index de0eec1..0000000
--- a/src/Scry.App/Converters/BoolToScanTextConverter.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-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
deleted file mode 100644
index 9d32a3a..0000000
--- a/src/Scry.App/Converters/InverseBoolConverter.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-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
deleted file mode 100644
index d549f8f..0000000
--- a/src/Scry.App/Converters/StringNotEmptyConverter.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-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
deleted file mode 100644
index ebeca2a..0000000
--- a/src/Scry.App/MauiProgram.cs
+++ /dev/null
@@ -1,96 +0,0 @@
-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
deleted file mode 100644
index 4b523d2..0000000
--- a/src/Scry.App/Models/CollectionEntry.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-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
deleted file mode 100644
index a3ec4f5..0000000
--- a/src/Scry.App/Platforms/Android/AndroidManifest.xml
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Scry.App/Platforms/Android/MainActivity.cs b/src/Scry.App/Platforms/Android/MainActivity.cs
deleted file mode 100644
index e569cf3..0000000
--- a/src/Scry.App/Platforms/Android/MainActivity.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-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
deleted file mode 100644
index 0e985c5..0000000
--- a/src/Scry.App/Platforms/Android/MainApplication.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-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
deleted file mode 100644
index 614ac41..0000000
--- a/src/Scry.App/Platforms/Android/Resources/values/colors.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- #512BD4
- #3B1F9E
- #512BD4
-
diff --git a/src/Scry.App/Resources/AppIcon/appicon.svg b/src/Scry.App/Resources/AppIcon/appicon.svg
deleted file mode 100644
index 86e49b4..0000000
--- a/src/Scry.App/Resources/AppIcon/appicon.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
diff --git a/src/Scry.App/Resources/AppIcon/appiconfg.svg b/src/Scry.App/Resources/AppIcon/appiconfg.svg
deleted file mode 100644
index 76d01d6..0000000
--- a/src/Scry.App/Resources/AppIcon/appiconfg.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
diff --git a/src/Scry.App/Resources/Fonts/.gitkeep b/src/Scry.App/Resources/Fonts/.gitkeep
deleted file mode 100644
index dcf2c80..0000000
--- a/src/Scry.App/Resources/Fonts/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-# Placeholder
diff --git a/src/Scry.App/Resources/Raw/.gitkeep b/src/Scry.App/Resources/Raw/.gitkeep
deleted file mode 100644
index dcf2c80..0000000
--- a/src/Scry.App/Resources/Raw/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-# Placeholder
diff --git a/src/Scry.App/Resources/Raw/card_hashes.db b/src/Scry.App/Resources/Raw/card_hashes.db
deleted file mode 100644
index 7f18094..0000000
Binary files a/src/Scry.App/Resources/Raw/card_hashes.db and /dev/null differ
diff --git a/src/Scry.App/Resources/Splash/splash.svg b/src/Scry.App/Resources/Splash/splash.svg
deleted file mode 100644
index be886b2..0000000
--- a/src/Scry.App/Resources/Splash/splash.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
diff --git a/src/Scry.App/Resources/Styles/Colors.xaml b/src/Scry.App/Resources/Styles/Colors.xaml
deleted file mode 100644
index 9f295a7..0000000
--- a/src/Scry.App/Resources/Styles/Colors.xaml
+++ /dev/null
@@ -1,47 +0,0 @@
-
-
-
-
- #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
deleted file mode 100644
index a1c5607..0000000
--- a/src/Scry.App/Resources/Styles/Styles.xaml
+++ /dev/null
@@ -1,184 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Scry.App/Scry.App.csproj b/src/Scry.App/Scry.App.csproj
deleted file mode 100644
index 3a04d80..0000000
--- a/src/Scry.App/Scry.App.csproj
+++ /dev/null
@@ -1,103 +0,0 @@
-
-
-
-
- $(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
deleted file mode 100644
index c481e11..0000000
--- a/src/Scry.App/Services/ICardRecognitionService.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-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
deleted file mode 100644
index 2eaa3fa..0000000
--- a/src/Scry.App/Services/ICardRepository.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-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
deleted file mode 100644
index e56f8d2..0000000
--- a/src/Scry.App/Services/InMemoryCardRepository.cs
+++ /dev/null
@@ -1,103 +0,0 @@
-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
deleted file mode 100644
index a2db310..0000000
--- a/src/Scry.App/Services/MockCardRecognitionService.cs
+++ /dev/null
@@ -1,129 +0,0 @@
-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
deleted file mode 100644
index 1afb57f..0000000
--- a/src/Scry.App/Services/RealCardRecognitionService.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-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
deleted file mode 100644
index f50c2ff..0000000
--- a/src/Scry.App/ViewModels/CardDetailViewModel.cs
+++ /dev/null
@@ -1,86 +0,0 @@
-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
deleted file mode 100644
index 677e921..0000000
--- a/src/Scry.App/ViewModels/CollectionViewModel.cs
+++ /dev/null
@@ -1,84 +0,0 @@
-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
deleted file mode 100644
index 8d1951a..0000000
--- a/src/Scry.App/ViewModels/ScanViewModel.cs
+++ /dev/null
@@ -1,130 +0,0 @@
-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
deleted file mode 100644
index 407a913..0000000
--- a/src/Scry.App/ViewModels/SettingsViewModel.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-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
deleted file mode 100644
index 4432868..0000000
--- a/src/Scry.App/Views/CardDetailPage.xaml
+++ /dev/null
@@ -1,145 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Scry.App/Views/CardDetailPage.xaml.cs b/src/Scry.App/Views/CardDetailPage.xaml.cs
deleted file mode 100644
index 9690709..0000000
--- a/src/Scry.App/Views/CardDetailPage.xaml.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using Scry.ViewModels;
-
-namespace Scry.Views;
-
-public partial class CardDetailPage : ContentPage
-{
- public CardDetailPage(CardDetailViewModel viewModel)
- {
- InitializeComponent();
- BindingContext = viewModel;
- }
-}
diff --git a/src/Scry.App/Views/CollectionPage.xaml b/src/Scry.App/Views/CollectionPage.xaml
deleted file mode 100644
index 70c7e3e..0000000
--- a/src/Scry.App/Views/CollectionPage.xaml
+++ /dev/null
@@ -1,156 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Scry.App/Views/CollectionPage.xaml.cs b/src/Scry.App/Views/CollectionPage.xaml.cs
deleted file mode 100644
index aa5e479..0000000
--- a/src/Scry.App/Views/CollectionPage.xaml.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using Scry.ViewModels;
-
-namespace Scry.Views;
-
-public partial class CollectionPage : ContentPage
-{
- private readonly CollectionViewModel _viewModel;
-
- public CollectionPage(CollectionViewModel viewModel)
- {
- InitializeComponent();
- BindingContext = _viewModel = viewModel;
- }
-
- protected override void OnAppearing()
- {
- base.OnAppearing();
- _viewModel.LoadCollectionCommand.Execute(null);
- }
-}
diff --git a/src/Scry.App/Views/ScanPage.xaml b/src/Scry.App/Views/ScanPage.xaml
deleted file mode 100644
index b00bd94..0000000
--- a/src/Scry.App/Views/ScanPage.xaml
+++ /dev/null
@@ -1,116 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Scry.App/Views/ScanPage.xaml.cs b/src/Scry.App/Views/ScanPage.xaml.cs
deleted file mode 100644
index 9122e6f..0000000
--- a/src/Scry.App/Views/ScanPage.xaml.cs
+++ /dev/null
@@ -1,54 +0,0 @@
-using Scry.ViewModels;
-
-namespace Scry.Views;
-
-public partial class ScanPage : ContentPage
-{
- public ScanPage(ScanViewModel viewModel)
- {
- InitializeComponent();
- BindingContext = viewModel;
- }
-
- protected override async void OnAppearing()
- {
- base.OnAppearing();
-
- // Request camera permission
- var status = await Permissions.CheckStatusAsync();
- if (status != PermissionStatus.Granted)
- {
- status = await Permissions.RequestAsync();
- if (status != PermissionStatus.Granted)
- {
- await DisplayAlertAsync("Permission Denied", "Camera permission is required to scan cards.", "OK");
- return;
- }
- }
-
- // Start camera when page appears
- try
- {
- await cameraView.StartCameraPreview(CancellationToken.None);
- }
- catch (Exception ex)
- {
- await DisplayAlertAsync("Camera Error", $"Could not start camera: {ex.Message}", "OK");
- }
- }
-
- protected override void OnDisappearing()
- {
- base.OnDisappearing();
-
- // Stop camera when leaving page
- try
- {
- cameraView.StopCameraPreview();
- }
- catch
- {
- // Ignore cleanup errors
- }
- }
-}
diff --git a/src/Scry.App/Views/SettingsPage.xaml b/src/Scry.App/Views/SettingsPage.xaml
deleted file mode 100644
index aa27e91..0000000
--- a/src/Scry.App/Views/SettingsPage.xaml
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Scry.App/Views/SettingsPage.xaml.cs b/src/Scry.App/Views/SettingsPage.xaml.cs
deleted file mode 100644
index 64f843d..0000000
--- a/src/Scry.App/Views/SettingsPage.xaml.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Scry.ViewModels;
-
-namespace Scry.Views;
-
-public partial class SettingsPage : ContentPage
-{
- public SettingsPage(SettingsViewModel viewModel)
- {
- InitializeComponent();
- BindingContext = viewModel;
- }
-
- protected override async void OnAppearing()
- {
- base.OnAppearing();
-
- if (BindingContext is SettingsViewModel vm)
- {
- await vm.LoadCommand.ExecuteAsync(null);
- }
- }
-}
diff --git a/src/Scry.App/global.json b/src/Scry.App/global.json
deleted file mode 100644
index bb62055..0000000
--- a/src/Scry.App/global.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "sdk": {
- "version": "10.0.102",
- "rollForward": "latestMinor"
- }
-}
diff --git a/src/Scry.Core/Data/CardDatabase.cs b/src/Scry.Core/Data/CardDatabase.cs
deleted file mode 100644
index 23a3dac..0000000
--- a/src/Scry.Core/Data/CardDatabase.cs
+++ /dev/null
@@ -1,739 +0,0 @@
-using Microsoft.Data.Sqlite;
-using Scry.Core.Models;
-
-namespace Scry.Core.Data;
-
-///
-/// SQLite database for storing card data and perceptual hashes.
-/// Schema mirrors Scryfall's data model: oracles (game cards), sets, and cards (printings).
-///
-public class CardDatabase : IDisposable
-{
- private readonly SqliteConnection _connection;
- private readonly string _dbPath;
-
- public CardDatabase(string dbPath)
- {
- _dbPath = dbPath;
- _connection = new SqliteConnection($"Data Source={dbPath}");
- _connection.Open();
- Initialize();
- }
-
- private void Initialize()
- {
- using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- -- Abstract game cards (oracle)
- CREATE TABLE IF NOT EXISTS oracles (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- mana_cost TEXT,
- cmc REAL,
- type_line TEXT,
- oracle_text TEXT,
- colors TEXT,
- color_identity TEXT,
- keywords TEXT,
- reserved INTEGER DEFAULT 0,
- legalities TEXT,
- power TEXT,
- toughness TEXT
- );
-
- CREATE INDEX IF NOT EXISTS idx_oracles_name ON oracles(name);
-
- -- MTG sets
- CREATE TABLE IF NOT EXISTS sets (
- id TEXT PRIMARY KEY,
- code TEXT NOT NULL UNIQUE,
- name TEXT NOT NULL,
- set_type TEXT,
- released_at TEXT,
- card_count INTEGER,
- icon_svg_uri TEXT,
- digital INTEGER DEFAULT 0,
- parent_set_code TEXT,
- block TEXT
- );
-
- CREATE INDEX IF NOT EXISTS idx_sets_code ON sets(code);
-
- -- Card printings with hashes
- CREATE TABLE IF NOT EXISTS cards (
- id TEXT PRIMARY KEY,
- oracle_id TEXT NOT NULL,
- set_id TEXT NOT NULL,
- set_code TEXT,
- name TEXT NOT NULL,
- collector_number TEXT,
- rarity TEXT,
- artist TEXT,
- illustration_id TEXT,
- image_uri TEXT,
- hash BLOB,
- 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 IF NOT EXISTS idx_cards_oracle_id ON cards(oracle_id);
- CREATE INDEX IF NOT EXISTS idx_cards_set_id ON cards(set_id);
- CREATE INDEX IF NOT EXISTS idx_cards_name ON cards(name);
- CREATE INDEX IF NOT EXISTS idx_cards_set_code ON cards(set_code);
-
- -- Metadata for tracking sync state
- CREATE TABLE IF NOT EXISTS metadata (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
- );
- """;
- cmd.ExecuteNonQuery();
- }
-
- #region Metadata
-
- public async Task GetMetadataAsync(string key, CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT value FROM metadata WHERE key = $key";
- cmd.Parameters.AddWithValue("$key", key);
-
- var result = await cmd.ExecuteScalarAsync(ct);
- return result as string;
- }
-
- public async Task SetMetadataAsync(string key, string value, CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- INSERT OR REPLACE INTO metadata (key, value) VALUES ($key, $value)
- """;
- cmd.Parameters.AddWithValue("$key", key);
- cmd.Parameters.AddWithValue("$value", value);
- await cmd.ExecuteNonQueryAsync(ct);
- }
-
- #endregion
-
- #region Oracles
-
- public async Task InsertOracleAsync(Oracle oracle, CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- INSERT OR REPLACE INTO oracles
- (id, name, mana_cost, cmc, type_line, oracle_text, colors, color_identity, keywords, reserved, legalities, power, toughness)
- VALUES ($id, $name, $mana_cost, $cmc, $type_line, $oracle_text, $colors, $color_identity, $keywords, $reserved, $legalities, $power, $toughness)
- """;
- cmd.Parameters.AddWithValue("$id", oracle.Id);
- cmd.Parameters.AddWithValue("$name", oracle.Name);
- cmd.Parameters.AddWithValue("$mana_cost", oracle.ManaCost ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$cmc", oracle.Cmc ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$type_line", oracle.TypeLine ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$oracle_text", oracle.OracleText ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$colors", oracle.Colors ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$color_identity", oracle.ColorIdentity ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$keywords", oracle.Keywords ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$reserved", oracle.Reserved ? 1 : 0);
- cmd.Parameters.AddWithValue("$legalities", oracle.Legalities ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$power", oracle.Power ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$toughness", oracle.Toughness ?? (object)DBNull.Value);
-
- await cmd.ExecuteNonQueryAsync(ct);
- }
-
- public async Task InsertOracleBatchAsync(IEnumerable oracles, CancellationToken ct = default)
- {
- await using var transaction = await _connection.BeginTransactionAsync(ct);
-
- try
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- INSERT OR REPLACE INTO oracles
- (id, name, mana_cost, cmc, type_line, oracle_text, colors, color_identity, keywords, reserved, legalities, power, toughness)
- VALUES ($id, $name, $mana_cost, $cmc, $type_line, $oracle_text, $colors, $color_identity, $keywords, $reserved, $legalities, $power, $toughness)
- """;
-
- var idParam = cmd.Parameters.Add("$id", SqliteType.Text);
- var nameParam = cmd.Parameters.Add("$name", SqliteType.Text);
- var manaCostParam = cmd.Parameters.Add("$mana_cost", SqliteType.Text);
- var cmcParam = cmd.Parameters.Add("$cmc", SqliteType.Real);
- var typeLineParam = cmd.Parameters.Add("$type_line", SqliteType.Text);
- var oracleTextParam = cmd.Parameters.Add("$oracle_text", SqliteType.Text);
- var colorsParam = cmd.Parameters.Add("$colors", SqliteType.Text);
- var colorIdentityParam = cmd.Parameters.Add("$color_identity", SqliteType.Text);
- var keywordsParam = cmd.Parameters.Add("$keywords", SqliteType.Text);
- var reservedParam = cmd.Parameters.Add("$reserved", SqliteType.Integer);
- var legalitiesParam = cmd.Parameters.Add("$legalities", SqliteType.Text);
- var powerParam = cmd.Parameters.Add("$power", SqliteType.Text);
- var toughnessParam = cmd.Parameters.Add("$toughness", SqliteType.Text);
-
- foreach (var oracle in oracles)
- {
- ct.ThrowIfCancellationRequested();
-
- idParam.Value = oracle.Id;
- nameParam.Value = oracle.Name;
- manaCostParam.Value = oracle.ManaCost ?? (object)DBNull.Value;
- cmcParam.Value = oracle.Cmc ?? (object)DBNull.Value;
- typeLineParam.Value = oracle.TypeLine ?? (object)DBNull.Value;
- oracleTextParam.Value = oracle.OracleText ?? (object)DBNull.Value;
- colorsParam.Value = oracle.Colors ?? (object)DBNull.Value;
- colorIdentityParam.Value = oracle.ColorIdentity ?? (object)DBNull.Value;
- keywordsParam.Value = oracle.Keywords ?? (object)DBNull.Value;
- reservedParam.Value = oracle.Reserved ? 1 : 0;
- legalitiesParam.Value = oracle.Legalities ?? (object)DBNull.Value;
- powerParam.Value = oracle.Power ?? (object)DBNull.Value;
- toughnessParam.Value = oracle.Toughness ?? (object)DBNull.Value;
-
- await cmd.ExecuteNonQueryAsync(ct);
- }
-
- await transaction.CommitAsync(ct);
- }
- catch
- {
- await transaction.RollbackAsync(ct);
- throw;
- }
- }
-
- public async Task GetOracleByIdAsync(string id, CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- SELECT id, name, mana_cost, cmc, type_line, oracle_text, colors, color_identity, keywords, reserved, legalities, power, toughness
- FROM oracles WHERE id = $id
- """;
- cmd.Parameters.AddWithValue("$id", id);
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- if (await reader.ReadAsync(ct))
- {
- return ReadOracle(reader);
- }
- return null;
- }
-
- public async Task GetOracleByNameAsync(string name, CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- SELECT id, name, mana_cost, cmc, type_line, oracle_text, colors, color_identity, keywords, reserved, legalities, power, toughness
- FROM oracles WHERE name = $name COLLATE NOCASE
- """;
- cmd.Parameters.AddWithValue("$name", name);
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- if (await reader.ReadAsync(ct))
- {
- return ReadOracle(reader);
- }
- return null;
- }
-
- public async Task> GetExistingOracleIdsAsync(CancellationToken ct = default)
- {
- var ids = new HashSet();
-
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT id FROM oracles";
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- while (await reader.ReadAsync(ct))
- {
- ids.Add(reader.GetString(0));
- }
-
- return ids;
- }
-
- public async Task GetOracleCountAsync(CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT COUNT(*) FROM oracles";
- var result = await cmd.ExecuteScalarAsync(ct);
- return Convert.ToInt32(result);
- }
-
- private static Oracle ReadOracle(SqliteDataReader reader) => new()
- {
- Id = reader.GetString(0),
- Name = reader.GetString(1),
- ManaCost = reader.IsDBNull(2) ? null : reader.GetString(2),
- Cmc = reader.IsDBNull(3) ? null : reader.GetDouble(3),
- TypeLine = reader.IsDBNull(4) ? null : reader.GetString(4),
- OracleText = reader.IsDBNull(5) ? null : reader.GetString(5),
- Colors = reader.IsDBNull(6) ? null : reader.GetString(6),
- ColorIdentity = reader.IsDBNull(7) ? null : reader.GetString(7),
- Keywords = reader.IsDBNull(8) ? null : reader.GetString(8),
- Reserved = reader.GetInt32(9) != 0,
- Legalities = reader.IsDBNull(10) ? null : reader.GetString(10),
- Power = reader.IsDBNull(11) ? null : reader.GetString(11),
- Toughness = reader.IsDBNull(12) ? null : reader.GetString(12),
- };
-
- #endregion
-
- #region Sets
-
- public async Task InsertSetAsync(Set set, CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- INSERT OR REPLACE INTO sets
- (id, code, name, set_type, released_at, card_count, icon_svg_uri, digital, parent_set_code, block)
- VALUES ($id, $code, $name, $set_type, $released_at, $card_count, $icon_svg_uri, $digital, $parent_set_code, $block)
- """;
- cmd.Parameters.AddWithValue("$id", set.Id);
- cmd.Parameters.AddWithValue("$code", set.Code);
- cmd.Parameters.AddWithValue("$name", set.Name);
- cmd.Parameters.AddWithValue("$set_type", set.SetType ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$released_at", set.ReleasedAt ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$card_count", set.CardCount ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$icon_svg_uri", set.IconSvgUri ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$digital", set.Digital ? 1 : 0);
- cmd.Parameters.AddWithValue("$parent_set_code", set.ParentSetCode ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$block", set.Block ?? (object)DBNull.Value);
-
- await cmd.ExecuteNonQueryAsync(ct);
- }
-
- public async Task InsertSetBatchAsync(IEnumerable sets, CancellationToken ct = default)
- {
- await using var transaction = await _connection.BeginTransactionAsync(ct);
-
- try
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- INSERT OR REPLACE INTO sets
- (id, code, name, set_type, released_at, card_count, icon_svg_uri, digital, parent_set_code, block)
- VALUES ($id, $code, $name, $set_type, $released_at, $card_count, $icon_svg_uri, $digital, $parent_set_code, $block)
- """;
-
- var idParam = cmd.Parameters.Add("$id", SqliteType.Text);
- var codeParam = cmd.Parameters.Add("$code", SqliteType.Text);
- var nameParam = cmd.Parameters.Add("$name", SqliteType.Text);
- var setTypeParam = cmd.Parameters.Add("$set_type", SqliteType.Text);
- var releasedAtParam = cmd.Parameters.Add("$released_at", SqliteType.Text);
- var cardCountParam = cmd.Parameters.Add("$card_count", SqliteType.Integer);
- var iconSvgUriParam = cmd.Parameters.Add("$icon_svg_uri", SqliteType.Text);
- var digitalParam = cmd.Parameters.Add("$digital", SqliteType.Integer);
- var parentSetCodeParam = cmd.Parameters.Add("$parent_set_code", SqliteType.Text);
- var blockParam = cmd.Parameters.Add("$block", SqliteType.Text);
-
- foreach (var set in sets)
- {
- ct.ThrowIfCancellationRequested();
-
- idParam.Value = set.Id;
- codeParam.Value = set.Code;
- nameParam.Value = set.Name;
- setTypeParam.Value = set.SetType ?? (object)DBNull.Value;
- releasedAtParam.Value = set.ReleasedAt ?? (object)DBNull.Value;
- cardCountParam.Value = set.CardCount ?? (object)DBNull.Value;
- iconSvgUriParam.Value = set.IconSvgUri ?? (object)DBNull.Value;
- digitalParam.Value = set.Digital ? 1 : 0;
- parentSetCodeParam.Value = set.ParentSetCode ?? (object)DBNull.Value;
- blockParam.Value = set.Block ?? (object)DBNull.Value;
-
- await cmd.ExecuteNonQueryAsync(ct);
- }
-
- await transaction.CommitAsync(ct);
- }
- catch
- {
- await transaction.RollbackAsync(ct);
- throw;
- }
- }
-
- public async Task GetSetByIdAsync(string id, CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- SELECT id, code, name, set_type, released_at, card_count, icon_svg_uri, digital, parent_set_code, block
- FROM sets WHERE id = $id
- """;
- cmd.Parameters.AddWithValue("$id", id);
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- if (await reader.ReadAsync(ct))
- {
- return ReadSet(reader);
- }
- return null;
- }
-
- public async Task GetSetByCodeAsync(string code, CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- SELECT id, code, name, set_type, released_at, card_count, icon_svg_uri, digital, parent_set_code, block
- FROM sets WHERE code = $code COLLATE NOCASE
- """;
- cmd.Parameters.AddWithValue("$code", code);
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- if (await reader.ReadAsync(ct))
- {
- return ReadSet(reader);
- }
- return null;
- }
-
- public async Task> GetExistingSetIdsAsync(CancellationToken ct = default)
- {
- var ids = new HashSet();
-
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT id FROM sets";
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- while (await reader.ReadAsync(ct))
- {
- ids.Add(reader.GetString(0));
- }
-
- return ids;
- }
-
- public async Task GetSetCountAsync(CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT COUNT(*) FROM sets";
- var result = await cmd.ExecuteScalarAsync(ct);
- return Convert.ToInt32(result);
- }
-
- private static Set ReadSet(SqliteDataReader reader) => new()
- {
- Id = reader.GetString(0),
- Code = reader.GetString(1),
- Name = reader.GetString(2),
- SetType = reader.IsDBNull(3) ? null : reader.GetString(3),
- ReleasedAt = reader.IsDBNull(4) ? null : reader.GetString(4),
- CardCount = reader.IsDBNull(5) ? null : reader.GetInt32(5),
- IconSvgUri = reader.IsDBNull(6) ? null : reader.GetString(6),
- Digital = reader.GetInt32(7) != 0,
- ParentSetCode = reader.IsDBNull(8) ? null : reader.GetString(8),
- Block = reader.IsDBNull(9) ? null : reader.GetString(9),
- };
-
- #endregion
-
- #region Cards (Printings)
-
- public async Task InsertCardAsync(Card card, CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- INSERT OR REPLACE INTO cards
- (id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil)
- VALUES ($id, $oracle_id, $set_id, $set_code, $name, $collector_number, $rarity, $artist, $illustration_id, $image_uri, $hash, $lang, $prices_usd, $prices_usd_foil)
- """;
- cmd.Parameters.AddWithValue("$id", card.Id);
- cmd.Parameters.AddWithValue("$oracle_id", card.OracleId);
- cmd.Parameters.AddWithValue("$set_id", card.SetId);
- cmd.Parameters.AddWithValue("$set_code", card.SetCode ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$name", card.Name);
- cmd.Parameters.AddWithValue("$collector_number", card.CollectorNumber ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$rarity", card.Rarity ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$artist", card.Artist ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$illustration_id", card.IllustrationId ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$image_uri", card.ImageUri ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$hash", card.Hash ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$lang", card.Lang ?? "en");
- cmd.Parameters.AddWithValue("$prices_usd", card.PricesUsd ?? (object)DBNull.Value);
- cmd.Parameters.AddWithValue("$prices_usd_foil", card.PricesUsdFoil ?? (object)DBNull.Value);
-
- await cmd.ExecuteNonQueryAsync(ct);
- }
-
- public async Task InsertCardBatchAsync(IEnumerable cards, CancellationToken ct = default)
- {
- await using var transaction = await _connection.BeginTransactionAsync(ct);
-
- try
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- INSERT OR REPLACE INTO cards
- (id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil)
- VALUES ($id, $oracle_id, $set_id, $set_code, $name, $collector_number, $rarity, $artist, $illustration_id, $image_uri, $hash, $lang, $prices_usd, $prices_usd_foil)
- """;
-
- var idParam = cmd.Parameters.Add("$id", SqliteType.Text);
- var oracleIdParam = cmd.Parameters.Add("$oracle_id", SqliteType.Text);
- var setIdParam = cmd.Parameters.Add("$set_id", SqliteType.Text);
- var setCodeParam = cmd.Parameters.Add("$set_code", SqliteType.Text);
- var nameParam = cmd.Parameters.Add("$name", SqliteType.Text);
- var collectorNumberParam = cmd.Parameters.Add("$collector_number", SqliteType.Text);
- var rarityParam = cmd.Parameters.Add("$rarity", SqliteType.Text);
- var artistParam = cmd.Parameters.Add("$artist", SqliteType.Text);
- var illustrationIdParam = cmd.Parameters.Add("$illustration_id", SqliteType.Text);
- var imageUriParam = cmd.Parameters.Add("$image_uri", SqliteType.Text);
- var hashParam = cmd.Parameters.Add("$hash", SqliteType.Blob);
- var langParam = cmd.Parameters.Add("$lang", SqliteType.Text);
- var pricesUsdParam = cmd.Parameters.Add("$prices_usd", SqliteType.Real);
- var pricesUsdFoilParam = cmd.Parameters.Add("$prices_usd_foil", SqliteType.Real);
-
- foreach (var card in cards)
- {
- ct.ThrowIfCancellationRequested();
-
- idParam.Value = card.Id;
- oracleIdParam.Value = card.OracleId;
- setIdParam.Value = card.SetId;
- setCodeParam.Value = card.SetCode ?? (object)DBNull.Value;
- nameParam.Value = card.Name;
- collectorNumberParam.Value = card.CollectorNumber ?? (object)DBNull.Value;
- rarityParam.Value = card.Rarity ?? (object)DBNull.Value;
- artistParam.Value = card.Artist ?? (object)DBNull.Value;
- illustrationIdParam.Value = card.IllustrationId ?? (object)DBNull.Value;
- imageUriParam.Value = card.ImageUri ?? (object)DBNull.Value;
- hashParam.Value = card.Hash ?? (object)DBNull.Value;
- langParam.Value = card.Lang ?? "en";
- pricesUsdParam.Value = card.PricesUsd ?? (object)DBNull.Value;
- pricesUsdFoilParam.Value = card.PricesUsdFoil ?? (object)DBNull.Value;
-
- await cmd.ExecuteNonQueryAsync(ct);
- }
-
- await transaction.CommitAsync(ct);
- }
- catch
- {
- await transaction.RollbackAsync(ct);
- throw;
- }
- }
-
- public async Task GetCardByIdAsync(string id, CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- SELECT id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil
- FROM cards WHERE id = $id
- """;
- cmd.Parameters.AddWithValue("$id", id);
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- if (await reader.ReadAsync(ct))
- {
- return ReadCard(reader);
- }
- return null;
- }
-
- public async Task> GetCardsByOracleIdAsync(string oracleId, CancellationToken ct = default)
- {
- var cards = new List();
-
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- SELECT id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil
- FROM cards WHERE oracle_id = $oracle_id
- """;
- cmd.Parameters.AddWithValue("$oracle_id", oracleId);
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- while (await reader.ReadAsync(ct))
- {
- cards.Add(ReadCard(reader));
- }
-
- return cards;
- }
-
- public async Task> GetCardsByNameAsync(string name, CancellationToken ct = default)
- {
- var cards = new List();
-
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- SELECT id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil
- FROM cards WHERE name = $name COLLATE NOCASE
- """;
- cmd.Parameters.AddWithValue("$name", name);
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- while (await reader.ReadAsync(ct))
- {
- cards.Add(ReadCard(reader));
- }
-
- return cards;
- }
-
- public async Task> GetAllCardsAsync(CancellationToken ct = default)
- {
- var cards = new List();
-
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- SELECT id, oracle_id, set_id, set_code, name, collector_number, rarity, artist, illustration_id, image_uri, hash, lang, prices_usd, prices_usd_foil
- FROM cards
- """;
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- while (await reader.ReadAsync(ct))
- {
- cards.Add(ReadCard(reader));
- }
-
- return cards;
- }
-
- public async Task> GetCardsWithHashAsync(CancellationToken ct = default)
- {
- var cards = new List();
-
- await using var cmd = _connection.CreateCommand();
- // Join with oracles and sets to get denormalized data
- cmd.CommandText = """
- SELECT c.id, c.oracle_id, c.set_id, c.set_code, c.name, c.collector_number, c.rarity, c.artist,
- c.illustration_id, c.image_uri, c.hash, c.lang, c.prices_usd, c.prices_usd_foil,
- o.mana_cost, o.type_line, o.oracle_text, o.power, o.toughness,
- s.name as set_name
- FROM cards c
- LEFT JOIN oracles o ON c.oracle_id = o.id
- LEFT JOIN sets s ON c.set_id = s.id
- WHERE c.hash IS NOT NULL
- """;
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- while (await reader.ReadAsync(ct))
- {
- cards.Add(ReadCardWithOracle(reader));
- }
-
- return cards;
- }
-
- public async Task> GetExistingCardIdsAsync(CancellationToken ct = default)
- {
- var ids = new HashSet();
-
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT id FROM cards";
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- while (await reader.ReadAsync(ct))
- {
- ids.Add(reader.GetString(0));
- }
-
- return ids;
- }
-
- public async Task> GetExistingCardNamesAsync(CancellationToken ct = default)
- {
- var names = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT DISTINCT name FROM cards";
-
- await using var reader = await cmd.ExecuteReaderAsync(ct);
- while (await reader.ReadAsync(ct))
- {
- names.Add(reader.GetString(0));
- }
-
- return names;
- }
-
- public async Task GetCardCountAsync(CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = "SELECT COUNT(*) FROM cards";
- var result = await cmd.ExecuteScalarAsync(ct);
- return Convert.ToInt32(result);
- }
-
- public async Task DeleteCardByIdAsync(string id, CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = "DELETE FROM cards WHERE id = $id";
- cmd.Parameters.AddWithValue("$id", id);
- await cmd.ExecuteNonQueryAsync(ct);
- }
-
- public async Task ClearCardsAsync(CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = "DELETE FROM cards";
- await cmd.ExecuteNonQueryAsync(ct);
- }
-
- public async Task ClearAllAsync(CancellationToken ct = default)
- {
- await using var cmd = _connection.CreateCommand();
- cmd.CommandText = """
- DELETE FROM cards;
- DELETE FROM oracles;
- DELETE FROM sets;
- DELETE FROM metadata;
- """;
- await cmd.ExecuteNonQueryAsync(ct);
- }
-
- private static Card ReadCard(SqliteDataReader reader) => new()
- {
- Id = reader.GetString(0),
- OracleId = reader.GetString(1),
- SetId = reader.GetString(2),
- SetCode = reader.IsDBNull(3) ? null : reader.GetString(3),
- Name = reader.GetString(4),
- CollectorNumber = reader.IsDBNull(5) ? null : reader.GetString(5),
- Rarity = reader.IsDBNull(6) ? null : reader.GetString(6),
- Artist = reader.IsDBNull(7) ? null : reader.GetString(7),
- IllustrationId = reader.IsDBNull(8) ? null : reader.GetString(8),
- ImageUri = reader.IsDBNull(9) ? null : reader.GetString(9),
- Hash = reader.IsDBNull(10) ? null : (byte[])reader.GetValue(10),
- Lang = reader.IsDBNull(11) ? null : reader.GetString(11),
- PricesUsd = reader.IsDBNull(12) ? null : (decimal)reader.GetDouble(12),
- PricesUsdFoil = reader.IsDBNull(13) ? null : (decimal)reader.GetDouble(13),
- };
-
- ///
- /// Reads a card with joined Oracle and Set data (columns 14-19).
- ///
- private static Card ReadCardWithOracle(SqliteDataReader reader) => new()
- {
- Id = reader.GetString(0),
- OracleId = reader.GetString(1),
- SetId = reader.GetString(2),
- SetCode = reader.IsDBNull(3) ? null : reader.GetString(3),
- Name = reader.GetString(4),
- CollectorNumber = reader.IsDBNull(5) ? null : reader.GetString(5),
- Rarity = reader.IsDBNull(6) ? null : reader.GetString(6),
- Artist = reader.IsDBNull(7) ? null : reader.GetString(7),
- IllustrationId = reader.IsDBNull(8) ? null : reader.GetString(8),
- ImageUri = reader.IsDBNull(9) ? null : reader.GetString(9),
- Hash = reader.IsDBNull(10) ? null : (byte[])reader.GetValue(10),
- Lang = reader.IsDBNull(11) ? null : reader.GetString(11),
- PricesUsd = reader.IsDBNull(12) ? null : (decimal)reader.GetDouble(12),
- PricesUsdFoil = reader.IsDBNull(13) ? null : (decimal)reader.GetDouble(13),
- // Denormalized Oracle fields (from JOIN)
- ManaCost = reader.IsDBNull(14) ? null : reader.GetString(14),
- TypeLine = reader.IsDBNull(15) ? null : reader.GetString(15),
- OracleText = reader.IsDBNull(16) ? null : reader.GetString(16),
- Power = reader.IsDBNull(17) ? null : reader.GetString(17),
- Toughness = reader.IsDBNull(18) ? null : reader.GetString(18),
- SetName = reader.IsDBNull(19) ? null : reader.GetString(19),
- };
-
- #endregion
-
- public void Dispose()
- {
- _connection.Dispose();
- }
-}
diff --git a/src/Scry.Core/Imaging/CardDetector.cs b/src/Scry.Core/Imaging/CardDetector.cs
deleted file mode 100644
index 5b147eb..0000000
--- a/src/Scry.Core/Imaging/CardDetector.cs
+++ /dev/null
@@ -1,625 +0,0 @@
-using SkiaSharp;
-
-namespace Scry.Core.Imaging;
-
-///
-/// Detects card boundaries in images using edge detection and contour analysis.
-///
-public static class CardDetector
-{
- ///
- /// Standard MTG card aspect ratio (height / width).
- /// Cards are 63mm x 88mm = 1.397 aspect ratio.
- ///
- private const float CardAspectRatio = 88f / 63f; // ~1.397
- private const float AspectRatioTolerance = 0.25f; // Allow 25% deviation
-
- ///
- /// Minimum area of detected card relative to image area.
- ///
- private const float MinCardAreaRatio = 0.05f; // Card must be at least 5% of image
-
- ///
- /// Maximum area of detected card relative to image area.
- ///
- private const float MaxCardAreaRatio = 0.98f; // Card can be at most 98% of image
-
- ///
- /// Result of card detection.
- ///
- public record CardDetectionResult(
- bool Found,
- SKPoint[] Corners,
- float Confidence,
- string? DebugMessage = null
- )
- {
- public static CardDetectionResult NotFound(string reason) =>
- new(false, Array.Empty(), 0, reason);
-
- public static CardDetectionResult Success(SKPoint[] corners, float confidence) =>
- new(true, corners, confidence);
- }
-
- ///
- /// Detect a card in the image and return its corner points.
- ///
- public static CardDetectionResult DetectCard(SKBitmap image)
- {
- // Step 1: Convert to grayscale
- using var grayscale = ToGrayscale(image);
-
- // Step 2: Apply Gaussian blur to reduce noise
- using var blurred = ApplyGaussianBlur(grayscale, 5);
-
- // Step 3: Apply Canny edge detection
- using var edges = ApplyCannyEdgeDetection(blurred, 50, 150);
-
- // Step 4: Find contours
- var contours = FindContours(edges);
-
- if (contours.Count == 0)
- return CardDetectionResult.NotFound("No contours found");
-
- // Step 5: Find the best card-like quadrilateral
- var imageArea = image.Width * image.Height;
- var bestQuad = FindBestCardQuadrilateral(contours, imageArea);
-
- if (bestQuad == null)
- return CardDetectionResult.NotFound("No card-like quadrilateral found");
-
- // Step 6: Order corners consistently (top-left, top-right, bottom-right, bottom-left)
- var orderedCorners = OrderCorners(bestQuad);
-
- // Calculate confidence based on how well the shape matches a card
- var confidence = CalculateConfidence(orderedCorners, imageArea);
-
- return CardDetectionResult.Success(orderedCorners, confidence);
- }
-
- ///
- /// Convert image to grayscale.
- ///
- private static SKBitmap ToGrayscale(SKBitmap source)
- {
- var result = new SKBitmap(source.Width, source.Height, SKColorType.Gray8, SKAlphaType.Opaque);
-
- for (var y = 0; y < source.Height; y++)
- {
- for (var x = 0; x < source.Width; x++)
- {
- var pixel = source.GetPixel(x, y);
- var gray = (byte)(0.299 * pixel.Red + 0.587 * pixel.Green + 0.114 * pixel.Blue);
- result.SetPixel(x, y, new SKColor(gray, gray, gray));
- }
- }
-
- return result;
- }
-
- ///
- /// Apply Gaussian blur using a simple box blur approximation.
- ///
- private static SKBitmap ApplyGaussianBlur(SKBitmap source, int radius)
- {
- var result = new SKBitmap(source.Width, source.Height, source.ColorType, source.AlphaType);
-
- // Use SkiaSharp's built-in blur
- using var surface = SKSurface.Create(new SKImageInfo(source.Width, source.Height));
- var canvas = surface.Canvas;
-
- using var paint = new SKPaint
- {
- ImageFilter = SKImageFilter.CreateBlur(radius, radius)
- };
-
- canvas.DrawBitmap(source, 0, 0, paint);
-
- using var image = surface.Snapshot();
- image.ReadPixels(result.Info, result.GetPixels(), result.RowBytes, 0, 0);
-
- return result;
- }
-
- ///
- /// Apply Canny edge detection.
- ///
- private static SKBitmap ApplyCannyEdgeDetection(SKBitmap source, int lowThreshold, int highThreshold)
- {
- var width = source.Width;
- var height = source.Height;
-
- // Step 1: Compute gradients using Sobel operators
- var gradientX = new float[height, width];
- var gradientY = new float[height, width];
- var magnitude = new float[height, width];
- var direction = new float[height, width];
-
- // Sobel kernels
- int[,] sobelX = { { -1, 0, 1 }, { -2, 0, 2 }, { -1, 0, 1 } };
- int[,] sobelY = { { -1, -2, -1 }, { 0, 0, 0 }, { 1, 2, 1 } };
-
- for (var y = 1; y < height - 1; y++)
- {
- for (var x = 1; x < width - 1; x++)
- {
- float gx = 0, gy = 0;
-
- for (var ky = -1; ky <= 1; ky++)
- {
- for (var kx = -1; kx <= 1; kx++)
- {
- var pixel = source.GetPixel(x + kx, y + ky).Red;
- gx += pixel * sobelX[ky + 1, kx + 1];
- gy += pixel * sobelY[ky + 1, kx + 1];
- }
- }
-
- gradientX[y, x] = gx;
- gradientY[y, x] = gy;
- magnitude[y, x] = MathF.Sqrt(gx * gx + gy * gy);
- direction[y, x] = MathF.Atan2(gy, gx);
- }
- }
-
- // Step 2: Non-maximum suppression
- var suppressed = new float[height, width];
-
- for (var y = 1; y < height - 1; y++)
- {
- for (var x = 1; x < width - 1; x++)
- {
- var angle = direction[y, x] * 180 / MathF.PI;
- if (angle < 0) angle += 180;
-
- float neighbor1, neighbor2;
-
- if (angle is < 22.5f or >= 157.5f)
- {
- neighbor1 = magnitude[y, x - 1];
- neighbor2 = magnitude[y, x + 1];
- }
- else if (angle is >= 22.5f and < 67.5f)
- {
- neighbor1 = magnitude[y - 1, x + 1];
- neighbor2 = magnitude[y + 1, x - 1];
- }
- else if (angle is >= 67.5f and < 112.5f)
- {
- neighbor1 = magnitude[y - 1, x];
- neighbor2 = magnitude[y + 1, x];
- }
- else
- {
- neighbor1 = magnitude[y - 1, x - 1];
- neighbor2 = magnitude[y + 1, x + 1];
- }
-
- if (magnitude[y, x] >= neighbor1 && magnitude[y, x] >= neighbor2)
- suppressed[y, x] = magnitude[y, x];
- }
- }
-
- // Step 3: Double thresholding and edge tracking
- var result = new SKBitmap(width, height, SKColorType.Gray8, SKAlphaType.Opaque);
- var strong = new bool[height, width];
- var weak = new bool[height, width];
-
- for (var y = 0; y < height; y++)
- {
- for (var x = 0; x < width; x++)
- {
- if (suppressed[y, x] >= highThreshold)
- strong[y, x] = true;
- else if (suppressed[y, x] >= lowThreshold)
- weak[y, x] = true;
- }
- }
-
- // Edge tracking by hysteresis
- for (var y = 1; y < height - 1; y++)
- {
- for (var x = 1; x < width - 1; x++)
- {
- byte value = 0;
-
- if (strong[y, x])
- {
- value = 255;
- }
- else if (weak[y, x])
- {
- // Check if connected to strong edge
- for (var dy = -1; dy <= 1; dy++)
- {
- for (var dx = -1; dx <= 1; dx++)
- {
- if (strong[y + dy, x + dx])
- {
- value = 255;
- break;
- }
- }
- if (value == 255) break;
- }
- }
-
- result.SetPixel(x, y, new SKColor(value, value, value));
- }
- }
-
- return result;
- }
-
- ///
- /// Find contours in a binary edge image.
- ///
- private static List> FindContours(SKBitmap edges)
- {
- var width = edges.Width;
- var height = edges.Height;
- var visited = new bool[height, width];
- var contours = new List>();
-
- // Simple contour tracing
- for (var y = 1; y < height - 1; y++)
- {
- for (var x = 1; x < width - 1; x++)
- {
- if (visited[y, x]) continue;
- if (edges.GetPixel(x, y).Red < 128) continue;
-
- var contour = TraceContour(edges, x, y, visited);
- if (contour.Count >= 4)
- {
- contours.Add(contour);
- }
- }
- }
-
- return contours;
- }
-
- ///
- /// Trace a contour starting from a point.
- ///
- private static List TraceContour(SKBitmap edges, int startX, int startY, bool[,] visited)
- {
- var contour = new List();
- var queue = new Queue<(int x, int y)>();
- queue.Enqueue((startX, startY));
-
- // 8-directional neighbors
- int[] dx = { -1, 0, 1, 1, 1, 0, -1, -1 };
- int[] dy = { -1, -1, -1, 0, 1, 1, 1, 0 };
-
- while (queue.Count > 0 && contour.Count < 10000) // Limit to prevent runaway
- {
- var (x, y) = queue.Dequeue();
-
- if (x < 0 || x >= edges.Width || y < 0 || y >= edges.Height)
- continue;
- if (visited[y, x])
- continue;
- if (edges.GetPixel(x, y).Red < 128)
- continue;
-
- visited[y, x] = true;
- contour.Add(new SKPoint(x, y));
-
- for (var i = 0; i < 8; i++)
- {
- queue.Enqueue((x + dx[i], y + dy[i]));
- }
- }
-
- return contour;
- }
-
- ///
- /// Find the best quadrilateral that matches a card shape.
- ///
- private static SKPoint[]? FindBestCardQuadrilateral(List> contours, float imageArea)
- {
- SKPoint[]? bestQuad = null;
- var bestScore = float.MinValue;
-
- foreach (var contour in contours)
- {
- // Simplify contour using Douglas-Peucker algorithm
- var simplified = SimplifyContour(contour, contour.Count * 0.02f);
-
- // Try to approximate as quadrilateral
- var quad = ApproximateQuadrilateral(simplified);
- if (quad == null) continue;
-
- // Check if it's a valid card shape
- var area = CalculateQuadArea(quad);
- var areaRatio = area / imageArea;
-
- if (areaRatio < MinCardAreaRatio || areaRatio > MaxCardAreaRatio)
- continue;
-
- // Check aspect ratio
- var aspectScore = CalculateAspectRatioScore(quad);
- if (aspectScore < 0.5f) continue;
-
- // Check if it's convex
- if (!IsConvex(quad)) continue;
-
- // Score based on area (prefer larger) and aspect ratio match
- var score = areaRatio * aspectScore;
-
- if (score > bestScore)
- {
- bestScore = score;
- bestQuad = quad;
- }
- }
-
- return bestQuad;
- }
-
- ///
- /// Simplify a contour using Douglas-Peucker algorithm.
- ///
- private static List SimplifyContour(List contour, float epsilon)
- {
- if (contour.Count < 3) return contour;
-
- // Find the point farthest from line between first and last
- var first = contour[0];
- var last = contour[^1];
-
- var maxDist = 0f;
- var maxIndex = 0;
-
- for (var i = 1; i < contour.Count - 1; i++)
- {
- var dist = PointToLineDistance(contour[i], first, last);
- if (dist > maxDist)
- {
- maxDist = dist;
- maxIndex = i;
- }
- }
-
- if (maxDist > epsilon)
- {
- var left = SimplifyContour(contour.Take(maxIndex + 1).ToList(), epsilon);
- var right = SimplifyContour(contour.Skip(maxIndex).ToList(), epsilon);
-
- return left.Take(left.Count - 1).Concat(right).ToList();
- }
-
- return new List { first, last };
- }
-
- ///
- /// Calculate distance from point to line.
- ///
- private static float PointToLineDistance(SKPoint point, SKPoint lineStart, SKPoint lineEnd)
- {
- var dx = lineEnd.X - lineStart.X;
- var dy = lineEnd.Y - lineStart.Y;
- var lengthSquared = dx * dx + dy * dy;
-
- if (lengthSquared == 0)
- return Distance(point, lineStart);
-
- var t = ((point.X - lineStart.X) * dx + (point.Y - lineStart.Y) * dy) / lengthSquared;
- t = Math.Clamp(t, 0, 1);
-
- var projection = new SKPoint(lineStart.X + t * dx, lineStart.Y + t * dy);
- return Distance(point, projection);
- }
-
- ///
- /// Try to approximate a contour as a quadrilateral.
- ///
- private static SKPoint[]? ApproximateQuadrilateral(List contour)
- {
- if (contour.Count < 4) return null;
-
- // If already 4 points, use them
- if (contour.Count == 4)
- return contour.ToArray();
-
- // Find convex hull
- var hull = ConvexHull(contour);
- if (hull.Count < 4) return null;
-
- // Find 4 corners by finding the 4 points that form the largest quadrilateral
- if (hull.Count == 4)
- return hull.ToArray();
-
- // Find the 4 most extreme points
- var corners = FindExtremePoints(hull);
- return corners.Length == 4 ? corners : null;
- }
-
- ///
- /// Calculate convex hull using Graham scan.
- ///
- private static List ConvexHull(List points)
- {
- if (points.Count < 3) return points;
-
- // Find bottom-most point (or left-most in case of tie)
- var start = points.OrderBy(p => p.Y).ThenBy(p => p.X).First();
-
- // Sort by polar angle
- var sorted = points
- .Where(p => p != start)
- .OrderBy(p => MathF.Atan2(p.Y - start.Y, p.X - start.X))
- .ThenBy(p => Distance(p, start))
- .ToList();
-
- var hull = new List { start };
-
- foreach (var point in sorted)
- {
- while (hull.Count > 1 && CrossProduct(hull[^2], hull[^1], point) <= 0)
- {
- hull.RemoveAt(hull.Count - 1);
- }
- hull.Add(point);
- }
-
- return hull;
- }
-
- ///
- /// Find the 4 most extreme points of a convex hull.
- ///
- private static SKPoint[] FindExtremePoints(List hull)
- {
- if (hull.Count <= 4) return hull.ToArray();
-
- // Find points with min/max X and Y
- var minX = hull.OrderBy(p => p.X).First();
- var maxX = hull.OrderByDescending(p => p.X).First();
- var minY = hull.OrderBy(p => p.Y).First();
- var maxY = hull.OrderByDescending(p => p.Y).First();
-
- var extremes = new HashSet { minX, maxX, minY, maxY };
-
- if (extremes.Count == 4)
- return extremes.ToArray();
-
- // If we have duplicates, add more points
- var sorted = hull.OrderBy(p => MathF.Atan2(p.Y - hull.Average(h => h.Y), p.X - hull.Average(h => h.X))).ToList();
-
- var step = sorted.Count / 4;
- return new[]
- {
- sorted[0],
- sorted[step],
- sorted[step * 2],
- sorted[step * 3]
- };
- }
-
- ///
- /// Calculate cross product of vectors (b-a) and (c-b).
- ///
- private static float CrossProduct(SKPoint a, SKPoint b, SKPoint c)
- {
- return (b.X - a.X) * (c.Y - a.Y) - (b.Y - a.Y) * (c.X - a.X);
- }
-
- ///
- /// Calculate distance between two points.
- ///
- private static float Distance(SKPoint a, SKPoint b)
- {
- var dx = b.X - a.X;
- var dy = b.Y - a.Y;
- return MathF.Sqrt(dx * dx + dy * dy);
- }
-
- ///
- /// Calculate the area of a quadrilateral.
- ///
- private static float CalculateQuadArea(SKPoint[] quad)
- {
- // Shoelace formula
- var area = 0f;
- for (var i = 0; i < 4; i++)
- {
- var j = (i + 1) % 4;
- area += quad[i].X * quad[j].Y;
- area -= quad[j].X * quad[i].Y;
- }
- return Math.Abs(area) / 2;
- }
-
- ///
- /// Calculate how well the aspect ratio matches a card.
- ///
- private static float CalculateAspectRatioScore(SKPoint[] quad)
- {
- // Calculate width and height of the quadrilateral
- var width1 = Distance(quad[0], quad[1]);
- var width2 = Distance(quad[2], quad[3]);
- var height1 = Distance(quad[1], quad[2]);
- var height2 = Distance(quad[3], quad[0]);
-
- var avgWidth = (width1 + width2) / 2;
- var avgHeight = (height1 + height2) / 2;
-
- // Ensure height > width (portrait orientation)
- var aspectRatio = avgWidth > avgHeight
- ? avgWidth / avgHeight
- : avgHeight / avgWidth;
-
- var expectedRatio = CardAspectRatio;
- var deviation = Math.Abs(aspectRatio - expectedRatio) / expectedRatio;
-
- // Score from 0 to 1 based on how close to expected ratio
- return Math.Max(0, 1 - deviation / AspectRatioTolerance);
- }
-
- ///
- /// Check if a quadrilateral is convex.
- ///
- private static bool IsConvex(SKPoint[] quad)
- {
- var sign = 0;
-
- for (var i = 0; i < 4; i++)
- {
- var cross = CrossProduct(quad[i], quad[(i + 1) % 4], quad[(i + 2) % 4]);
-
- if (Math.Abs(cross) < 0.0001f) continue;
-
- var currentSign = cross > 0 ? 1 : -1;
-
- if (sign == 0)
- sign = currentSign;
- else if (sign != currentSign)
- return false;
- }
-
- return true;
- }
-
- ///
- /// Order corners consistently: top-left, top-right, bottom-right, bottom-left.
- ///
- private static SKPoint[] OrderCorners(SKPoint[] corners)
- {
- // Find center
- var centerX = corners.Average(c => c.X);
- var centerY = corners.Average(c => c.Y);
-
- // Classify each corner
- var topLeft = corners.Where(c => c.X < centerX && c.Y < centerY).OrderBy(c => c.X + c.Y).FirstOrDefault();
- var topRight = corners.Where(c => c.X >= centerX && c.Y < centerY).OrderBy(c => c.Y - c.X).FirstOrDefault();
- var bottomRight = corners.Where(c => c.X >= centerX && c.Y >= centerY).OrderByDescending(c => c.X + c.Y).FirstOrDefault();
- var bottomLeft = corners.Where(c => c.X < centerX && c.Y >= centerY).OrderByDescending(c => c.Y - c.X).FirstOrDefault();
-
- // Handle edge cases by sorting by angle from center
- if (topLeft == default || topRight == default || bottomRight == default || bottomLeft == default)
- {
- var sorted = corners.OrderBy(c => MathF.Atan2(c.Y - centerY, c.X - centerX)).ToArray();
- // Rotate to start with top-left
- var minSum = sorted.Select((c, i) => (c.X + c.Y, i)).Min().i;
- return sorted.Skip(minSum).Concat(sorted.Take(minSum)).ToArray();
- }
-
- return new[] { topLeft, topRight, bottomRight, bottomLeft };
- }
-
- ///
- /// Calculate confidence of the detection.
- ///
- private static float CalculateConfidence(SKPoint[] corners, float imageArea)
- {
- var area = CalculateQuadArea(corners);
- var areaScore = Math.Min(area / imageArea / 0.5f, 1f); // Prefer larger cards
- var aspectScore = CalculateAspectRatioScore(corners);
-
- return areaScore * 0.4f + aspectScore * 0.6f;
- }
-}
diff --git a/src/Scry.Core/Imaging/ImagePreprocessor.cs b/src/Scry.Core/Imaging/ImagePreprocessor.cs
deleted file mode 100644
index 6eb0429..0000000
--- a/src/Scry.Core/Imaging/ImagePreprocessor.cs
+++ /dev/null
@@ -1,228 +0,0 @@
-using SkiaSharp;
-
-namespace Scry.Core.Imaging;
-
-public static class ImagePreprocessor
-{
- public static SKBitmap ApplyClahe(SKBitmap source, int tileSize = 8, float clipLimit = 2.0f)
- {
- var width = source.Width;
- var height = source.Height;
- var result = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
-
- var labImage = ConvertToLab(source);
-
- var tilesX = (width + tileSize - 1) / tileSize;
- var tilesY = (height + tileSize - 1) / tileSize;
-
- var tileMappings = new byte[tilesY, tilesX, 256];
-
- for (var ty = 0; ty < tilesY; ty++)
- {
- for (var tx = 0; tx < tilesX; tx++)
- {
- var startX = tx * tileSize;
- var startY = ty * tileSize;
- var endX = Math.Min(startX + tileSize, width);
- var endY = Math.Min(startY + tileSize, height);
-
- var histogram = new int[256];
- var pixelCount = 0;
-
- for (var y = startY; y < endY; y++)
- {
- for (var x = startX; x < endX; x++)
- {
- var l = (int)labImage[y, x, 0];
- histogram[l]++;
- pixelCount++;
- }
- }
-
- var clipCount = (int)(clipLimit * pixelCount / 256);
- var excess = 0;
-
- for (var i = 0; i < 256; i++)
- {
- if (histogram[i] > clipCount)
- {
- excess += histogram[i] - clipCount;
- histogram[i] = clipCount;
- }
- }
-
- var redistribution = excess / 256;
- for (var i = 0; i < 256; i++)
- {
- histogram[i] += redistribution;
- }
-
- var cdf = new int[256];
- cdf[0] = histogram[0];
- for (var i = 1; i < 256; i++)
- {
- cdf[i] = cdf[i - 1] + histogram[i];
- }
-
- var cdfMin = cdf.FirstOrDefault(c => c > 0);
- if (cdfMin == 0) cdfMin = 1;
- var denominator = Math.Max(1, pixelCount - cdfMin);
-
- for (var i = 0; i < 256; i++)
- {
- tileMappings[ty, tx, i] = (byte)Math.Clamp((cdf[i] - cdfMin) * 255 / denominator, 0, 255);
- }
- }
- }
-
- for (var y = 0; y < height; y++)
- {
- for (var x = 0; x < width; x++)
- {
- var l = (int)labImage[y, x, 0];
- var a = labImage[y, x, 1];
- var b = labImage[y, x, 2];
-
- var txFloat = (float)x / tileSize - 0.5f;
- var tyFloat = (float)y / tileSize - 0.5f;
-
- var tx1 = Math.Clamp((int)Math.Floor(txFloat), 0, tilesX - 1);
- var ty1 = Math.Clamp((int)Math.Floor(tyFloat), 0, tilesY - 1);
- var tx2 = Math.Clamp(tx1 + 1, 0, tilesX - 1);
- var ty2 = Math.Clamp(ty1 + 1, 0, tilesY - 1);
-
- var xRatio = txFloat - tx1;
- var yRatio = tyFloat - ty1;
- xRatio = Math.Clamp(xRatio, 0, 1);
- yRatio = Math.Clamp(yRatio, 0, 1);
-
- var v1 = tileMappings[ty1, tx1, l];
- var v2 = tileMappings[ty1, tx2, l];
- var v3 = tileMappings[ty2, tx1, l];
- var v4 = tileMappings[ty2, tx2, l];
-
- var top = v1 * (1 - xRatio) + v2 * xRatio;
- var bottom = v3 * (1 - xRatio) + v4 * xRatio;
- var newL = top * (1 - yRatio) + bottom * yRatio;
-
- var (r, g, bl) = LabToRgb(newL, a, b);
- result.SetPixel(x, y, new SKColor(r, g, bl));
- }
- }
-
- return result;
- }
-
- private static float[,,] ConvertToLab(SKBitmap bitmap)
- {
- var result = new float[bitmap.Height, bitmap.Width, 3];
-
- for (var y = 0; y < bitmap.Height; y++)
- {
- for (var x = 0; x < bitmap.Width; x++)
- {
- var pixel = bitmap.GetPixel(x, y);
- var (l, a, b) = RgbToLab(pixel.Red, pixel.Green, pixel.Blue);
- result[y, x, 0] = l;
- result[y, x, 1] = a;
- result[y, x, 2] = b;
- }
- }
-
- return result;
- }
-
- private static (float L, float A, float B) RgbToLab(byte r, byte g, byte b)
- {
- var rf = PivotRgb(r / 255.0);
- var gf = PivotRgb(g / 255.0);
- var bf = PivotRgb(b / 255.0);
-
- var x = rf * 0.4124564 + gf * 0.3575761 + bf * 0.1804375;
- var y = rf * 0.2126729 + gf * 0.7151522 + bf * 0.0721750;
- var z = rf * 0.0193339 + gf * 0.1191920 + bf * 0.9503041;
-
- x /= 0.95047;
- z /= 1.08883;
-
- x = PivotXyz(x);
- y = PivotXyz(y);
- z = PivotXyz(z);
-
- var l = (float)(116 * y - 16);
- var a = (float)(500 * (x - y));
- var bVal = (float)(200 * (y - z));
-
- l = l * 255 / 100;
-
- return (l, a, bVal);
- }
-
- private static (byte R, byte G, byte B) LabToRgb(double l, double a, double b)
- {
- l = l * 100 / 255;
-
- var y = (l + 16) / 116;
- var x = a / 500 + y;
- var z = y - b / 200;
-
- var x3 = x * x * x;
- var y3 = y * y * y;
- var z3 = z * z * z;
-
- x = x3 > 0.008856 ? x3 : (x - 16.0 / 116) / 7.787;
- y = y3 > 0.008856 ? y3 : (y - 16.0 / 116) / 7.787;
- z = z3 > 0.008856 ? z3 : (z - 16.0 / 116) / 7.787;
-
- x *= 0.95047;
- z *= 1.08883;
-
- var rf = x * 3.2404542 + y * -1.5371385 + z * -0.4985314;
- var gf = x * -0.9692660 + y * 1.8760108 + z * 0.0415560;
- var bf = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;
-
- rf = rf > 0.0031308 ? 1.055 * Math.Pow(rf, 1 / 2.4) - 0.055 : 12.92 * rf;
- gf = gf > 0.0031308 ? 1.055 * Math.Pow(gf, 1 / 2.4) - 0.055 : 12.92 * gf;
- bf = bf > 0.0031308 ? 1.055 * Math.Pow(bf, 1 / 2.4) - 0.055 : 12.92 * bf;
-
- return (
- (byte)Math.Clamp(rf * 255, 0, 255),
- (byte)Math.Clamp(gf * 255, 0, 255),
- (byte)Math.Clamp(bf * 255, 0, 255)
- );
- }
-
- private static double PivotRgb(double n)
- {
- return n > 0.04045 ? Math.Pow((n + 0.055) / 1.055, 2.4) : n / 12.92;
- }
-
- private static double PivotXyz(double n)
- {
- return n > 0.008856 ? Math.Pow(n, 1.0 / 3) : 7.787 * n + 16.0 / 116;
- }
-
- public static SKBitmap Resize(SKBitmap source, int width, int height)
- {
- var info = new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
- var sampling = new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear);
- return source.Resize(info, sampling);
- }
-
- public static SKBitmap ToGrayscale(SKBitmap source)
- {
- var result = new SKBitmap(source.Width, source.Height, SKColorType.Gray8, SKAlphaType.Opaque);
-
- for (var y = 0; y < source.Height; y++)
- {
- for (var x = 0; x < source.Width; x++)
- {
- var pixel = source.GetPixel(x, y);
- var gray = (byte)(0.299 * pixel.Red + 0.587 * pixel.Green + 0.114 * pixel.Blue);
- result.SetPixel(x, y, new SKColor(gray, gray, gray));
- }
- }
-
- return result;
- }
-}
diff --git a/src/Scry.Core/Imaging/PerceptualHash.cs b/src/Scry.Core/Imaging/PerceptualHash.cs
deleted file mode 100644
index 7d90c87..0000000
--- a/src/Scry.Core/Imaging/PerceptualHash.cs
+++ /dev/null
@@ -1,208 +0,0 @@
-using SkiaSharp;
-
-namespace Scry.Core.Imaging;
-
-public static class PerceptualHash
-{
- private const int HashSize = 32;
- private const int DctSize = 32;
-
- public static byte[] ComputeHash(SKBitmap image)
- {
- using var resized = ImagePreprocessor.Resize(image, DctSize, DctSize);
- using var grayscale = ImagePreprocessor.ToGrayscale(resized);
-
- var pixels = new double[DctSize, DctSize];
- for (var y = 0; y < DctSize; y++)
- {
- for (var x = 0; x < DctSize; x++)
- {
- var pixel = grayscale.GetPixel(x, y);
- pixels[y, x] = pixel.Red;
- }
- }
-
- var dct = ComputeDct(pixels);
-
- var lowFreq = new double[8 * 8];
- var idx = 0;
- for (var y = 0; y < 8; y++)
- {
- for (var x = 0; x < 8; x++)
- {
- if (x == 0 && y == 0) continue;
- lowFreq[idx++] = dct[y, x];
- }
- }
-
- var median = GetMedian(lowFreq.Take(63).ToArray());
-
- var hash = new byte[8];
- idx = 0;
- for (var y = 0; y < 8; y++)
- {
- for (var x = 0; x < 8; x++)
- {
- if (x == 0 && y == 0) continue;
- if (dct[y, x] > median)
- {
- hash[idx / 8] |= (byte)(1 << (7 - idx % 8));
- }
- idx++;
- }
- }
-
- return hash;
- }
-
- public static byte[] ComputeColorHash(SKBitmap image)
- {
- using var resized = ImagePreprocessor.Resize(image, DctSize, DctSize);
-
- var redPixels = new double[DctSize, DctSize];
- var greenPixels = new double[DctSize, DctSize];
- var bluePixels = new double[DctSize, DctSize];
-
- for (var y = 0; y < DctSize; y++)
- {
- for (var x = 0; x < DctSize; x++)
- {
- var pixel = resized.GetPixel(x, y);
- redPixels[y, x] = pixel.Red;
- greenPixels[y, x] = pixel.Green;
- bluePixels[y, x] = pixel.Blue;
- }
- }
-
- var redHash = ComputeChannelHash(redPixels);
- var greenHash = ComputeChannelHash(greenPixels);
- var blueHash = ComputeChannelHash(bluePixels);
-
- var combined = new byte[redHash.Length + greenHash.Length + blueHash.Length];
- Array.Copy(redHash, 0, combined, 0, redHash.Length);
- Array.Copy(greenHash, 0, combined, redHash.Length, greenHash.Length);
- Array.Copy(blueHash, 0, combined, redHash.Length + greenHash.Length, blueHash.Length);
-
- return combined;
- }
-
- private static byte[] ComputeChannelHash(double[,] pixels)
- {
- var dct = ComputeDct(pixels);
-
- var lowFreq = new double[63];
- var idx = 0;
- for (var y = 0; y < 8; y++)
- {
- for (var x = 0; x < 8; x++)
- {
- if (x == 0 && y == 0) continue;
- lowFreq[idx++] = dct[y, x];
- }
- }
-
- var median = GetMedian(lowFreq);
-
- var hash = new byte[8];
- idx = 0;
- for (var y = 0; y < 8; y++)
- {
- for (var x = 0; x < 8; x++)
- {
- if (x == 0 && y == 0) continue;
- if (dct[y, x] > median)
- {
- hash[idx / 8] |= (byte)(1 << (7 - idx % 8));
- }
- idx++;
- }
- }
-
- return hash;
- }
-
- private static double[,] ComputeDct(double[,] input)
- {
- var n = input.GetLength(0);
- var output = new double[n, n];
-
- var coefficients = new double[n];
- coefficients[0] = 1.0 / Math.Sqrt(n);
- for (var i = 1; i < n; i++)
- {
- coefficients[i] = Math.Sqrt(2.0 / n);
- }
-
- var cosTable = new double[n, n];
- for (var i = 0; i < n; i++)
- {
- for (var j = 0; j < n; j++)
- {
- cosTable[i, j] = Math.Cos((2 * j + 1) * i * Math.PI / (2 * n));
- }
- }
-
- var temp = new double[n, n];
- for (var u = 0; u < n; u++)
- {
- for (var y = 0; y < n; y++)
- {
- var sum = 0.0;
- for (var x = 0; x < n; x++)
- {
- sum += input[y, x] * cosTable[u, x];
- }
- temp[u, y] = sum * coefficients[u];
- }
- }
-
- for (var u = 0; u < n; u++)
- {
- for (var v = 0; v < n; v++)
- {
- var sum = 0.0;
- for (var y = 0; y < n; y++)
- {
- sum += temp[u, y] * cosTable[v, y];
- }
- output[v, u] = sum * coefficients[v];
- }
- }
-
- return output;
- }
-
- private static double GetMedian(double[] values)
- {
- var sorted = values.OrderBy(v => v).ToArray();
- var mid = sorted.Length / 2;
- return sorted.Length % 2 == 0
- ? (sorted[mid - 1] + sorted[mid]) / 2
- : sorted[mid];
- }
-
- public static int HammingDistance(byte[] hash1, byte[] hash2)
- {
- if (hash1.Length != hash2.Length)
- throw new ArgumentException("Hash lengths must match");
-
- var distance = 0;
- for (var i = 0; i < hash1.Length; i++)
- {
- var xor = hash1[i] ^ hash2[i];
- while (xor != 0)
- {
- distance += xor & 1;
- xor >>= 1;
- }
- }
-
- return distance;
- }
-
- public static float CalculateConfidence(int hammingDistance, int hashBits)
- {
- var similarity = 1.0f - (float)hammingDistance / hashBits;
- return similarity;
- }
-}
diff --git a/src/Scry.Core/Imaging/PerspectiveCorrection.cs b/src/Scry.Core/Imaging/PerspectiveCorrection.cs
deleted file mode 100644
index 7f5f02f..0000000
--- a/src/Scry.Core/Imaging/PerspectiveCorrection.cs
+++ /dev/null
@@ -1,295 +0,0 @@
-using SkiaSharp;
-
-namespace Scry.Core.Imaging;
-
-///
-/// Performs perspective correction to transform a quadrilateral region into a rectangle.
-///
-public static class PerspectiveCorrection
-{
- ///
- /// Standard output size for corrected card images.
- /// Using 480x670 to maintain card aspect ratio (63:88).
- ///
- public const int OutputWidth = 480;
- public const int OutputHeight = 670;
-
- ///
- /// Apply perspective correction to extract and normalize a card from an image.
- ///
- /// Source image containing the card.
- /// Four corners of the card in order: top-left, top-right, bottom-right, bottom-left.
- /// Width of the output image.
- /// Height of the output image.
- /// A new bitmap with the perspective-corrected card.
- public static SKBitmap WarpPerspective(
- SKBitmap source,
- SKPoint[] corners,
- int outputWidth = OutputWidth,
- int outputHeight = OutputHeight)
- {
- if (corners.Length != 4)
- throw new ArgumentException("Exactly 4 corners required", nameof(corners));
-
- // Determine if the card is in landscape orientation (rotated 90°)
- var width1 = Distance(corners[0], corners[1]);
- var height1 = Distance(corners[1], corners[2]);
-
- // If width > height, card is landscape - swap dimensions and reorder corners
- SKPoint[] orderedCorners;
- int targetWidth, targetHeight;
-
- if (width1 > height1)
- {
- // Card is landscape - rotate corners to portrait
- orderedCorners = new[] { corners[1], corners[2], corners[3], corners[0] };
- targetWidth = outputWidth;
- targetHeight = outputHeight;
- }
- else
- {
- orderedCorners = corners;
- targetWidth = outputWidth;
- targetHeight = outputHeight;
- }
-
- // Compute the perspective transform matrix
- var matrix = ComputePerspectiveTransform(orderedCorners, targetWidth, targetHeight);
-
- // Apply the transform
- var result = new SKBitmap(targetWidth, targetHeight, SKColorType.Rgba8888, SKAlphaType.Premul);
-
- for (var y = 0; y < targetHeight; y++)
- {
- for (var x = 0; x < targetWidth; x++)
- {
- // Apply inverse transform to find source coordinates
- var srcPoint = ApplyInverseTransform(matrix, x, y);
-
- // Bilinear interpolation for smooth sampling
- var color = SampleBilinear(source, srcPoint.X, srcPoint.Y);
- result.SetPixel(x, y, color);
- }
- }
-
- return result;
- }
-
- ///
- /// Compute a perspective transform matrix from quad corners to rectangle.
- /// Uses the Direct Linear Transform (DLT) algorithm.
- ///
- private static float[] ComputePerspectiveTransform(SKPoint[] src, int dstWidth, int dstHeight)
- {
- // Destination corners (rectangle)
- var dst = new SKPoint[]
- {
- new(0, 0),
- new(dstWidth - 1, 0),
- new(dstWidth - 1, dstHeight - 1),
- new(0, dstHeight - 1)
- };
-
- // Build the 8x8 matrix for solving the homography
- // We're solving for the 8 parameters of the 3x3 perspective matrix (h33 = 1)
- var A = new double[8, 8];
- var b = new double[8];
-
- for (var i = 0; i < 4; i++)
- {
- var sx = src[i].X;
- var sy = src[i].Y;
- var dx = dst[i].X;
- var dy = dst[i].Y;
-
- A[i * 2, 0] = sx;
- A[i * 2, 1] = sy;
- A[i * 2, 2] = 1;
- A[i * 2, 3] = 0;
- A[i * 2, 4] = 0;
- A[i * 2, 5] = 0;
- A[i * 2, 6] = -dx * sx;
- A[i * 2, 7] = -dx * sy;
- b[i * 2] = dx;
-
- A[i * 2 + 1, 0] = 0;
- A[i * 2 + 1, 1] = 0;
- A[i * 2 + 1, 2] = 0;
- A[i * 2 + 1, 3] = sx;
- A[i * 2 + 1, 4] = sy;
- A[i * 2 + 1, 5] = 1;
- A[i * 2 + 1, 6] = -dy * sx;
- A[i * 2 + 1, 7] = -dy * sy;
- b[i * 2 + 1] = dy;
- }
-
- // Solve using Gaussian elimination
- var h = SolveLinearSystem(A, b);
-
- // Return the 3x3 matrix as a flat array [h11, h12, h13, h21, h22, h23, h31, h32, h33]
- return new float[]
- {
- (float)h[0], (float)h[1], (float)h[2],
- (float)h[3], (float)h[4], (float)h[5],
- (float)h[6], (float)h[7], 1f
- };
- }
-
- ///
- /// Solve a linear system Ax = b using Gaussian elimination with partial pivoting.
- ///
- private static double[] SolveLinearSystem(double[,] A, double[] b)
- {
- var n = b.Length;
- var augmented = new double[n, n + 1];
-
- // Create augmented matrix
- for (var i = 0; i < n; i++)
- {
- for (var j = 0; j < n; j++)
- augmented[i, j] = A[i, j];
- augmented[i, n] = b[i];
- }
-
- // Forward elimination with partial pivoting
- for (var col = 0; col < n; col++)
- {
- // Find pivot
- var maxRow = col;
- for (var row = col + 1; row < n; row++)
- {
- if (Math.Abs(augmented[row, col]) > Math.Abs(augmented[maxRow, col]))
- maxRow = row;
- }
-
- // Swap rows
- for (var j = 0; j <= n; j++)
- {
- (augmented[col, j], augmented[maxRow, j]) = (augmented[maxRow, j], augmented[col, j]);
- }
-
- // Eliminate
- for (var row = col + 1; row < n; row++)
- {
- if (Math.Abs(augmented[col, col]) < 1e-10) continue;
-
- var factor = augmented[row, col] / augmented[col, col];
- for (var j = col; j <= n; j++)
- {
- augmented[row, j] -= factor * augmented[col, j];
- }
- }
- }
-
- // Back substitution
- var x = new double[n];
- for (var i = n - 1; i >= 0; i--)
- {
- x[i] = augmented[i, n];
- for (var j = i + 1; j < n; j++)
- {
- x[i] -= augmented[i, j] * x[j];
- }
- if (Math.Abs(augmented[i, i]) > 1e-10)
- x[i] /= augmented[i, i];
- }
-
- return x;
- }
-
- ///
- /// Apply the inverse of the perspective transform to map destination to source coordinates.
- ///
- private static SKPoint ApplyInverseTransform(float[] H, float x, float y)
- {
- // H maps src -> dst, we need dst -> src
- // Compute inverse of H
- var inv = InvertMatrix3x3(H);
-
- // Apply inverse transform
- var w = inv[6] * x + inv[7] * y + inv[8];
- if (Math.Abs(w) < 1e-10) w = 1e-10f;
-
- var srcX = (inv[0] * x + inv[1] * y + inv[2]) / w;
- var srcY = (inv[3] * x + inv[4] * y + inv[5]) / w;
-
- return new SKPoint(srcX, srcY);
- }
-
- ///
- /// Invert a 3x3 matrix.
- ///
- private static float[] InvertMatrix3x3(float[] m)
- {
- var det = m[0] * (m[4] * m[8] - m[5] * m[7])
- - m[1] * (m[3] * m[8] - m[5] * m[6])
- + m[2] * (m[3] * m[7] - m[4] * m[6]);
-
- if (Math.Abs(det) < 1e-10f)
- det = 1e-10f;
-
- var invDet = 1f / det;
-
- return new float[]
- {
- (m[4] * m[8] - m[5] * m[7]) * invDet,
- (m[2] * m[7] - m[1] * m[8]) * invDet,
- (m[1] * m[5] - m[2] * m[4]) * invDet,
- (m[5] * m[6] - m[3] * m[8]) * invDet,
- (m[0] * m[8] - m[2] * m[6]) * invDet,
- (m[2] * m[3] - m[0] * m[5]) * invDet,
- (m[3] * m[7] - m[4] * m[6]) * invDet,
- (m[1] * m[6] - m[0] * m[7]) * invDet,
- (m[0] * m[4] - m[1] * m[3]) * invDet
- };
- }
-
- ///
- /// Sample a pixel using bilinear interpolation.
- ///
- private static SKColor SampleBilinear(SKBitmap source, float x, float y)
- {
- // Clamp to valid range
- x = Math.Clamp(x, 0, source.Width - 1);
- y = Math.Clamp(y, 0, source.Height - 1);
-
- var x0 = (int)Math.Floor(x);
- var y0 = (int)Math.Floor(y);
- var x1 = Math.Min(x0 + 1, source.Width - 1);
- var y1 = Math.Min(y0 + 1, source.Height - 1);
-
- var xFrac = x - x0;
- var yFrac = y - y0;
-
- var c00 = source.GetPixel(x0, y0);
- var c10 = source.GetPixel(x1, y0);
- var c01 = source.GetPixel(x0, y1);
- var c11 = source.GetPixel(x1, y1);
-
- // Interpolate
- var r = Lerp(Lerp(c00.Red, c10.Red, xFrac), Lerp(c01.Red, c11.Red, xFrac), yFrac);
- var g = Lerp(Lerp(c00.Green, c10.Green, xFrac), Lerp(c01.Green, c11.Green, xFrac), yFrac);
- var b = Lerp(Lerp(c00.Blue, c10.Blue, xFrac), Lerp(c01.Blue, c11.Blue, xFrac), yFrac);
- var a = Lerp(Lerp(c00.Alpha, c10.Alpha, xFrac), Lerp(c01.Alpha, c11.Alpha, xFrac), yFrac);
-
- return new SKColor((byte)r, (byte)g, (byte)b, (byte)a);
- }
-
- ///
- /// Linear interpolation.
- ///
- private static float Lerp(float a, float b, float t)
- {
- return a + (b - a) * t;
- }
-
- ///
- /// Calculate distance between two points.
- ///
- private static float Distance(SKPoint a, SKPoint b)
- {
- var dx = b.X - a.X;
- var dy = b.Y - a.Y;
- return MathF.Sqrt(dx * dx + dy * dy);
- }
-}
diff --git a/src/Scry.Core/Models/Card.cs b/src/Scry.Core/Models/Card.cs
deleted file mode 100644
index 50d3bbd..0000000
--- a/src/Scry.Core/Models/Card.cs
+++ /dev/null
@@ -1,143 +0,0 @@
-namespace Scry.Core.Models;
-
-///
-/// Represents a specific printing of a card in a set.
-/// Maps to Scryfall's Card object (which is really a printing).
-/// Contains the perceptual hash for image matching.
-/// Includes denormalized Oracle data for convenience.
-///
-public record Card
-{
- ///
- /// Scryfall's unique card ID for this specific printing.
- ///
- public required string Id { get; init; }
-
- ///
- /// Oracle ID linking to the abstract game card.
- ///
- public required string OracleId { get; init; }
-
- ///
- /// Set ID this printing belongs to.
- ///
- public required string SetId { get; init; }
-
- ///
- /// Set code (e.g., "lea", "mh2") - denormalized for convenience.
- ///
- public string? SetCode { get; init; }
-
- ///
- /// Set name - denormalized for convenience.
- ///
- public string? SetName { get; init; }
-
- ///
- /// Card name - denormalized from Oracle for convenience.
- ///
- public required string Name { get; init; }
-
- ///
- /// Collector number within the set.
- ///
- public string? CollectorNumber { get; init; }
-
- ///
- /// Rarity (common, uncommon, rare, mythic).
- ///
- public string? Rarity { get; init; }
-
- ///
- /// Artist name.
- ///
- public string? Artist { get; init; }
-
- ///
- /// Illustration ID - same across printings with identical art.
- ///
- public string? IllustrationId { get; init; }
-
- ///
- /// URI to the card image (normal size).
- ///
- public string? ImageUri { get; init; }
-
- ///
- /// Perceptual hash for image matching.
- ///
- public byte[]? Hash { get; init; }
-
- ///
- /// Language code (e.g., "en", "ja").
- ///
- public string? Lang { get; init; }
-
- ///
- /// USD price for non-foil.
- ///
- public decimal? PricesUsd { get; init; }
-
- ///
- /// USD price for foil.
- ///
- public decimal? PricesUsdFoil { get; init; }
-
- #region Denormalized Oracle Fields (for App layer convenience)
-
- ///
- /// Mana cost in Scryfall notation (e.g., "{2}{U}{U}").
- /// Denormalized from Oracle.
- ///
- public string? ManaCost { get; init; }
-
- ///
- /// Full type line (e.g., "Legendary Creature — Human Wizard").
- /// Denormalized from Oracle.
- ///
- public string? TypeLine { get; init; }
-
- ///
- /// Official Oracle rules text.
- /// Denormalized from Oracle.
- ///
- public string? OracleText { get; init; }
-
- ///
- /// Power for creatures (may contain non-numeric values like "*").
- /// Denormalized from Oracle.
- ///
- public string? Power { get; init; }
-
- ///
- /// Toughness for creatures (may contain non-numeric values like "*").
- /// Denormalized from Oracle.
- ///
- public string? Toughness { get; init; }
-
- #endregion
-
- #region Compatibility Aliases
-
- ///
- /// Alias for ImageUri for compatibility.
- ///
- public string? ImageUrl => ImageUri;
-
- ///
- /// Alias for PricesUsd for compatibility.
- ///
- public decimal? Price => PricesUsd;
-
- ///
- /// Alias for Id (Scryfall ID) for compatibility.
- ///
- public string ScryfallId => Id;
-
- ///
- /// Alias for PricesUsd for compatibility.
- ///
- public decimal? PriceUsd => PricesUsd;
-
- #endregion
-}
diff --git a/src/Scry.Core/Models/Oracle.cs b/src/Scry.Core/Models/Oracle.cs
deleted file mode 100644
index 9b76120..0000000
--- a/src/Scry.Core/Models/Oracle.cs
+++ /dev/null
@@ -1,73 +0,0 @@
-namespace Scry.Core.Models;
-
-///
-/// Represents an abstract game card - the rules object shared across all printings.
-/// Maps to Scryfall's oracle_id concept.
-///
-public record Oracle
-{
- ///
- /// Scryfall's oracle_id - unique identifier for this game card across all printings.
- ///
- public required string Id { get; init; }
-
- ///
- /// The card name (e.g., "Lightning Bolt").
- ///
- public required string Name { get; init; }
-
- ///
- /// Mana cost in Scryfall notation (e.g., "{2}{U}{U}").
- ///
- public string? ManaCost { get; init; }
-
- ///
- /// Mana value (converted mana cost).
- ///
- public double? Cmc { get; init; }
-
- ///
- /// Full type line (e.g., "Legendary Creature — Human Wizard").
- ///
- public string? TypeLine { get; init; }
-
- ///
- /// Official Oracle rules text.
- ///
- public string? OracleText { get; init; }
-
- ///
- /// Card colors as JSON array (e.g., ["U", "R"]).
- ///
- public string? Colors { get; init; }
-
- ///
- /// Color identity for Commander as JSON array.
- ///
- public string? ColorIdentity { get; init; }
-
- ///
- /// Keywords as JSON array (e.g., ["Flying", "Trample"]).
- ///
- public string? Keywords { get; init; }
-
- ///
- /// Whether this card is on the Reserved List.
- ///
- public bool Reserved { get; init; }
-
- ///
- /// Format legalities as JSON object.
- ///
- public string? Legalities { get; init; }
-
- ///
- /// Power for creatures (may contain non-numeric values like "*").
- ///
- public string? Power { get; init; }
-
- ///
- /// Toughness for creatures (may contain non-numeric values like "*").
- ///
- public string? Toughness { get; init; }
-}
diff --git a/src/Scry.Core/Models/ScanResult.cs b/src/Scry.Core/Models/ScanResult.cs
deleted file mode 100644
index 61feb0b..0000000
--- a/src/Scry.Core/Models/ScanResult.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-namespace Scry.Core.Models;
-
-public record ScanResult
-{
- public bool Success { get; init; }
- public Card? Card { get; init; }
- public float Confidence { get; init; }
- public string? ErrorMessage { get; init; }
- public int HammingDistance { get; init; }
- public TimeSpan ProcessingTime { get; init; }
-
- public static ScanResult Failed(string message) => new()
- {
- Success = false,
- ErrorMessage = message,
- Confidence = 0
- };
-
- public static ScanResult Matched(Card card, float confidence, int hammingDistance, TimeSpan processingTime) => new()
- {
- Success = true,
- Card = card,
- Confidence = confidence,
- HammingDistance = hammingDistance,
- ProcessingTime = processingTime
- };
-}
diff --git a/src/Scry.Core/Models/Set.cs b/src/Scry.Core/Models/Set.cs
deleted file mode 100644
index c2f1b19..0000000
--- a/src/Scry.Core/Models/Set.cs
+++ /dev/null
@@ -1,57 +0,0 @@
-namespace Scry.Core.Models;
-
-///
-/// Represents an MTG set. Maps to Scryfall's Set object.
-///
-public record Set
-{
- ///
- /// Scryfall's unique set ID.
- ///
- public required string Id { get; init; }
-
- ///
- /// Unique 3-6 letter set code (e.g., "lea", "mh2").
- ///
- public required string Code { get; init; }
-
- ///
- /// English name of the set (e.g., "Limited Edition Alpha").
- ///
- public required string Name { get; init; }
-
- ///
- /// Set classification (e.g., "expansion", "core", "masters", "commander").
- ///
- public string? SetType { get; init; }
-
- ///
- /// Release date in ISO 8601 format.
- ///
- public string? ReleasedAt { get; init; }
-
- ///
- /// Number of cards in the set.
- ///
- public int? CardCount { get; init; }
-
- ///
- /// URI to the set's icon SVG.
- ///
- public string? IconSvgUri { get; init; }
-
- ///
- /// Whether this is a digital-only set.
- ///
- public bool Digital { get; init; }
-
- ///
- /// Parent set code for promo/token sets.
- ///
- public string? ParentSetCode { get; init; }
-
- ///
- /// Block name, if applicable.
- ///
- public string? Block { get; init; }
-}
diff --git a/src/Scry.Core/Recognition/CardRecognitionService.cs b/src/Scry.Core/Recognition/CardRecognitionService.cs
deleted file mode 100644
index b560e14..0000000
--- a/src/Scry.Core/Recognition/CardRecognitionService.cs
+++ /dev/null
@@ -1,429 +0,0 @@
-using System.Diagnostics;
-using Microsoft.Extensions.Options;
-using Scry.Core.Data;
-using Scry.Core.Imaging;
-using Scry.Core.Models;
-using SkiaSharp;
-
-namespace Scry.Core.Recognition;
-
-public class CardRecognitionService : IDisposable
-{
- private readonly CardDatabase _database;
- private readonly RecognitionOptions _options;
- private List? _cardCache;
- private readonly SemaphoreSlim _cacheLock = new(1, 1);
-
- private const int ColorHashBits = 192;
- private const int MatchThreshold = 25;
- private const float MinConfidence = 0.85f;
-
- public CardRecognitionService(CardDatabase database, IOptions options)
- {
- _database = database;
- _options = options.Value;
- }
-
- public CardRecognitionService(CardDatabase database) : this(database, Options.Create(new RecognitionOptions()))
- {
- }
-
- public async Task RecognizeAsync(Stream imageStream, CancellationToken ct = default)
- {
- var stopwatch = Stopwatch.StartNew();
-
- try
- {
- using var bitmap = SKBitmap.Decode(imageStream);
- if (bitmap == null)
- {
- return ScanResult.Failed("Could not decode image");
- }
-
- return await RecognizeAsync(bitmap, ct);
- }
- catch (Exception ex)
- {
- return ScanResult.Failed($"Recognition error: {ex.Message}");
- }
- finally
- {
- stopwatch.Stop();
- }
- }
-
- public async Task RecognizeAsync(SKBitmap bitmap, CancellationToken ct = default)
- {
- var stopwatch = Stopwatch.StartNew();
- var debugDir = _options.DebugOutputDirectory;
- var debugEnabled = !string.IsNullOrEmpty(debugDir);
-
- if (debugEnabled)
- {
- Directory.CreateDirectory(debugDir!);
- SaveDebugImage(bitmap, debugDir!, "01_input");
- }
-
- try
- {
- var cards = await GetCardCacheAsync(ct);
- Console.WriteLine($"[Scry] Database has {cards.Count} cards with hashes");
-
- if (cards.Count == 0)
- {
- return ScanResult.Failed("No cards in database. Run sync first.");
- }
-
- // Step 1: Detect and extract card from image (if enabled)
- SKBitmap cardImage;
- bool cardDetected = false;
-
- if (_options.EnableCardDetection)
- {
- var detection = CardDetector.DetectCard(bitmap);
-
- if (debugEnabled)
- {
- // Save detection visualization
- SaveDetectionDebugImage(bitmap, detection, debugDir!);
- }
-
- if (detection.Found)
- {
- cardImage = PerspectiveCorrection.WarpPerspective(bitmap, detection.Corners);
- cardDetected = true;
- Console.WriteLine($"[Scry] Card detected with confidence {detection.Confidence:P0}");
-
- if (debugEnabled)
- {
- SaveDebugImage(cardImage, debugDir!, "03_perspective_corrected");
- }
- }
- else
- {
- // Fall back to using the whole image
- Console.WriteLine($"[Scry] Card detection failed: {detection.DebugMessage}, using full image");
- cardImage = bitmap;
- }
- }
- else
- {
- cardImage = bitmap;
- }
-
- try
- {
- // Step 2: Try matching with rotation variants (if enabled)
- var bestMatch = _options.EnableRotationMatching
- ? await FindBestMatchWithRotationsAsync(cardImage, cards, debugDir, ct)
- : FindBestMatchSingle(cardImage, cards, debugDir);
-
- stopwatch.Stop();
-
- if (bestMatch == null)
- {
- return ScanResult.Failed($"No match found (detection={cardDetected})");
- }
-
- var (matchedCard, distance, rotation) = bestMatch.Value;
- var confidence = PerceptualHash.CalculateConfidence(distance, ColorHashBits);
- Console.WriteLine($"[Scry] Best match: {matchedCard.Name}, distance={distance}, confidence={confidence:P0}, rotation={rotation}°");
-
- if (confidence < MinConfidence)
- {
- return ScanResult.Failed($"Match confidence too low: {confidence:P0}");
- }
-
- return ScanResult.Matched(matchedCard, confidence, distance, stopwatch.Elapsed);
- }
- finally
- {
- // Dispose card image only if we created a new one
- if (cardDetected && cardImage != bitmap)
- {
- cardImage.Dispose();
- }
- }
- }
- catch (Exception ex)
- {
- stopwatch.Stop();
- return ScanResult.Failed($"Recognition error: {ex.Message}");
- }
- }
-
- ///
- /// Compute hash for a card image with the full preprocessing pipeline.
- ///
- public byte[] ComputeHash(SKBitmap bitmap, bool applyPreprocessing = true)
- {
- if (applyPreprocessing)
- {
- // CLAHE is applied as the last step before hashing
- using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
- return PerceptualHash.ComputeColorHash(preprocessed);
- }
-
- return PerceptualHash.ComputeColorHash(bitmap);
- }
-
- ///
- /// Compute hash for a reference image (used when building the database).
- /// Reference images are assumed to be already cropped and oriented correctly.
- ///
- public byte[] ComputeReferenceHash(SKBitmap bitmap)
- {
- using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
- return PerceptualHash.ComputeColorHash(preprocessed);
- }
-
- public async Task InvalidateCacheAsync()
- {
- await _cacheLock.WaitAsync();
- try
- {
- _cardCache = null;
- }
- finally
- {
- _cacheLock.Release();
- }
- }
-
- private async Task> GetCardCacheAsync(CancellationToken ct)
- {
- if (_cardCache != null)
- return _cardCache;
-
- await _cacheLock.WaitAsync(ct);
- try
- {
- _cardCache ??= await _database.GetCardsWithHashAsync(ct);
- return _cardCache;
- }
- finally
- {
- _cacheLock.Release();
- }
- }
-
- ///
- /// Find best match trying all 4 rotations (0°, 90°, 180°, 270°).
- ///
- private Task<(Card Card, int Distance, int Rotation)?> FindBestMatchWithRotationsAsync(
- SKBitmap cardImage,
- List candidates,
- string? debugDir,
- CancellationToken ct)
- {
- return Task.Run(() =>
- {
- Card? bestMatch = null;
- var bestDistance = int.MaxValue;
- var bestRotation = 0;
- var debugEnabled = !string.IsNullOrEmpty(debugDir);
-
- var rotations = new[] { 0, 90, 180, 270 };
-
- foreach (var rotation in rotations)
- {
- ct.ThrowIfCancellationRequested();
-
- using var rotated = rotation == 0 ? null : RotateImage(cardImage, rotation);
- var imageToHash = rotated ?? cardImage;
-
- if (debugEnabled && rotation != 0)
- {
- SaveDebugImage(imageToHash, debugDir!, $"04_rotated_{rotation}");
- }
-
- // Apply CLAHE and compute hash
- using var preprocessed = ImagePreprocessor.ApplyClahe(imageToHash);
-
- if (debugEnabled)
- {
- SaveDebugImage(preprocessed, debugDir!, $"05_clahe_{rotation}");
- }
-
- var queryHash = PerceptualHash.ComputeColorHash(preprocessed);
-
- // Find best match for this rotation
- foreach (var candidate in candidates)
- {
- if (candidate.Hash == null || candidate.Hash.Length != queryHash.Length)
- continue;
-
- var distance = PerceptualHash.HammingDistance(queryHash, candidate.Hash);
-
- if (distance < bestDistance)
- {
- bestDistance = distance;
- bestMatch = candidate;
- bestRotation = rotation;
- }
-
- // Early exit on perfect match
- if (distance == 0 && bestMatch != null)
- return (bestMatch, bestDistance, bestRotation);
- }
- }
-
- if (bestMatch == null || bestDistance > MatchThreshold)
- return null;
-
- return ((Card Card, int Distance, int Rotation)?)(bestMatch, bestDistance, bestRotation);
- }, ct);
- }
-
- ///
- /// Find best match without rotation (single orientation).
- ///
- private (Card Card, int Distance, int Rotation)? FindBestMatchSingle(
- SKBitmap cardImage,
- List candidates,
- string? debugDir)
- {
- var debugEnabled = !string.IsNullOrEmpty(debugDir);
-
- // Apply CLAHE and compute hash
- using var preprocessed = ImagePreprocessor.ApplyClahe(cardImage);
-
- if (debugEnabled)
- {
- SaveDebugImage(preprocessed, debugDir!, "05_clahe_0");
- }
-
- var queryHash = PerceptualHash.ComputeColorHash(preprocessed);
-
- Card? bestMatch = null;
- var bestDistance = int.MaxValue;
-
- foreach (var candidate in candidates)
- {
- if (candidate.Hash == null || candidate.Hash.Length != queryHash.Length)
- continue;
-
- var distance = PerceptualHash.HammingDistance(queryHash, candidate.Hash);
-
- if (distance < bestDistance)
- {
- bestDistance = distance;
- bestMatch = candidate;
- }
-
- if (distance == 0)
- break;
- }
-
- if (bestMatch == null || bestDistance > MatchThreshold)
- return null;
-
- return (bestMatch, bestDistance, 0);
- }
-
- ///
- /// Rotate an image by the specified degrees (90, 180, or 270).
- ///
- private static SKBitmap RotateImage(SKBitmap source, int degrees)
- {
- var (newWidth, newHeight) = degrees switch
- {
- 90 or 270 => (source.Height, source.Width),
- _ => (source.Width, source.Height)
- };
-
- var rotated = new SKBitmap(newWidth, newHeight, source.ColorType, source.AlphaType);
-
- using var canvas = new SKCanvas(rotated);
- canvas.Clear(SKColors.Black);
-
- canvas.Translate(newWidth / 2f, newHeight / 2f);
- canvas.RotateDegrees(degrees);
- canvas.Translate(-source.Width / 2f, -source.Height / 2f);
- canvas.DrawBitmap(source, 0, 0);
-
- return rotated;
- }
-
- ///
- /// Save a debug image to disk.
- ///
- private static void SaveDebugImage(SKBitmap bitmap, string directory, string name)
- {
- var path = Path.Combine(directory, $"{name}.png");
- using var image = SKImage.FromBitmap(bitmap);
- using var data = image.Encode(SKEncodedImageFormat.Png, 100);
- using var stream = File.OpenWrite(path);
- data.SaveTo(stream);
- Console.WriteLine($"[Scry Debug] Saved: {path}");
- }
-
- ///
- /// Save a debug image showing the card detection result.
- ///
- private static void SaveDetectionDebugImage(SKBitmap original, CardDetector.CardDetectionResult detection, string directory)
- {
- using var annotated = new SKBitmap(original.Width, original.Height, original.ColorType, original.AlphaType);
- using var canvas = new SKCanvas(annotated);
-
- canvas.DrawBitmap(original, 0, 0);
-
- if (detection.Found && detection.Corners.Length == 4)
- {
- // Draw detected corners and edges
- using var cornerPaint = new SKPaint
- {
- Color = SKColors.Red,
- Style = SKPaintStyle.Fill,
- IsAntialias = true
- };
-
- using var edgePaint = new SKPaint
- {
- Color = SKColors.Lime,
- Style = SKPaintStyle.Stroke,
- StrokeWidth = 3,
- IsAntialias = true
- };
-
- var corners = detection.Corners;
-
- // Draw edges
- for (int i = 0; i < 4; i++)
- {
- var p1 = corners[i];
- var p2 = corners[(i + 1) % 4];
- canvas.DrawLine(p1.X, p1.Y, p2.X, p2.Y, edgePaint);
- }
-
- // Draw corners
- foreach (var corner in corners)
- {
- canvas.DrawCircle(corner.X, corner.Y, 8, cornerPaint);
- }
- }
-
- // Add debug text
- using var textPaint = new SKPaint
- {
- Color = detection.Found ? SKColors.Lime : SKColors.Red,
- IsAntialias = true
- };
- using var font = new SKFont
- {
- Size = 24
- };
-
- var message = detection.Found
- ? $"Detected: {detection.Confidence:P0}"
- : $"Not found: {detection.DebugMessage}";
- canvas.DrawText(message, 10, 30, SKTextAlign.Left, font, textPaint);
-
- SaveDebugImage(annotated, directory, "02_detection");
- }
-
- public void Dispose()
- {
- _cacheLock.Dispose();
- }
-}
diff --git a/src/Scry.Core/Recognition/HashDatabaseSyncService.cs b/src/Scry.Core/Recognition/HashDatabaseSyncService.cs
deleted file mode 100644
index 80402ae..0000000
--- a/src/Scry.Core/Recognition/HashDatabaseSyncService.cs
+++ /dev/null
@@ -1,263 +0,0 @@
-using Scry.Core.Data;
-using Scry.Core.Imaging;
-using Scry.Core.Models;
-using Scry.Core.Scryfall;
-using SkiaSharp;
-
-namespace Scry.Core.Recognition;
-
-public class HashDatabaseSyncService
-{
- private readonly ScryfallClient _scryfallClient;
- private readonly CardDatabase _database;
- private readonly HttpClient _imageClient;
-
- public event Action? OnProgress;
-
- public HashDatabaseSyncService(ScryfallClient scryfallClient, CardDatabase database, HttpClient? imageClient = null)
- {
- _scryfallClient = scryfallClient;
- _database = database;
- _imageClient = imageClient ?? new HttpClient();
- }
-
- public async Task SyncAsync(SyncOptions? options = null, CancellationToken ct = default)
- {
- options ??= new SyncOptions();
- var result = new SyncResult();
- var startTime = DateTime.UtcNow;
-
- try
- {
- // Fetch all sets first
- ReportProgress(new SyncProgress { Stage = SyncStage.Initializing, Message = "Fetching sets..." });
- var scryfallSets = await _scryfallClient.GetAllSetsAsync(ct);
- var existingSetIds = await _database.GetExistingSetIdsAsync(ct);
-
- var newSets = scryfallSets
- .Where(s => s.Id != null && !existingSetIds.Contains(s.Id))
- .Select(s => s.ToSet())
- .ToList();
-
- if (newSets.Count > 0)
- {
- await _database.InsertSetBatchAsync(newSets, ct);
- }
-
- var bulkInfo = await _scryfallClient.GetBulkDataInfoAsync(options.BulkDataType, ct);
- if (bulkInfo?.DownloadUri == null)
- {
- result.Error = "Could not get bulk data info from Scryfall";
- return result;
- }
-
- var lastSync = await _database.GetMetadataAsync("last_sync_date", ct);
- if (!options.ForceFullSync && lastSync != null)
- {
- if (DateTime.TryParse(lastSync, out var lastSyncDate) &&
- bulkInfo.UpdatedAt <= lastSyncDate)
- {
- result.Skipped = true;
- result.Message = "Database is up to date";
- return result;
- }
- }
-
- ReportProgress(new SyncProgress { Stage = SyncStage.Downloading, Message = "Downloading card data..." });
-
- var existingOracleIds = await _database.GetExistingOracleIdsAsync(ct);
- var cardBatch = new List();
- var oracleBatch = new Dictionary();
- var processed = 0;
- var errors = 0;
-
- await foreach (var scryfallCard in _scryfallClient.StreamBulkDataAsync(bulkInfo.DownloadUri, ct))
- {
- ct.ThrowIfCancellationRequested();
-
- if (scryfallCard.Lang != "en" && !options.IncludeNonEnglish)
- continue;
-
- var imageUri = scryfallCard.GetImageUri(options.ImageSize);
- if (string.IsNullOrEmpty(imageUri))
- continue;
-
- var oracleId = scryfallCard.OracleId ?? scryfallCard.Id ?? "";
-
- try
- {
- var imageBytes = await DownloadWithRetryAsync(imageUri, options.MaxRetries, ct);
- if (imageBytes == null)
- {
- errors++;
- continue;
- }
-
- using var bitmap = SKBitmap.Decode(imageBytes);
- if (bitmap == null)
- {
- errors++;
- continue;
- }
-
- using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
- var hash = PerceptualHash.ComputeColorHash(preprocessed);
-
- // Track oracle if new
- if (!existingOracleIds.Contains(oracleId) && !oracleBatch.ContainsKey(oracleId))
- {
- oracleBatch[oracleId] = scryfallCard.ToOracle();
- }
-
- // Create card with hash
- var card = scryfallCard.ToCard() with { Hash = hash };
- cardBatch.Add(card);
-
- processed++;
-
- if (cardBatch.Count >= options.BatchSize)
- {
- // Insert oracles first
- if (oracleBatch.Count > 0)
- {
- await _database.InsertOracleBatchAsync(oracleBatch.Values, ct);
- foreach (var id in oracleBatch.Keys)
- {
- existingOracleIds.Add(id);
- }
- oracleBatch.Clear();
- }
-
- await _database.InsertCardBatchAsync(cardBatch, ct);
- result.ProcessedCards += cardBatch.Count;
- cardBatch.Clear();
-
- ReportProgress(new SyncProgress
- {
- Stage = SyncStage.Processing,
- ProcessedCards = result.ProcessedCards,
- Message = $"Processed {result.ProcessedCards} cards..."
- });
- }
-
- if (options.RateLimitMs > 0)
- {
- await Task.Delay(options.RateLimitMs, ct);
- }
- }
- catch (Exception ex)
- {
- errors++;
- if (options.StopOnError)
- throw;
-
- result.Errors.Add($"{scryfallCard.Name}: {ex.Message}");
- }
- }
-
- // Insert remaining batches
- if (oracleBatch.Count > 0)
- {
- await _database.InsertOracleBatchAsync(oracleBatch.Values, ct);
- }
-
- if (cardBatch.Count > 0)
- {
- await _database.InsertCardBatchAsync(cardBatch, ct);
- result.ProcessedCards += cardBatch.Count;
- }
-
- await _database.SetMetadataAsync("last_sync_date", DateTime.UtcNow.ToString("O"), ct);
- await _database.SetMetadataAsync("bulk_data_updated", bulkInfo.UpdatedAt?.ToString("O") ?? "", ct);
-
- result.Success = true;
- result.Duration = DateTime.UtcNow - startTime;
- result.ErrorCount = errors;
- result.Message = $"Synced {result.ProcessedCards} cards in {result.Duration.TotalSeconds:F1}s";
-
- ReportProgress(new SyncProgress
- {
- Stage = SyncStage.Complete,
- ProcessedCards = result.ProcessedCards,
- Message = result.Message
- });
- }
- catch (OperationCanceledException)
- {
- result.Cancelled = true;
- result.Message = "Sync cancelled";
- }
- catch (Exception ex)
- {
- result.Error = ex.Message;
- }
-
- return result;
- }
-
- private async Task DownloadWithRetryAsync(string url, int maxRetries, CancellationToken ct)
- {
- for (var attempt = 0; attempt <= maxRetries; attempt++)
- {
- try
- {
- return await _imageClient.GetByteArrayAsync(url, ct);
- }
- catch (HttpRequestException) when (attempt < maxRetries)
- {
- await Task.Delay(1000 * (attempt + 1), ct);
- }
- }
-
- return null;
- }
-
- private void ReportProgress(SyncProgress progress)
- {
- OnProgress?.Invoke(progress);
- }
-}
-
-public class SyncOptions
-{
- public string BulkDataType { get; set; } = "unique_artwork";
- public string ImageSize { get; set; } = "normal";
- public int BatchSize { get; set; } = 100;
- public int RateLimitMs { get; set; } = 50;
- public int MaxRetries { get; set; } = 3;
- public bool ForceFullSync { get; set; }
- public bool IncludeNonEnglish { get; set; }
- public bool StopOnError { get; set; }
-}
-
-public class SyncResult
-{
- public bool Success { get; set; }
- public bool Skipped { get; set; }
- public bool Cancelled { get; set; }
- public int ProcessedCards { get; set; }
- public int ErrorCount { get; set; }
- public TimeSpan Duration { get; set; }
- public string? Message { get; set; }
- public string? Error { get; set; }
- public List Errors { get; set; } = new();
-}
-
-public class SyncProgress
-{
- public SyncStage Stage { get; set; }
- public int ProcessedCards { get; set; }
- public int TotalCards { get; set; }
- public string? Message { get; set; }
-
- public float Percentage => TotalCards > 0 ? (float)ProcessedCards / TotalCards * 100 : 0;
-}
-
-public enum SyncStage
-{
- Initializing,
- Downloading,
- Processing,
- Complete,
- Error
-}
diff --git a/src/Scry.Core/Recognition/RecognitionOptions.cs b/src/Scry.Core/Recognition/RecognitionOptions.cs
deleted file mode 100644
index 8a9f4ee..0000000
--- a/src/Scry.Core/Recognition/RecognitionOptions.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-namespace Scry.Core.Recognition;
-
-///
-/// Configuration options for card recognition.
-///
-public class RecognitionOptions
-{
- ///
- /// When set, saves debug images of each pipeline step to this directory.
- ///
- public string? DebugOutputDirectory { get; set; }
-
- ///
- /// Enable card detection and perspective correction.
- /// When disabled, assumes the input image is already a cropped card.
- ///
- public bool EnableCardDetection { get; set; } = true;
-
- ///
- /// Try multiple rotations (0°, 90°, 180°, 270°) when matching.
- /// Useful when card orientation is unknown.
- ///
- public bool EnableRotationMatching { get; set; } = true;
-}
diff --git a/src/Scry.Core/Scry.Core.csproj b/src/Scry.Core/Scry.Core.csproj
deleted file mode 100644
index 9464c4d..0000000
--- a/src/Scry.Core/Scry.Core.csproj
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
- net10.0
- enable
- enable
- Scry.Core
-
-
-
-
-
-
-
-
-
diff --git a/src/Scry.Core/Scryfall/ScryfallClient.cs b/src/Scry.Core/Scryfall/ScryfallClient.cs
deleted file mode 100644
index 58cf64c..0000000
--- a/src/Scry.Core/Scryfall/ScryfallClient.cs
+++ /dev/null
@@ -1,302 +0,0 @@
-using System.IO.Compression;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using Scry.Core.Models;
-
-namespace Scry.Core.Scryfall;
-
-public class ScryfallClient : IDisposable
-{
- private readonly HttpClient _httpClient;
- private const string BulkDataUrl = "https://api.scryfall.com/bulk-data";
- private const string SetsUrl = "https://api.scryfall.com/sets";
-
- public ScryfallClient(HttpClient? httpClient = null)
- {
- _httpClient = httpClient ?? new HttpClient();
- _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Scry/1.0 (MTG Card Scanner)");
- }
-
- public async Task GetBulkDataInfoAsync(string type = "unique_artwork", CancellationToken ct = default)
- {
- var response = await _httpClient.GetStringAsync(BulkDataUrl, ct);
- var bulkData = JsonSerializer.Deserialize(response, JsonOptions);
-
- return bulkData?.Data?.FirstOrDefault(d =>
- d.Type?.Equals(type, StringComparison.OrdinalIgnoreCase) == true);
- }
-
- public async Task> GetAllSetsAsync(CancellationToken ct = default)
- {
- var sets = new List();
- var url = SetsUrl;
-
- while (!string.IsNullOrEmpty(url))
- {
- var response = await _httpClient.GetStringAsync(url, ct);
- var setsResponse = JsonSerializer.Deserialize(response, JsonOptions);
-
- if (setsResponse?.Data != null)
- {
- sets.AddRange(setsResponse.Data);
- }
-
- url = setsResponse?.NextPage;
- }
-
- return sets;
- }
-
- public async IAsyncEnumerable StreamBulkDataAsync(
- string downloadUri,
- [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
- {
- using var response = await _httpClient.GetAsync(downloadUri, HttpCompletionOption.ResponseHeadersRead, ct);
- response.EnsureSuccessStatusCode();
-
- await using var stream = await response.Content.ReadAsStreamAsync(ct);
-
- Stream dataStream = stream;
- if (downloadUri.EndsWith(".gz", StringComparison.OrdinalIgnoreCase))
- {
- dataStream = new GZipStream(stream, CompressionMode.Decompress);
- }
-
- await foreach (var card in JsonSerializer.DeserializeAsyncEnumerable(dataStream, JsonOptions, ct))
- {
- if (card != null)
- {
- yield return card;
- }
- }
- }
-
- public async Task DownloadImageAsync(string imageUri, CancellationToken ct = default)
- {
- try
- {
- return await _httpClient.GetByteArrayAsync(imageUri, ct);
- }
- catch (HttpRequestException)
- {
- return null;
- }
- }
-
- public void Dispose()
- {
- _httpClient.Dispose();
- }
-
- private static readonly JsonSerializerOptions JsonOptions = new()
- {
- PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
- PropertyNameCaseInsensitive = true
- };
-}
-
-#region API Response Models
-
-public record BulkDataResponse
-{
- public List? Data { get; init; }
-}
-
-public record BulkDataInfo
-{
- public string? Id { get; init; }
- public string? Type { get; init; }
- public string? Name { get; init; }
- public string? DownloadUri { get; init; }
- public DateTime? UpdatedAt { get; init; }
- public long? Size { get; init; }
-}
-
-public record SetsResponse
-{
- public List? Data { get; init; }
- public bool HasMore { get; init; }
- public string? NextPage { get; init; }
-}
-
-public record ScryfallSet
-{
- public string? Id { get; init; }
- public string? Code { get; init; }
- public string? Name { get; init; }
- public string? SetType { get; init; }
- public string? ReleasedAt { get; init; }
- public int? CardCount { get; init; }
- public string? IconSvgUri { get; init; }
- public bool Digital { get; init; }
- public string? ParentSetCode { get; init; }
- public string? Block { get; init; }
-}
-
-public record ScryfallCard
-{
- // Core identifiers
- public string? Id { get; init; }
- public string? OracleId { get; init; }
-
- // Oracle/game card fields
- public string? Name { get; init; }
- public string? ManaCost { get; init; }
- public double? Cmc { get; init; }
- public string? TypeLine { get; init; }
- public string? OracleText { get; init; }
- public List? Colors { get; init; }
- public List? ColorIdentity { get; init; }
- public List? Keywords { get; init; }
- public bool Reserved { get; init; }
- public Dictionary? Legalities { get; init; }
- public string? Power { get; init; }
- public string? Toughness { get; init; }
-
- // Printing-specific fields
- public string? Set { get; init; }
- public string? SetId { get; init; }
- public string? SetName { get; init; }
- public string? CollectorNumber { get; init; }
- public string? Rarity { get; init; }
- public string? Artist { get; init; }
- public string? IllustrationId { get; init; }
- public string? Lang { get; init; }
-
- // Images and prices
- public ImageUris? ImageUris { get; init; }
- public Prices? Prices { get; init; }
-
- // Multi-face cards
- public List? CardFaces { get; init; }
-}
-
-public record ImageUris
-{
- public string? Small { get; init; }
- public string? Normal { get; init; }
- public string? Large { get; init; }
- public string? Png { get; init; }
- public string? ArtCrop { get; init; }
- public string? BorderCrop { get; init; }
-}
-
-public record Prices
-{
- public string? Usd { get; init; }
- public string? UsdFoil { get; init; }
- public string? Eur { get; init; }
-}
-
-public record CardFace
-{
- public string? Name { get; init; }
- public string? ManaCost { get; init; }
- public string? TypeLine { get; init; }
- public string? OracleText { get; init; }
- public List? Colors { get; init; }
- public string? Power { get; init; }
- public string? Toughness { get; init; }
- public ImageUris? ImageUris { get; init; }
-}
-
-#endregion
-
-#region Extension Methods
-
-public static class ScryfallCardExtensions
-{
- ///
- /// Extracts the Oracle (abstract game card) from a Scryfall card.
- ///
- public static Oracle ToOracle(this ScryfallCard scryfall)
- {
- return new Oracle
- {
- Id = scryfall.OracleId ?? scryfall.Id ?? Guid.NewGuid().ToString(),
- Name = scryfall.Name ?? "Unknown",
- ManaCost = scryfall.ManaCost,
- Cmc = scryfall.Cmc,
- TypeLine = scryfall.TypeLine,
- OracleText = scryfall.OracleText,
- Colors = scryfall.Colors != null ? JsonSerializer.Serialize(scryfall.Colors) : null,
- ColorIdentity = scryfall.ColorIdentity != null ? JsonSerializer.Serialize(scryfall.ColorIdentity) : null,
- Keywords = scryfall.Keywords != null ? JsonSerializer.Serialize(scryfall.Keywords) : null,
- Reserved = scryfall.Reserved,
- Legalities = scryfall.Legalities != null ? JsonSerializer.Serialize(scryfall.Legalities) : null,
- Power = scryfall.Power,
- Toughness = scryfall.Toughness,
- };
- }
-
- ///
- /// Converts a Scryfall card to a Card (printing) model.
- /// Note: Hash must be computed separately and set on the returned Card.
- ///
- public static Card ToCard(this ScryfallCard scryfall)
- {
- var imageUris = scryfall.ImageUris ?? scryfall.CardFaces?.FirstOrDefault()?.ImageUris;
-
- return new Card
- {
- Id = scryfall.Id ?? Guid.NewGuid().ToString(),
- OracleId = scryfall.OracleId ?? scryfall.Id ?? Guid.NewGuid().ToString(),
- SetId = scryfall.SetId ?? "",
- SetCode = scryfall.Set,
- SetName = scryfall.SetName,
- Name = scryfall.Name ?? "Unknown",
- CollectorNumber = scryfall.CollectorNumber,
- Rarity = scryfall.Rarity,
- Artist = scryfall.Artist,
- IllustrationId = scryfall.IllustrationId,
- ImageUri = imageUris?.Normal,
- Lang = scryfall.Lang,
- PricesUsd = decimal.TryParse(scryfall.Prices?.Usd, out var usd) ? usd : null,
- PricesUsdFoil = decimal.TryParse(scryfall.Prices?.UsdFoil, out var foil) ? foil : null,
- Hash = null, // Must be computed separately
- // Denormalized Oracle fields
- ManaCost = scryfall.ManaCost,
- TypeLine = scryfall.TypeLine,
- OracleText = scryfall.OracleText,
- Power = scryfall.Power,
- Toughness = scryfall.Toughness,
- };
- }
-
- ///
- /// Converts a Scryfall set to a Set model.
- ///
- public static Set ToSet(this ScryfallSet scryfall)
- {
- return new Set
- {
- Id = scryfall.Id ?? Guid.NewGuid().ToString(),
- Code = scryfall.Code ?? "???",
- Name = scryfall.Name ?? "Unknown",
- SetType = scryfall.SetType,
- ReleasedAt = scryfall.ReleasedAt,
- CardCount = scryfall.CardCount,
- IconSvgUri = scryfall.IconSvgUri,
- Digital = scryfall.Digital,
- ParentSetCode = scryfall.ParentSetCode,
- Block = scryfall.Block,
- };
- }
-
- public static string? GetImageUri(this ScryfallCard card, string size = "normal")
- {
- var uris = card.ImageUris ?? card.CardFaces?.FirstOrDefault()?.ImageUris;
-
- return size.ToLowerInvariant() switch
- {
- "small" => uris?.Small,
- "large" => uris?.Large,
- "png" => uris?.Png,
- "art_crop" => uris?.ArtCrop,
- "border_crop" => uris?.BorderCrop,
- _ => uris?.Normal
- };
- }
-}
-
-#endregion
diff --git a/test/Scry.Tests/CardDatabaseTests.cs b/test/Scry.Tests/CardDatabaseTests.cs
deleted file mode 100644
index 090998e..0000000
--- a/test/Scry.Tests/CardDatabaseTests.cs
+++ /dev/null
@@ -1,304 +0,0 @@
-using Microsoft.Data.Sqlite;
-using Scry.Core.Data;
-using Scry.Core.Models;
-using Xunit;
-
-namespace Scry.Tests;
-
-public class CardDatabaseTests : IDisposable
-{
- private readonly string _dbPath;
- private readonly CardDatabase _database;
-
- public CardDatabaseTests()
- {
- _dbPath = Path.Combine(Path.GetTempPath(), $"scry_test_{Guid.NewGuid()}.db");
- _database = new CardDatabase(_dbPath);
- }
-
- [Fact]
- public async Task InsertCard_ThenRetrieve_ReturnsMatch()
- {
- // First insert oracle and set (foreign keys)
- var oracle = new Oracle
- {
- Id = "oracle-1",
- Name = "Test Card",
- ManaCost = "{1}{U}",
- TypeLine = "Creature"
- };
- await _database.InsertOracleAsync(oracle);
-
- var set = new Set
- {
- Id = "set-1",
- Code = "TST",
- Name = "Test Set"
- };
- await _database.InsertSetAsync(set);
-
- var card = new Card
- {
- Id = "test-id",
- OracleId = "oracle-1",
- SetId = "set-1",
- SetCode = "TST",
- Name = "Test Card",
- CollectorNumber = "1",
- Hash = new byte[] { 0x01, 0x02, 0x03 },
- ImageUri = "https://example.com/image.jpg"
- };
-
- await _database.InsertCardAsync(card);
- var retrieved = await _database.GetCardByIdAsync("test-id");
-
- Assert.NotNull(retrieved);
- Assert.Equal("Test Card", retrieved.Name);
- Assert.Equal("TST", retrieved.SetCode);
- Assert.Equal(card.Hash, retrieved.Hash);
- }
-
- [Fact]
- public async Task InsertCardBatch_InsertsAllCards()
- {
- // Insert oracle first
- var oracle = new Oracle { Id = "oracle-batch", Name = "Batch Card" };
- await _database.InsertOracleAsync(oracle);
-
- var set = new Set { Id = "set-batch", Code = "TST", Name = "Test Set" };
- await _database.InsertSetAsync(set);
-
- var cards = Enumerable.Range(0, 100).Select(i => new Card
- {
- Id = $"card-{i}",
- OracleId = "oracle-batch",
- SetId = "set-batch",
- SetCode = "TST",
- Name = $"Card {i}",
- Hash = new byte[] { (byte)i }
- }).ToList();
-
- await _database.InsertCardBatchAsync(cards);
- var count = await _database.GetCardCountAsync();
-
- Assert.Equal(100, count);
- }
-
- [Fact]
- public async Task GetAllCards_ReturnsAllCards()
- {
- var oracle = new Oracle { Id = "oracle-all", Name = "All Card" };
- await _database.InsertOracleAsync(oracle);
-
- var set = new Set { Id = "set-all", Code = "TST", Name = "Test Set" };
- await _database.InsertSetAsync(set);
-
- var cards = Enumerable.Range(0, 10).Select(i => new Card
- {
- Id = $"card-{i}",
- OracleId = "oracle-all",
- SetId = "set-all",
- SetCode = "TST",
- Name = $"Card {i}",
- Hash = new byte[] { (byte)i }
- }).ToList();
-
- await _database.InsertCardBatchAsync(cards);
- var all = await _database.GetAllCardsAsync();
-
- Assert.Equal(10, all.Count);
- }
-
- [Fact]
- public async Task GetCardsByOracleId_ReturnsAllPrintings()
- {
- var oracle = new Oracle { Id = "oracle-multi", Name = "Multi Print Card" };
- await _database.InsertOracleAsync(oracle);
-
- var set1 = new Set { Id = "set-1", Code = "S1", Name = "Set 1" };
- var set2 = new Set { Id = "set-2", Code = "S2", Name = "Set 2" };
- await _database.InsertSetAsync(set1);
- await _database.InsertSetAsync(set2);
-
- var cards = new[]
- {
- new Card { Id = "print-1", OracleId = "oracle-multi", SetId = "set-1", SetCode = "S1", Name = "Multi Print Card", Hash = new byte[] { 0x01 } },
- new Card { Id = "print-2", OracleId = "oracle-multi", SetId = "set-2", SetCode = "S2", Name = "Multi Print Card", Hash = new byte[] { 0x02 } },
- };
-
- await _database.InsertCardBatchAsync(cards);
- var printings = await _database.GetCardsByOracleIdAsync("oracle-multi");
-
- Assert.Equal(2, printings.Count);
- }
-
- [Fact]
- public async Task Metadata_SetAndGet()
- {
- await _database.SetMetadataAsync("test_key", "test_value");
- var value = await _database.GetMetadataAsync("test_key");
-
- Assert.Equal("test_value", value);
- }
-
- [Fact]
- public async Task ClearCards_RemovesAllCards()
- {
- var oracle = new Oracle { Id = "oracle-clear", Name = "Clear Card" };
- await _database.InsertOracleAsync(oracle);
-
- var set = new Set { Id = "set-clear", Code = "TST", Name = "Test Set" };
- await _database.InsertSetAsync(set);
-
- var cards = Enumerable.Range(0, 10).Select(i => new Card
- {
- Id = $"card-{i}",
- OracleId = "oracle-clear",
- SetId = "set-clear",
- SetCode = "TST",
- Name = $"Card {i}",
- Hash = new byte[] { (byte)i }
- }).ToList();
-
- await _database.InsertCardBatchAsync(cards);
- await _database.ClearCardsAsync();
- var count = await _database.GetCardCountAsync();
-
- Assert.Equal(0, count);
- }
-
- [Fact]
- public async Task InsertCard_DuplicateId_Updates()
- {
- var oracle = new Oracle { Id = "oracle-dup", Name = "Dup Card" };
- await _database.InsertOracleAsync(oracle);
-
- var set = new Set { Id = "set-dup", Code = "TST", Name = "Test Set" };
- await _database.InsertSetAsync(set);
-
- var card1 = new Card
- {
- Id = "duplicate-id",
- OracleId = "oracle-dup",
- SetId = "set-dup",
- SetCode = "TST",
- Name = "Original Name",
- Hash = new byte[] { 0x01 }
- };
-
- var card2 = new Card
- {
- Id = "duplicate-id",
- OracleId = "oracle-dup",
- SetId = "set-dup",
- SetCode = "TST",
- Name = "Updated Name",
- Hash = new byte[] { 0x02 }
- };
-
- await _database.InsertCardAsync(card1);
- await _database.InsertCardAsync(card2);
-
- var retrieved = await _database.GetCardByIdAsync("duplicate-id");
-
- Assert.NotNull(retrieved);
- Assert.Equal("Updated Name", retrieved.Name);
- Assert.Equal(new byte[] { 0x02 }, retrieved.Hash);
- }
-
- [Fact]
- public async Task InsertOracle_ThenRetrieveByName()
- {
- var oracle = new Oracle
- {
- Id = "oracle-name",
- Name = "Lightning Bolt",
- ManaCost = "{R}",
- Cmc = 1,
- TypeLine = "Instant",
- OracleText = "Lightning Bolt deals 3 damage to any target."
- };
-
- await _database.InsertOracleAsync(oracle);
- var retrieved = await _database.GetOracleByNameAsync("Lightning Bolt");
-
- Assert.NotNull(retrieved);
- Assert.Equal("{R}", retrieved.ManaCost);
- Assert.Equal(1, retrieved.Cmc);
- }
-
- [Fact]
- public async Task InsertSet_ThenRetrieveByCode()
- {
- var set = new Set
- {
- Id = "set-lea",
- Code = "lea",
- Name = "Limited Edition Alpha",
- SetType = "expansion",
- ReleasedAt = "1993-08-05",
- CardCount = 295
- };
-
- await _database.InsertSetAsync(set);
- var retrieved = await _database.GetSetByCodeAsync("lea");
-
- Assert.NotNull(retrieved);
- Assert.Equal("Limited Edition Alpha", retrieved.Name);
- Assert.Equal(295, retrieved.CardCount);
- }
-
- [Fact]
- public async Task GetCardsWithHash_OnlyReturnsCardsWithHash()
- {
- var oracle = new Oracle { Id = "oracle-hash", Name = "Hash Card" };
- await _database.InsertOracleAsync(oracle);
-
- var set = new Set { Id = "set-hash", Code = "TST", Name = "Test Set" };
- await _database.InsertSetAsync(set);
-
- var cardWithHash = new Card
- {
- Id = "card-with-hash",
- OracleId = "oracle-hash",
- SetId = "set-hash",
- SetCode = "TST",
- Name = "Has Hash",
- Hash = new byte[] { 0x01 }
- };
-
- var cardWithoutHash = new Card
- {
- Id = "card-no-hash",
- OracleId = "oracle-hash",
- SetId = "set-hash",
- SetCode = "TST",
- Name = "No Hash",
- Hash = null
- };
-
- await _database.InsertCardAsync(cardWithHash);
- await _database.InsertCardAsync(cardWithoutHash);
-
- var cardsWithHash = await _database.GetCardsWithHashAsync();
-
- Assert.Single(cardsWithHash);
- Assert.Equal("card-with-hash", cardsWithHash[0].Id);
- }
-
- public void Dispose()
- {
- _database.Dispose();
- SqliteConnection.ClearAllPools();
- try
- {
- if (File.Exists(_dbPath))
- {
- File.Delete(_dbPath);
- }
- }
- catch (IOException)
- {
- }
- }
-}
diff --git a/test/Scry.Tests/CardRecognitionTests.cs b/test/Scry.Tests/CardRecognitionTests.cs
deleted file mode 100644
index 005abbe..0000000
--- a/test/Scry.Tests/CardRecognitionTests.cs
+++ /dev/null
@@ -1,250 +0,0 @@
-using Microsoft.Data.Sqlite;
-using Scry.Core.Data;
-using Scry.Core.Imaging;
-using Scry.Core.Models;
-using Scry.Core.Recognition;
-using SkiaSharp;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Scry.Tests;
-
-public class CardRecognitionTests : IDisposable
-{
- private readonly ITestOutputHelper _output;
- private readonly string _dbPath;
- private readonly CardDatabase _database;
- private readonly CardRecognitionService _recognitionService;
-
- public CardRecognitionTests(ITestOutputHelper output)
- {
- _output = output;
- _dbPath = Path.Combine(Path.GetTempPath(), $"scry_recognition_test_{Guid.NewGuid()}.db");
- _database = new CardDatabase(_dbPath);
- _recognitionService = new CardRecognitionService(_database);
- }
-
- [Fact]
- public async Task RecognizeAsync_EmptyDatabase_ReturnsFailed()
- {
- using var bitmap = CreateTestBitmap(100, 100);
-
- var result = await _recognitionService.RecognizeAsync(bitmap);
-
- Assert.False(result.Success);
- Assert.Contains("No cards", result.ErrorMessage);
- }
-
- [Fact]
- public async Task RecognizeAsync_ExactMatch_ReturnsSuccess()
- {
- using var bitmap = CreateTestBitmap(100, 100);
- var hash = _recognitionService.ComputeHash(bitmap);
-
- // Insert oracle and set first
- await _database.InsertOracleAsync(new Oracle { Id = "oracle-test", Name = "Test Card" });
- await _database.InsertSetAsync(new Set { Id = "set-test", Code = "TST", Name = "Test Set" });
-
- await _database.InsertCardAsync(new Card
- {
- Id = "test-card",
- OracleId = "oracle-test",
- SetId = "set-test",
- SetCode = "TST",
- Name = "Test Card",
- Hash = hash,
- ImageUri = "https://example.com/test.jpg"
- });
- await _recognitionService.InvalidateCacheAsync();
-
- var result = await _recognitionService.RecognizeAsync(bitmap);
-
- Assert.True(result.Success);
- Assert.Equal("Test Card", result.Card?.Name);
- Assert.Equal(1.0f, result.Confidence);
- Assert.Equal(0, result.HammingDistance);
- }
-
- [Theory]
- [InlineData("reference/brainstorm.png")]
- [InlineData("reference/force_of_will.png")]
- [InlineData("single_cards/llanowar_elves.jpg")]
- public async Task RecognizeAsync_ReferenceImage_SelfMatch(string imagePath)
- {
- var fullPath = Path.Combine("TestImages", imagePath);
- if (!File.Exists(fullPath))
- {
- _output.WriteLine($"Skipping test - file not found: {fullPath}");
- return;
- }
-
- using var bitmap = SKBitmap.Decode(fullPath);
- Assert.NotNull(bitmap);
-
- var hash = _recognitionService.ComputeHash(bitmap);
- var cardName = Path.GetFileNameWithoutExtension(imagePath);
-
- await _database.InsertOracleAsync(new Oracle { Id = $"oracle-{cardName}", Name = cardName });
- await _database.InsertSetAsync(new Set { Id = "set-ref", Code = "REF", Name = "Reference Set" });
-
- await _database.InsertCardAsync(new Card
- {
- Id = cardName,
- OracleId = $"oracle-{cardName}",
- SetId = "set-ref",
- SetCode = "REF",
- Name = cardName,
- Hash = hash
- });
- await _recognitionService.InvalidateCacheAsync();
-
- var result = await _recognitionService.RecognizeAsync(bitmap);
-
- Assert.True(result.Success, $"Recognition failed: {result.ErrorMessage}");
- Assert.Equal(cardName, result.Card?.Name);
- Assert.True(result.Confidence >= 0.85f);
-
- _output.WriteLine($"Matched: {cardName}, Confidence: {result.Confidence:P0}, Distance: {result.HammingDistance}");
- }
-
- [Fact]
- public async Task RecognizeAsync_SerraAngelFromDatabase_Matches()
- {
- // Find the solution root (where .git is)
- var currentDir = Directory.GetCurrentDirectory();
- var rootDir = currentDir;
- while (!Directory.Exists(Path.Combine(rootDir, ".git")) && Directory.GetParent(rootDir) != null)
- {
- rootDir = Directory.GetParent(rootDir)!.FullName;
- }
-
- var dbPath = Path.Combine(rootDir, "src", "Scry.App", "Resources", "Raw", "card_hashes.db");
- if (!File.Exists(dbPath))
- {
- _output.WriteLine($"Skipping - database not found at {dbPath}");
- return;
- }
-
- var imagePath = Path.Combine(rootDir, "TestImages", "reference_alpha", "serra_angel.jpg");
- if (!File.Exists(imagePath))
- {
- _output.WriteLine($"Skipping - image not found at {imagePath}");
- return;
- }
-
- using var testDb = new CardDatabase(dbPath);
- using var testRecognition = new CardRecognitionService(testDb);
-
- using var bitmap = SKBitmap.Decode(imagePath);
- Assert.NotNull(bitmap);
-
- // First, just compute hash and check distance manually
- var queryHash = testRecognition.ComputeHash(bitmap);
- var allCards = await testDb.GetCardsWithHashAsync();
-
- _output.WriteLine($"Query hash length: {queryHash.Length} bytes");
- _output.WriteLine($"Database has {allCards.Count} cards with hashes");
-
- // Find Serra Angel and compute distance
- var serraCard = allCards.FirstOrDefault(c => c.Name == "Serra Angel");
- if (serraCard?.Hash != null)
- {
- var distance = PerceptualHash.HammingDistance(queryHash, serraCard.Hash);
- _output.WriteLine($"Serra Angel hash length: {serraCard.Hash.Length} bytes");
- _output.WriteLine($"Distance to Serra Angel: {distance}");
- }
-
- // Find the actual best match
- int bestDistance = int.MaxValue;
- string? bestName = null;
- foreach (var card in allCards)
- {
- if (card.Hash == null || card.Hash.Length != queryHash.Length) continue;
- var dist = PerceptualHash.HammingDistance(queryHash, card.Hash);
- if (dist < bestDistance)
- {
- bestDistance = dist;
- bestName = card.Name;
- }
- }
- _output.WriteLine($"Best match: {bestName}, distance: {bestDistance}");
-
- // Now try actual recognition
- var result = await testRecognition.RecognizeAsync(bitmap);
-
- if (result.Success)
- {
- _output.WriteLine($"Recognition succeeded: {result.Card?.Name}, confidence: {result.Confidence:P0}");
- Assert.Equal("Serra Angel", result.Card?.Name);
- }
- else
- {
- _output.WriteLine($"Recognition failed: {result.ErrorMessage}");
- // For debugging - this should be 0 since we're using the exact same image
- Assert.Fail($"Expected to match Serra Angel, but got: {result.ErrorMessage}");
- }
- }
-
- [Fact]
- public async Task RecognizeAsync_MeasuresProcessingTime()
- {
- using var bitmap = CreateTestBitmap(200, 300);
- var hash = _recognitionService.ComputeHash(bitmap);
-
- await _database.InsertOracleAsync(new Oracle { Id = "oracle-timing", Name = "Timing Test Card" });
- await _database.InsertSetAsync(new Set { Id = "set-timing", Code = "TST", Name = "Test Set" });
-
- await _database.InsertCardAsync(new Card
- {
- Id = "timing-test",
- OracleId = "oracle-timing",
- SetId = "set-timing",
- SetCode = "TST",
- Name = "Timing Test Card",
- Hash = hash
- });
- await _recognitionService.InvalidateCacheAsync();
-
- var result = await _recognitionService.RecognizeAsync(bitmap);
-
- Assert.True(result.Success);
- Assert.True(result.ProcessingTime.TotalMilliseconds > 0);
- _output.WriteLine($"Processing time: {result.ProcessingTime.TotalMilliseconds:F2}ms");
- }
-
- private static SKBitmap CreateTestBitmap(int width, int height)
- {
- var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
- var random = new Random(42);
-
- for (var y = 0; y < height; y++)
- {
- for (var x = 0; x < width; x++)
- {
- var r = (byte)random.Next(256);
- var g = (byte)random.Next(256);
- var b = (byte)random.Next(256);
- bitmap.SetPixel(x, y, new SKColor(r, g, b));
- }
- }
-
- return bitmap;
- }
-
- public void Dispose()
- {
- _recognitionService.Dispose();
- _database.Dispose();
- SqliteConnection.ClearAllPools();
- try
- {
- if (File.Exists(_dbPath))
- {
- File.Delete(_dbPath);
- }
- }
- catch (IOException)
- {
- }
- }
-}
diff --git a/test/Scry.Tests/ImagePreprocessorTests.cs b/test/Scry.Tests/ImagePreprocessorTests.cs
deleted file mode 100644
index 8498443..0000000
--- a/test/Scry.Tests/ImagePreprocessorTests.cs
+++ /dev/null
@@ -1,131 +0,0 @@
-using Scry.Core.Imaging;
-using SkiaSharp;
-using Xunit;
-
-namespace Scry.Tests;
-
-public class ImagePreprocessorTests
-{
- [Fact]
- public void Resize_ProducesCorrectDimensions()
- {
- using var bitmap = CreateTestBitmap(100, 100);
-
- using var resized = ImagePreprocessor.Resize(bitmap, 32, 32);
-
- Assert.Equal(32, resized.Width);
- Assert.Equal(32, resized.Height);
- }
-
- [Fact]
- public void ToGrayscale_ProducesGrayscaleImage()
- {
- using var bitmap = CreateColorBitmap(10, 10);
-
- using var grayscale = ImagePreprocessor.ToGrayscale(bitmap);
-
- Assert.Equal(10, grayscale.Width);
- Assert.Equal(10, grayscale.Height);
- }
-
- [Fact]
- public void ApplyClahe_PreservesDimensions()
- {
- using var bitmap = CreateTestBitmap(64, 64);
-
- using var result = ImagePreprocessor.ApplyClahe(bitmap);
-
- Assert.Equal(64, result.Width);
- Assert.Equal(64, result.Height);
- }
-
- [Fact]
- public void ApplyClahe_EnhancesContrast()
- {
- using var bitmap = CreateLowContrastBitmap(64, 64);
-
- using var result = ImagePreprocessor.ApplyClahe(bitmap);
-
- Assert.NotNull(result);
- Assert.Equal(bitmap.Width, result.Width);
- Assert.Equal(bitmap.Height, result.Height);
- }
-
- [Theory]
- [InlineData("varying_quality/test1.jpg")]
- [InlineData("low_light/glare_toploader.png")]
- [InlineData("foil/rainbow_foil_secret_lair.jpg")]
- public void ApplyClahe_RealImages_DoesNotCrash(string imagePath)
- {
- var fullPath = Path.Combine("TestImages", imagePath);
- if (!File.Exists(fullPath))
- {
- return;
- }
-
- using var bitmap = SKBitmap.Decode(fullPath);
- Assert.NotNull(bitmap);
-
- using var result = ImagePreprocessor.ApplyClahe(bitmap);
-
- Assert.Equal(bitmap.Width, result.Width);
- Assert.Equal(bitmap.Height, result.Height);
- }
-
- private static SKBitmap CreateTestBitmap(int width, int height)
- {
- var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
- using var canvas = new SKCanvas(bitmap);
- canvas.Clear(SKColors.Gray);
- return bitmap;
- }
-
- private static SKBitmap CreateColorBitmap(int width, int height)
- {
- var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
- for (var y = 0; y < height; y++)
- {
- for (var x = 0; x < width; x++)
- {
- var r = (byte)(x * 255 / width);
- var g = (byte)(y * 255 / height);
- var b = (byte)((x + y) * 127 / (width + height));
- bitmap.SetPixel(x, y, new SKColor(r, g, b));
- }
- }
- return bitmap;
- }
-
- private static SKBitmap CreateLowContrastBitmap(int width, int height)
- {
- var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
- for (var y = 0; y < height; y++)
- {
- for (var x = 0; x < width; x++)
- {
- var gray = (byte)(120 + (x + y) % 20);
- bitmap.SetPixel(x, y, new SKColor(gray, gray, gray));
- }
- }
- return bitmap;
- }
-
- private static (byte Min, byte Max) GetLuminanceRange(SKBitmap bitmap)
- {
- byte min = 255;
- byte max = 0;
-
- for (var y = 0; y < bitmap.Height; y++)
- {
- for (var x = 0; x < bitmap.Width; x++)
- {
- var pixel = bitmap.GetPixel(x, y);
- var luminance = (byte)(0.299 * pixel.Red + 0.587 * pixel.Green + 0.114 * pixel.Blue);
- min = Math.Min(min, luminance);
- max = Math.Max(max, luminance);
- }
- }
-
- return (min, max);
- }
-}
diff --git a/test/Scry.Tests/PerceptualHashTests.cs b/test/Scry.Tests/PerceptualHashTests.cs
deleted file mode 100644
index 6553bf5..0000000
--- a/test/Scry.Tests/PerceptualHashTests.cs
+++ /dev/null
@@ -1,148 +0,0 @@
-using Scry.Core.Imaging;
-using SkiaSharp;
-using Xunit;
-
-namespace Scry.Tests;
-
-public class PerceptualHashTests
-{
- [Fact]
- public void ComputeHash_ReturnsConsistentHash()
- {
- using var bitmap = CreateTestBitmap(32, 32, SKColors.Red);
-
- var hash1 = PerceptualHash.ComputeHash(bitmap);
- var hash2 = PerceptualHash.ComputeHash(bitmap);
-
- Assert.Equal(hash1, hash2);
- }
-
- [Fact]
- public void ComputeColorHash_Returns24Bytes()
- {
- using var bitmap = CreateTestBitmap(32, 32, SKColors.Blue);
-
- var hash = PerceptualHash.ComputeColorHash(bitmap);
-
- Assert.Equal(24, hash.Length);
- }
-
- [Fact]
- public void HammingDistance_IdenticalHashes_ReturnsZero()
- {
- var hash = new byte[] { 0xFF, 0x00, 0xAB, 0xCD };
-
- var distance = PerceptualHash.HammingDistance(hash, hash);
-
- Assert.Equal(0, distance);
- }
-
- [Fact]
- public void HammingDistance_OppositeHashes_ReturnsMaxBits()
- {
- var hash1 = new byte[] { 0x00, 0x00 };
- var hash2 = new byte[] { 0xFF, 0xFF };
-
- var distance = PerceptualHash.HammingDistance(hash1, hash2);
-
- Assert.Equal(16, distance);
- }
-
- [Fact]
- public void HammingDistance_SingleBitDifference()
- {
- var hash1 = new byte[] { 0b00000000 };
- var hash2 = new byte[] { 0b00000001 };
-
- var distance = PerceptualHash.HammingDistance(hash1, hash2);
-
- Assert.Equal(1, distance);
- }
-
- [Fact]
- public void CalculateConfidence_ZeroDistance_ReturnsOne()
- {
- var confidence = PerceptualHash.CalculateConfidence(0, 192);
-
- Assert.Equal(1.0f, confidence);
- }
-
- [Fact]
- public void CalculateConfidence_HalfDistance_ReturnsHalf()
- {
- var confidence = PerceptualHash.CalculateConfidence(96, 192);
-
- Assert.Equal(0.5f, confidence);
- }
-
- [Theory]
- [InlineData("reference/brainstorm.png")]
- [InlineData("reference/force_of_will.png")]
- [InlineData("single_cards/llanowar_elves.jpg")]
- public void ComputeColorHash_RealImages_ProducesValidHash(string imagePath)
- {
- var fullPath = Path.Combine("TestImages", imagePath);
- if (!File.Exists(fullPath))
- {
- return;
- }
-
- using var bitmap = SKBitmap.Decode(fullPath);
- Assert.NotNull(bitmap);
-
- var hash = PerceptualHash.ComputeColorHash(bitmap);
-
- Assert.Equal(24, hash.Length);
- Assert.True(hash.Any(b => b != 0), "Hash should not be all zeros");
- }
-
- [Fact]
- public void SimilarImages_HaveLowHammingDistance()
- {
- using var bitmap1 = CreateGradientBitmap(32, 32, SKColors.Red, SKColors.Blue);
- using var bitmap2 = CreateGradientBitmap(32, 32, SKColors.Red, SKColors.Blue);
-
- var hash1 = PerceptualHash.ComputeColorHash(bitmap1);
- var hash2 = PerceptualHash.ComputeColorHash(bitmap2);
-
- var distance = PerceptualHash.HammingDistance(hash1, hash2);
-
- Assert.Equal(0, distance);
- }
-
- [Fact]
- public void DifferentImages_HaveHighHammingDistance()
- {
- using var bitmap1 = CreateTestBitmap(32, 32, SKColors.Red);
- using var bitmap2 = CreateTestBitmap(32, 32, SKColors.Blue);
-
- var hash1 = PerceptualHash.ComputeColorHash(bitmap1);
- var hash2 = PerceptualHash.ComputeColorHash(bitmap2);
-
- var distance = PerceptualHash.HammingDistance(hash1, hash2);
-
- Assert.True(distance > 10, $"Expected distance > 10, got {distance}");
- }
-
- private static SKBitmap CreateTestBitmap(int width, int height, SKColor color)
- {
- var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
- using var canvas = new SKCanvas(bitmap);
- canvas.Clear(color);
- return bitmap;
- }
-
- private static SKBitmap CreateGradientBitmap(int width, int height, SKColor start, SKColor end)
- {
- var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
- using var canvas = new SKCanvas(bitmap);
- using var paint = new SKPaint();
- paint.Shader = SKShader.CreateLinearGradient(
- new SKPoint(0, 0),
- new SKPoint(width, height),
- new[] { start, end },
- SKShaderTileMode.Clamp);
- canvas.DrawRect(0, 0, width, height, paint);
- return bitmap;
- }
-}
diff --git a/test/Scry.Tests/RobustnessAnalysisTests.cs b/test/Scry.Tests/RobustnessAnalysisTests.cs
deleted file mode 100644
index c335347..0000000
--- a/test/Scry.Tests/RobustnessAnalysisTests.cs
+++ /dev/null
@@ -1,505 +0,0 @@
-using Scry.Core.Data;
-using Scry.Core.Imaging;
-using Scry.Core.Models;
-using Scry.Core.Recognition;
-using SkiaSharp;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Scry.Tests;
-
-///
-/// Tests to analyze robustness of perceptual hashing under various camera scanning conditions.
-///
-public class RobustnessAnalysisTests : IDisposable
-{
- private readonly ITestOutputHelper _output;
- private readonly string _dbPath;
- private readonly CardDatabase _database;
- private readonly CardRecognitionService _recognitionService;
-
- public RobustnessAnalysisTests(ITestOutputHelper output)
- {
- _output = output;
- _dbPath = Path.Combine(Path.GetTempPath(), $"scry_robustness_test_{Guid.NewGuid()}.db");
- _database = new CardDatabase(_dbPath);
- _recognitionService = new CardRecognitionService(_database);
- }
-
- ///
- /// Test how rotation affects hash matching.
- /// pHash uses DCT which is NOT rotation invariant - this tests the impact.
- ///
- [Theory]
- [InlineData(5)] // Slight tilt
- [InlineData(15)] // Moderate tilt
- [InlineData(45)] // Significant rotation
- [InlineData(90)] // Portrait vs landscape
- [InlineData(180)] // Upside down
- public async Task Rotation_ImpactOnHashDistance(int rotationDegrees)
- {
- var imagePath = FindTestImage("reference_alpha/serra_angel.jpg");
- if (imagePath == null)
- {
- _output.WriteLine("Test image not found, skipping");
- return;
- }
-
- using var original = SKBitmap.Decode(imagePath);
- Assert.NotNull(original);
-
- // Compute hash of original
- var originalHash = _recognitionService.ComputeHash(original);
-
- // Register original in database
- await _database.InsertOracleAsync(new Oracle { Id = "oracle-serra", Name = "Serra Angel" });
- await _database.InsertSetAsync(new Set { Id = "set-lea", Code = "LEA", Name = "Alpha" });
-
- await _database.InsertCardAsync(new Card
- {
- Id = "serra-angel",
- OracleId = "oracle-serra",
- SetId = "set-lea",
- SetCode = "LEA",
- Name = "Serra Angel",
- Hash = originalHash
- });
- await _recognitionService.InvalidateCacheAsync();
-
- // Rotate the image
- using var rotated = RotateImage(original, rotationDegrees);
- var rotatedHash = _recognitionService.ComputeHash(rotated);
-
- var distance = PerceptualHash.HammingDistance(originalHash, rotatedHash);
- var confidence = PerceptualHash.CalculateConfidence(distance, 192);
-
- // Try recognition
- var result = await _recognitionService.RecognizeAsync(rotated);
-
- _output.WriteLine($"Rotation: {rotationDegrees}°");
- _output.WriteLine($" Hamming distance: {distance}/192 bits");
- _output.WriteLine($" Confidence: {confidence:P0}");
- _output.WriteLine($" Recognition: {(result.Success ? "SUCCESS" : "FAILED")}");
- _output.WriteLine($" Result: {result.Card?.Name ?? result.ErrorMessage}");
-
- // Document expected behavior
- if (rotationDegrees <= 5)
- {
- // Small rotations might still work
- _output.WriteLine($" [Small rotation - may still match]");
- }
- else
- {
- // pHash is NOT rotation invariant - this is expected to fail
- _output.WriteLine($" [pHash is NOT rotation invariant - failure expected]");
- }
- }
-
- ///
- /// Test how scaling/distance affects hash matching.
- ///
- [Theory]
- [InlineData(0.25f)] // Very small in frame (far away)
- [InlineData(0.50f)] // Half size
- [InlineData(0.75f)] // 3/4 size
- [InlineData(1.25f)] // Slightly larger
- [InlineData(2.0f)] // Double size (close up)
- public async Task Scale_ImpactOnHashDistance(float scaleFactor)
- {
- var imagePath = FindTestImage("reference_alpha/serra_angel.jpg");
- if (imagePath == null)
- {
- _output.WriteLine("Test image not found, skipping");
- return;
- }
-
- using var original = SKBitmap.Decode(imagePath);
- Assert.NotNull(original);
-
- var originalHash = _recognitionService.ComputeHash(original);
-
- await _database.InsertOracleAsync(new Oracle { Id = "oracle-serra", Name = "Serra Angel" });
- await _database.InsertSetAsync(new Set { Id = "set-lea", Code = "LEA", Name = "Alpha" });
-
- await _database.InsertCardAsync(new Card
- {
- Id = "serra-angel",
- OracleId = "oracle-serra",
- SetId = "set-lea",
- SetCode = "LEA",
- Name = "Serra Angel",
- Hash = originalHash
- });
- await _recognitionService.InvalidateCacheAsync();
-
- // Scale the image
- var newWidth = (int)(original.Width * scaleFactor);
- var newHeight = (int)(original.Height * scaleFactor);
- using var scaled = ImagePreprocessor.Resize(original, newWidth, newHeight);
- var scaledHash = _recognitionService.ComputeHash(scaled);
-
- var distance = PerceptualHash.HammingDistance(originalHash, scaledHash);
- var confidence = PerceptualHash.CalculateConfidence(distance, 192);
-
- var result = await _recognitionService.RecognizeAsync(scaled);
-
- _output.WriteLine($"Scale: {scaleFactor:P0}");
- _output.WriteLine($" Original: {original.Width}x{original.Height}");
- _output.WriteLine($" Scaled: {newWidth}x{newHeight}");
- _output.WriteLine($" Hamming distance: {distance}/192 bits");
- _output.WriteLine($" Confidence: {confidence:P0}");
- _output.WriteLine($" Recognition: {(result.Success ? "SUCCESS" : "FAILED")}");
-
- // pHash should be relatively scale-invariant since it resizes to 32x32 internally
- _output.WriteLine($" [pHash resizes internally - should be scale invariant]");
- }
-
- ///
- /// Test impact of card being placed on different backgrounds.
- ///
- [Theory]
- [InlineData(0, 0, 0)] // Black background
- [InlineData(255, 255, 255)] // White background
- [InlineData(128, 128, 128)] // Gray background
- [InlineData(139, 69, 19)] // Brown (wood table)
- [InlineData(0, 128, 0)] // Green (playmat)
- public async Task Background_ImpactOnHashDistance(byte r, byte g, byte b)
- {
- var imagePath = FindTestImage("reference_alpha/serra_angel.jpg");
- if (imagePath == null)
- {
- _output.WriteLine("Test image not found, skipping");
- return;
- }
-
- using var original = SKBitmap.Decode(imagePath);
- Assert.NotNull(original);
-
- var originalHash = _recognitionService.ComputeHash(original);
-
- await _database.InsertOracleAsync(new Oracle { Id = "oracle-serra", Name = "Serra Angel" });
- await _database.InsertSetAsync(new Set { Id = "set-lea", Code = "LEA", Name = "Alpha" });
-
- await _database.InsertCardAsync(new Card
- {
- Id = "serra-angel",
- OracleId = "oracle-serra",
- SetId = "set-lea",
- SetCode = "LEA",
- Name = "Serra Angel",
- Hash = originalHash
- });
- await _recognitionService.InvalidateCacheAsync();
-
- // Create image with card on colored background (with padding)
- using var withBackground = PlaceOnBackground(original, new SKColor(r, g, b), 100);
- var bgHash = _recognitionService.ComputeHash(withBackground);
-
- var distance = PerceptualHash.HammingDistance(originalHash, bgHash);
- var confidence = PerceptualHash.CalculateConfidence(distance, 192);
-
- var result = await _recognitionService.RecognizeAsync(withBackground);
-
- _output.WriteLine($"Background: RGB({r},{g},{b})");
- _output.WriteLine($" Image size: {withBackground.Width}x{withBackground.Height}");
- _output.WriteLine($" Hamming distance: {distance}/192 bits");
- _output.WriteLine($" Confidence: {confidence:P0}");
- _output.WriteLine($" Recognition: {(result.Success ? "SUCCESS" : "FAILED")}");
-
- // Background WILL affect hash significantly since no card detection/cropping is done
- _output.WriteLine($" [No card detection - background included in hash - CRITICAL ISSUE]");
- }
-
- ///
- /// Test brightness variations (simulating different lighting).
- ///
- [Theory]
- [InlineData(-50)] // Darker
- [InlineData(-25)] // Slightly darker
- [InlineData(25)] // Slightly brighter
- [InlineData(50)] // Brighter
- [InlineData(100)] // Very bright (overexposed)
- public async Task Brightness_ImpactOnHashDistance(int adjustment)
- {
- var imagePath = FindTestImage("reference_alpha/serra_angel.jpg");
- if (imagePath == null)
- {
- _output.WriteLine("Test image not found, skipping");
- return;
- }
-
- using var original = SKBitmap.Decode(imagePath);
- Assert.NotNull(original);
-
- var originalHash = _recognitionService.ComputeHash(original);
-
- await _database.InsertOracleAsync(new Oracle { Id = "oracle-serra", Name = "Serra Angel" });
- await _database.InsertSetAsync(new Set { Id = "set-lea", Code = "LEA", Name = "Alpha" });
-
- await _database.InsertCardAsync(new Card
- {
- Id = "serra-angel",
- OracleId = "oracle-serra",
- SetId = "set-lea",
- SetCode = "LEA",
- Name = "Serra Angel",
- Hash = originalHash
- });
- await _recognitionService.InvalidateCacheAsync();
-
- using var adjusted = AdjustBrightness(original, adjustment);
- var adjustedHash = _recognitionService.ComputeHash(adjusted);
-
- var distance = PerceptualHash.HammingDistance(originalHash, adjustedHash);
- var confidence = PerceptualHash.CalculateConfidence(distance, 192);
-
- var result = await _recognitionService.RecognizeAsync(adjusted);
-
- _output.WriteLine($"Brightness adjustment: {adjustment:+#;-#;0}");
- _output.WriteLine($" Hamming distance: {distance}/192 bits");
- _output.WriteLine($" Confidence: {confidence:P0}");
- _output.WriteLine($" Recognition: {(result.Success ? "SUCCESS" : "FAILED")}");
-
- // CLAHE should help normalize lighting
- _output.WriteLine($" [CLAHE preprocessing should help normalize lighting]");
- }
-
- ///
- /// Test how perspective distortion affects matching.
- ///
- [Theory]
- [InlineData(5)] // Slight perspective
- [InlineData(15)] // Moderate perspective
- [InlineData(30)] // Significant perspective
- public async Task Perspective_ImpactOnHashDistance(int perspectiveDegrees)
- {
- var imagePath = FindTestImage("reference_alpha/serra_angel.jpg");
- if (imagePath == null)
- {
- _output.WriteLine("Test image not found, skipping");
- return;
- }
-
- using var original = SKBitmap.Decode(imagePath);
- Assert.NotNull(original);
-
- var originalHash = _recognitionService.ComputeHash(original);
-
- await _database.InsertOracleAsync(new Oracle { Id = "oracle-serra", Name = "Serra Angel" });
- await _database.InsertSetAsync(new Set { Id = "set-lea", Code = "LEA", Name = "Alpha" });
-
- await _database.InsertCardAsync(new Card
- {
- Id = "serra-angel",
- OracleId = "oracle-serra",
- SetId = "set-lea",
- SetCode = "LEA",
- Name = "Serra Angel",
- Hash = originalHash
- });
- await _recognitionService.InvalidateCacheAsync();
-
- // Apply perspective transform (shear as approximation)
- using var perspective = ApplyPerspective(original, perspectiveDegrees);
- var perspectiveHash = _recognitionService.ComputeHash(perspective);
-
- var distance = PerceptualHash.HammingDistance(originalHash, perspectiveHash);
- var confidence = PerceptualHash.CalculateConfidence(distance, 192);
-
- var result = await _recognitionService.RecognizeAsync(perspective);
-
- _output.WriteLine($"Perspective: {perspectiveDegrees}° tilt");
- _output.WriteLine($" Hamming distance: {distance}/192 bits");
- _output.WriteLine($" Confidence: {confidence:P0}");
- _output.WriteLine($" Recognition: {(result.Success ? "SUCCESS" : "FAILED")}");
-
- _output.WriteLine($" [No perspective correction - distortion affects hash]");
- }
-
- ///
- /// Test real-world photos vs reference images.
- ///
- [Fact]
- public async Task RealPhotos_VsReferenceImages()
- {
- // Find the production database
- var currentDir = Directory.GetCurrentDirectory();
- var rootDir = currentDir;
- while (!Directory.Exists(Path.Combine(rootDir, ".git")) && Directory.GetParent(rootDir) != null)
- {
- rootDir = Directory.GetParent(rootDir)!.FullName;
- }
-
- var dbPath = Path.Combine(rootDir, "src", "Scry.App", "Resources", "Raw", "card_hashes.db");
- if (!File.Exists(dbPath))
- {
- _output.WriteLine($"Database not found at {dbPath}");
- return;
- }
-
- using var prodDb = new CardDatabase(dbPath);
- using var prodRecognition = new CardRecognitionService(prodDb);
-
- var testImagesDir = Path.Combine(rootDir, "TestImages");
- var categoriesToTest = new[] { "real_photos", "varying_quality", "angled", "low_light" };
-
- _output.WriteLine("=== Real-World Recognition Test ===");
- _output.WriteLine($"Database cards: {(await prodDb.GetCardsWithHashAsync()).Count}");
- _output.WriteLine("");
-
- foreach (var category in categoriesToTest)
- {
- var categoryPath = Path.Combine(testImagesDir, category);
- if (!Directory.Exists(categoryPath))
- continue;
-
- _output.WriteLine($"--- {category} ---");
-
- var imageFiles = Directory.GetFiles(categoryPath)
- .Where(f => new[] { ".jpg", ".jpeg", ".png", ".webp" }
- .Contains(Path.GetExtension(f).ToLowerInvariant()))
- .Take(5)
- .ToList();
-
- var successes = 0;
- var failures = 0;
-
- foreach (var file in imageFiles)
- {
- try
- {
- using var bitmap = SKBitmap.Decode(file);
- if (bitmap == null) continue;
-
- var result = await prodRecognition.RecognizeAsync(bitmap);
- var fileName = Path.GetFileName(file);
-
- if (result.Success)
- {
- successes++;
- _output.WriteLine($" [OK] {fileName} -> {result.Card?.Name} ({result.Confidence:P0})");
- }
- else
- {
- failures++;
- _output.WriteLine($" [FAIL] {fileName} -> {result.ErrorMessage}");
- }
- }
- catch (Exception ex)
- {
- _output.WriteLine($" [ERROR] {Path.GetFileName(file)}: {ex.Message}");
- }
- }
-
- _output.WriteLine($" Results: {successes} OK, {failures} failed");
- _output.WriteLine("");
- }
- }
-
- #region Helper Methods
-
- private static string? FindTestImage(string relativePath)
- {
- var currentDir = Directory.GetCurrentDirectory();
- var rootDir = currentDir;
- while (!Directory.Exists(Path.Combine(rootDir, ".git")) && Directory.GetParent(rootDir) != null)
- {
- rootDir = Directory.GetParent(rootDir)!.FullName;
- }
-
- var fullPath = Path.Combine(rootDir, "TestImages", relativePath);
- return File.Exists(fullPath) ? fullPath : null;
- }
-
- private static SKBitmap RotateImage(SKBitmap original, int degrees)
- {
- var radians = degrees * Math.PI / 180;
- var cos = Math.Abs(Math.Cos(radians));
- var sin = Math.Abs(Math.Sin(radians));
-
- var newWidth = (int)(original.Width * cos + original.Height * sin);
- var newHeight = (int)(original.Width * sin + original.Height * cos);
-
- var rotated = new SKBitmap(newWidth, newHeight, SKColorType.Rgba8888, SKAlphaType.Premul);
-
- using var canvas = new SKCanvas(rotated);
- canvas.Clear(SKColors.White);
- canvas.Translate(newWidth / 2f, newHeight / 2f);
- canvas.RotateDegrees(degrees);
- canvas.Translate(-original.Width / 2f, -original.Height / 2f);
- canvas.DrawBitmap(original, 0, 0);
-
- return rotated;
- }
-
- private static SKBitmap PlaceOnBackground(SKBitmap card, SKColor bgColor, int padding)
- {
- var newWidth = card.Width + padding * 2;
- var newHeight = card.Height + padding * 2;
-
- var result = new SKBitmap(newWidth, newHeight, SKColorType.Rgba8888, SKAlphaType.Premul);
-
- using var canvas = new SKCanvas(result);
- canvas.Clear(bgColor);
- canvas.DrawBitmap(card, padding, padding);
-
- return result;
- }
-
- private static SKBitmap AdjustBrightness(SKBitmap original, int adjustment)
- {
- var result = new SKBitmap(original.Width, original.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
-
- for (var y = 0; y < original.Height; y++)
- {
- for (var x = 0; x < original.Width; x++)
- {
- var pixel = original.GetPixel(x, y);
- var r = (byte)Math.Clamp(pixel.Red + adjustment, 0, 255);
- var g = (byte)Math.Clamp(pixel.Green + adjustment, 0, 255);
- var b = (byte)Math.Clamp(pixel.Blue + adjustment, 0, 255);
- result.SetPixel(x, y, new SKColor(r, g, b, pixel.Alpha));
- }
- }
-
- return result;
- }
-
- private static SKBitmap ApplyPerspective(SKBitmap original, int degrees)
- {
- // Approximate perspective with horizontal shear
- var shearFactor = (float)Math.Tan(degrees * Math.PI / 180);
- var extraWidth = (int)(original.Height * Math.Abs(shearFactor));
-
- var result = new SKBitmap(original.Width + extraWidth, original.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
-
- using var canvas = new SKCanvas(result);
- canvas.Clear(SKColors.White);
-
- var matrix = SKMatrix.CreateSkew(shearFactor, 0);
- if (shearFactor > 0)
- {
- matrix = SKMatrix.Concat(SKMatrix.CreateTranslation(extraWidth, 0), matrix);
- }
-
- canvas.SetMatrix(matrix);
- canvas.DrawBitmap(original, 0, 0);
-
- return result;
- }
-
- #endregion
-
- public void Dispose()
- {
- _recognitionService.Dispose();
- _database.Dispose();
- Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
- try
- {
- if (File.Exists(_dbPath))
- File.Delete(_dbPath);
- }
- catch { }
- }
-}
diff --git a/test/Scry.Tests/Scry.Tests.csproj b/test/Scry.Tests/Scry.Tests.csproj
deleted file mode 100644
index f53b84d..0000000
--- a/test/Scry.Tests/Scry.Tests.csproj
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
- net10.0
- enable
- enable
- false
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- PreserveNewest
-
-
-
-
diff --git a/test/Scry.Tests/TestImageBenchmarks.cs b/test/Scry.Tests/TestImageBenchmarks.cs
deleted file mode 100644
index a164e40..0000000
--- a/test/Scry.Tests/TestImageBenchmarks.cs
+++ /dev/null
@@ -1,232 +0,0 @@
-using Scry.Core.Imaging;
-using SkiaSharp;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Scry.Tests;
-
-public class TestImageBenchmarks
-{
- private readonly ITestOutputHelper _output;
- private static readonly string TestImagesDir = "TestImages";
-
- public TestImageBenchmarks(ITestOutputHelper output)
- {
- _output = output;
- }
-
- [Fact]
- public void ProcessAllTestImages_ComputeHashes()
- {
- if (!Directory.Exists(TestImagesDir))
- {
- _output.WriteLine("TestImages directory not found, skipping benchmark");
- return;
- }
-
- var categories = Directory.GetDirectories(TestImagesDir);
- var results = new List<(string Category, int Count, double AvgTimeMs, int Failures)>();
-
- foreach (var categoryPath in categories)
- {
- var category = Path.GetFileName(categoryPath);
- var imageFiles = GetImageFiles(categoryPath);
-
- if (!imageFiles.Any())
- continue;
-
- var times = new List();
- var failures = 0;
-
- foreach (var file in imageFiles)
- {
- try
- {
- var sw = System.Diagnostics.Stopwatch.StartNew();
-
- using var bitmap = SKBitmap.Decode(file);
- if (bitmap == null)
- {
- failures++;
- continue;
- }
-
- using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
- var hash = PerceptualHash.ComputeColorHash(preprocessed);
-
- sw.Stop();
- times.Add(sw.Elapsed.TotalMilliseconds);
- }
- catch (Exception ex)
- {
- failures++;
- _output.WriteLine($" Failed: {Path.GetFileName(file)} - {ex.Message}");
- }
- }
-
- if (times.Any())
- {
- var avgTime = times.Average();
- results.Add((category, times.Count, avgTime, failures));
- _output.WriteLine($"{category}: {times.Count} images, {avgTime:F1}ms avg, {failures} failures");
- }
- }
-
- _output.WriteLine("");
- _output.WriteLine("=== Summary ===");
- var totalImages = results.Sum(r => r.Count);
- var totalFailures = results.Sum(r => r.Failures);
- var overallAvg = results.SelectMany((r, _) => Enumerable.Repeat(r.AvgTimeMs, r.Count)).Average();
-
- _output.WriteLine($"Total: {totalImages} images processed");
- _output.WriteLine($"Failures: {totalFailures}");
- _output.WriteLine($"Overall avg: {overallAvg:F1}ms per image");
-
- Assert.True(totalImages > 0, "Should process at least some images");
- }
-
- [Theory]
- [InlineData("foil")]
- [InlineData("worn")]
- [InlineData("low_light")]
- [InlineData("foreign")]
- [InlineData("tokens")]
- public void ProcessCategory_AllImagesHash(string category)
- {
- var categoryPath = Path.Combine(TestImagesDir, category);
- if (!Directory.Exists(categoryPath))
- {
- _output.WriteLine($"Category not found: {category}");
- return;
- }
-
- var imageFiles = GetImageFiles(categoryPath);
- _output.WriteLine($"Processing {imageFiles.Count} images in {category}/");
-
- var processed = 0;
- var failed = 0;
-
- foreach (var file in imageFiles)
- {
- var fileName = Path.GetFileName(file);
- try
- {
- using var bitmap = SKBitmap.Decode(file);
- if (bitmap == null)
- {
- _output.WriteLine($" [DECODE FAIL] {fileName}");
- failed++;
- continue;
- }
-
- using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
- var hash = PerceptualHash.ComputeColorHash(preprocessed);
-
- Assert.Equal(24, hash.Length);
- processed++;
- _output.WriteLine($" [OK] {fileName} ({bitmap.Width}x{bitmap.Height})");
- }
- catch (Exception ex)
- {
- _output.WriteLine($" [ERROR] {fileName}: {ex.Message}");
- failed++;
- }
- }
-
- _output.WriteLine($"");
- _output.WriteLine($"Results: {processed} OK, {failed} failed");
-
- Assert.True(processed > 0 || !imageFiles.Any(), $"Should process at least one image in {category}");
- }
-
- [Fact]
- public void HashStability_SameImageProducesSameHash()
- {
- var testFile = Path.Combine(TestImagesDir, "reference", "brainstorm.png");
- if (!File.Exists(testFile))
- {
- testFile = GetImageFiles(TestImagesDir).FirstOrDefault();
- if (testFile == null)
- {
- _output.WriteLine("No test images found");
- return;
- }
- }
-
- using var bitmap = SKBitmap.Decode(testFile);
- Assert.NotNull(bitmap);
-
- var hashes = new List();
- for (var i = 0; i < 5; i++)
- {
- using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
- hashes.Add(PerceptualHash.ComputeColorHash(preprocessed));
- }
-
- for (var i = 1; i < hashes.Count; i++)
- {
- Assert.Equal(hashes[0], hashes[i]);
- }
-
- _output.WriteLine($"Hash is stable across {hashes.Count} runs");
- }
-
- [Fact]
- public void HashVariance_DifferentImagesProduceDifferentHashes()
- {
- var imageFiles = GetImageFiles(TestImagesDir).Take(20).ToList();
- if (imageFiles.Count < 2)
- {
- _output.WriteLine("Not enough test images for variance test");
- return;
- }
-
- var hashDict = new Dictionary();
-
- foreach (var file in imageFiles)
- {
- try
- {
- using var bitmap = SKBitmap.Decode(file);
- if (bitmap == null) continue;
-
- using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
- var hash = PerceptualHash.ComputeColorHash(preprocessed);
- hashDict[file] = hash;
- }
- catch
- {
- }
- }
-
- var collisions = 0;
- var comparisons = 0;
- var files = hashDict.Keys.ToList();
-
- for (var i = 0; i < files.Count; i++)
- {
- for (var j = i + 1; j < files.Count; j++)
- {
- var distance = PerceptualHash.HammingDistance(hashDict[files[i]], hashDict[files[j]]);
- comparisons++;
-
- if (distance < 5)
- {
- collisions++;
- _output.WriteLine($"Near collision (distance={distance}): {Path.GetFileName(files[i])} vs {Path.GetFileName(files[j])}");
- }
- }
- }
-
- _output.WriteLine($"Checked {comparisons} pairs, found {collisions} near-collisions");
- }
-
- private static List GetImageFiles(string directory)
- {
- var extensions = new[] { ".jpg", ".jpeg", ".png", ".webp", ".bmp" };
-
- return Directory.EnumerateFiles(directory, "*.*", SearchOption.AllDirectories)
- .Where(f => extensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
- .ToList();
- }
-}
diff --git a/tools/DbGenerator/DbGenerator.csproj b/tools/DbGenerator/DbGenerator.csproj
deleted file mode 100644
index 0b7c3bc..0000000
--- a/tools/DbGenerator/DbGenerator.csproj
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
- Exe
- net10.0
- enable
- enable
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tools/DbGenerator/GenerateCommand.cs b/tools/DbGenerator/GenerateCommand.cs
deleted file mode 100644
index 2c9cfb5..0000000
--- a/tools/DbGenerator/GenerateCommand.cs
+++ /dev/null
@@ -1,495 +0,0 @@
-using System.ComponentModel;
-using Scry.Core.Data;
-using Scry.Core.Imaging;
-using Scry.Core.Models;
-using Scry.Core.Scryfall;
-using SkiaSharp;
-using Spectre.Console;
-using Spectre.Console.Cli;
-
-namespace DbGenerator;
-
-public sealed class GenerateSettings : CommandSettings
-{
- [CommandArgument(0, "[output]")]
- [Description("Output database file path")]
- [DefaultValue("card_hashes.db")]
- public string Output { get; set; } = "card_hashes.db";
-
- [CommandOption("-c|--count")]
- [Description("Maximum number of cards to include")]
- [DefaultValue(500)]
- public int Count { get; set; } = 500;
-
- [CommandOption("--include-test-cards")]
- [Description("Include priority test cards (default: true)")]
- [DefaultValue(true)]
- public bool IncludeTestCards { get; set; } = true;
-
- [CommandOption("--no-test-cards")]
- [Description("Exclude priority test cards")]
- public bool NoTestCards { get; set; }
-
- [CommandOption("-f|--force")]
- [Description("Force rebuild from scratch")]
- public bool Force { get; set; }
-}
-
-public sealed class GenerateCommand : AsyncCommand
-{
- // Cards that should be included for testing with preferred sets
- private static readonly Dictionary PriorityCardsWithSets = new(StringComparer.OrdinalIgnoreCase)
- {
- // From reference_alpha/ - prefer LEA (Alpha) or LEB (Beta) for classic look
- ["Ancestral Recall"] = ["lea", "leb"],
- ["Badlands"] = ["lea", "leb"],
- ["Balance"] = ["lea", "leb"],
- ["Bayou"] = ["lea", "leb"],
- ["Birds of Paradise"] = ["lea", "leb"],
- ["Black Lotus"] = ["lea", "leb"],
- ["Channel"] = ["lea", "leb"],
- ["Chaos Orb"] = ["lea", "leb"],
- ["Clone"] = ["lea", "leb"],
- ["Control Magic"] = ["lea", "leb"],
- ["Counterspell"] = ["lea", "leb"],
- ["Dark Ritual"] = ["lea", "leb"],
- ["Demonic Tutor"] = ["lea", "leb"],
- ["Disenchant"] = ["lea", "leb"],
- ["Fireball"] = ["lea", "leb"],
- ["Force of Nature"] = ["lea", "leb"],
- ["Fork"] = ["lea", "leb"],
- ["Giant Growth"] = ["lea", "leb"],
- ["Hypnotic Specter"] = ["lea", "leb"],
- ["Lightning Bolt"] = ["lea", "leb"],
- ["Llanowar Elves"] = ["lea", "leb"],
- ["Mahamoti Djinn"] = ["lea", "leb"],
- ["Mind Twist"] = ["lea", "leb"],
- ["Mox Emerald"] = ["lea", "leb"],
- ["Mox Jet"] = ["lea", "leb"],
- ["Mox Pearl"] = ["lea", "leb"],
- ["Mox Ruby"] = ["lea", "leb"],
- ["Mox Sapphire"] = ["lea", "leb"],
- ["Nightmare"] = ["lea", "leb"],
- ["Plateau"] = ["lea", "leb"],
- ["Regrowth"] = ["lea", "leb"],
- ["Rock Hydra"] = ["lea", "leb"],
- ["Royal Assassin"] = ["lea", "leb"],
- ["Savannah"] = ["lea", "leb"],
- ["Scrubland"] = ["lea", "leb"],
- ["Serra Angel"] = ["lea", "leb"],
- ["Shivan Dragon"] = ["lea", "leb"],
- ["Sol Ring"] = ["lea", "leb"],
- ["Swords to Plowshares"] = ["lea", "leb"],
- ["Taiga"] = ["lea", "leb"],
- ["Time Walk"] = ["lea", "leb"],
- ["Timetwister"] = ["lea", "leb"],
- ["Tropical Island"] = ["lea", "leb"],
- ["Tundra"] = ["lea", "leb"],
- ["Underground Sea"] = ["lea", "leb"],
- ["Wheel of Fortune"] = ["lea", "leb"],
- ["Wrath of God"] = ["lea", "leb"],
-
- // From reference/ - any set is fine
- ["Brainstorm"] = [],
- ["Force of Will"] = [],
- ["Griselbrand"] = [],
- ["Lotus Petal"] = [],
- ["Ponder"] = [],
- ["Show and Tell"] = [],
- ["Volcanic Island"] = [],
- ["Wasteland"] = [],
-
- // From single_cards/ - any set is fine
- ["Adanto Vanguard"] = [],
- ["Angel of Sanctions"] = [],
- ["Attunement"] = [],
- ["Avaricious Dragon"] = [],
- ["Burgeoning"] = [],
- ["Jarad, Golgari Lich Lord"] = [],
- ["Meletis Charlatan"] = [],
- ["Mindstab Thrull"] = [],
- ["Pacifism"] = [],
- ["Platinum Angel"] = [],
- ["Queen Marchesa"] = [],
- ["Spellseeker"] = [],
- ["Tarmogoyf"] = [],
- ["Thought Reflection"] = [],
- ["Unsummon"] = [],
-
- // From varying_quality - prefer older sets
- ["Dragon Whelp"] = ["lea", "leb"],
- ["Evil Eye of Orms-by-Gore"] = [],
- ["Instill Energy"] = ["lea", "leb"],
-
- // Popular cards for general testing
- ["Lightning Helix"] = [],
- ["Path to Exile"] = [],
- ["Thoughtseize"] = [],
- ["Fatal Push"] = [],
- ["Snapcaster Mage"] = [],
- ["Jace, the Mind Sculptor"] = [],
- ["Liliana of the Veil"] = [],
- ["Noble Hierarch"] = [],
- ["Goblin Guide"] = [],
- ["Eidolon of the Great Revel"] = [],
- };
-
- public override async Task ExecuteAsync(CommandContext context, GenerateSettings settings)
- {
- var outputDb = settings.Output;
- var maxCards = settings.Count;
- var includeTestCards = settings.IncludeTestCards && !settings.NoTestCards;
- var forceRebuild = settings.Force;
-
- // Header
- AnsiConsole.Write(new FigletText("Scry DB Gen").Color(Color.Blue));
-
- var configTable = new Table()
- .Border(TableBorder.Rounded)
- .AddColumn("Setting")
- .AddColumn("Value");
-
- configTable.AddRow("Output", outputDb);
- configTable.AddRow("Max Cards", maxCards.ToString());
- configTable.AddRow("Test Cards", includeTestCards ? "[green]Yes[/]" : "[grey]No[/]");
- configTable.AddRow("Force Rebuild", forceRebuild ? "[yellow]Yes[/]" : "[grey]No[/]");
-
- AnsiConsole.Write(configTable);
- AnsiConsole.WriteLine();
-
- var priorityCards = new HashSet(PriorityCardsWithSets.Keys, StringComparer.OrdinalIgnoreCase);
-
- // Force rebuild if requested
- if (forceRebuild && File.Exists(outputDb))
- {
- AnsiConsole.MarkupLine("[yellow]Force rebuild requested, removing existing database...[/]");
- File.Delete(outputDb);
- }
-
- using var httpClient = new HttpClient();
- httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Scry/1.0 (MTG Card Scanner - Database Generator)");
-
- using var scryfallClient = new ScryfallClient(httpClient);
- using var db = new CardDatabase(outputDb);
-
- // Check existing database state
- var existingCardIds = await db.GetExistingCardIdsAsync();
- var existingCardNames = await db.GetExistingCardNamesAsync();
- var existingOracleIds = await db.GetExistingOracleIdsAsync();
- var existingSetIds = await db.GetExistingSetIdsAsync();
- var existingCount = await db.GetCardCountAsync();
- var storedScryfallDate = await db.GetMetadataAsync("scryfall_updated_at");
-
- var dbStateTable = new Table()
- .Border(TableBorder.Rounded)
- .Title("[blue]Current Database State[/]")
- .AddColumn("Metric")
- .AddColumn("Count", c => c.RightAligned());
-
- dbStateTable.AddRow("Cards", existingCount.ToString());
- dbStateTable.AddRow("Oracles", existingOracleIds.Count.ToString());
- dbStateTable.AddRow("Sets", existingSetIds.Count.ToString());
-
- AnsiConsole.Write(dbStateTable);
- AnsiConsole.WriteLine();
-
- // Fetch all sets
- List scryfallSets = [];
- await AnsiConsole.Status()
- .Spinner(Spinner.Known.Dots)
- .StartAsync("Fetching sets from Scryfall...", async ctx =>
- {
- scryfallSets = await scryfallClient.GetAllSetsAsync();
- });
-
- AnsiConsole.MarkupLine($"[green]✓[/] Found [blue]{scryfallSets.Count}[/] sets");
-
- var setsById = scryfallSets.ToDictionary(s => s.Id ?? "", s => s);
- var setsByCode = scryfallSets.ToDictionary(s => s.Code ?? "", s => s, StringComparer.OrdinalIgnoreCase);
-
- // Insert any new sets
- var newSets = scryfallSets
- .Where(s => s.Id != null && !existingSetIds.Contains(s.Id))
- .Select(s => s.ToSet())
- .ToList();
-
- if (newSets.Count > 0)
- {
- AnsiConsole.MarkupLine($"[green]✓[/] Inserting [blue]{newSets.Count}[/] new sets");
- await db.InsertSetBatchAsync(newSets);
- }
-
- // Fetch bulk data info
- BulkDataInfo? bulkInfo = null;
- await AnsiConsole.Status()
- .Spinner(Spinner.Known.Dots)
- .StartAsync("Fetching bulk data info...", async ctx =>
- {
- bulkInfo = await scryfallClient.GetBulkDataInfoAsync("unique_artwork");
- });
-
- if (bulkInfo?.DownloadUri == null)
- {
- AnsiConsole.MarkupLine("[red]✗ Failed to get bulk data info from Scryfall[/]");
- return 1;
- }
-
- AnsiConsole.MarkupLine($"[green]✓[/] Scryfall data last updated: [blue]{bulkInfo.UpdatedAt:yyyy-MM-dd HH:mm}[/]");
-
- // Check if we need to update at all
- var scryfallDateStr = bulkInfo.UpdatedAt?.ToString("O") ?? "";
- var needsUpdate = existingCount == 0 ||
- storedScryfallDate != scryfallDateStr ||
- existingCount < maxCards;
-
- // Also check if all priority cards exist
- var missingPriorityCards = includeTestCards
- ? priorityCards.Where(c => !existingCardNames.Contains(c)).ToList()
- : [];
-
- if (missingPriorityCards is not [])
- {
- AnsiConsole.MarkupLine($"[yellow]![/] Missing [blue]{missingPriorityCards.Count}[/] priority cards");
- needsUpdate = true;
- }
-
- if (!needsUpdate)
- {
- AnsiConsole.MarkupLine("[green]✓ Database is up-to-date, no changes needed[/]");
- return 0;
- }
-
- AnsiConsole.WriteLine();
-
- var newCards = new List();
- var newOracles = new Dictionary();
- var processed = 0;
- var errors = 0;
- var skipped = 0;
- var priorityFound = 0;
- var priorityNeeded = includeTestCards ? priorityCards.Count : 0;
-
- // Track which priority cards we've already found with their set
- var foundPriorityWithSet = new Dictionary(StringComparer.OrdinalIgnoreCase);
-
- // Helper to check if a set is preferred for a priority card
- static bool IsPreferredSet(string cardName, string setCode)
- {
- if (!PriorityCardsWithSets.TryGetValue(cardName, out var preferredSets))
- return false;
-
- return preferredSets.Length == 0 || preferredSets.Contains(setCode, StringComparer.OrdinalIgnoreCase);
- }
-
- await AnsiConsole.Progress()
- .AutoClear(false)
- .HideCompleted(false)
- .Columns(
- new RemainingTimeColumn(),
- new SpinnerColumn(),
- new ProgressBarColumn(),
- new PercentageColumn(),
- new TaskDescriptionColumn()
- {
- Alignment = Justify.Left,
- }
- )
- .StartAsync(async ctx =>
- {
- var downloadTask = ctx.AddTask("[blue]Downloading & processing cards[/]", maxValue: maxCards);
- var priorityTask = ctx.AddTask("[green]Priority cards[/]", maxValue: priorityNeeded);
-
- await foreach (var scryfallCard in scryfallClient.StreamBulkDataAsync(bulkInfo.DownloadUri))
- {
- // Skip non-English cards
- if (scryfallCard.Lang != "en")
- continue;
-
- var imageUri = scryfallCard.GetImageUri("normal");
- if (string.IsNullOrEmpty(imageUri))
- continue;
-
- var cardId = scryfallCard.Id ?? Guid.NewGuid().ToString();
- var cardName = scryfallCard.Name ?? "Unknown";
- var setCode = scryfallCard.Set ?? "???";
- var oracleId = scryfallCard.OracleId ?? cardId;
- var setId = scryfallCard.SetId ?? "";
-
- // Check if this card already exists in the database
- if (existingCardIds.Contains(cardId))
- {
- skipped++;
- continue;
- }
-
- // Check if this is a priority card we might need
- var isPriorityCard = includeTestCards && priorityCards.Contains(cardName);
- var isPreferred = isPriorityCard && IsPreferredSet(cardName, setCode);
-
- // If this priority card already found with preferred set, skip
- if (isPriorityCard && foundPriorityWithSet.TryGetValue(cardName, out var existingSet))
- {
- if (IsPreferredSet(cardName, existingSet))
- continue;
- if (!isPreferred)
- continue;
- }
-
- // Calculate how many slots we have left
- var totalCards = existingCount + newCards.Count;
- var priorityRemaining = priorityNeeded - foundPriorityWithSet.Count;
- var slotsForNonPriority = maxCards - priorityRemaining;
-
- // Skip if we have enough non-priority cards and this isn't priority
- if (!isPriorityCard && totalCards >= slotsForNonPriority)
- continue;
-
- // Download and process image
- try
- {
- downloadTask.Description = $"[blue]{Markup.Escape(cardName.Length > 30 ? cardName[..27] + "..." : cardName)}[/]";
-
- var imageBytes = await httpClient.GetByteArrayAsync(imageUri);
- using var bitmap = SKBitmap.Decode(imageBytes);
-
- if (bitmap == null)
- {
- errors++;
- continue;
- }
-
- // Apply CLAHE preprocessing and compute hash
- using var preprocessed = ImagePreprocessor.ApplyClahe(bitmap);
- var hash = PerceptualHash.ComputeColorHash(preprocessed);
-
- // Create Card (printing) with hash
- var card = scryfallCard.ToCard() with { Hash = hash };
- newCards.Add(card);
-
- // Track Oracle if we haven't seen it
- if (!existingOracleIds.Contains(oracleId) && !newOracles.ContainsKey(oracleId))
- {
- newOracles[oracleId] = scryfallCard.ToOracle();
- }
-
- if (isPriorityCard)
- {
- foundPriorityWithSet[cardName] = setCode;
- priorityFound++;
- priorityTask.Increment(1);
- }
-
- processed++;
- downloadTask.Increment(1);
-
- // Check if we have enough cards
- var foundAllPriority = foundPriorityWithSet.Count >= priorityNeeded;
- if (existingCount + newCards.Count >= maxCards && foundAllPriority)
- break;
-
- // Rate limit to be nice to Scryfall
- await Task.Delay(50);
- }
- catch
- {
- errors++;
- }
- }
-
- downloadTask.Value = downloadTask.MaxValue;
- priorityTask.Value = priorityTask.MaxValue;
- });
-
- AnsiConsole.WriteLine();
-
- // Summary table
- var summaryTable = new Table()
- .Border(TableBorder.Rounded)
- .Title("[blue]Processing Summary[/]")
- .AddColumn("Metric")
- .AddColumn("Count", c => c.RightAligned());
-
- summaryTable.AddRow("Skipped (already in DB)", skipped.ToString());
- summaryTable.AddRow("Newly processed", $"[green]{processed}[/]");
- summaryTable.AddRow("New oracles", newOracles.Count.ToString());
- summaryTable.AddRow("Priority cards found", $"{priorityFound}/{priorityNeeded}");
- summaryTable.AddRow("Errors", errors > 0 ? $"[red]{errors}[/]" : "0");
-
- AnsiConsole.Write(summaryTable);
- AnsiConsole.WriteLine();
-
- // Insert oracles first (cards reference them)
- if (newOracles.Count > 0)
- {
- await AnsiConsole.Status()
- .Spinner(Spinner.Known.Dots)
- .StartAsync($"Inserting {newOracles.Count} new oracles...", async ctx =>
- {
- await db.InsertOracleBatchAsync(newOracles.Values);
- });
- AnsiConsole.MarkupLine($"[green]✓[/] Inserted [blue]{newOracles.Count}[/] oracles");
- }
-
- if (newCards.Count > 0)
- {
- await AnsiConsole.Status()
- .Spinner(Spinner.Known.Dots)
- .StartAsync($"Inserting {newCards.Count} new cards...", async ctx =>
- {
- await db.InsertCardBatchAsync(newCards);
- });
- AnsiConsole.MarkupLine($"[green]✓[/] Inserted [blue]{newCards.Count}[/] cards");
- }
-
- await db.SetMetadataAsync("generated_at", DateTime.UtcNow.ToString("O"));
- await db.SetMetadataAsync("scryfall_updated_at", scryfallDateStr);
-
- var finalCardCount = await db.GetCardCountAsync();
- var finalOracleCount = await db.GetOracleCountAsync();
- var finalSetCount = await db.GetSetCountAsync();
-
- await db.SetMetadataAsync("card_count", finalCardCount.ToString());
- await db.SetMetadataAsync("oracle_count", finalOracleCount.ToString());
- await db.SetMetadataAsync("set_count", finalSetCount.ToString());
-
- AnsiConsole.WriteLine();
-
- var finalTable = new Table()
- .Border(TableBorder.Double)
- .Title("[green]Final Database State[/]")
- .AddColumn("Metric")
- .AddColumn("Count", c => c.RightAligned());
-
- finalTable.AddRow("Cards", $"[green]{finalCardCount}[/]");
- finalTable.AddRow("Oracles", $"[green]{finalOracleCount}[/]");
- finalTable.AddRow("Sets", $"[green]{finalSetCount}[/]");
- finalTable.AddRow("Output", $"[blue]{outputDb}[/]");
-
- AnsiConsole.Write(finalTable);
-
- // Report missing priority cards
- if (includeTestCards)
- {
- var missing = priorityCards.Where(c => !foundPriorityWithSet.ContainsKey(c)).ToList();
-
- if (missing.Count > 0)
- {
- AnsiConsole.WriteLine();
- AnsiConsole.MarkupLine($"[yellow]Missing priority cards ({missing.Count}):[/]");
-
- var tree = new Tree("[yellow]Missing Cards[/]");
- foreach (var name in missing.Take(20))
- {
- tree.AddNode($"[grey]{Markup.Escape(name)}[/]");
- }
- if (missing.Count > 20)
- {
- tree.AddNode($"[grey]... and {missing.Count - 20} more[/]");
- }
- AnsiConsole.Write(tree);
- }
- }
-
- return 0;
- }
-}
diff --git a/tools/DbGenerator/Program.cs b/tools/DbGenerator/Program.cs
deleted file mode 100644
index 99d841a..0000000
--- a/tools/DbGenerator/Program.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using DbGenerator;
-using Spectre.Console.Cli;
-
-var app = new CommandApp();
-
-app.Configure(config =>
-{
- config.SetApplicationName("dbgen");
- config.SetApplicationVersion("1.0.0");
-});
-
-return await app.RunAsync(args);