MTG Card Scanner: Hybrid OCR + Art Matching + Live Pricing
2025-10-04
Scanning physical Magic cards should feel instant and accurate—no typing names, no guessing which printing you're holding. We just shipped a major upgrade to our card scanner that combines three different matching systems: OCR text recognition, visual art similarity, and live market pricing. Here's how it works and why it matters for deck building and collection management.
The Problem: Single-Method Scanning Fails
Most card scanners rely on just one approach:
- Text-only OCR misses short names ("Snap" → "Instant Ramen" because "Instant" appears on the card)
- Image-only matching fails with glare, rotation, or damaged cards
- Manual search wastes time and breaks flow when you're sorting bulk
The solution: combine all three methods and let them vote.
How Hybrid Search Works
1. OCR Text Extraction (Primary)
When you scan or upload a card photo:
- Tesseract.js extracts all visible text
- Smart cleaning removes OCR artifacts ("Snap NO" → "Snap")
- Card type words ("Instant", "Sorcery") are filtered out
- First 5 lines get priority weighting (card names appear at top)
- Short names (≤6 chars) get lenient matching thresholds
Result: 100% text match for "Snap" across 10+ printings.
2. Art Similarity Analysis (Tiebreaker)
For cards with identical text scores (all "Snap" prints = 100%):
- Extract art region from scan (8.5% offset, 83%×48% dimensions)
- Downsample to 96×96 pixels for fast comparison
- Load cached local images for candidate matches
- Compute pixel-by-pixel RGB similarity
- Dynamic weight adjustment: 65%/35% text/art default, shifts based on confidence
Result: 98% art match identifies exact printing, even when 8 variants exist.
3. Ranking Algorithm (Confidence)
Final sort prioritizes:
- Cards with confirmed art matches over text-only
- Higher combined scores (weighted text + art)
- Cards with local image cache over remote lookups
Edge case handled: Cards that fail to load images (CORS, missing cache) rank below art-confirmed matches to ensure the correct print wins.
Instant TCGplayer Pricing
Every scanned card result shows:
- Market price (large, prominent)
- Low/Mid prices (smaller, below market)
- "Buy on TCGplayer" button (affiliate link, opens new tab)
Data Flow
Scan → OCR + Art Match → Enrich with Local Price Cache → Display
- Source: Nightly cache (
tcgcsv-prices.json
, updated 21:10 UTC) - Coverage: ~110k cards with TCGplayer product IDs
- Response time: ~5ms (no network request during scan)
- Fallback: No price? No price box (graceful degradation)
Why Local Cache Matters
Network requests during scanning kill responsiveness. We pre-fetch all TCGplayer pricing once per day, so every scan is instant. The price box only appears if data exists—no loading spinners, no API delays.
Technical Implementation
Name Extraction Improvements
// Before: "Snap NO" matched against "Instant Ramen" (77%)
// After: cleanOCRName() strips trailing junk
cleanOCRName("Snap NO") → "Snap" → exact match (100%)
Cleaning logic:
- Remove trailing "NO", "NA", single chars/numbers
- Preserve apostrophes, commas, hyphens (for "Jace, the Mind Sculptor")
- Extract first word separately with extra weight
Art Matching Pipeline
1. extractArtRegion(scanImage) → 96×96 RGB samples
2. loadCachedCardImages(candidateIds) → local files or remote URLs
3. computeArtSimilarity(scan, candidate) → percentage
4. computeCombinedScore(textScore, artScore) → weighted blend
5. sortByArtConfidence() → prioritize confirmed matches
CORS workaround: Local image cache (public/cards/*.jpg
) avoids cross-origin canvas restrictions. Remote Scryfall images fail silently and fall back to text-only scoring.
Pricing Integration
// API route enriches matches with prices
const cardsWithPrices = await applyLocalPrices(results.map(r => r.card));
// Client displays price box
{match.tcgcsv?.market && (
<PriceBox>
${match.tcgcsv.market.toFixed(2)}
<BuyButton href={buildTcgProductUrl(match.tcgplayer_id)} />
Low: ${match.tcgcsv.low} | Mid: ${match.tcgcsv.mid}
</PriceBox>
)}
Use Cases: Why This Matters
Deck Building
- Scan alternate art versions and compare prices before buying
- Check if you already own a specific printing (foil vs. non-foil)
- Identify reprints instantly (Original Exodus Snap vs. List reprint)
Collection Management
- Bulk sorting: scan piles quickly, group by value
- Trade evaluation: instant market prices during negotiations
- Inventory tracking: match exact printings for insurance/resale
Budget Optimization
- Price checking: see if that bulk rare spiked
- Version comparison: original set vs. reprint pricing
- Buy decisions: one-click affiliate link to TCGplayer
Debug Mode: See the Thinking
Toggle "Show Debug Info" to watch the system work:
=== OCR TEXT EXTRACTION ===
Raw OCR: "Snap NO\n= A Sr ED 5\nInstant ()\nReturn target creature..."
Extracted: ["Snap", "Snap", "Snap", "A Sr ED", "Instant ()"]
=== TEXT MATCHING ===
1. Snap (Exodus) - 100%
2. Snap (The List) - 100%
3. Snap (Duel Decks) - 100%
=== ART SIMILARITY ===
[1] Snap (Exodus): Text=100% Art=98% → Combined=99%
[2] Snap (The List): Text=100% Art=76% → Combined=89%
[3] Snap (Duel Decks): Text=100% Art=94% → Combined=97%
=== FINAL RANKING ===
1. Snap (Exodus) - 99% (Text: 100% | Art: 98%) ← YOUR CARD
2. Snap (Duel Decks) - 97% (Text: 100% | Art: 94%)
3. Snap (The List) - 89% (Text: 100% | Art: 76%)
This transparency helps verify accuracy and understand why the system chose a specific print.
Performance Characteristics
Speed
- OCR extraction: ~2-4 seconds (Tesseract.js client-side)
- Text matching: ~100ms (fuzzy search across 110k cards)
- Art comparison: ~60-200ms (6 candidates × 96×96 pixels)
- Price enrichment: ~5ms (local cache lookup)
- Total: ~3-5 seconds from scan to results
Accuracy (Tested with "Snap" Card)
- Text-only: 77% confidence on wrong card ("Instant Ramen")
- Hybrid (text + art): 99% confidence on correct print (Exodus)
- Improvement: 22 percentage points, correct card moves from #9 to #1
Storage Requirements
- Card images: ~11GB for 110k cards (~100KB avg per image)
- Price cache: ~15MB (
tcgcsv-prices.json
) - Trade-off: Storage for speed and accuracy (no runtime network requests)
Common Edge Cases Handled
Short Card Names
- Problem: "Snap" only 4 characters, many false positives
- Solution: Lower threshold for short names, boost first-word matches, filter card types
OCR Artifacts
- Problem: "Snap NO" extracted instead of "Snap"
- Solution: Strip trailing junk (NO, NA, single chars/numbers)
Missing Local Images
- Problem: 40k+ cards don't have cached images yet
- Solution: Fall back to text-only scores, rank art-confirmed matches higher
Identical Text Matches
- Problem: 8 "Snap" prints, all 100% text match
- Solution: Art similarity breaks ties, correct print ranks #1
CORS Blocking Remote Images
- Problem: Browser blocks Scryfall canvas access
- Solution: Download images locally once, use cached versions
Roadmap: What's Next
Price Features
- 7-day trend arrows (↑ spiking, ↓ dropping)
- Foil pricing displayed separately
- "Great deal" badges for below-market listings
- Stock indicators ("In stock: 47 copies")
Scanner Enhancements
- Rotation correction for tilted scans
- Multi-card detection (scan full hand at once)
- Set symbol recognition (bypass text OCR entirely)
- Bulk mode (rapid-fire scanning for collection input)
Database Improvements
- Database name search (third hybrid dimension)
- Perceptual hashing (pre-compute art signatures for speed)
- Commander-specific fields (show if card is commander-legal)
Try It Now
Navigate to /scan
or click "📸 Scan" in the header. Upload a card photo or use your phone camera. Toggle debug mode to see how the hybrid search works. Check if prices appear for your cards.
The system works best with:
- Clear lighting (avoid glare on foils)
- Card name visible (OCR needs readable text)
- Centered framing (art region at standard position)
For cards without local images, art matching falls back gracefully to text-only scoring—you'll still get correct results, just without the art-confirmed confidence boost.
Technical Debt Paid
This wasn't just feature work—we fixed underlying architecture:
- Type safety: Removed
any
usage, added strictCardMatch
interface - Error handling: CORS failures now silent (no console spam)
- Performance: Local cache eliminates 110k potential network requests
- Maintainability: Debug logging makes future improvements easier
- Documentation: Comprehensive knowledge log in
AGENTS.md
Why This Approach Works
Hybrid search mirrors how humans identify cards: we read the name (OCR), recognize the art (similarity), and check the price (market data). By combining all three signals and weighting them intelligently, the scanner achieves better-than-human accuracy at machine speed.
The key insight: no single method is perfect, but voting systems are robust. When text OCR fails (short names, glare), art similarity rescues it. When art comparison fails (CORS, missing images), text matching carries the load. Pricing data adds instant value without slowing anything down.
Bottom line: Scan cards as fast as you can flip them. Get accurate printings, instant pricing, and one-click purchasing. No typing, no guessing, no network delays.