Compare commits

..

No commits in common. "experimental/expo-convex" and "main" have entirely different histories.

315 changed files with 436 additions and 9754 deletions

View file

@ -1,42 +0,0 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

View file

@ -1,30 +0,0 @@
{
"$schema": "https://charm.land/crush.json",
"permissions": {
"allowed_tools": [
"view",
"ls",
"grep",
"glob",
"edit",
"go",
"just",
"agent",
"agentic_fetch"
]
},
"options": {
"initialize_as": "AGENTS.md",
"skills_paths": ["./.agents/skills"]
},
"lsp": {
"typescript": {
"command": "typescript-language-server",
"args": ["--stdio"]
},
"omnisharp": {
"command": "wsl",
"args": ["--", "bash", "-lc", "\"OmniSharp\""]
}
}
}

54
.gitignore vendored
View file

@ -1,41 +1,23 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files # .NET
bin/
# dependencies obj/
node_modules/
# Expo
.expo/
dist/ dist/
web-build/ *.dll
expo-env.d.ts *.exe
*.pdb
# Native # IDE
.kotlin/ .vs/
*.orig.* .vscode/
*.jks .idea/
*.p8 *.user
*.p12 *.suo
*.key
*.mobileprovision
# Metro # OS
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store .DS_Store
*.pem Thumbs.db
# local env files # Project specific
.env*.local *.csv
*.dlens
# typescript *.apk
*.tsbuildinfo
# generated native folders
/ios
/android

View file

@ -1,35 +0,0 @@
set shell := ["C:/Program Files/Git/usr/bin/bash.exe", "-c"]
set unstable := true
android_sdk := replace(env('LOCALAPPDATA'), '\', '/') / "Android/Sdk"
adb := android_sdk / "platform-tools/adb.exe"
avd := android_sdk / "cmdline-tools/latest/bin/avdmanager.bat"
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"
default camera="virtual":
{{ emulator }} -avd Pixel_6 {{ if camera == "virtual" { camera_virtual } else { camera_webcam } }} -no-snapshot-load -gpu host
install:
{{ avd }} delete avd -n Pixel_6
{{ avd }} create avd -n Pixel_6 -k "system-images;android-36;google_apis_playstore;x86_64" -d pixel_6
# 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

View file

@ -1,80 +1,19 @@
# Scry development commands # Scry build recipes
set shell := ["C:/Program Files/Git/usr/bin/bash.exe", "-c"] # Default recipe - show available commands
set unstable := true default:
@just --list
mod emu '.just/emu.just' # Build both standard and embedded versions for all platforms
build apk="delver.apk":
rm -rf dist
dotnet publish -c Release -r win-x64 -o dist/win-x64/standard
dotnet publish -c Release -r win-x64 -p:EmbeddedApk={{apk}} -o dist/win-x64/embedded
dotnet publish -c Release -r linux-x64 -o dist/linux-x64/standard
dotnet publish -c Release -r linux-x64 -p:EmbeddedApk={{apk}} -o dist/linux-x64/embedded
dotnet publish -c Release -r osx-x64 -o dist/osx-x64/standard
dotnet publish -c Release -r osx-x64 -p:EmbeddedApk={{apk}} -o dist/osx-x64/embedded
# Android SDK paths # Clean build artifacts
clean:
android_sdk := replace(env('LOCALAPPDATA'), '\', '/') / "Android/Sdk" rm -rf bin obj dist
adb := android_sdk / "platform-tools/adb.exe"
[private]
@default:
just --list
# 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"
# Start Expo dev server with Convex (hot reload)
dev:
bun run dev
# Start just the Expo dev server
start:
bun run dev:expo
# Start just Convex dev server
convex-dev:
bun run dev:convex
# Run Expo app on Android emulator
android:
bun run android
# Install Expo app dependencies
expo-install:
bun install
# Run hash migration to Convex
expo-migrate:
bun run migrate
# TypeScript check for Expo app
expo-typecheck:
bun run typecheck

288
AGENTS.md
View file

@ -1,288 +0,0 @@
# Agent Instructions
## Overview
Scry is a Magic: The Gathering card scanner app built with Expo (React Native) and Convex. It uses perceptual hashing to match photographed cards against a database of known card images from Scryfall.
**Tech Stack:**
- **Frontend**: Expo/React Native with Expo Router (file-based routing)
- **Backend**: Convex (serverless functions + real-time database)
- **Image Processing**: React Native Skia, fast-opencv
- **Camera**: Adaptive (expo-camera in dev, react-native-vision-camera in production)
- **Auth**: Convex Auth with Zitadel OIDC (GDPR-compliant, no PII stored)
- **Package Manager**: Bun (not npm/yarn)
## Build Commands
Use `just` commands (defined in `.justfile`):
| Task | Command | Notes |
|------|---------|-------|
| Start dev server | `just dev` | Runs Convex + Expo together |
| Expo only | `just start` | Just the Expo dev server |
| Convex only | `just convex-dev` | Just the Convex dev server |
| Run on Android | `just android` | Starts Android emulator |
| Install deps | `just expo-install` | Runs `bun install` |
| Migrate hashes | `just expo-migrate` | Migrate card hashes to Convex |
| Type check | `just expo-typecheck` | TypeScript check |
| Start emulator | `just emu` | Virtual camera (submodule) |
### Direct Bun Commands
```bash
bun install # Install dependencies
bun run dev # Convex + Expo hot reload
bun run dev:convex # Convex dev server only
bun run dev:expo # Expo dev server only
bun run android # Run on Android
bun run migrate # Migrate hashes to Convex
bun run typecheck # TypeScript check
bunx convex dev # Convex CLI
```
## Project Structure
```
app/ # Expo Router pages
├── _layout.tsx # Root layout (Convex + HashCache providers)
├── modal.tsx # Card details modal
├── +not-found.tsx # 404 page
└── (tabs)/ # Tab navigation group
├── _layout.tsx # Tab bar layout
├── index.tsx # Collection tab (home)
├── scan.tsx # Camera scan tab
└── settings.tsx # Settings tab
components/
├── camera/ # Adaptive camera system
│ ├── index.tsx # AdaptiveCamera wrapper
│ ├── ExpoCamera.tsx # expo-camera (Expo Go)
│ └── VisionCamera.tsx # react-native-vision-camera (production)
└── *.tsx # Shared UI components
convex/ # Backend (Convex functions + schema)
├── schema.ts # Database schema
├── auth.ts # Zitadel OIDC configuration
├── http.ts # HTTP endpoints for auth
├── cards.ts # Card queries/mutations
├── collections.ts # User collection functions
├── users.ts # User functions
├── scanHistory.ts # Scan history functions
└── _generated/ # Auto-generated types
lib/
├── recognition/ # Card recognition pipeline
│ ├── recognitionService.ts # Main recognition logic
│ ├── cardDetector.ts # Edge detection, find card quad
│ ├── perspectiveCorrection.ts # Warp to rectangle
│ ├── clahe.ts # CLAHE lighting normalization
│ ├── perceptualHash.ts # 192-bit color hash (24 bytes)
│ ├── imageUtils.ts # Resize, rotate, grayscale
│ ├── imageLoader.ts # Load/resize images
│ └── skiaDecoder.ts # Decode images with Skia
├── hooks/ # React hooks
│ ├── useAuth.ts # OAuth flow with expo-auth-session
│ ├── useCamera.ts # Adaptive camera permissions
│ ├── useConvex.ts # Convex data hooks
│ ├── useSync.ts # Hash sync hook
│ └── useUserProfile.ts # Fetch profile from Zitadel
├── context/ # React contexts
│ └── HashCacheContext.tsx # In-memory hash cache
└── db/ # Local database utilities
├── localDatabase.ts # SQLite wrapper
└── syncService.ts # Sync with Convex
scripts/
└── migrate-hashes.ts # Migration script
TestImages/ # Test images (225 files)
```
## Architecture
### Recognition Pipeline
```
Camera Image
┌─────────────────────┐
│ loadImageAsBase64 │ ← Resize to 480×640
└─────────────────────┘
┌─────────────────────┐
│ decodeImageBase64 │ ← Skia decodes to RGBA pixels
└─────────────────────┘
┌─────────────────────┐
│ detectCard │ ← Edge detection, find card quad
│ (optional) │
└─────────────────────┘
┌─────────────────────┐
│ warpPerspective │ ← Warp detected quad to rectangle
└─────────────────────┘
┌─────────────────────┐
│ applyCLAHE │ ← Lighting normalization
└─────────────────────┘
┌─────────────────────┐
│ computeColorHash │ ← Compute 192-bit color hash (24 bytes)
└─────────────────────┘
┌─────────────────────┐
│ recognizeCard │ ← Hamming distance match against cache
└─────────────────────┘
```
### Data Model (Convex Schema)
| Table | Purpose |
|-------|---------|
| `users` | Minimal auth (no PII, GDPR compliant) |
| `cards` | Card printings with 24-byte perceptual hashes |
| `oracles` | Abstract game cards (one per unique card name) |
| `sets` | MTG sets with metadata |
| `collections` | User card collections |
| `scanHistory` | Scan history with confidence scores |
| `metadata` | Sync metadata |
### GDPR Compliance
- Database stores **no user PII** - only auth subject ID
- User profile (name, email, image) fetched from Zitadel userinfo endpoint on demand
- Profile held in memory only, never persisted
### Adaptive Camera System
The app detects its runtime environment and uses the appropriate camera:
- **Expo Go** (`Constants.appOwnership === "expo"`): Uses `expo-camera`
- **Production builds**: Uses `react-native-vision-camera`
Both expose the same `CameraHandle` interface with `takePhoto()`.
## 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
## Code Conventions
### General
- **TypeScript**: Strict mode enabled
- **Formatting**: Prettier defaults
- **Package Manager**: Bun (never use npm/yarn)
### React/React Native
- Functional components with hooks
- Expo Router for navigation (file-based)
- Convex hooks for data (`useQuery`, `useMutation`)
### Naming
- Hooks: `useCardHashes`, `useCameraPermission`
- Components: PascalCase (`AdaptiveCamera`, `ScanScreen`)
- Files: camelCase for modules, PascalCase for components
- Convex functions: camelCase (`cards.ts`, `getByScryfallId`)
### Convex Backend
- Queries are reactive and cached
- Mutations are transactional
- Use `v.` validators for all arguments
- Index frequently queried fields
## Environment Variables
### Convex Backend (`convex/.env.local`)
```bash
AUTH_ZITADEL_ID=your-client-id
AUTH_ZITADEL_SECRET=your-client-secret
AUTH_ZITADEL_ISSUER=https://your-zitadel-instance
```
### Client Side (`.env`)
```bash
EXPO_PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
EXPO_PUBLIC_ZITADEL_ISSUER=https://your-zitadel-instance
```
## Common Tasks
### Adding a New Convex Function
1. Add function to appropriate file in `convex/` (e.g., `cards.ts`)
2. Run `bunx convex dev` to regenerate types
3. Import from `convex/_generated/api` in client code
### Testing Recognition
1. Add test images to `TestImages/`
2. Use the scan tab in the app
3. Check console logs for `[Scry]` prefixed messages
### Debugging Camera Issues
- In Expo Go: Uses `expo-camera`, check for "Dev mode" indicator
- In production: Uses Vision Camera, requires EAS build
## Dependencies
### Core
- `expo` ~54.0.33
- `expo-router` ~6.0.23
- `convex` ^1.31.7
- `@convex-dev/auth` ^0.0.90
- `react` 19.1.0
- `react-native` 0.81.5
### Image Processing
- `@shopify/react-native-skia` ^2.4.18
- `react-native-fast-opencv` ^0.4.7
### Camera
- `expo-camera` ^17.0.10 (Expo Go)
- `react-native-vision-camera` ^4.7.3 (production)
### Auth
- `expo-auth-session` ^7.0.10
- `expo-secure-store` ^15.0.8
## External Resources
- [Scryfall API](https://scryfall.com/docs/api) - Card data source
- [Convex Docs](https://docs.convex.dev/) - Backend documentation
- [Expo Router](https://docs.expo.dev/router/introduction/) - Navigation
- [docs/CARD_RECOGNITION.md](docs/CARD_RECOGNITION.md) - Recognition architecture

369
Program.cs Normal file
View file

@ -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<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 Normal file
View file

@ -0,0 +1,33 @@
<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>

View file

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 568 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 962 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 486 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 865 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Some files were not shown because too many files have changed in this diff Show more