Compare commits
3 commits
main
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56499d5af9 | ||
|
|
54ba7496c6 | ||
|
|
0801ceee6a |
1
.gitignore
vendored
|
|
@ -21,3 +21,4 @@ Thumbs.db
|
||||||
*.csv
|
*.csv
|
||||||
*.dlens
|
*.dlens
|
||||||
*.apk
|
*.apk
|
||||||
|
debug/
|
||||||
|
|
|
||||||
97
.justfile
|
|
@ -1,19 +1,84 @@
|
||||||
# Scry build recipes
|
# Scry development commands
|
||||||
|
|
||||||
# Default recipe - show available commands
|
set shell := ["C:/Program Files/Git/usr/bin/bash.exe", "-c"]
|
||||||
default:
|
set unstable := true
|
||||||
@just --list
|
|
||||||
|
|
||||||
# Build both standard and embedded versions for all platforms
|
# Android SDK paths
|
||||||
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
|
|
||||||
|
|
||||||
# Clean build artifacts
|
android_sdk := replace(env('LOCALAPPDATA'), '\', '/') / "Android/Sdk"
|
||||||
clean:
|
adb := android_sdk / "platform-tools/adb.exe"
|
||||||
rm -rf bin obj dist
|
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
|
||||||
|
|
|
||||||
291
AGENTS.md
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Scry is a Magic: The Gathering card scanner app built with .NET MAUI. It uses perceptual hashing to match photographed cards against a database of known card images from Scryfall.
|
||||||
|
|
||||||
|
**Key components:**
|
||||||
|
- Mobile scanning app (MAUI/Android)
|
||||||
|
- Card recognition via perceptual hashing (not OCR)
|
||||||
|
- SQLite database with pre-computed hashes
|
||||||
|
- Scryfall API integration for card data
|
||||||
|
|
||||||
|
## Build Commands
|
||||||
|
|
||||||
|
Use `just` commands (defined in `.justfile`):
|
||||||
|
|
||||||
|
| Task | Command | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| Build project | `just build` | Builds Android debug |
|
||||||
|
| Run tests | `just test` | Runs xUnit tests |
|
||||||
|
| Generate card database | `just gen-db` | Downloads from Scryfall, computes hashes |
|
||||||
|
| Publish app | `just publish` | Creates release APK |
|
||||||
|
| Hot reload dev | `just dev` | Uses `dotnet watch` |
|
||||||
|
| Start emulator | `just emu` | Virtual camera with Serra Angel |
|
||||||
|
| Install to device | `just install` | Installs release APK |
|
||||||
|
|
||||||
|
### Database Generator Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
just gen-db # Default: 500 cards with test images
|
||||||
|
dotnet run --project tools/DbGenerator -- -c 1000 # More cards
|
||||||
|
dotnet run --project tools/DbGenerator -- --force # Rebuild from scratch
|
||||||
|
dotnet run --project tools/DbGenerator -- --no-test-cards # Skip priority test cards
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── Scry.App/ # MAUI mobile app (Android target)
|
||||||
|
│ ├── Views/ # XAML pages (ScanPage, CollectionPage, etc.)
|
||||||
|
│ ├── ViewModels/ # MVVM ViewModels using CommunityToolkit.Mvvm
|
||||||
|
│ ├── Services/ # App-layer services (ICardRecognitionService, ICardRepository)
|
||||||
|
│ ├── Converters/ # XAML value converters
|
||||||
|
│ ├── Models/ # App-specific models (CollectionEntry)
|
||||||
|
│ └── Resources/Raw/ # Bundled card_hashes.db
|
||||||
|
│
|
||||||
|
└── Scry.Core/ # Platform-independent core library
|
||||||
|
├── Recognition/ # CardRecognitionService, RecognitionOptions
|
||||||
|
├── Imaging/ # PerceptualHash, ImagePreprocessor, CardDetector
|
||||||
|
├── Data/ # CardDatabase (SQLite)
|
||||||
|
├── Models/ # Card, Oracle, Set, ScanResult
|
||||||
|
└── Scryfall/ # ScryfallClient for API/bulk data
|
||||||
|
|
||||||
|
test/
|
||||||
|
└── Scry.Tests/ # xUnit tests
|
||||||
|
|
||||||
|
tools/
|
||||||
|
└── DbGenerator/ # CLI tool to generate card_hashes.db
|
||||||
|
|
||||||
|
TestImages/ # Test images organized by category
|
||||||
|
├── reference_alpha/ # Alpha/Beta cards for testing
|
||||||
|
├── single_cards/ # Individual card photos
|
||||||
|
├── varying_quality/ # Different lighting/quality
|
||||||
|
├── hands/ # Cards held in hand
|
||||||
|
├── foil/ # Foil cards with glare
|
||||||
|
└── ... # More categories
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Recognition Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
Camera Image
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ CardDetector │ ← Edge detection, find card quad
|
||||||
|
│ (optional) │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ PerspectiveCorrection│ ← Warp to rectangle
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ ImagePreprocessor │ ← CLAHE for lighting normalization
|
||||||
|
│ (ApplyClahe) │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ PerceptualHash │ ← Compute 192-bit color hash (24 bytes)
|
||||||
|
│ (ComputeColorHash) │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ CardRecognitionService│ ← Hamming distance match against DB
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
Three-table schema mirroring Scryfall:
|
||||||
|
|
||||||
|
- **oracles** - Abstract game cards (one per unique card name)
|
||||||
|
- **sets** - MTG sets with metadata
|
||||||
|
- **cards** - Printings with perceptual hashes (one per unique artwork)
|
||||||
|
|
||||||
|
The `Card` model includes denormalized Oracle fields for convenience.
|
||||||
|
|
||||||
|
### Key Classes
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `CardRecognitionService` | Main recognition logic, caches DB, handles rotation matching |
|
||||||
|
| `PerceptualHash` | DCT-based color hash (192-bit = 8 bytes × 3 RGB channels) |
|
||||||
|
| `ImagePreprocessor` | CLAHE, resize, grayscale conversions |
|
||||||
|
| `CardDetector` | Edge detection + contour analysis to find card boundaries |
|
||||||
|
| `PerspectiveCorrection` | Warp detected quad to rectangle |
|
||||||
|
| `CardDatabase` | SQLite wrapper with batch insert, queries |
|
||||||
|
| `ScryfallClient` | Bulk data streaming, image downloads |
|
||||||
|
|
||||||
|
## Code Conventions
|
||||||
|
|
||||||
|
### General
|
||||||
|
|
||||||
|
- **Target**: .NET 10.0 (net10.0-android for app, net10.0 for Core/tools)
|
||||||
|
- **Nullable**: Enabled everywhere (`<Nullable>enable</Nullable>`)
|
||||||
|
- **Warnings as errors**: `<TreatWarningsAsErrors>true</TreatWarningsAsErrors>`
|
||||||
|
- **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
|
||||||
10
Directory.Build.props
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
29
Directory.Packages.props
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="CommunityToolkit.Maui" Version="14.0.0" />
|
||||||
|
<PackageVersion Include="CommunityToolkit.Maui.Camera" Version="6.0.0" />
|
||||||
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Maui.Controls" Version="10.0.30" />
|
||||||
|
<PackageVersion
|
||||||
|
Include="Microsoft.Maui.Controls.Compatibility"
|
||||||
|
Version="10.0.30"
|
||||||
|
/>
|
||||||
|
<PackageVersion
|
||||||
|
Include="Microsoft.Extensions.Logging.Debug"
|
||||||
|
Version="10.0.0"
|
||||||
|
/>
|
||||||
|
<PackageVersion Include="SkiaSharp" Version="3.119.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.1" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||||
|
<PackageVersion Include="Spectre.Console" Version="0.50.0" />
|
||||||
|
<PackageVersion Include="Spectre.Console.Cli" Version="0.50.0" />
|
||||||
|
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
369
Program.cs
|
|
@ -1,369 +0,0 @@
|
||||||
using System.CommandLine;
|
|
||||||
using System.Reflection;
|
|
||||||
using System.Text;
|
|
||||||
using ICSharpCode.SharpZipLib.Zip;
|
|
||||||
using Microsoft.Data.Sqlite;
|
|
||||||
using Spectre.Console;
|
|
||||||
|
|
||||||
// Ensure UTF-8 output for Unicode characters
|
|
||||||
Console.OutputEncoding = Encoding.UTF8;
|
|
||||||
|
|
||||||
var dlensArgument = new Argument<FileInfo>("dlens");
|
|
||||||
dlensArgument.Description = "Path to the .dlens database file";
|
|
||||||
|
|
||||||
var outputOption = new Option<FileInfo?>("--output", "-o");
|
|
||||||
outputOption.Description = "Output CSV file path (defaults to collection.csv)";
|
|
||||||
|
|
||||||
var showTableOption = new Option<bool>("--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<FileInfo>("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<ScannedCard>? scannedCards = null;
|
|
||||||
List<CollectionCard>? 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<List<CollectionCard>> ResolveCollection(FileInfo? apkFile, List<ScannedCard> 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<int, (string Name, string SetCode, string CollectorNumber, string ScryfallId)>();
|
|
||||||
|
|
||||||
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<CollectionCard>();
|
|
||||||
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<CollectionCard> 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<CollectionCard> 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<CollectionCard> 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<List<ScannedCard>> GetScannedCards(FileInfo dlensFile)
|
|
||||||
{
|
|
||||||
var cards = new List<ScannedCard>();
|
|
||||||
|
|
||||||
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);
|
|
||||||
33
Scry.csproj
|
|
@ -1,33 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<OutputType>Exe</OutputType>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<PublishReadyToRun>true</PublishReadyToRun>
|
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
|
||||||
<SelfContained>true</SelfContained>
|
|
||||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
|
||||||
<DebugType>none</DebugType>
|
|
||||||
<DebugSymbols>false</DebugSymbols>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(EmbeddedApk)' != ''">
|
|
||||||
<DefineConstants>$(DefineConstants);EMBEDDED_APK</DefineConstants>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup Condition="'$(EmbeddedApk)' != ''">
|
|
||||||
<EmbeddedResource Include="$(EmbeddedApk)">
|
|
||||||
<LogicalName>delver.apk</LogicalName>
|
|
||||||
</EmbeddedResource>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.2" />
|
|
||||||
<PackageReference Include="SharpZipLib" Version="1.4.2" />
|
|
||||||
<PackageReference Include="Spectre.Console" Version="0.54.0" />
|
|
||||||
<PackageReference Include="System.CommandLine" Version="2.0.2" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
9
Scry.slnx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/">
|
||||||
|
<Project Path="src/Scry.App/Scry.App.csproj" />
|
||||||
|
<Project Path="src/Scry.Core/Scry.Core.csproj" />
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/test/">
|
||||||
|
<Project Path="test/Scry.Tests/Scry.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
151
TestImages/README.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
# Test Images
|
||||||
|
|
||||||
|
This directory contains **225 reference images** for testing card recognition algorithms without requiring actual hardware.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
| Category | Count | Description |
|
||||||
|
|----------|-------|-------------|
|
||||||
|
| `reference_alpha/` | 47 | Alpha edition reference cards (old frame) |
|
||||||
|
| `varying_quality/` | 38 | Different lighting, blur, exposure, angles |
|
||||||
|
| `single_cards/` | 19 | Individual card photos |
|
||||||
|
| `real_photos/` | 18 | Phone camera photos from Visions project |
|
||||||
|
| `foreign/` | 16 | Non-English cards (Japanese, German, French, etc.) |
|
||||||
|
| `worn/` | 15 | Heavily played, damaged, worn cards |
|
||||||
|
| `foil/` | 14 | Foil cards with holographic glare/reflections |
|
||||||
|
| `low_light/` | 14 | Poor lighting, glare, shadows, amateur photos |
|
||||||
|
| `tokens/` | 13 | Tokens and planeswalker emblems |
|
||||||
|
| `hands/` | 11 | Cards held in hand (partial visibility) |
|
||||||
|
| `ocr_test/` | 10 | Images optimized for OCR testing |
|
||||||
|
| `reference/` | 9 | High-quality reference scans |
|
||||||
|
| `multiple_cards/` | 6 | Multiple cards in frame |
|
||||||
|
| `augmented/` | 4 | Augmented training examples |
|
||||||
|
| `training_examples/` | 3 | ML training set samples |
|
||||||
|
| `angled/` | 2 | Perspective distortion |
|
||||||
|
|
||||||
|
## Image Sources
|
||||||
|
|
||||||
|
Images from open-source MIT-licensed projects:
|
||||||
|
|
||||||
|
- [hj3yoo/mtg_card_detector](https://github.com/hj3yoo/mtg_card_detector)
|
||||||
|
- [tmikonen/magic_card_detector](https://github.com/tmikonen/magic_card_detector)
|
||||||
|
- [fortierq/mtgscan](https://github.com/fortierq/mtgscan)
|
||||||
|
- [LauriHursti/visions](https://github.com/LauriHursti/visions)
|
||||||
|
- [KLuml/CardScanner](https://github.com/KLuml/CardScanner)
|
||||||
|
- [dills122/MTG-Card-Analyzer](https://github.com/dills122/MTG-Card-Analyzer)
|
||||||
|
- [ryanlin/Turtle](https://github.com/ryanlin/Turtle)
|
||||||
|
|
||||||
|
Additional images from:
|
||||||
|
- Reddit r/magicTCG (user-submitted photos)
|
||||||
|
- Flickr (Creative Commons)
|
||||||
|
- Card Kingdom / Face to Face Games grading guides
|
||||||
|
- Scryfall (foreign language card scans)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Theory]
|
||||||
|
[InlineData("varying_quality/test1.jpg")]
|
||||||
|
[InlineData("angled/tilted_card_1.jpg")]
|
||||||
|
[InlineData("hands/hand_of_card_1.png")]
|
||||||
|
[InlineData("foil/rainbow_foil_secret_lair.jpg")]
|
||||||
|
[InlineData("worn/hp_shuffle_crease.webp")]
|
||||||
|
[InlineData("foreign/japanese_aang.jpg")]
|
||||||
|
public async Task RecognizeCard_VaryingConditions(string imagePath)
|
||||||
|
{
|
||||||
|
using var stream = File.OpenRead(Path.Combine("TestImages", imagePath));
|
||||||
|
var result = await _recognitionService.RecognizeCardAsync(stream);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.NotNull(result.Card);
|
||||||
|
Assert.True(result.Confidence >= 0.7f);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Category Details
|
||||||
|
|
||||||
|
### foil/
|
||||||
|
Foil cards showing holographic effects that challenge recognition:
|
||||||
|
- Rainbow foils with color-shifting (`rainbow_foil_secret_lair.jpg`)
|
||||||
|
- Surge foils with holo stickers (`surge_foils_holo.jpeg`)
|
||||||
|
- Old-style foils (`old_foil_yawgmoth.jpg`)
|
||||||
|
- Textured/dragonscale foils (`dragonscale_foil.jpg`)
|
||||||
|
- Foil curling examples showing warping
|
||||||
|
|
||||||
|
### worn/
|
||||||
|
Heavily played and damaged cards:
|
||||||
|
- Edge whitening (`edge_white.png`, `very_good_*.jpg`)
|
||||||
|
- Scratches and scuffs (`scratch.png`, `hp_scratches.png`)
|
||||||
|
- Creases and bends (`hp_shuffle_crease.webp`, `bent_creased.jpg`)
|
||||||
|
- Binder damage (`hp_binder_bite_*.webp`)
|
||||||
|
- Water damage (`hp_water_warping.png`)
|
||||||
|
- Corner damage (`hp_compromised_corner.webp`)
|
||||||
|
|
||||||
|
### low_light/
|
||||||
|
Poor lighting and amateur photography conditions:
|
||||||
|
- Glare from toploaders/sleeves (`glare_toploader.png`)
|
||||||
|
- Direct light causing hotspots (`glare_straight_down.jpg`)
|
||||||
|
- Depth of field blur (`dof_blur_amateur.jpg`)
|
||||||
|
- Amateur condition photos with shadows
|
||||||
|
- Flickr collection shots with mixed lighting
|
||||||
|
|
||||||
|
### foreign/
|
||||||
|
Non-English cards (8 languages):
|
||||||
|
- Japanese (日本語)
|
||||||
|
- German (Deutsch)
|
||||||
|
- French (Français)
|
||||||
|
- Italian (Italiano)
|
||||||
|
- Spanish (Español)
|
||||||
|
- Russian (Русский)
|
||||||
|
- Simplified Chinese (简体中文)
|
||||||
|
- Korean (한국어)
|
||||||
|
|
||||||
|
### tokens/
|
||||||
|
Tokens and planeswalker emblems:
|
||||||
|
- Official WotC tokens
|
||||||
|
- Custom/altered tokens
|
||||||
|
- Planeswalker emblems (Elspeth, Gideon, Narset)
|
||||||
|
- Token collections and gameplay shots
|
||||||
|
|
||||||
|
### varying_quality/
|
||||||
|
Images with various real-world challenges:
|
||||||
|
- Different camera exposures
|
||||||
|
- BGS graded cases (`counterspell_bgs.jpg`)
|
||||||
|
- Cards in plastic sleeves (`card_in_plastic_case.jpg`)
|
||||||
|
- Various lighting conditions
|
||||||
|
- 28 numbered test images (`test1.jpg` - `test27.jpg`)
|
||||||
|
|
||||||
|
### reference_alpha/
|
||||||
|
47 Limited Edition Alpha cards for old-frame recognition:
|
||||||
|
- Power Nine (Black Lotus, Ancestral Recall, Moxen, etc.)
|
||||||
|
- Dual lands (Underground Sea, Volcanic Island, etc.)
|
||||||
|
- Classic staples (Lightning Bolt, Counterspell, Sol Ring)
|
||||||
|
|
||||||
|
### hands/
|
||||||
|
Cards held in hand - partial visibility, stacked:
|
||||||
|
- Various deck archetypes (Tron, Green, Red)
|
||||||
|
- New and old frame cards
|
||||||
|
- Different lighting conditions
|
||||||
|
|
||||||
|
### real_photos/
|
||||||
|
Phone camera photos from Visions project:
|
||||||
|
- Real-world scanning conditions
|
||||||
|
- Various resolutions and crops
|
||||||
|
- Includes processed result images
|
||||||
|
|
||||||
|
### ocr_test/
|
||||||
|
From CardScanner project, graded by difficulty:
|
||||||
|
- `card0-4.jpg`: Easier recognition
|
||||||
|
- `card10-13.jpg`: Harder recognition (noted ~less accuracy)
|
||||||
|
|
||||||
|
## TODO: Additional Categories Needed
|
||||||
|
|
||||||
|
- [ ] **double_faced/** - Transform/MDFC cards (both sides)
|
||||||
|
- [ ] **art_cards/** - Art series cards without text boxes
|
||||||
|
- [ ] **promos/** - Extended art, borderless, showcase frames
|
||||||
|
- [ ] **very_low_light/** - Near-dark conditions
|
||||||
|
- [ ] **motion_blur/** - Cards in motion during scanning
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Card artwork is property of Wizards of the Coast. Images used for testing/research purposes only.
|
||||||
BIN
TestImages/angled/tilted_card_1.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
TestImages/angled/tilted_card_2.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
TestImages/augmented/augmented_1.jpg
Normal file
|
After Width: | Height: | Size: 568 KiB |
BIN
TestImages/augmented/augmented_2.jpg
Normal file
|
After Width: | Height: | Size: 622 KiB |
BIN
TestImages/augmented/augmented_3.jpg
Normal file
|
After Width: | Height: | Size: 503 KiB |
BIN
TestImages/augmented/augmented_4.jpg
Normal file
|
After Width: | Height: | Size: 500 KiB |
BIN
TestImages/foil/dragonscale_foil.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
TestImages/foil/foil_curling_1.jpg
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
TestImages/foil/foil_curling_2.jpg
Normal file
|
After Width: | Height: | Size: 962 KiB |
BIN
TestImages/foil/foil_jpn_mystical_archives.jpg
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
TestImages/foil/foil_peel_holo_layer.jpg
Normal file
|
After Width: | Height: | Size: 695 KiB |
BIN
TestImages/foil/foil_quality_comparison.jpeg
Normal file
|
After Width: | Height: | Size: 879 KiB |
BIN
TestImages/foil/foil_swamp_collection.jpg
Normal file
|
After Width: | Height: | Size: 888 KiB |
BIN
TestImages/foil/modern_vs_og_foils.jpg
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
TestImages/foil/old_foil_yawgmoth.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
TestImages/foil/rainbow_foil_secret_lair.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
TestImages/foil/rainbow_foil_sheldons.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
TestImages/foil/surge_foil_rhino.jpeg
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
TestImages/foil/surge_foils_holo.jpeg
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
TestImages/foil/textured_foils.jpg
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
TestImages/foreign/chinese_aarakocra.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
TestImages/foreign/chinese_abattoir_ghoul.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
TestImages/foreign/french_aang.jpg
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
TestImages/foreign/french_abattoir_ghoul.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
TestImages/foreign/german_aang.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
TestImages/foreign/german_abattoir_ghoul.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
TestImages/foreign/italian_aang.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
TestImages/foreign/japanese_aang.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
TestImages/foreign/japanese_abduction.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
TestImages/foreign/japanese_aberrant_researcher.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
TestImages/foreign/japanese_abhorrent_overlord.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
TestImages/foreign/korean_aarakocra.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
TestImages/foreign/korean_abattoir_ghoul.jpg
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
TestImages/foreign/russian_aarakocra.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
TestImages/foreign/russian_abattoir_ghoul.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
TestImages/foreign/spanish_aang.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
TestImages/hands/handOfCards.jpg
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
TestImages/hands/hand_of_card_1.png
Normal file
|
After Width: | Height: | Size: 430 KiB |
BIN
TestImages/hands/hand_of_card_green_1.jpg
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
TestImages/hands/hand_of_card_green_2.jpeg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
TestImages/hands/hand_of_card_ktk.png
Normal file
|
After Width: | Height: | Size: 464 KiB |
BIN
TestImages/hands/hand_of_card_new_frame.webp
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
TestImages/hands/hand_of_card_one_hand.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
TestImages/hands/hand_of_card_red.jpeg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
TestImages/hands/hand_of_card_tron.png
Normal file
|
After Width: | Height: | Size: 486 KiB |
BIN
TestImages/hands/klomparens_hand.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
TestImages/hands/li38_handOfCards.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
TestImages/low_light/authenticity_check.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
TestImages/low_light/basic_lands_amateur.jpg
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
TestImages/low_light/condition_amateur_1.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
TestImages/low_light/condition_amateur_2.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
TestImages/low_light/diy_lighting_rig.jpg
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
TestImages/low_light/dof_blur_amateur.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
TestImages/low_light/fake_detection.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
TestImages/low_light/flickr_collection_1.jpg
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
TestImages/low_light/flickr_collection_2.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
TestImages/low_light/flickr_collection_3.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
TestImages/low_light/glare_straight_down.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
TestImages/low_light/glare_toploader.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
TestImages/low_light/grading_amateur.jpg
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
TestImages/low_light/macro_monday_shadows.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
TestImages/multiple_cards/alpha_deck.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
TestImages/multiple_cards/geyser_twister_fireball.jpg
Normal file
|
After Width: | Height: | Size: 865 KiB |
BIN
TestImages/multiple_cards/lands_and_fatties.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
TestImages/multiple_cards/magic1.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
TestImages/multiple_cards/pro_tour_side.png
Normal file
|
After Width: | Height: | Size: 999 KiB |
BIN
TestImages/multiple_cards/pro_tour_table.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
TestImages/ocr_test/card.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
TestImages/ocr_test/card0.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
TestImages/ocr_test/card1.jpg
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
TestImages/ocr_test/card10.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
TestImages/ocr_test/card11.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
TestImages/ocr_test/card12.jpg
Normal file
|
After Width: | Height: | Size: 808 KiB |
BIN
TestImages/ocr_test/card13.jpg
Normal file
|
After Width: | Height: | Size: 747 KiB |
BIN
TestImages/ocr_test/card2.jpg
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
TestImages/ocr_test/card3.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
TestImages/ocr_test/card4.jpg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
TestImages/real_photos/visions_1.jpg
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
TestImages/real_photos/visions_1_square.jpg
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
TestImages/real_photos/visions_2.jpg
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
TestImages/real_photos/visions_2_square.jpg
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
TestImages/real_photos/visions_3.jpg
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
TestImages/real_photos/visions_4.jpg
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
TestImages/real_photos/visions_5.jpg
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
TestImages/real_photos/visions_6.jpg
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
TestImages/real_photos/visions_6_square.jpg
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
TestImages/real_photos/visions_7.jpg
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
TestImages/real_photos/visions_8.jpg
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
TestImages/real_photos/visions_8_big.jpg
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
TestImages/real_photos/visions_9.jpg
Normal file
|
After Width: | Height: | Size: 458 KiB |
BIN
TestImages/real_photos/visions_9_small.jpg
Normal file
|
After Width: | Height: | Size: 192 KiB |