Files
commander-ai-lab/scanner/ximilar_client.py
T
KoalaTrapLord a1fd290d5f refactor: adopt Python logging module with named loggers
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)
2026-03-13 18:09:52 +00:00

207 lines
6.2 KiB
Python

"""
Card Scanner — Ximilar Visual AI Client
═════════════════════════════════════════
Calls the Ximilar Collectibles TCG Identification API
to recognize MTG cards from images using visual AI.
API Endpoint: POST https://api.ximilar.com/collectibles/v2/tcg_id
Auth: Token-based (Authorization: Token <api_key>)
"""
import base64
import json
from typing import Optional, List
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
import logging
log = logging.getLogger("commander_ai_lab.scanner.ximilar")
XIMILAR_ENDPOINT = "https://api.ximilar.com/collectibles/v2/tcg_id"
class XimilarResult:
"""Result for a single card identified by Ximilar."""
__slots__ = (
"name", "full_name", "set_name", "set_code",
"card_number", "rarity", "year",
"tcgplayer_url", "scryfall_url", "ebay_url",
"confidence", "error",
)
def __init__(self, **kwargs):
for slot in self.__slots__:
setattr(self, slot, kwargs.get(slot, ""))
if "confidence" not in kwargs:
self.confidence = 0.0
if "error" not in kwargs:
self.error = ""
def to_dict(self) -> dict:
return {s: getattr(self, s) for s in self.__slots__}
def identify_cards(image_bytes: bytes, api_key: str,
analyze_all: bool = True) -> List[XimilarResult]:
"""
Send image bytes to Ximilar TCG identification API.
Args:
image_bytes: Raw image bytes (JPEG, PNG, etc.)
api_key: Ximilar API token
analyze_all: If True, detect and identify ALL cards in the image.
If False, identify only the dominant card.
Returns:
List of XimilarResult objects, one per detected card.
"""
if not api_key:
return [XimilarResult(error="No Ximilar API key configured")]
# Encode image to base64
b64_data = base64.b64encode(image_bytes).decode("ascii")
# Build request body
body = {
"records": [
{"_base64": b64_data}
],
}
if analyze_all:
body["analyze_all"] = True
payload = json.dumps(body).encode("utf-8")
# Build HTTP request
req = Request(
XIMILAR_ENDPOINT,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Token {api_key}",
"User-Agent": "CommanderAILab/1.0",
},
method="POST",
)
try:
log.info(f" Sending {len(image_bytes):,} bytes to API (analyze_all={analyze_all})")
with urlopen(req, timeout=60) as resp:
resp_data = json.loads(resp.read().decode("utf-8"))
except HTTPError as e:
error_body = ""
try:
error_body = e.read().decode("utf-8", errors="replace")
except Exception:
pass
return [XimilarResult(error=f"Ximilar API error {e.code}: {error_body[:500]}")]
except URLError as e:
return [XimilarResult(error=f"Ximilar API connection error: {e.reason}")]
except Exception as e:
return [XimilarResult(error=f"Ximilar API request failed: {e}")]
# Parse response
return _parse_response(resp_data)
def _parse_response(resp_data: dict) -> List[XimilarResult]:
"""
Parse the Ximilar API response into XimilarResult objects.
Response structure:
{
"records": [
{
"_objects": [
{
"_identification": {
"best_match": {
"name": "Lightning Bolt",
"full_name": "Lightning Bolt (M11) #149",
"set": "Magic 2011",
"set_code": "m11",
"card_number": "149",
"rarity": "common",
"year": "2010",
"links": {
"tcgplayer": "https://...",
"scryfall": "https://...",
"ebay": "https://..."
}
},
"top_k": [...]
}
},
...
]
}
]
}
"""
results = []
records = resp_data.get("records", [])
if not records:
return [XimilarResult(error="Ximilar returned no records")]
for record in records:
objects = record.get("_objects", [])
if not objects:
# Check if there's a record-level identification (single card mode)
ident = record.get("_identification")
if ident:
result = _parse_identification(ident)
if result:
results.append(result)
else:
results.append(XimilarResult(error="No cards detected in image"))
continue
for obj in objects:
ident = obj.get("_identification")
if not ident:
results.append(XimilarResult(error="Object detected but not identified"))
continue
result = _parse_identification(ident)
if result:
results.append(result)
if not results:
results.append(XimilarResult(error="Ximilar could not identify any cards"))
log.info(f" Identified {len(results)} card(s)")
for r in results:
if r.name:
log.info(f" - {r.name} ({r.set_code}) conf={r.confidence:.2f}")
elif r.error:
log.error(f" - ERROR: {r.error}")
return results
def _parse_identification(ident: dict) -> Optional[XimilarResult]:
"""Parse a single _identification block."""
best = ident.get("best_match")
if not best:
return XimilarResult(error="No best match in identification")
links = best.get("links", {})
prob = best.get("prob", 0.0)
return XimilarResult(
name=best.get("name", ""),
full_name=best.get("full_name", ""),
set_name=best.get("set", ""),
set_code=best.get("set_code", ""),
card_number=str(best.get("card_number", "")),
rarity=best.get("rarity", ""),
year=str(best.get("year", "")),
tcgplayer_url=links.get("tcgplayer", ""),
scryfall_url=links.get("scryfall", ""),
ebay_url=links.get("ebay", ""),
confidence=float(prob) if prob else 0.0,
)