Replace all print() calls with structured logging using the Python logging module. This provides: - Named loggers per subsystem: commander_ai_lab.api, .batch, .sim, .coach, .deckgen, .collection, .scanner, .ml, .cache, .pplx - Dual output: console (StreamHandler) + rotating file (RotatingFileHandler) at logs/commander-ai-lab.log (25 MB x 5 backups = 125 MB max) - Configurable via env vars: CAL_LOG_DIR, CAL_LOG_MAX_MB, CAL_LOG_BACKUP_COUNT - --verbose / -v flag for DEBUG-level output - overnight-run.py: separate rotating log at logs/overnight-run.log (10 MB x 5) - Sub-module loggers in ml/, scanner/, deck_sources/, src/ all propagate to the root commander_ai_lab logger - Stripped redundant [TAG] prefixes from messages (logger name carries context) - Format: '2026-03-13 14:00:00 INFO [commander_ai_lab.batch] message' Files changed: lab_api.py, overnight-run.py, and 11 sub-module .py files 176 total print() calls converted (101 in lab_api.py, 76 in sub-modules)
191 lines
7.1 KiB
Python
191 lines
7.1 KiB
Python
"""
|
||
Card Scanner — Recognition Pipeline (Ximilar Visual AI)
|
||
═══════════════════════════════════════════════════════════
|
||
|
||
Orchestrates the full scan workflow using the Ximilar Collectibles
|
||
TCG Identification API for card recognition:
|
||
1. Send image bytes to Ximilar API
|
||
2. Parse identified cards (name, set, number, rarity, links)
|
||
3. Optionally enrich with Scryfall for scryfall_id + image URI
|
||
4. Return structured results
|
||
|
||
No local OCR or OpenCV needed — all recognition is done by the
|
||
Ximilar cloud API using visual AI.
|
||
"""
|
||
from typing import Callable, Optional
|
||
import logging
|
||
|
||
log = logging.getLogger("commander_ai_lab.scanner")
|
||
|
||
|
||
from .ximilar_client import identify_cards, XimilarResult
|
||
|
||
|
||
# ── Data structures ──────────────────────────────────────────
|
||
|
||
class ScanResult:
|
||
"""Result for a single recognized card."""
|
||
__slots__ = ("raw_ocr", "matched_name", "set_code", "scryfall_id",
|
||
"confidence", "image_uri", "error", "corrected_name",
|
||
"collector_number", "rarity", "tcgplayer_url")
|
||
|
||
def __init__(self, raw_ocr: str = "", matched_name: str = "",
|
||
set_code: str = "", scryfall_id: str = "",
|
||
confidence: str = "low", image_uri: str = "",
|
||
error: str = "", corrected_name: str = "",
|
||
collector_number: str = "", rarity: str = "",
|
||
tcgplayer_url: str = ""):
|
||
self.raw_ocr = raw_ocr
|
||
self.matched_name = matched_name
|
||
self.set_code = set_code
|
||
self.scryfall_id = scryfall_id
|
||
self.confidence = confidence # "high" | "medium" | "low"
|
||
self.image_uri = image_uri # Scryfall card image for preview
|
||
self.error = error
|
||
self.corrected_name = corrected_name
|
||
self.collector_number = collector_number
|
||
self.rarity = rarity
|
||
self.tcgplayer_url = tcgplayer_url
|
||
|
||
def to_dict(self) -> dict:
|
||
return {
|
||
"raw_ocr": self.raw_ocr,
|
||
"corrected_name": self.corrected_name,
|
||
"matched_name": self.matched_name,
|
||
"set_code": self.set_code,
|
||
"scryfall_id": self.scryfall_id,
|
||
"confidence": self.confidence,
|
||
"image_uri": self.image_uri,
|
||
"error": self.error,
|
||
"collector_number": self.collector_number,
|
||
"rarity": self.rarity,
|
||
"tcgplayer_url": self.tcgplayer_url,
|
||
}
|
||
|
||
|
||
# ── Public API ───────────────────────────────────────────────
|
||
|
||
def scan_single(image_bytes: bytes,
|
||
scryfall_lookup: Callable[[str], Optional[dict]],
|
||
ximilar_api_key: str = "") -> ScanResult:
|
||
"""
|
||
Scan a single card image using Ximilar Visual AI.
|
||
|
||
Args:
|
||
image_bytes: Raw image bytes (JPEG/PNG)
|
||
scryfall_lookup: Callback to look up a card on Scryfall (for scryfall_id + image)
|
||
ximilar_api_key: Ximilar API token
|
||
|
||
Returns:
|
||
ScanResult with identified card info
|
||
"""
|
||
if not ximilar_api_key:
|
||
return ScanResult(error="Ximilar API key not configured. Set it in lab config.")
|
||
|
||
# Call Ximilar API with analyze_all=False for single card
|
||
ximilar_results = identify_cards(image_bytes, ximilar_api_key, analyze_all=False)
|
||
|
||
if not ximilar_results:
|
||
return ScanResult(error="Ximilar returned no results")
|
||
|
||
# Take the first (best) result
|
||
xr = ximilar_results[0]
|
||
|
||
if xr.error and not xr.name:
|
||
return ScanResult(error=xr.error)
|
||
|
||
return _ximilar_to_scan_result(xr, scryfall_lookup)
|
||
|
||
|
||
def scan_multi(image_bytes: bytes,
|
||
scryfall_lookup: Callable[[str], Optional[dict]],
|
||
ximilar_api_key: str = "") -> list:
|
||
"""
|
||
Scan an image containing multiple cards using Ximilar Visual AI.
|
||
|
||
Args:
|
||
image_bytes: Raw image bytes (JPEG/PNG)
|
||
scryfall_lookup: Callback to look up a card on Scryfall
|
||
ximilar_api_key: Ximilar API token
|
||
|
||
Returns:
|
||
List of ScanResult objects, one per detected card
|
||
"""
|
||
if not ximilar_api_key:
|
||
return [ScanResult(error="Ximilar API key not configured. Set it in lab config.")]
|
||
|
||
# Call Ximilar API with analyze_all=True for multi-card detection
|
||
ximilar_results = identify_cards(image_bytes, ximilar_api_key, analyze_all=True)
|
||
|
||
if not ximilar_results:
|
||
return [ScanResult(error="Ximilar returned no results")]
|
||
|
||
results = []
|
||
for xr in ximilar_results:
|
||
if xr.error and not xr.name:
|
||
results.append(ScanResult(error=xr.error))
|
||
else:
|
||
results.append(_ximilar_to_scan_result(xr, scryfall_lookup))
|
||
|
||
return results
|
||
|
||
|
||
# ── Internal helpers ─────────────────────────────────────────
|
||
|
||
def _ximilar_to_scan_result(xr: XimilarResult,
|
||
scryfall_lookup: Callable[[str], Optional[dict]]) -> ScanResult:
|
||
"""
|
||
Convert a Ximilar identification result to a ScanResult,
|
||
enriching with Scryfall data for scryfall_id and image URI.
|
||
"""
|
||
result = ScanResult(
|
||
raw_ocr=xr.full_name or xr.name,
|
||
corrected_name=xr.name,
|
||
matched_name=xr.name,
|
||
set_code=xr.set_code,
|
||
collector_number=xr.card_number,
|
||
rarity=xr.rarity,
|
||
tcgplayer_url=xr.tcgplayer_url,
|
||
)
|
||
|
||
# Map Ximilar confidence (0.0–1.0) to our levels
|
||
if xr.confidence >= 0.7:
|
||
result.confidence = "high"
|
||
elif xr.confidence >= 0.4:
|
||
result.confidence = "medium"
|
||
else:
|
||
result.confidence = "low"
|
||
|
||
# Try to get scryfall_id and image URI via Scryfall lookup
|
||
# First try exact set/number lookup, then fuzzy name
|
||
scryfall_data = None
|
||
|
||
if xr.name:
|
||
try:
|
||
scryfall_data = scryfall_lookup(xr.name)
|
||
except Exception as e:
|
||
log.error(f" Scryfall lookup failed for '{xr.name}': {e}")
|
||
|
||
if scryfall_data and scryfall_data.get("object") != "error":
|
||
result.scryfall_id = scryfall_data.get("id", "")
|
||
result.matched_name = scryfall_data.get("name", xr.name)
|
||
|
||
# Get card image for preview
|
||
image_uris = scryfall_data.get("image_uris", {})
|
||
if not image_uris and scryfall_data.get("card_faces"):
|
||
image_uris = scryfall_data["card_faces"][0].get("image_uris", {})
|
||
result.image_uri = image_uris.get("normal", image_uris.get("small", ""))
|
||
|
||
# Update set_code from Scryfall if available
|
||
if not result.set_code:
|
||
result.set_code = scryfall_data.get("set", "")
|
||
else:
|
||
# No Scryfall match — use Scryfall URL from Ximilar if available
|
||
if xr.scryfall_url:
|
||
# Extract image from scryfall URL pattern
|
||
# e.g., https://scryfall.com/card/m11/149/lightning-bolt
|
||
result.image_uri = "" # Will be fetched by frontend via scryfall_id
|
||
log.info(f" No Scryfall match for '{xr.name}' — using Ximilar data only")
|
||
|
||
return result
|