Detect and identify Magic: The Gathering cards — train a model locally on your Mac or on a cloud GPU, then point your webcam at a card to see its name, oracle text, and price in real time.
The core model is a function: detect(image) -> [{class, bbox, confidence}, ...]. It takes an image and returns a list of detected regions, each with a class label, bounding box coordinates, and a confidence score. The full identification pipeline chains detection → OCR → Scryfall API lookup to go from a raw image to complete card data.
Best performance: 96.7% mAP50 / 77.7% mAP50-95 on Dataset v1 (Roboflow project version 8; 891 images) — the model correctly finds and identifies card regions ~97% of the time.
Canonical lineage used throughout this repo:
- Dataset v1 = Roboflow project version 8 export used in early baseline runs (891 images: train ~623 / valid ~178 / test ~90).
- Dataset v2 = expanded export used by current setup script (4,065 images: train 3,761 / valid 223 / test 81).
| Dataset version | Image count | Split sizes (train/valid/test) | Date | Training run docs |
|---|---|---|---|---|
| v1 | 891 | ~623 / ~178 / ~90 | 2026-03-28 (documented) | v1 baseline metrics |
| v2 | 4,065 | 3,761 / 223 / 81 | 2026-03-28 (documented) | v2 experiment metrics |
A "class" is like an enum value — an integer ID (0-6) mapped to a string name:
| ID | Class | What It Is | Typical Size |
|---|---|---|---|
| 0 | art | The illustration area | Large |
| 1 | card | The full card boundary | Very large |
| 2 | description | Rules text box | Large |
| 3 | mana-cost | Mana symbols (top right) | Small |
| 4 | power | Power/toughness (bottom right) | Small |
| 5 | tags | Type line (e.g., "Creature — Dragon") | Medium |
| 6 | title | Card name at the top | Medium |
Six commands from zero to card identification:
# 1. Install dependencies
uv sync
# 2. Download the dataset (get your free key at roboflow.com)
export ROBOFLOW_API_KEY="your_key_here"
uv run python scripts/01_setup_dataset.py
# 3. Explore the data (see what you're working with)
uv run python scripts/02_explore_dataset.py
# 4. Train the model (~28 hrs on M3 CPU, or ~29 min on RunPod — see Step 3)
uv run python scripts/03_train.py
# 5. Hold a card up to your webcam!
uv run python scripts/06_live_detect.py
# 6. Identify cards with full info (name, price, oracle text)
uv run python scripts/10_live_identify.pyexport ROBOFLOW_API_KEY="your_key_here"
uv run python scripts/01_setup_dataset.pyWhat happens: Downloads Dataset v2 from Roboflow (4,065 annotated MTG card images), pre-split into train (3,761), validation (223), and test (81) sets. Each image has a matching .txt label file with bounding box coordinates for every region. The three splits serve different purposes — train is where the model learns, validation monitors for overfitting during training, and test provides a final unbiased evaluation.
Output: data/mtg-detection/ directory with train/, valid/, test/ subdirectories.
Deep dive: docs/concepts.md#data-splits | docs/architecture.md#file-format-reference
uv run python scripts/02_explore_dataset.pyWhat happens: Generates visual summaries of the dataset — a 3x3 grid of randomly sampled training images with bounding boxes drawn, a class distribution chart, and data quality checks (missing labels, empty files).
Output: outputs/exploration/sample_grid.png, outputs/exploration/class_distribution.png
You have two options: local training (free, slower) or cloud GPU training (fast, ~$1-6).
uv run python scripts/03_train.pyWhat happens: Loads yolo11n.pt (a model pretrained on 80 everyday object types) and fine-tunes it on the MTG card dataset. This is transfer learning — like forking a well-tested open-source library and customizing it for your use case. The model's 2.6 million float parameters are iteratively adjusted over up to 100 epochs (passes through the data) to minimize prediction errors. Training stops early if validation performance plateaus for 20 consecutive epochs. Data augmentation includes geometric transforms (rotation up to 15°, perspective, shear), multi-scale training (320-960px), color shifts, mosaic, and mixup — creating variations the model hasn't seen to handle real-world webcam conditions like tilted cards, varying distances, and background clutter.
Training uses CPU (not GPU) because PyTorch 2.10 MPS has known bugs on macOS 26. CPU training on Apple Silicon is still fast thanks to high-bandwidth unified memory.
Output: models/mtg-detect-best.pt (the trained model), runs/mtg-detect/ (training logs)
For higher accuracy, train on a cloud GPU with larger models and higher resolution. Three scripts are available, each representing an iteration of the training approach:
| Script | Model | Resolution | Epochs | Est. Cost | Est. Time |
|---|---|---|---|---|---|
train_cloud.py |
yolo11n | 640 | 100 | ~$0.73 | ~29 min |
train_cloud_v2.py |
yolo11n/s/m | 1280 | 150-200 | $0.90-$3.50 | 1.5-6 hrs |
train_cloud_v3.py |
yolo11m | 1280 | 250 | ~$5.60 | ~6 hrs |
Prerequisites:
- A RunPod account with balance (~$5-15)
runpodctlCLI installed:brew install runpod/runpodctl/runpodctl- API key configured:
runpodctl doctor(prompts for key and saves it)
Step-by-step:
# 1. Create a GPU pod (RTX 4090 recommended)
runpodctl pod create \
--name "mtg-training" \
--gpu-id "NVIDIA GeForce RTX 4090" \
--template-id runpod-torch-v240 \
--volume-in-gb 20 \
--cloud-type SECURE
# 2. Get SSH connection info (wait ~1 min for pod to start)
runpodctl ssh info <pod-id>
# 3. Upload the training script
scp -i ~/.runpod/ssh/RunPod-Key-Go -P <port> \
-o StrictHostKeyChecking=no \
scripts/train_cloud_v3.py root@<pod-ip>:/workspace/
# 4. Install dependencies on the pod
ssh -i ~/.runpod/ssh/RunPod-Key-Go -p <port> \
-o StrictHostKeyChecking=no root@<pod-ip> \
"pip install ultralytics roboflow"
# 5. Start training (runs in background so SSH disconnect is safe)
ssh -i ~/.runpod/ssh/RunPod-Key-Go -p <port> \
-o StrictHostKeyChecking=no root@<pod-ip> \
"cd /workspace && ROBOFLOW_API_KEY=your_key \
nohup python train_cloud_v3.py > training.log 2>&1 &"
# 6. Check progress
ssh -i ~/.runpod/ssh/RunPod-Key-Go -p <port> \
-o StrictHostKeyChecking=no root@<pod-ip> \
"tail -5 /workspace/training.log"
# 7. Download trained model when complete
scp -i ~/.runpod/ssh/RunPod-Key-Go -P <port> \
-o StrictHostKeyChecking=no \
root@<pod-ip>:/workspace/runs/detect/v3-final/weights/best.pt \
models/mtg-detect-v3.pt
# 8. Stop and remove the pod to stop billing
runpodctl pod stop <pod-id>
runpodctl pod remove <pod-id>The <pod-id>, <port>, and <pod-ip> values come from the output of steps 1 and 2.
Live dashboard: In a separate terminal, run:
uv run tensorboard --logdir runs/Open http://localhost:6006 to watch training metrics update in real time — loss curves, precision/recall, mAP, and learning rate schedules.
Run the entire pipeline (train + validate + predict + identify) in a single script on a cloud GPU:
# 1. Create pod and SCP the script (same pod setup as Option B)
scp -i ~/.runpod/ssh/RunPod-Key-Go -P <port> \
-o StrictHostKeyChecking=no \
scripts/run_cloud.py root@<pod-ip>:/workspace/
# 2. Install dependencies
ssh -i ~/.runpod/ssh/RunPod-Key-Go -p <port> \
-o StrictHostKeyChecking=no root@<pod-ip> \
"pip install ultralytics roboflow rapidocr-onnxruntime opencv-python-headless matplotlib"
# 3. Run the full pipeline
ssh -i ~/.runpod/ssh/RunPod-Key-Go -p <port> \
-o StrictHostKeyChecking=no root@<pod-ip> \
"cd /workspace && ROBOFLOW_API_KEY=your_key \
nohup python run_cloud.py --all > pipeline.log 2>&1 &"
# 4. Download all outputs when complete
scp -i ~/.runpod/ssh/RunPod-Key-Go -P <port> -r \
-o StrictHostKeyChecking=no \
root@<pod-ip>:/workspace/outputs/ ./outputs/You can also run individual steps or customize training:
# Just train and validate with a larger model
python run_cloud.py --setup --train --validate --model-size m --imgsz 1280 --epochs 200 --batch 4
# Validate an existing model
python run_cloud.py --setup --validate --model /workspace/runs/detect/train/weights/best.pt
# Download test images and run identification
python run_cloud.py --setup --download-test-images --identifyAvailable flags: --setup, --explore, --train, --validate, --predict, --download-test-images, --identify, --all. Training flags: --model-size (n/s/m), --imgsz, --epochs, --batch, --patience, --resume.
Deep dive: docs/concepts.md#what-is-training | docs/concepts.md#transfer-learning | docs/parameters.md
# Default: validate with the baseline model
uv run python scripts/04_validate.py
# Validate a specific model at its training resolution
uv run python scripts/04_validate.py --model models/mtg-detect-v3.pt --imgsz 1280
# With test-time augmentation (flips + scales for extra accuracy)
uv run python scripts/04_validate.py --model models/mtg-detect-v3.pt --imgsz 1280 --ttaWhat happens: Runs the trained model on the validation set (images it never saw during training) and computes standard detection metrics. Produces a per-class metrics table, confusion matrix, and precision-recall curves.
Options:
--model: Path to model weights (default:models/mtg-detect-best.pt)--imgsz: Validation image size — should match training resolution (default: 640)--tta: Enable test-time augmentation for +0.5-1% mAP gain at the cost of 3-5x slower inference
Output: outputs/validation/metrics.json (or outputs/validation-1280/, outputs/validation-1280-tta/ for non-default settings), plus 6 diagnostic plots (confusion matrices, PR/F1/P/R curves)
Deep dive: docs/metrics-guide.md
# On test split images
uv run python scripts/05_predict.py
# On your own image
uv run python scripts/05_predict.py --source path/to/card.jpg
# Adjust detection sensitivity
uv run python scripts/05_predict.py --conf 0.3 --iou 0.5What happens: Runs inference — the trained model processes each image in a single forward pass, producing ~8,400 candidate bounding boxes. These are filtered by confidence threshold (--conf, default 0.25) and de-duplicated via Non-Maximum Suppression (--iou, default 0.45), leaving ~5-15 final detections per card.
Output: outputs/predictions/run/ with annotated images and prediction labels
Deep dive: docs/concepts.md#how-inference-works | docs/parameters.md#inference-parameters
uv run python scripts/06_live_detect.pyWhat happens: Opens your webcam and runs the model on every frame in real time (~15-30 FPS). Hold a card in front of the camera and watch bounding boxes appear around detected regions. When a card title is detected, OCR reads the name and fetches the English reference image from Scryfall, displaying it in the top-right corner for visual comparison.
Controls: q quit | s screenshot | c toggle confidence labels | r toggle reference card
Troubleshooting camera issues: docs/troubleshooting.md#step-6-live-detection
# Default: 10 creature cards per language, all 11 languages
uv run python scripts/07_download_test_images.py
# Customize
uv run python scripts/07_download_test_images.py --per-language 20 --languages en ja de frWhat happens: Downloads creature card images from the Scryfall API (free, no authentication) across 11 languages. These are completely independent images the model has never seen — ideal for testing how well it generalizes.
Output: test_images/{lang}/ directories with card images, plus test_images/manifest.json with metadata (card name, language, set).
# Export model predictions in Label Studio format
uv run python scripts/08_export_for_correction.py
# Then fix annotations in Label Studio
pip install label-studio && label-studio startWhat happens: Runs the trained model on your test images and exports predictions as YOLO-format annotations that Label Studio can import. You fix any mistakes in Label Studio's visual editor, export the corrections, and merge them back into training data for retraining.
Output: outputs/correction/ with images, labels, and Label Studio config files.
Full workflow: docs/annotation-correction.md
# Identify a single card image
uv run python scripts/09_identify_card.py test_images/en/card_001.jpg
# Process a directory of card images
uv run python scripts/09_identify_card.py test_images/en/ --save
# Adjust detection sensitivity
uv run python scripts/09_identify_card.py card.jpg --conf 0.3What happens: Combines all three stages of the full identification pipeline:
- YOLO detection — finds the 7 card regions (title, art, mana-cost, etc.)
- OCR — crops the title region and reads the card name using RapidOCR
- Scryfall lookup — queries the Scryfall API with the OCR'd name to fetch full card data
Prints complete card information: name, mana cost, type line, oracle text, power/toughness, rarity, set, price, and Scryfall link.
Output: Console output with card details. With --save, annotated images in outputs/identified/.
uv run python scripts/10_live_identify.pyWhat happens: Real-time webcam card identification with a side info panel. Hold a card in front of the camera and the system detects regions, OCRs the title, looks up the card on Scryfall, and displays a detailed panel showing the card's reference image, name, mana cost, type line, oracle text, P/T, rarity, set, and price — all in real time.
OCR runs every ~1 second to avoid performance impact. Results are cached so re-scanning the same card is instant.
Controls: q quit | s screenshot | c toggle confidence labels | i toggle info panel
uv run python -c "
from roboflow import Roboflow
import os, shutil
# Prepare directory structure Roboflow expects
os.makedirs('models/deploy/weights', exist_ok=True)
shutil.copy('models/mtg-detect-best.pt', 'models/deploy/weights/best.pt')
rf = Roboflow(api_key=os.environ['ROBOFLOW_API_KEY'])
project = rf.workspace('magic-the-gathering').project('mtg-detection-cixf6')
version = project.version(8)
version.deploy('yolov11', 'models/deploy')
shutil.rmtree('models/deploy')
print('Model deployed to Roboflow!')
"What happens: Uploads your trained model weights to Roboflow's hosted inference API. Once deployed, you can run inference via the Roboflow API without needing local compute.
ROBOFLOW_API_KEY=your_key uv run uvicorn web.app:app --reload --host 0.0.0.0 --port 8000What happens: Launches a web UI at http://localhost:8000 with two modes:
- Upload mode — drop, paste, or select an image for full 4-stage identification (detect → OCR → Scryfall → DINOv2 art matching)
- Live camera mode — point your webcam at a card for real-time identification with detection overlay
The web app uses Roboflow's hosted inference API (requires a deployed model from Step 11) and adds DINOv2 art matching to identify the exact printing — not just the card name, but which set and collector number.
Full details: docs/solution.md
| Experiment | Model | Resolution | mAP50 | mAP50-95 | Cost | Notes |
|---|---|---|---|---|---|---|
| Local CPU | yolo11n | 640 | 95.1% | 71.3% | Free | Dataset v1 (~28 hrs on M3 CPU) |
| v1 (RunPod) | yolo11n | 640 | 96.7% | 77.7% | $0.73 | Dataset v1; best overall balance |
| v2-quick | yolo11n | 1280 | 96.2% | 74.8% | ~$0.90 | Dataset v2; card class collapsed |
| v3-final | yolo11m | 1280 | 96.1% | 77.2% | ~$5.60 | Dataset v2; better small objects, worse card |
| Class | Precision | Recall | mAP50 | mAP50-95 |
|---|---|---|---|---|
| art | 0.963 | 0.972 | 0.982 | 0.918 |
| card | 0.967 | 0.969 | 0.959 | 0.880 |
| description | 0.955 | 0.961 | 0.974 | 0.851 |
| mana-cost | 0.954 | 0.847 | 0.959 | 0.710 |
| power | 0.824 | 0.919 | 0.937 | 0.703 |
| tags | 0.984 | 0.964 | 0.983 | 0.651 |
| title | 0.986 | 0.949 | 0.974 | 0.727 |
| ALL | 0.948 | 0.940 | 0.967 | 0.777 |
Reading these numbers (Dataset v1, reported 2026-03-28):
- Precision (0.948): 94.8% of detections are correct (low false alarm rate)
- Recall (0.940): 94% of real regions are found (few misses)
- mAP50 (0.967): Overall detection quality at 50% overlap — 96.7% is excellent
- mAP50-95 (0.777): Stricter overlap requirement — 77.7% reflects that small regions (mana-cost, power) are harder to localize with pixel precision
Large regions (art, card, description) score highest because small positioning errors barely affect their IoU. Small regions (mana-cost, power) score lower on strict metrics because even tiny pixel errors cause proportionally large IoU drops.
- Resolution increase helps small objects — mana-cost and power gained +5-11 points at 1280px
- Larger models don't always win — yolo11m at 1280 destabilized the
cardclass (88% → 48.5% mAP50-95), likely due tocopy_pasteaugmentation and training dynamics - The nano model is surprisingly competitive — yolo11n at 640 remains the best balanced model at a fraction of the cost
- Annotation quality is the ceiling — the 19-point gap between mAP50 (96.7%) and mAP50-95 (77.7%) indicates annotation noise limits further gains
Full experiment log: docs/training-v2-status.md | Metrics analysis: docs/metrics-guide.md
object-detection/
├── scripts/
│ ├── 01_setup_dataset.py # Download dataset from Roboflow API
│ ├── 02_explore_dataset.py # Visualize data & check quality
│ ├── 03_train.py # Train YOLOv11n locally (transfer learning)
│ ├── 04_validate.py # Evaluate with full metrics (+TTA support)
│ ├── 05_predict.py # Run inference on images
│ ├── 06_live_detect.py # Real-time webcam detection
│ ├── 07_download_test_images.py # Download multilingual test images
│ ├── 08_export_for_correction.py # Export predictions for Label Studio
│ ├── 09_identify_card.py # Identify cards: YOLO + OCR + Scryfall
│ ├── 10_live_identify.py # Live webcam card identification
│ ├── run_cloud.py # Unified cloud pipeline (all steps)
│ ├── train_cloud.py # Cloud GPU training v1 (yolo11n @ 640)
│ ├── train_cloud_v2.py # Cloud GPU training v2 (preset experiments)
│ └── train_cloud_v3.py # Cloud GPU training v3 (yolo11m @ 1280)
├── data/ # Dataset (gitignored)
│ └── mtg-detection/
│ ├── data.yaml # Class names + split paths
│ ├── train/ # 3,761 images + labels
│ ├── valid/ # 223 images + labels
│ └── test/ # 81 images + labels
├── models/ # Trained weights (gitignored)
│ ├── mtg-detect-best.pt # Best model — v1 yolo11n (~5.2 MB)
│ ├── mtg-detect-v2-balanced.pt # v2-balanced yolo11s (~19 MB)
│ └── mtg-detect-v3.pt # v3 yolo11m (~41 MB)
├── runs/ # Training logs (gitignored, created by training)
│ └── detect/ # YOLO validation/training outputs
├── outputs/ # Generated results (gitignored)
│ ├── exploration/ # Dataset visualization plots
│ ├── validation/ # v1 metrics + diagnostic plots
│ ├── validation-1280/ # v3 1280px validation results
│ ├── validation-1280-tta/ # v3 1280px with TTA results
│ ├── predictions/ # Annotated prediction images
│ ├── correction/ # Label Studio export for fixing
│ └── screenshots/ # Webcam screenshots
├── web/ # Web application
│ ├── app.py # FastAPI server (4-stage pipeline)
│ ├── services/
│ │ ├── detection.py # Roboflow hosted inference client
│ │ ├── ocr.py # RapidOCR title extraction
│ │ ├── scryfall.py # Card lookup + printings
│ │ └── image_match.py # DINOv2 art matching
│ └── static/ # Frontend (vanilla JS)
│ ├── index.html # Upload + live camera tabs
│ ├── css/style.css
│ └── js/ # upload.js, live.js, card-panel.js
├── docs/ # Deep-dive documentation
│ ├── solution.md # Complete solution overview
│ ├── concepts.md # Every ML concept via SWE analogies
│ ├── architecture.md # Pipeline data flow & file formats
│ ├── parameters.md # Every parameter explained
│ ├── metrics-guide.md # How to read your results
│ ├── troubleshooting.md # Error messages & fixes
│ ├── annotation-correction.md # Fix mistakes & retrain guide
│ ├── training-v2-status.md # Cloud training experiment log
│ ├── training-strategies.md # Augmentation & hyperparameter deep-dive
│ ├── training-cost-analysis.md # GPU cost comparison & estimates
│ └── manual-vs-roboflow.md # Annotation workflow comparison
├── test_images/ # Multilingual test card images
├── pyproject.toml # Dependencies
└── README.md # You are here
Full architecture diagram: docs/architecture.md
- Python: 3.12 (auto-installed by
uvif needed) - uv: Install with
curl -LsSf https://astral.sh/uv/install.sh | sh - Roboflow account: Free at roboflow.com — needed for API key
- Webcam: Built-in or external (for step 6)
- Mac: Apple Silicon (M1/M2/M3/M4) with 8 GB+ RAM (16 GB+ recommended)
- RunPod account: runpod.io with ~$5-15 balance
- runpodctl:
brew install runpod/runpodctl/runpodctl, thenrunpodctl doctorto set up API key
| Problem | Likely Cause | Quick Fix |
|---|---|---|
ModuleNotFoundError |
Not using uv run |
Use uv run python scripts/... |
| Camera not working | macOS permissions | System Settings > Privacy > Camera > enable your terminal |
| Training NaN loss | MPS device bug | Ensure device="cpu" in 03_train.py |
| No detections | Confidence too high | Try --conf 0.1 to see all predictions |
| Low mAP | Too few epochs | Increase patience or try cloud training |
| Scryfall 429 error | Rate limited | Script auto-retries; just re-run (skips existing) |
| Reference card not showing | Missing OCR dependency | Run uv sync to install rapidocr-onnxruntime |
| RunPod "no resources" | GPU sold out | Try --cloud-type SECURE or a different GPU |
| OCR reads wrong name | Low-res title region | Hold card closer to camera; try --conf 0.3 |
| Info panel blank | No title detected yet | Hold card steady for 1-2 seconds |
| RunPod host key error | New pod IP | Add -o StrictHostKeyChecking=no to SSH/SCP |
Full guide: docs/troubleshooting.md
- Your own photos: Drop card images into
test_images/and run step 5 - Cloud training: Use
train_cloud_v2.py --preset balancedfor a middle-ground experiment - Custom classes: Annotate your own dataset on Roboflow with whatever regions you want
- Card identification: Already built — run
09_identify_card.pyor10_live_identify.pyto detect, OCR, and look up cards on Scryfall - Web app: Run the FastAPI web app for browser-based detection with DINOv2 art matching — see Step 12
- Solution overview: Read docs/solution.md for the complete system documented end-to-end
- Annotation refinement: The path to 90%+ mAP50-95 requires tighter bounding boxes on small objects (mana-cost, power, tags) — see docs/training-v2-status.md
| Term | Meaning | Deep Dive |
|---|---|---|
| Model | Function with 2.6M configurable float parameters | concepts.md#what-is-a-model |
| Training | Optimization loop adjusting weights to minimize errors | concepts.md#what-is-training |
| Transfer learning | Starting from pretrained weights instead of scratch | concepts.md#transfer-learning |
| Epoch | One complete pass through all training data | concepts.md#epochs-batches-and-learning-rate |
| Batch size | Images processed together per weight update | concepts.md#epochs-batches-and-learning-rate |
| Learning rate | Step size for weight updates | concepts.md#epochs-batches-and-learning-rate |
| Early stopping | Stop training when performance plateaus | concepts.md#early-stopping |
| Overfitting | Model memorizes training data instead of learning | concepts.md#data-splits |
| Data augmentation | Random image modifications during training | concepts.md#data-augmentation |
| Inference | Running the trained model on new images | concepts.md#how-inference-works |
| Confidence | Model's certainty score (0-1) for each detection | concepts.md#how-inference-works |
| NMS | Removing duplicate overlapping detections | concepts.md#how-inference-works |
| IoU | Overlap ratio between two boxes (intersection/union) | concepts.md#how-inference-works |
| TTA | Test-time augmentation — running inference with flips/scales for better accuracy | concepts.md#data-augmentation |
| Bounding box | Rectangle marking an object's location | concepts.md#bounding-boxes-and-yolo-format |
| Precision | Of all detections, what % are correct | metrics-guide.md#precision |
| Recall | Of all real objects, what % were found | metrics-guide.md#recall |
| mAP50 | Overall detection quality metric | metrics-guide.md#map50 |
| Loss | Scoring function: 0 = perfect, higher = worse | concepts.md#what-is-training |
| Gradient | How much each weight contributed to the error | concepts.md#what-is-training |
| Weights | The float numbers that define model behavior | concepts.md#what-is-a-model |
| Component | Tool |
|---|---|
| Model | YOLOv11 (Ultralytics) — nano, small, or medium |
| Dataset | Roboflow Universe |
| Local training | CPU (Apple Silicon) |
| Cloud training | RunPod (RTX 4090) |
| Inference | MPS (Metal Performance Shaders) |
| Model hosting | Roboflow Deploy |
| Python | 3.12 via uv |
| OCR | RapidOCR (ONNX Runtime) |
| Card data | Scryfall API |
| Visualization | OpenCV + Matplotlib |