Compare commits
No commits in common. "experimental/expo-convex" and "main" have entirely different histories.
experiment
...
main
|
|
@ -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.
|
|
||||||
30
.crush.json
|
|
@ -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
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
93
.justfile
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
@ -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.
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 568 KiB |
|
Before Width: | Height: | Size: 622 KiB |
|
Before Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 962 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 695 KiB |
|
Before Width: | Height: | Size: 879 KiB |
|
Before Width: | Height: | Size: 888 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 486 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 865 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 999 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 808 KiB |
|
Before Width: | Height: | Size: 747 KiB |
|
Before Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 272 KiB |
|
Before Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 192 KiB |