Initial commit: Card Grader deployed to hippofam.com/cards

PWA card-grading app, deployed behind Nginx Proxy Manager on Unraid with
basic auth. Includes CARD_GRADER_BASE_PATH support for running under a
sub-path, and Docker/compose config for the Unraid deployment.
This commit is contained in:
Barely Removable 2026-08-22 09:22:41 -07:00
commit c7bd71a3e1
19 changed files with 3618 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
grades.db
data/
__pycache__/
*.pyc

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM python:3.12-slim
# Only optional deps (see README) — the app itself is stdlib only.
RUN pip install --no-cache-dir anthropic pillow
WORKDIR /app
COPY app.py cardimage.py store.py vision.py ./
COPY static/ ./static/
# grades.db lives here; mount a volume at /data to persist it across
# container recreates and image updates.
ENV CARD_GRADER_DB_PATH=/data/grades.db
ENV PORT=8778
EXPOSE 8778
CMD ["python3", "app.py"]

289
README.md Normal file
View file

@ -0,0 +1,289 @@
# Card Grader
A local web app that estimates a trading card's PSA grade from photographs.
Works on any card PSA grades — Pokemon, sports, Magic, whatever — and keeps
a local history of everything you've checked.
This is the single-purpose extraction of the grading feature out of a larger
Pokemon card flipping tracker. No inventory, no price lookups, no catalog —
just "how would this card likely grade," and a log of what you've graded.
## Why this is more than "ask Claude to look at a photo"
Two of PSA's four grading categories are *measured* directly from the pixels
in `cardimage.py`, rather than asked for by eye:
- **Edge whitening.** The outermost sliver of each edge is compared,
column by column, against the card's own unworn border. The comparison is
local (outer band vs. the band just inside it), which is what tells real
wear apart from glare or a glossy sleeve reflection — both raise the whole
region evenly and produce no local step, while paper showing through at a
worn cut does. On a white, silver, foil or refractor border — where the
measurement's core assumption breaks down — it refuses to report a number
at all rather than guess, because a wrong accusation of wear is worse than
no answer.
- **Centering.** The border width on each of the four sides is found and
turned into left/right and top/bottom ratios, accurate to within about two
percentage points. PSA publishes hard centering tolerances (55/45 for a 10,
60/40 for a 9, 65/35 for an 8...), so this is the one category that gets
checked against the actual published standard instead of estimated.
On top of that, a full-card photo is cut into twelve close-ups before
grading: the four corners and four edge strips magnified (a corner is a tiny
fraction of a full-card frame — by the time a vision model finishes scaling
the whole photo down, there's often nothing left to judge it from), plus four
surface quadrants each paired with a processed copy that cancels the artwork
so scratches and print lines survive contrast against holo texture and
halftone dots.
All of this was arrived at by testing, not by guessing — corner/edge/surface
handling was tuned against synthetic cards with known, deliberately-planted
defects (and known-clean controls) until false positives on clean cards
dropped to zero while genuine wear still detected. Read the comments in
`cardimage.py` and `vision.py` if you want the specifics; several approaches
that sounded reasonable (a saturation-based edge map, testing each edge only
against itself) measurably backfired and were reverted, with the reasoning
left in place so they don't get re-tried.
**The honest limit, stated plainly:** a photo cannot show everything a
grader's raking light and magnification can. Surface scratches, light edge
wear, and the entire back of the card are frequently invisible in a normal
photo — especially a seller's listing photo, which is often lit specifically
to hide them. The model is instructed to say `cannot_assess` rather than
guess, and to widen the estimated range when it genuinely can't tell. This is
a rough screen to help you decide whether a card is worth sending in, not a
substitute for actually sending it in.
## Running it
The core app has no dependencies — stock macOS Python 3 is enough.
```bash
python3 /Users/user/CardGrader/app.py
```
Then open **http://localhost:8778**. The terminal also prints a
`192.168.x.x` address you can use from your phone on the same Wi-Fi — the
upload button works with your phone's camera directly.
Stop it with Ctrl-C. To use a different port: `PORT=9000 python3 app.py`.
All your data lives in `grades.db` in this folder — your settings and your
entire grading history, thumbnails included. Back up that one file and
you've backed up everything.
### Optional packages
Two packages unlock real functionality. Each is checked for at runtime, so
the app runs without them — you just lose that capability.
```bash
python3 -m pip install --user anthropic # required — this is what does the grading
python3 -m pip install --user pillow # the corner/edge/surface measurement pipeline
```
Without `anthropic`, grading simply doesn't work — add a key in Settings once
it's installed. Without `pillow`, grading still runs, but on the full photo
alone: no measured centering, no measured edge whitening, no magnified
close-ups. Corners and edges will come back `cannot_assess` far more often,
because a plain full-card photo genuinely doesn't show enough detail in those
regions for a model to judge them from. Install `pillow` — it's the
difference between an answer and a shrug.
### API key
Get one at [console.anthropic.com](https://console.anthropic.com), then paste
it into Settings in the app. Grading costs a few cents per card (Sonnet 5,
~$0.04-0.06 depending on how many photos you send) — nothing is charged until
you click "Estimate the grade."
## Why Sonnet 5, not a cheaper model
Settings lets you switch to Haiku 4.5 (about a third of the cost) or Opus 5
(more expensive, marginally more careful). Sonnet is the default for a
tested reason, not a hunch: run head-to-head against Haiku on the same
photos, Haiku inverted a PSA centering-tolerance comparison — read 59/41 as
*exceeding* the tolerance for a 9, when 59/41 is actually well inside it —
and the error alone dragged its estimate three grade levels below Sonnet's.
Haiku also lacks extended thinking entirely, which matters for exactly the
judgment calls this task is full of: is this a scratch or a print texture,
a reflection or real edge wear, a print line or a crease. If cost matters
enough to switch, that's a real, deliberate tradeoff — not a free lunch.
## The workflow
1. **Choose photo(s)…** — front is the minimum; add the back if you can, since
it's the only way to judge back centering, and it's what actually settles
a print-line-vs-crease call when the front alone is ambiguous.
2. **Estimate the grade.** A few seconds, a few cents.
3. Read the category table: centering, corners, edges, surface, each with a
severity and a specific observation — not just a number.
4. It's saved to **History** automatically (give it a name first if you want
to find it again later). Click any row to see the full breakdown, rename
it, or delete it.
## Installing it on a phone (and sharing it with friends)
This is a PWA, so it installs to a phone's home screen and runs full-screen
like a native app — no App Store, no Play Store, no developer account, no
$99/year, nothing to approve. It just needs to be reachable over **HTTPS**.
That HTTPS requirement is the only real hurdle, and it is not optional:
iOS and Android both refuse camera access and home-screen install over plain
`http://` on anything except `localhost`. So handing a friend your
`192.168.x.x` address will not work — it has to be a real HTTPS URL.
### Option A: a quick tunnel from your Mac
```bash
brew install cloudflared
cloudflared tunnel --url http://localhost:8778
```
That prints a public `https://something.trycloudflare.com` URL, free, no
account, no card. Anyone you send it to can open it and install it. The
catch: the Mac running `app.py` has to stay awake and online the whole time
someone might want to use it, and the URL changes each time you restart the
tunnel — fine for "try this out right now", not for "usable whenever my
friends feel like it."
### Option B: run it on an always-on box you own (e.g. Unraid), behind nginx
This is the one that works when your Mac is asleep, closed, or off — the app
runs on hardware that's already always-on, with no cloud account, no card,
and no monthly fee, at a URL under a domain you actually own. The repo
includes a `Dockerfile`, `docker-compose.yml`, and an nginx config
(`nginx/card-grader.conf`) set up for exactly this.
1. **Copy the `CardGrader` folder onto the Unraid box** — e.g. into
`/mnt/user/appdata/card-grader`.
2. **Point your domain at it.** Add an `A` record for the subdomain you want
(e.g. `card-grader.yourdomain.com`) to your home network's public IP —
or, if your ISP doesn't give you a static IP, use a dynamic-DNS hostname
from your domain's DNS provider instead. Forward ports **80** and **443**
on your router to the Unraid box.
3. **Bring the app up** (over SSH on the box, or the Unraid *Compose
Manager* plugin pointed at the same folder):
```bash
docker compose up -d --build
```
This starts `card-grader` bound only to `127.0.0.1:8778` on the host —
reachable from nginx, not directly from the LAN or internet. Data
persists to `./data/grades.db` so it survives image rebuilds.
4. **Run nginx + certbot.** If Unraid doesn't already have nginx installed
as a system service, the simplest path is a container on the host
network so it can reach `127.0.0.1:8778`:
```bash
docker run -d --name nginx --network host --restart unless-stopped \
-v /mnt/user/appdata/card-grader/nginx:/etc/nginx/conf.d \
-v /mnt/user/appdata/card-grader/certbot/www:/var/www/certbot \
-v /mnt/user/appdata/card-grader/certbot/conf:/etc/letsencrypt \
nginx:alpine
```
Edit `nginx/card-grader.conf` first and replace
`card-grader.example.com` with your real subdomain. Then get a
certificate (one-time, and again every ~60 days — cron it or use
`certbot`'s built-in renewal timer):
```bash
docker run --rm \
-v /mnt/user/appdata/card-grader/certbot/www:/var/www/certbot \
-v /mnt/user/appdata/card-grader/certbot/conf:/etc/letsencrypt \
certbot/certbot certonly --webroot -w /var/www/certbot \
-d card-grader.yourdomain.com
docker restart nginx
```
5. **Set your Anthropic API key** by opening `https://card-grader.yourdomain.com`
yourself first, going to Settings, and pasting it in — this only works
*before* `CARD_GRADER_LOCK=1` takes effect, so either set the key first
and then bring the lock up, or temporarily comment out that line in
`docker-compose.yml`, restart, set the key, uncomment it, and restart
again.
If Unraid already has an nginx-based reverse proxy set up (Nginx Proxy
Manager, SWAG, etc.), skip step 4 and just add a proxy host there pointing
at `127.0.0.1:8778` with the settings from `nginx/card-grader.conf` (the
body-size and read-timeout lines matter — copy those over) — those tools
handle the Let's Encrypt cert issuance for you through their own UI.
### Option C: a cloud host (Fly.io, Render)
Also works — it's a single Python file with no build step — but means
creating an account on someone else's infrastructure (and on Fly.io,
adding a card even though usage this small stays free). `grades.db` needs
a mounted persistent volume there too, same as Option B, or history resets
on every redeploy. Worth it only if you don't have an always-on box of your
own; Option B is strictly cheaper and simpler if you do.
### Installing, once it's on HTTPS
- **iPhone:** open the URL in Safari (this does not work in Chrome on iOS),
Share → **Add to Home Screen**.
- **Android:** Chrome shows an **Install app** button in the app's own header,
or use ⋮ → Install app.
### Before you hand out the URL
Anyone with the link can use it, and **grading costs money on whoever's API
key is being used**. Two things in the app deal with this:
1. **Friends can bring their own key.** Settings has a personal API key field
that lives in *their* browser and is sent with *their* requests only —
never stored on your server. If they set one, they pay for their own
grades. If they don't, they fall back to your server key and you do.
2. **Lock your settings when hosting.** Start the server with:
```bash
CARD_GRADER_LOCK=1 python3 app.py
```
which refuses all settings writes. Without it, any visitor could overwrite
your stored API key or switch the model to the most expensive one. Grading
still works normally; only the settings write is blocked.
Your stored API key is never sent to any browser regardless — the settings
endpoint returns only a boolean saying whether one is configured.
Everyone shares one `grades.db`, so the History list is communal. That's
usually fine among friends; if it isn't, run separate instances.
## Files
| File | Purpose |
|---|---|
| `app.py` | HTTP server and JSON API. Run this. |
| `store.py` | SQLite: settings + grading history |
| `vision.py` | The Claude vision call — prompts, schemas, cost estimation |
| `cardimage.py` | The measurement pipeline: card detection, corner/edge/surface crops, centering and edge-whitening measurement |
| `static/` | The web UI |
| `static/sw.js` | Service worker — makes it installable; served from `/sw.js` so its scope covers the whole app |
| `grades.db` | Your data (created on first run) |
| `Dockerfile` | Container image for Option B/C deployment |
| `docker-compose.yml` | Runs the container with a persistent `./data` volume |
| `nginx/card-grader.conf` | Reverse-proxy + Let's Encrypt config for Option B |
## If something breaks
Port already in use:
```bash
lsof -ti :8778 | xargs kill
```
The "Install app" button never appears, or the app opens with a browser
address bar: you're on `http://`, not `https://`. Browsers only allow install
and camera access on a secure origin — see the tunnel section above.
`localhost` is the one exception, which is why it works on your own Mac.
## Where this came from
Extracted from a personal Pokemon card flip-tracking app where grading
started as one feature among many (inventory, price lookups, an eBay lot
evaluator). The grading pipeline turned out to be the most generally useful
part — it doesn't care what card game something is from — so it's its own
project now. If you want the original's inventory/pricing features too,
that's a separate app; this one only grades.

410
app.py Normal file
View file

@ -0,0 +1,410 @@
#!/usr/bin/env python3
"""Card Grader — local web app.
Stdlib only, except the optional `anthropic` and `pillow` packages (see
README.md). Run it with: python3 app.py
Then open http://localhost:8778
Data lives in grades.db next to this file. Back that one file up and you have
backed up your whole grading history.
This is the single-purpose extraction of the grading feature out of a larger
Pokemon card flipping tracker: no inventory, no price lookups, no catalog
just "how would this card likely grade," estimated from photos, with a local
history of what you've checked. See cardimage.py for the actual mechanism:
two of PSA's four categories (centering, edge whitening) are measured
directly from the pixels rather than eyeballed by the model.
"""
import base64
import io
import json
import os
import sys
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import cardimage
import store
import vision
# A photo arrives base64-encoded inside JSON (avoids hand-rolling multipart
# parsing on top of the stdlib server). Cap it so a stray upload can't wedge
# the process reading an unbounded body.
MAX_UPLOAD_BYTES = 12 * 1024 * 1024
# More angles help — front, back, corner close-ups — but past a handful the
# extra photos cost tokens without adding evidence.
MAX_GRADE_IMAGES = 6
# Small enough to sit in a history list without bloating the database; big
# enough to still recognise the card at a glance.
THUMBNAIL_TARGET_PX = 320
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, "static")
PORT = int(os.environ.get("PORT", "8778"))
# Set when this app is reverse-proxied under a sub-path (e.g. "/cards")
# rather than served at its own domain root. The four files that reference
# absolute URLs (index.html, manifest.json, sw.js, app.js) get this spliced
# in for every "__BASE__" placeholder — see _static_templated. Everything
# else (routing included) works the same either way, since nginx forwards
# the full "/cards/..." path through unchanged and _route() below strips it
# right back off before matching.
BASE_PATH = (os.environ.get("CARD_GRADER_BASE_PATH") or "").rstrip("/")
TEMPLATED_STATIC = {"index.html", "manifest.json", "sw.js", "app.js"}
# Set CARD_GRADER_LOCK=1 when exposing this beyond your own machine. It stops
# visitors rewriting your stored settings — including swapping the model to
# the most expensive one, or replacing your API key. Grading still works
# normally; only the settings write is refused.
SETTINGS_LOCKED = os.environ.get("CARD_GRADER_LOCK", "").strip() not in ("", "0")
CONTENT_TYPES = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".json": "application/manifest+json",
}
# --------------------------------------------------------------- helpers
def _decode_uploaded_images(raw_images):
"""Decode base64 uploads. Returns (images, error_message).
The format is decided from the bytes, not the filename phones hand
over names this code has no business trusting (no extension, a
content:// URI, .HEIC), and an unreadable *name* is no reason to reject
a perfectly readable *photo*. See cardimage.normalize_upload.
"""
images, total = [], 0
for entry in raw_images or []:
raw = (entry or {}).get("image_base64") or ""
filename = (entry or {}).get("filename") or "upload"
if not raw:
return None, "No image received"
try:
image_bytes = base64.b64decode(raw, validate=True)
except Exception:
return None, "Image data was not valid base64"
total += len(image_bytes)
if total > MAX_UPLOAD_BYTES:
return None, "Images are {:.1f} MB combined; the limit is {} MB.".format(
total / 1024 / 1024, MAX_UPLOAD_BYTES // 1024 // 1024)
image_bytes, filename, error = cardimage.normalize_upload(image_bytes, filename)
if error:
return None, error
images.append((image_bytes, filename))
return images, None
def _public_settings():
"""Settings safe to hand to a browser.
The stored API key never leaves the server. Once this is reachable by
anyone but you which is the whole point of putting it behind a tunnel
for friends returning the raw key would hand every visitor the ability
to spend your Anthropic credits anywhere they like. They get a boolean
saying whether one is configured, which is all the UI needs.
"""
s = store.get_settings()
return {
"vision_model": s.get("vision_model"),
"vision_effort": s.get("vision_effort"),
"server_key_configured": bool(s.get("anthropic_api_key")),
"settings_locked": SETTINGS_LOCKED,
}
def _make_thumbnail(image_bytes):
"""Small base64 JPEG for the history list, or None if Pillow is missing."""
if not cardimage.available():
return None
try:
from PIL import Image
img = Image.open(io.BytesIO(image_bytes))
img.load()
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
factor = THUMBNAIL_TARGET_PX / float(max(img.size))
if factor < 1:
img = img.resize(
(max(1, int(img.width * factor)), max(1, int(img.height * factor))),
Image.LANCZOS)
buffer = io.BytesIO()
img.convert("RGB").save(buffer, format="JPEG", quality=78)
return "data:image/jpeg;base64," + base64.standard_b64encode(
buffer.getvalue()).decode("ascii")
except Exception:
return None
# ---------------------------------------------------------------- handler
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "CardGrader"
def log_message(self, fmt, *args):
if str(args[1] if len(args) > 1 else "").startswith(("4", "5")):
sys.stderr.write(" {} {}\n".format(self.address_string(), fmt % args))
# ------------------------------------------------------------ helpers
def _send(self, code, body, content_type="application/json; charset=utf-8",
cache_control="no-store"):
if isinstance(body, str):
body = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", cache_control)
self.end_headers()
self.wfile.write(body)
def _json(self, obj, code=200):
self._send(code, json.dumps(obj, default=str))
def _error(self, message, code=400):
self._json({"error": message}, code)
def _body(self):
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
try:
return json.loads(self.rfile.read(length).decode("utf-8"))
except ValueError:
return {}
def _route(self):
path = urllib.parse.urlparse(self.path).path
# nginx forwards the full "/cards/..." URI through unchanged (same
# pattern as this box's other proxied apps, each of which handles
# its own URL-base) — strip it back off so route matching below
# doesn't need to know whether it's mounted at the domain root or
# under a sub-path.
if BASE_PATH and (path == BASE_PATH or path.startswith(BASE_PATH + "/")):
path = path[len(BASE_PATH):]
return path.rstrip("/") or "/"
def _static(self, relative):
safe = os.path.normpath(relative).lstrip(os.sep)
path = os.path.join(STATIC_DIR, safe)
if not path.startswith(STATIC_DIR) or not os.path.isfile(path):
return self._error("Not found", 404)
ext = os.path.splitext(path)[1]
if os.path.basename(path) in TEMPLATED_STATIC:
with open(path, "r", encoding="utf-8") as handle:
body = handle.read().replace("__BASE__", BASE_PATH).encode("utf-8")
else:
with open(path, "rb") as handle:
body = handle.read()
# "no-cache" (revalidate every time), NOT "no-store". They sound
# interchangeable and are not: Chrome refuses to register a service
# worker whose script came back no-store, so the whole install-as-an-
# app path silently dies. no-cache still guarantees you never get a
# stale file, since the browser revalidates before using it.
self._send(200, body, CONTENT_TYPES.get(ext, "application/octet-stream"),
cache_control="no-cache")
# ---------------------------------------------------------------- GET
def do_GET(self):
route = self._route()
try:
if route == "/" or route == "/index.html":
return self._static("index.html")
# Served from the root deliberately: a service worker's scope is
# its own directory, so one living at /static/sw.js could only
# ever control /static/ — not the app. Same file, root URL.
if route == "/sw.js":
return self._static("sw.js")
if route.startswith("/static/"):
return self._static(route[len("/static/"):])
if route == "/api/settings":
return self._json(_public_settings())
if route == "/api/vision-models":
return self._json(vision.price_guide())
if route == "/api/history":
return self._json({"grades": store.list_grades()})
if route.startswith("/api/history/"):
grade = store.get_grade(int(route.split("/")[3]))
if not grade:
return self._error("No such grade", 404)
return self._json({"grade": grade})
return self._error("Not found", 404)
except (ValueError, IndexError):
return self._error("Bad request path", 400)
except Exception as exc:
return self._error("Server error: {}".format(exc), 500)
# --------------------------------------------------------------- POST
def do_POST(self):
route = self._route()
try:
body = self._body()
if route == "/api/settings":
if SETTINGS_LOCKED:
return self._error(
"Settings are locked on this server. Use your own API "
"key in this browser instead.", 403)
store.save_settings(body)
return self._json(_public_settings())
if route == "/api/grade":
return self._grade_card(body)
return self._error("Not found", 404)
except (ValueError, IndexError):
return self._error("Bad request", 400)
except Exception as exc:
return self._error("Server error: {}".format(exc), 500)
def do_PATCH(self):
route = self._route()
try:
if route.startswith("/api/history/"):
grade_id = int(route.split("/")[3])
body = self._body()
if store.get_grade(grade_id) is None:
return self._error("No such grade", 404)
grade = store.update_grade_label(grade_id, body.get("label"))
return self._json({"grade": grade})
return self._error("Not found", 404)
except (ValueError, IndexError):
return self._error("Bad request", 400)
except Exception as exc:
return self._error("Server error: {}".format(exc), 500)
def do_DELETE(self):
route = self._route()
try:
if route.startswith("/api/history/"):
store.delete_grade(int(route.split("/")[3]))
return self._json({"ok": True})
return self._error("Not found", 404)
except (ValueError, IndexError):
return self._error("Bad request", 400)
except Exception as exc:
return self._error("Server error: {}".format(exc), 500)
# ------------------------------------------------------------- grade
def _grade_card(self, body):
"""Estimate the PSA grade for a card, from uploaded photo(s).
Always returns the estimate. Saves it to history too, unless the
caller explicitly opts out with save=false a one-off sanity check
before you've decided a card is worth logging.
"""
settings = store.get_settings()
raw_images = body.get("images") or []
if not isinstance(raw_images, list) or not raw_images:
return self._error("Send at least one image.", 400)
if len(raw_images) > MAX_GRADE_IMAGES:
return self._error("Send at most {} images.".format(MAX_GRADE_IMAGES), 400)
images, error = _decode_uploaded_images(raw_images)
if error:
return self._error(error, 400)
model = body.get("model") if body.get("model") in vision.MODELS else settings.get("vision_model")
# A key sent with the request wins over the server's own. That is what
# makes this shareable: hand a friend the URL and they can bring their
# own Anthropic key rather than spending yours. Never stored — it
# lives in their browser and is used for this one call.
caller_key = (body.get("api_key") or "").strip() or None
print("[grade] calling vision model={} on {} image(s){}".format(
model, len(images), " (caller key)" if caller_key else ""), flush=True)
t0 = time.time()
try:
result = vision.grade_card(
images,
caller_key or settings.get("anthropic_api_key") or None,
model=model,
effort=settings.get("vision_effort"),
)
except vision.VisionError as exc:
return self._error(str(exc), 502)
print("[grade] done in {:.1f}s".format(time.time() - t0), flush=True)
grade = {
"image_count": len(images),
"closeups": result["closeups"],
"card_type": result["card_type"],
"card_note": result["card_note"],
"edge_measurements": result["edge_measurements"],
"centering_measurement": result["centering_measurement"],
"estimated_grade": result["estimated_grade"],
"grade_low": result["grade_low"],
"grade_high": result["grade_high"],
"confidence": result["confidence"],
"categories": result["categories"],
"limitations": result["limitations"],
"note": result["note"],
"usage": result["usage"],
"estimated_cost": vision.estimate_cost(result["usage"]),
}
grade_id = None
if body.get("save", True):
thumbnail = _make_thumbnail(images[0][0])
grade_id = store.save_grade(grade, thumbnail=thumbnail, label=body.get("label"))
return self._json({"grade": grade, "grade_id": grade_id}, 201)
def lan_ip():
"""Best-effort LAN address, so a phone can reach this."""
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.connect(("8.8.8.8", 80))
return sock.getsockname()[0]
except Exception:
return None
finally:
sock.close()
class Server(ThreadingHTTPServer):
allow_reuse_address = True
daemon_threads = True
def main():
store.init()
try:
server = Server(("0.0.0.0", PORT), Handler)
except OSError as exc:
print("\n Could not start on port {}: {}".format(PORT, exc))
print(" Something else is using it. Free it with:")
print(" lsof -ti :{} | xargs kill\n".format(PORT))
raise SystemExit(1)
ip = lan_ip()
print("\n Card Grader")
print(" " + "-" * 42)
print(" On this Mac: http://localhost:{}".format(PORT))
if ip:
print(" On your phone: http://{}:{} (same Wi-Fi)".format(ip, PORT))
print(" Database: {}".format(store.DB_PATH))
print("\n Ctrl-C to stop.\n")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n Stopped.")
server.shutdown()
if __name__ == "__main__":
main()

802
cardimage.py Normal file
View file

@ -0,0 +1,802 @@
"""Corner close-ups for grading, cut out of a full-card photo.
Corners are the category the grader struggled with most, and the reason is
mechanical rather than a prompt problem: a card corner is a tiny fraction of
the frame, so once a full-card photo is scaled down for the vision model
there are barely any pixels left where the whitening and fraying actually
live. A human grader solves this with a loupe. This does the same thing
find the card in the photo, cut out each of the four corners, and upscale
them into their own images so the detail survives.
Pillow is an optional dependency. Without it everything still works, just
without the close-ups, so this never becomes a hard requirement.
"""
import io
try:
from PIL import Image, ImageChops, ImageFilter, ImageOps
except ImportError:
Image = None
ImageChops = None
ImageFilter = None
ImageOps = None
# Fraction of the card's width/height each corner crop covers. A card corner's
# actual wear lives in the outer few millimetres, but a crop that tight loses
# the context needed to judge whether an edge is cut straight.
CORNER_FRACTION = 0.28
# How deep an edge strip reaches into the card, as a fraction of the
# perpendicular dimension. Kept deliberately shallow: whitening sits in the
# outermost millimetre or two, so a deeper strip is mostly card interior and
# the wear ends up a sliver at one end of a frame full of artwork — which is
# exactly how it gets overlooked. Shallow enough that the cut edge dominates
# what's on screen, with just enough border either side to give it context.
EDGE_FRACTION = 0.07
# Upscale target for the long edge of each crop. Large enough that fine
# whitening survives, small enough to stay well inside the model's per-image
# cap (and its token cost).
CORNER_TARGET_PX = 700
# Edge strips are long and thin, so magnifying them by their LONG axis (the
# way a squarish corner crop is handled) does nothing useful — that axis is
# already big. What matters is how many pixels lie across the strip, since
# that's the direction a whitening band is measured in. So these target the
# short axis, with a cap on the long one to stay inside the model's per-image
# pixel limit.
EDGE_SHORT_TARGET_PX = 340
EDGE_LONG_CAP_PX = 2500
# Surface inspection, as a band-pass rather than a plain high-pass. A plain
# high-pass keeps the very finest detail, which on a printed card means the
# halftone dot rosettes — they swamp the picture and hide the very marks
# being looked for. Scratches and print lines sit in a band between those
# dots and the artwork itself, so the fine radius blurs the dots away and
# the coarse one takes the artwork out, leaving what's in between.
SURFACE_FINE_RADIUS = 1.4
SURFACE_COARSE_RADIUS = 6.0
SURFACE_AUTOCONTRAST_CUTOFF = 0.4
# The card face is split into quadrants for surface inspection, so each one
# keeps enough resolution to show a hairline scratch. Rows x columns.
SURFACE_TILES = (2, 2)
SURFACE_TILE_TARGET_PX = 1150
SURFACE_QUADRANTS = ("upper-left", "upper-right", "lower-left", "lower-right")
# Below this the source photo has no detail worth zooming into — upscaling it
# would just produce a convincing-looking blur for the model to over-read.
MIN_SOURCE_PX = 600
CORNERS = ("top-left", "top-right", "bottom-left", "bottom-right")
EDGES = ("top-edge", "right-edge", "bottom-edge", "left-edge")
def available():
return Image is not None
# Magic numbers, so an image is identified by what it actually is rather than
# by what its filename claims. Phones routinely hand over names the extension
# check can't cope with — no extension at all, a content:// URI, or .HEIC —
# and rejecting a perfectly readable photo over its name is indefensible.
_SIGNATURES = (
(b"\x89PNG\r\n\x1a\n", "png"),
(b"\xff\xd8\xff", "jpeg"),
(b"GIF87a", "gif"),
(b"GIF89a", "gif"),
(b"BM", "bmp"),
(b"II*\x00", "tiff"),
(b"MM\x00*", "tiff"),
)
# Formats the Anthropic API accepts directly; anything else has to be
# converted before it can be sent.
DIRECTLY_SUPPORTED = {"png", "jpeg", "gif", "webp"}
def sniff_format(image_bytes):
"""Identify an image from its leading bytes. Returns a short name or None."""
if not image_bytes:
return None
head = image_bytes[:32]
for signature, name in _SIGNATURES:
if head.startswith(signature):
return name
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
return "webp"
# HEIC/HEIF (iPhone's default) declares itself in an 'ftyp' box.
if head[4:8] == b"ftyp":
brand = head[8:12]
if brand in (b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1", b"heim"):
return "heic"
if brand in (b"avif", b"avis"):
return "avif"
return None
def normalize_upload(image_bytes, filename="upload"):
"""Return (bytes, filename, error) with the image in a sendable format.
Passes through anything the API already accepts. Anything else that
Pillow can open HEIC with the right plugin, BMP, TIFF, AVIF is
re-encoded as JPEG rather than refused, since the bytes are perfectly
good and only the container is wrong.
"""
fmt = sniff_format(image_bytes)
if fmt in DIRECTLY_SUPPORTED:
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
return image_bytes, "{}.{}".format(stem or "upload", "jpg" if fmt == "jpeg" else fmt), None
if Image is None:
return None, None, (
"That file is {} and this app can only send PNG, JPEG, GIF or WebP. "
"Installing pillow (python3 -m pip install --user pillow) would let "
"it convert automatically.".format(fmt or "an unrecognised format"))
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=92)
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
return buffer.getvalue(), "{}.jpg".format(stem or "upload"), None
except Exception:
if fmt in ("heic", "avif"):
return None, None, (
"That photo is {} format, which needs an extra decoder. Either "
"install it (python3 -m pip install --user pillow-heif) or set "
"your phone's camera to save JPEG instead of "
"High Efficiency.".format(fmt.upper()))
return None, None, (
"Couldn't read that file — it doesn't look like a readable image.")
def _detect_card_box(img):
"""Best-effort bounding box of the card within the photo.
Works by estimating the background colour from the photo's own corners
and then finding the rows and columns that stop looking like background.
That beats edge-density detection here: a card's interior is full of
artwork and text, so edge density peaks in the middle and gives a box
that drifts several percent past the real cut line. For edge strips that
slop matters it fills the strip with desk or mat instead of the card's
border, which is the whole thing being examined.
Returns None when the result doesn't look like a card, so the caller can
fall back to treating the whole frame as the card.
"""
rgb = img.convert("RGB")
# Detect at a fairly high resolution. Downscaling harder is cheaper, but
# it blurs the outermost pixels of the card into the background — and on
# a pale background, a heavily whitened edge then reads AS background, so
# the boundary walks inward past the wear and the measurement misses the
# very thing it is looking for. Found exactly that way round in testing.
scale = 800.0 / max(rgb.size)
if scale < 1:
rgb = rgb.resize((max(1, int(rgb.width * scale)),
max(1, int(rgb.height * scale))), Image.BILINEAR)
else:
scale = 1.0
w, h = rgb.size
if w < 20 or h < 20:
return None
px = rgb.load()
# Estimate the background from the four image corners. If the card fills
# the frame these samples are card, every pixel then reads as "not
# background", and the box correctly comes back as the whole image.
patch = max(2, min(w, h) // 25)
samples = []
for cx, cy in ((0, 0), (w - patch, 0), (0, h - patch), (w - patch, h - patch)):
for x in range(cx, min(w, cx + patch)):
for y in range(cy, min(h, cy + patch)):
samples.append(px[x, y])
bg = tuple(sum(c[i] for c in samples) // len(samples) for i in range(3))
def differs(p):
return abs(p[0] - bg[0]) + abs(p[1] - bg[1]) + abs(p[2] - bg[2]) > 90
row_counts = [0] * h
col_counts = [0] * w
for y in range(h):
for x in range(w):
if differs(px[x, y]):
row_counts[y] += 1
col_counts[x] += 1
def span(counts, extent):
# A real card edge makes most of a row/column stop being background
# at once, so key off a share of the perpendicular extent rather than
# off the peak — that keeps a few stray specks of noise in the
# background from widening the box.
cutoff = extent * 0.35
hits = [i for i, c in enumerate(counts) if c >= cutoff]
return (hits[0], hits[-1]) if hits else None
rows, cols = span(row_counts, w), span(col_counts, h)
if not rows or not cols:
return None
box = (int(cols[0] / scale), int(rows[0] / scale),
int(round(cols[1] / scale)), int(round(rows[1] / scale)))
box = (max(0, box[0]), max(0, box[1]),
min(img.width, box[2] + 1), min(img.height, box[3] + 1))
bw, bh = box[2] - box[0], box[3] - box[1]
if bw < 40 or bh < 40:
return None
# A tiny box means detection latched onto something that isn't the card.
if float(bw * bh) / float(img.width * img.height) < 0.15:
return None
return box
def _surface_map(piece):
"""A band-pass view that isolates surface texture from the artwork.
Scratches, print lines and dents are low-contrast marks sitting on
artwork that is far higher contrast than they are which is exactly why
they vanish in a normal view. Subtracting a heavily blurred copy cancels
the smooth artwork; subtracting from a lightly blurred copy rather than
the raw pixels first drops the halftone dots, which otherwise dominate
the result on any printed card. What survives is the band where surface
damage lives, and stretching the contrast makes it legible.
The output deliberately exaggerates: holo texture, foil patterns and JPEG
blocking all light up alongside real damage, so it is only ever shown
beside the untouched crop for comparison, never on its own.
"""
grey = piece.convert("L")
fine = grey.filter(ImageFilter.GaussianBlur(SURFACE_FINE_RADIUS))
coarse = grey.filter(ImageFilter.GaussianBlur(SURFACE_COARSE_RADIUS))
band = ImageChops.difference(fine, coarse)
return ImageOps.autocontrast(band, cutoff=SURFACE_AUTOCONTRAST_CUTOFF)
def _band_stats(luma_px, hsv_px, x, y0, y1):
"""Mean luma and saturation down one column of a band.
Luma rather than HSV's "value": V is max(R,G,B), which makes a saturated
yellow border and bare white paper both read as 255 the exact case this
is trying to measure. Luma weights the channels the way brightness is
actually perceived, so yellow lands near 212 and white at 255, leaving a
real difference to detect.
"""
n = 0
l_total = 0
s_total = 0
for y in range(y0, y1):
l_total += luma_px[x, y]
s_total += hsv_px[x, y][1]
n += 1
if not n:
return None, None
return l_total / float(n), s_total / float(n)
def _column_reference(luma_px, hsv_px, x, y0, y1):
"""Median luma/saturation down a column, plus how much it varies.
Median rather than mean so a few pixels of text don't drag the baseline,
and the spread is returned so the caller can throw the column out
entirely when the reference clearly isn't uniform border.
"""
lumas = []
sats = []
for y in range(y0, y1):
lumas.append(luma_px[x, y])
sats.append(hsv_px[x, y][1])
if not lumas:
return None, None, None
lumas.sort()
sats.sort()
n = len(lumas)
spread = lumas[int(n * 0.9)] - lumas[int(n * 0.1)]
return lumas[n // 2], sats[n // 2], spread
def _measure_one_edge(card, geometry):
"""Whitening measurement for the TOP edge of whatever is passed in.
Each of the four edges is rotated to the top before calling this, so the
logic only ever has to handle one orientation.
The measurement is a local, column-by-column comparison: the outermost
sliver of border is compared against the same border slightly deeper in,
at the same x. Whitening is the paper core showing through, so the outer
band goes lighter and loses saturation relative to the reference while
a lighting gradient, a coloured border, or a dark card all affect both
bands together and cancel out. That self-referencing is the point: it
needs no idea what the card is supposed to look like.
"""
inset, band, gap = geometry
w, h = card.size
if h < inset + gap + band * 2 or w < 20:
return None
luma = card.convert("L").load()
hsv = card.convert("HSV").load()
edge_y = (inset, inset + band)
# Corners have their own category and their own rounding, so leave them
# out — otherwise every card's four rounded corners inflate every edge.
margin = max(2, int(w * 0.05))
# Read the outermost band once per column. Comparing against a band
# further into the card was tried first and cannot work generally: a
# Pokemon border is barely ten pixels deep, so any reference deep enough
# to be separate lands on the copyright line or the artwork, and a bright
# border measured against dark text reads as whitening down the entire
# edge. The baseline instead comes from the card's own border, worked out
# by the caller across all four edges at once.
# Also read a band just INSIDE the edge band, per column. Whitening is
# confined to the outermost millimetre or two at the cut, so it shows up
# as a step between these two bands. Glare, a glossy sleeve catching the
# light, or simply one side of the photo being brighter lifts BOTH bands
# together and produces no step — which is how the two get told apart.
inner_y = (edge_y[1] + max(1, band // 4), edge_y[1] + max(1, band // 4) + band)
if inner_y[1] > h:
inner_y = None
columns = []
for x in range(margin, w - margin):
l_edge, s_edge = _band_stats(luma, hsv, x, *edge_y)
if l_edge is None:
continue
if inner_y is None:
columns.append((l_edge, s_edge, None, None, None))
continue
l_in, s_in, spread = _column_reference(luma, hsv, x, *inner_y)
columns.append((l_edge, s_edge, l_in, s_in, spread))
if len(columns) < 20:
return None
return columns
def _score_edge(columns, base_l, base_s):
"""Share of an edge's columns that read as whitened.
A column has to clear two independent tests. The first compares it with
the card's own border pooled across all four edges, which catches wear
wherever it sits. The second requires an actual step between the
outermost band and the band just inside it, which is what makes it wear
rather than lighting: a bright reflection raises both bands equally and
fails this test, while paper showing through at the cut does not.
"""
whitened = 0
deltas = []
counted = 0
for l_edge, s_edge, l_in, s_in, spread in columns:
d_light = l_edge - base_l
d_desat = base_s - s_edge
pooled = d_desat >= 55 or d_light >= 28 or (d_light >= 10 and d_desat >= 20)
if l_in is None:
local = True # no inner band available; fall back to pooled only
elif spread is not None and spread > 55:
# The inner band landed on text or artwork, so it cannot confirm
# anything. Skip the column rather than guess — a false "clean" is
# cheaper here than a false accusation of wear.
continue
else:
local = (l_edge - l_in) >= 8 or (s_in - s_edge) >= 18
counted += 1
if pooled and local:
whitened += 1
deltas.append(max(d_light, d_desat * 0.4))
if counted < 20:
return None
return {
"percent": round(100.0 * whitened / counted, 1),
"mean_lift": round(sum(deltas) / float(len(deltas)), 1) if deltas else 0.0,
"columns_used": counted,
"baseline_luma": round(base_l, 1),
"baseline_saturation": round(base_s, 1),
}
def edge_wear_profile(image_bytes):
"""Measure whitening along all four edges. Returns None if unavailable.
Gives back, per edge, the share of its length that reads as whitened and
how strong the lift is numbers a vision model cannot produce by eye,
and which are immune to the thing that kept defeating it: telling a
genuine pale band apart from the cut line and the border's own
anti-aliasing.
"""
if Image is None:
return None
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
box = _detect_card_box(img) or (0, 0, img.width, img.height)
card = img.crop(box).convert("RGB")
short = min(card.size)
geometry = (
# Only just enough to clear the cut line and its anti-aliasing.
# Whitening starts AT the cut, so an inset chosen to be safe
# against background bleed instead steps straight over the thing
# being measured — that alone was halving the signal.
max(2, int(round(short * 0.004))),
max(4, int(round(short * 0.011))), # band thickness
max(8, int(round(short * 0.020))), # depth of the reference band
)
rotations = {
"top": 0,
"right": 90, # rotating 90 CCW brings the right edge to the top
"bottom": 180,
"left": 270,
}
collected = {}
for name, angle in rotations.items():
face = card if angle == 0 else card.rotate(angle, expand=True)
collected[name] = _measure_one_edge(face, geometry)
if all(v is None for v in collected.values()):
return None
# Baseline from ALL four edges pooled, not each edge against itself.
# Whitening only ever raises luma and lowers saturation, so unworn
# border sits at the low end of one and the high end of the other,
# and quartiles across the whole card find it. Scoring an edge
# against only its own length silently fails on the case that
# matters most — an edge worn evenly end to end, where the baseline
# becomes the wear and the damage cancels itself out. Pooling means
# three clean edges anchor the fourth.
pooled = [c for cols in collected.values() if cols for c in cols]
if len(pooled) < 40:
return None
lumas = sorted(c[0] for c in pooled)
sats = sorted(c[1] for c in pooled)
base_l = lumas[int(len(lumas) * 0.25)]
base_s = sats[int(len(sats) * 0.75)]
# Decide whether this card can be measured at all before reporting a
# number for it. The method detects paper showing through a printed
# border, which presumes the border is darker and more saturated than
# bare card stock. Silver, white, foil and refractor borders break
# that presumption outright — they are already pale and colourless,
# so ordinary variation in them reads exactly like wear, and the
# result is a confident accusation against a clean card. Refusing to
# answer is the right outcome there; a wrong number is worse than no
# number, because it drags the whole grade down with it.
iqr = lumas[int(len(lumas) * 0.75)] - lumas[int(len(lumas) * 0.25)]
reason = None
if base_l >= 205 and base_s <= 45:
reason = ("the card's border is white, silver or foil, where paper "
"showing through looks the same as the border itself")
elif iqr >= 70:
reason = ("the border's brightness varies too much across the card "
"— typical of a refractor or prismatic finish — for a "
"whitening measurement to mean anything")
edges = {name: (_score_edge(cols, base_l, base_s) if cols else None)
for name, cols in collected.items()}
return {
"edges": edges,
"reliable": reason is None,
"reason": reason,
"border_luma": round(base_l, 1),
"border_saturation": round(base_s, 1),
}
except Exception:
return None
def _border_width(px, size, side, border_rgb, skip=0, tolerance=70):
"""How far the uniform border reaches in from one side, in pixels.
Sampled along several lines and taken as the median, so a logo or a bit
of artwork touching the border on one line doesn't decide the answer.
Corners are avoided their rounding would read as a wider border.
`skip` steps over the cut line and its anti-aliasing before measuring;
without it the very first pixel is still background and every side reads
as a zero-width border.
"""
w, h = size
along = h if side in ("left", "right") else w
depth_limit = int((w if side in ("left", "right") else h) * 0.30)
if depth_limit < 3:
return None
def matches(p):
return (abs(p[0] - border_rgb[0]) + abs(p[1] - border_rgb[1])
+ abs(p[2] - border_rgb[2])) <= tolerance
# Walk inward one row at a time asking how much of that row is still
# border, rather than stopping at the first pixel that isn't. Text
# printed inside the border — a vintage nameplate, a modern copyright
# line, the collector number — otherwise halts the scan almost at the cut
# and reports a border a fraction of its real width. Those characters are
# thin, so the row they sit on is still mostly border; the design proper
# takes the whole row at once, which is the transition being looked for.
positions = [int(along * (0.2 + 0.6 * i / 24.0)) for i in range(25)]
positions = [p for p in positions if 0 <= p < along]
if not positions:
return None
for depth in range(skip, depth_limit):
hits = 0
for pos in positions:
if side == "left":
p = px[depth, pos]
elif side == "right":
p = px[w - 1 - depth, pos]
elif side == "top":
p = px[pos, depth]
else:
p = px[pos, h - 1 - depth]
if matches(p):
hits += 1
if hits < len(positions) * 0.5:
return depth
return depth_limit
def centering_profile(image_bytes):
"""Measure how well centred the card's design is inside its border.
Centering is the one PSA category that is purely geometric it is a
ratio of border widths, with published tolerances attached so it can
be measured outright rather than estimated. Returns the widths, the
left/right and top/bottom ratios, and the worse of the two, which is
what a grader keys off.
Returns None when the card has no uniform border to measure against
(full-bleed modern cards) or the read looks implausible, so the caller
can fall back to the model's own eye rather than trust a bad number.
"""
if Image is None:
return None
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
card = img.crop(_detect_card_box(img) or (0, 0, img.width, img.height))
card = card.convert("RGB")
w, h = card.size
if w < 60 or h < 60:
return None
px = card.load()
# The border colour, sampled just inside the cut at the midpoint of
# each side — far from corners and from any design element.
inset = max(2, int(min(w, h) * 0.012))
samples = [px[inset, h // 2], px[w - 1 - inset, h // 2],
px[w // 2, inset], px[w // 2, h - 1 - inset]]
border_rgb = tuple(sorted(c[i] for c in samples)[len(samples) // 2]
for i in range(3))
widths = {side: _border_width(px, (w, h), side, border_rgb, skip=inset)
for side in ("left", "right", "top", "bottom")}
if any(v is None for v in widths.values()):
return None
# A border that vanishes, or that swallows a third of the card, means
# this isn't a bordered card or the sample missed — either way the
# ratio would be meaningless.
if min(widths.values()) < 2:
return None
if max(widths["left"], widths["right"]) > w * 0.28:
return None
if max(widths["top"], widths["bottom"]) > h * 0.28:
return None
def ratio(a, b):
total = float(a + b)
if total <= 0:
return None
bigger = 100.0 * max(a, b) / total
return round(bigger, 1)
horizontal = ratio(widths["left"], widths["right"])
vertical = ratio(widths["top"], widths["bottom"])
if horizontal is None or vertical is None:
return None
return {
"widths_px": widths,
"horizontal": horizontal,
"vertical": vertical,
"worst": round(max(horizontal, vertical), 1),
"horizontal_label": "{:.0f}/{:.0f}".format(horizontal, 100 - horizontal),
"vertical_label": "{:.0f}/{:.0f}".format(vertical, 100 - vertical),
"wider_side": ("left" if widths["left"] > widths["right"] else "right",
"top" if widths["top"] > widths["bottom"] else "bottom"),
}
except Exception:
return None
def _edge_enhanced(strip):
"""Whitening map of an edge strip, keyed on colour saturation.
Edge whitening is physically the paper core showing through a printed
border, so its signature is a loss of saturation rather than a change in
brightness. Mapping saturation and inverting it therefore isolates
exactly the thing being looked for: worn paper lights up, printed colour
goes dark, whatever the border's colour happens to be.
Brightness-based contrast stretching was tried first and is actively
misleading here on a light border (a yellow Pokemon frame, a white
1980s border) it pushes the border itself to near-white and buries the
very wear it was meant to reveal.
The limit worth knowing: on an already-unsaturated white border there is
no saturation left to lose, so this map stays flat and the untouched
strip beside it has to carry the judgement.
"""
saturation = strip.convert("HSV").split()[1]
return ImageOps.autocontrast(
ImageOps.invert(saturation), cutoff=1).convert("RGB")
def _stack(top, bottom, gap=10):
top = top.convert("RGB")
bottom = bottom.convert("RGB")
w = max(top.width, bottom.width)
canvas = Image.new("RGB", (w, top.height + gap + bottom.height), (18, 18, 18))
canvas.paste(top, (0, 0))
canvas.paste(bottom, (0, top.height + gap))
return canvas
def _side_by_side(left, right, gap=14):
"""Untouched crop beside its surface map, so one can check the other."""
left = left.convert("RGB")
right = right.convert("RGB")
h = max(left.height, right.height)
canvas = Image.new("RGB", (left.width + gap + right.width, h), (18, 18, 18))
canvas.paste(left, (0, 0))
canvas.paste(right, (left.width + gap, 0))
return canvas
def _encode(img):
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=92)
return buffer.getvalue()
def _resized(piece, factor):
if factor > 1:
piece = piece.resize(
(max(1, int(piece.width * factor)), max(1, int(piece.height * factor))),
Image.LANCZOS,
)
return piece if piece.mode == "RGB" else piece.convert("RGB")
def _magnify(piece, target_px):
"""Scale a roughly square crop so its long edge hits target_px."""
return _resized(piece, target_px / float(max(piece.size)))
def _magnify_strip(piece):
"""Scale a long thin strip by its short axis, capping the long one.
Targeting the long axis here would be a no-op it's already large — and
would leave the across-the-strip detail, which is the part that actually
shows whitening, at whatever the source happened to give.
"""
factor = EDGE_SHORT_TARGET_PX / float(min(piece.size))
factor = min(factor, EDGE_LONG_CAP_PX / float(max(piece.size)))
return _resized(piece, factor)
def detail_crops(image_bytes, filename="card.jpg", corners=True, edges=True,
surface=True):
"""Magnified crops of a card's corners and edge strips, in that order.
Returns [(bytes, filename), ...]. Empty when Pillow is missing, the image
is too small to zoom into, or anything goes wrong grading then proceeds
on the full photo alone, which is the pre-Pillow behaviour.
Corners and edges are cropped separately rather than relying on the
corner crops alone: a corner crop only covers the ends of each side, so
whitening running along the middle of an edge falls between them.
"""
if Image is None:
return []
try:
img = Image.open(io.BytesIO(image_bytes))
img.load()
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
if max(img.size) < MIN_SOURCE_PX:
return []
box = _detect_card_box(img) or (0, 0, img.width, img.height)
card = img.crop(box)
w, h = card.width, card.height
stem = filename.rsplit(".", 1)[0]
crops = []
if corners:
cw = max(1, int(w * CORNER_FRACTION))
ch = max(1, int(h * CORNER_FRACTION))
regions = {
"top-left": (0, 0, cw, ch),
"top-right": (w - cw, 0, w, ch),
"bottom-left": (0, h - ch, cw, h),
"bottom-right": (w - cw, h - ch, w, h),
}
for name in CORNERS:
piece = card.crop(regions[name])
if min(piece.size) < 8:
continue
crops.append((_encode(_magnify(piece, CORNER_TARGET_PX)),
"{}-{}.jpg".format(stem, name)))
if edges:
# Pull in a couple of pixels first. Box detection lands within
# about two pixels of the cut, and any background left in the
# strip is a problem specifically for the saturation map: an
# unsaturated backdrop (a dark mat, a white desk) reads as bright
# there, sitting exactly where whitening would be and faking it
# on a perfectly clean card. Costs a sliver of the real edge,
# which is worth it to kill a false positive.
inset = max(4, int(round(min(w, h) * 0.012)))
face = card.crop((inset, inset, max(inset + 1, w - inset),
max(inset + 1, h - inset)))
fw, fh = face.size
ew = max(1, int(fw * EDGE_FRACTION))
eh = max(1, int(fh * EDGE_FRACTION))
regions = {
"top-edge": (0, 0, fw, eh),
"right-edge": (fw - ew, 0, fw, fh),
"bottom-edge": (0, fh - eh, fw, fh),
"left-edge": (0, 0, ew, fh),
}
card_for_edges = face
for name in EDGES:
piece = card_for_edges.crop(regions[name])
if min(piece.size) < 8:
continue
# Deliberately the plain strip, with no processed companion.
# A saturation map was tried here (worn paper is desaturated,
# so in principle whitening should light up) and measurably
# backfired: every card, clean ones included, then came back
# "minor whitening", with the location moving between runs.
# The cut line itself and the border's own anti-aliasing
# produce a signal indistinguishable from light wear, so the
# map added noise the model anchored on rather than evidence.
# Surface keeps its processed companion because there the
# signal is genuinely separable; edges do better without one.
crops.append((_encode(_magnify_strip(piece)),
"{}-{}.jpg".format(stem, name)))
if surface:
rows, cols = SURFACE_TILES
index = 0
for ry in range(rows):
for cx in range(cols):
piece = card.crop((
int(w * cx / cols), int(h * ry / rows),
int(w * (cx + 1) / cols), int(h * (ry + 1) / rows),
))
if min(piece.size) < 16:
index += 1
continue
combo = _side_by_side(piece, _surface_map(piece))
name = SURFACE_QUADRANTS[index] if index < len(SURFACE_QUADRANTS) \
else "region{}".format(index)
crops.append((_encode(_magnify(combo, SURFACE_TILE_TARGET_PX)),
"{}-surface-{}.jpg".format(stem, name)))
index += 1
return crops
except Exception:
# A grading run is worth more than a perfect crop — never let an
# image-processing failure take out the whole request.
return []
def corner_crops(image_bytes, filename="card.jpg"):
"""Corner close-ups only. Kept for callers that don't want edge strips."""
return detail_crops(image_bytes, filename, corners=True, edges=False)

19
docker-compose.yml Normal file
View file

@ -0,0 +1,19 @@
services:
card-grader:
build: .
container_name: card-grader
restart: unless-stopped
# Bound to 127.0.0.1 deliberately: nginx (running on the Unraid host,
# or in its own container sharing the host network) is the only thing
# that should reach this port directly. Nothing on the LAN or internet
# can hit :8778 without going through nginx's TLS termination.
ports:
- "127.0.0.1:8778:8778"
volumes:
- ./data:/data
environment:
# Refuses settings writes from anyone but you, so a visitor can't
# overwrite your API key or switch to a pricier model. Set your key
# via the container's own console the first time (see README), then
# leave this on.
- CARD_GRADER_LOCK=1

43
nginx/card-grader.conf Normal file
View file

@ -0,0 +1,43 @@
# Reverse proxy for Card Grader. Replace card-grader.example.com below with
# your actual subdomain, point an A record at your Unraid box's public IP
# (or use a dynamic-DNS hostname if your ISP doesn't give you a static one),
# and forward ports 80 + 443 on your router to the Unraid box.
server {
listen 80;
server_name card-grader.example.com;
# certbot's webroot plugin needs this path reachable over plain HTTP to
# issue/renew the certificate; everything else redirects to HTTPS.
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name card-grader.example.com;
ssl_certificate /etc/letsencrypt/live/card-grader.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/card-grader.example.com/privkey.pem;
# Grading uploads can be a few photos at once; app.py caps the combined
# decoded size at 12MB, and base64 inflates that by ~33% on the wire —
# give nginx enough headroom that it isn't the thing rejecting uploads.
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:8778;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Grading calls out to Anthropic and can take a while on Opus/careful
# effort; don't let nginx's default 60s read timeout cut it off.
proxy_read_timeout 120s;
}
}

572
static/app.js Normal file
View file

@ -0,0 +1,572 @@
/* Card Grader — UI */
const state = { settings: {}, modelGuide: {}, history: [] };
const $ = (sel) => document.querySelector(sel);
function esc(text) {
return String(text === null || text === undefined ? '' : text)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/* ------------------------------------------------------------------ api */
// The path this app is mounted under (e.g. "/cards"), or "" at the domain
// root — set server-side via a data attribute since a script has no other
// reliable way to know where it was served from.
const APP_BASE = document.documentElement.dataset.base || '';
async function api(path, options = {}) {
const response = await fetch(APP_BASE + path, {
headers: { 'Content-Type': 'application/json' },
...options,
body: options.body ? JSON.stringify(options.body) : undefined,
});
const text = await response.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (_) { data = null; }
if (!response.ok) {
throw new Error((data && data.error) || `Request failed (${response.status})`);
}
return data;
}
function banner(message, isError = false) {
const node = $('#banner');
if (!message) { node.hidden = true; return; }
node.className = isError ? 'banner err' : 'banner';
node.textContent = message;
node.hidden = false;
if (!isError) setTimeout(() => { node.hidden = true; }, 3200);
}
/* ------------------------------------------------------------- file read */
//
// Read straight to a data URL at pick time, and only clear the input once
// the bytes are safely in hand. Holding File objects and reading them later
// looks equivalent and isn't: clearing input.value (which is what lets you
// re-pick the same file) can detach the underlying blob in some browsers, so
// a read attempted afterward fails with no useful reason. Reading now
// sidesteps that, and the data URL doubles as the preview source.
function bytesToBase64(bytes) {
// Chunked: fromCharCode.apply on a multi-megabyte array blows the stack.
let binary = '';
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
async function readOneImage(file) {
// Two independent read paths, because they fail independently. FileReader
// is the older API and is the one that trips over files a phone exposes
// through a cloud provider or a scoped-storage URI; file.arrayBuffer() is
// the modern path and frequently succeeds where it doesn't. Trying both
// turns a hard failure into a retry, and if both fail the real
// DOMException name gets reported rather than a guess at the cause.
const errors = [];
try {
const buffer = await file.arrayBuffer();
if (buffer && buffer.byteLength) {
const type = file.type || 'image/jpeg';
return `data:${type};base64,${bytesToBase64(new Uint8Array(buffer))}`;
}
errors.push('arrayBuffer returned nothing');
} catch (err) {
errors.push(`arrayBuffer: ${err && err.name ? err.name : err}`);
}
try {
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error || new Error('unknown'));
reader.readAsDataURL(file);
});
} catch (err) {
errors.push(`FileReader: ${err && err.name ? err.name : err}`);
}
const detail = errors.join('; ');
const hint = /NotReadable|NotFound/i.test(detail)
? ' The file may be stored in the cloud rather than on the device — open it once in your photo app so it downloads, then try again.'
: ' Try saving a copy of it first, then pick the copy.';
throw new Error(`Could not read "${file.name || 'that file'}" (${detail}).${hint}`);
}
async function readPickedImages(input, limit) {
const picked = Array.from(input.files || []).slice(0, Math.max(0, limit));
const out = [];
for (const file of picked) {
out.push({ name: file.name || 'upload', dataUrl: await readOneImage(file) });
}
input.value = '';
return out;
}
function pickedToPayload(items) {
return items.map((i) => ({ filename: i.name, image_base64: i.dataUrl.split(',')[1] }));
}
/* --------------------------------------------------------------- grading */
const SEVERITY_PILL = {
none: 'pill-grade', minor: 'pill-marginal', moderate: 'pill-marginal',
major: 'pill-critical', cannot_assess: 'pill-raw',
};
const SEVERITY_LABEL = {
none: 'clean', minor: 'minor', moderate: 'moderate', major: 'major',
cannot_assess: "can't tell from photo",
};
const gradeState = { files: [], result: null, busy: false };
function renderGradeBlock(g) {
if (!g) return '';
const m = g.edge_measurements || null;
let measuredLine = '';
if (m && m.reliable === false) {
measuredLine = `not measurable — ${m.reason || "this card's finish"}`;
} else if (m && m.edges) {
measuredLine = ['top', 'right', 'bottom', 'left']
.filter((s) => m.edges[s])
.map((s) => `${s} ${m.edges[s].percent.toFixed(0)}%`).join(' · ');
}
const cm = g.centering_measurement || null;
const centeringLine = cm
? `measured — left/right ${cm.horizontal_label} · top/bottom ${cm.vertical_label}` : '';
const rows = ['centering', 'corners', 'edges', 'surface'].map((key) => {
const cat = (g.categories || {})[key] || {};
const sev = cat.severity || 'cannot_assess';
let extra = '';
if (key === 'edges' && measuredLine) {
extra = `<div class="hint">measured whitening — ${esc(measuredLine)}</div>`;
} else if (key === 'centering' && centeringLine) {
extra = `<div class="hint">${esc(centeringLine)}</div>`;
}
return `<tr>
<td style="text-transform:capitalize">${key}</td>
<td><span class="pill ${SEVERITY_PILL[sev]}">${esc(SEVERITY_LABEL[sev])}</span></td>
<td class="cardcell-meta">${esc(cat.observation || '')}${extra}</td>
</tr>`;
}).join('');
return `
<div class="note">A photo can't show surface scratches, print lines, or light edge wear the
way a grader's raking light does — and a seller's listing photo is often lit to hide them.
Treat this as a rough screen, not a prediction of what it comes back as.</div>
<div style="margin:14px 0">${renderSlab(g)}</div>
<div class="table-scroll"><table class="grid"><tbody>${rows}</tbody></table></div>
${(g.limitations || []).length ? `<div style="margin-top:10px"><strong>Couldn't check:</strong>
<ul>${g.limitations.map((l) => `<li>${esc(l)}</li>`).join('')}</ul></div>` : ''}
${g.note ? `<p class="cardcell-meta">${esc(g.note)}</p>` : ''}`;
}
const GRADE_WORD = {
10: 'Gem Mint', 9: 'Mint', 8: 'NM-MT', 7: 'Near Mint', 6: 'EX-MT',
5: 'Excellent', 4: 'VG-EX', 3: 'Very Good', 2: 'Good', 1: 'Poor',
};
const TYPE_LABEL = {
pokemon: 'Pokémon', sports: 'Sports', other_tcg: 'TCG', other: 'Card',
};
function slabClass(grade) {
if (grade === null || grade === undefined) return 'slab-low';
if (grade >= 10) return 'slab-10';
if (grade === 9) return 'slab-9';
if (grade >= 7) return 'slab-8';
if (grade >= 5) return 'slab-6';
return 'slab-low';
}
function renderSlab(g, opts = {}) {
const grade = g.estimated_grade;
const range = (g.grade_low !== null && g.grade_high !== null && g.grade_low !== g.grade_high)
? `realistically ${g.grade_low}${g.grade_high}` : '';
const title = opts.title || g.card_note || 'Card';
const bits = [range, `${g.confidence || ''} confidence`].filter(Boolean).join(' · ');
return `
<div class="slab ${slabClass(grade)}">
<div class="slab-main">
${g.card_type ? `<div><span class="card-type-tag">${esc(TYPE_LABEL[g.card_type] || 'Card')}</span></div>` : ''}
<div class="slab-title">${esc(title)}</div>
<div class="slab-sub">${esc(bits)}</div>
</div>
<div class="slab-grade">
<span class="n">${grade === null || grade === undefined ? '—' : grade}</span>
<span class="word">${esc(GRADE_WORD[grade] || 'estimate')}</span>
</div>
</div>`;
}
function renderGradeReview() {
const el = $('#grade-review');
if (!gradeState.files.length && !gradeState.result) { el.innerHTML = ''; return; }
if (!gradeState.result) {
el.innerHTML = `
<div class="panel" style="margin-top:14px;background:var(--plane)">
<div class="panel-head">
<h2>${gradeState.files.length} photo(s) ready</h2>
<span class="hint">more angles = a tighter estimate</span>
</div>
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
${gradeState.files.map((f) =>
`<img src="${f.dataUrl}" alt="" style="height:110px;border-radius:8px">`).join('')}
<div style="display:flex;flex-direction:column;gap:8px">
<button class="btn btn-quiet btn-sm" id="grade-add-more">+ Add another photo</button>
<button class="btn" id="grade-run"${gradeState.busy ? ' disabled' : ''}>
${gradeState.busy ? 'Grading…' : 'Estimate the grade'}</button>
<button class="btn btn-quiet btn-sm" id="grade-cancel">Cancel</button>
</div>
</div>
</div>`;
return;
}
const g = gradeState.result.grade;
const cost = gradeState.result.grade.estimated_cost;
el.innerHTML = `
<div class="panel" style="margin-top:14px;background:var(--plane)">
<div class="panel-head">
<h2>Grade estimate</h2>
<span class="hint">${cost ? `about $${cost.toFixed(3)}` : ''}</span>
</div>
${renderGradeBlock(g)}
<div class="drawer-actions">
<button class="btn btn-quiet" id="grade-discard">Done</button>
</div>
</div>`;
}
async function runGrade() {
gradeState.busy = true;
renderGradeReview();
const status = $('#grade-status');
status.hidden = false;
status.textContent = `Reading ${gradeState.files.length} photo(s)… this takes a few seconds and costs a few cents.`;
try {
const body = {
images: pickedToPayload(gradeState.files),
label: ($('#grade-label').value || '').trim() || null,
};
const key = myApiKey();
if (key) body.api_key = key;
gradeState.result = await api('/api/grade', { method: 'POST', body });
status.hidden = true;
$('#grade-label').value = '';
loadHistory().catch(() => {});
} catch (err) {
status.textContent = `Grading failed: ${err.message}`;
} finally {
gradeState.busy = false;
renderGradeReview();
}
}
function resetGradeState() {
gradeState.files = [];
gradeState.result = null;
gradeState.busy = false;
$('#grade-status').hidden = true;
renderGradeReview();
}
$('#btn-grade').addEventListener('click', () => {
resetGradeState();
$('#grade-file').click();
});
$('#grade-file').addEventListener('change', async (e) => {
try {
const picked = await readPickedImages(e.target, 6 - gradeState.files.length);
if (!picked.length) return;
gradeState.files.push(...picked);
renderGradeReview();
} catch (err) {
$('#grade-status').hidden = false;
$('#grade-status').textContent = err.message;
}
});
$('#grade-review').addEventListener('click', (e) => {
if (e.target.closest('#grade-add-more')) { $('#grade-file').click(); return; }
if (e.target.closest('#grade-cancel') || e.target.closest('#grade-discard')) {
resetGradeState(); return;
}
if (e.target.closest('#grade-run')) {
if (!gradeState.busy) runGrade();
}
});
/* --------------------------------------------------------------- history */
function gradePillClass(grade) {
if (grade === null || grade === undefined) return 'pill-raw';
if (grade >= 8) return 'pill-grade';
if (grade >= 5) return 'pill-marginal';
return 'pill-critical';
}
function fmtWhen(iso) {
if (!iso) return '';
return String(iso).replace('T', ' ').slice(0, 16);
}
async function loadHistory() {
const data = await api('/api/history');
state.history = data.grades;
renderHistory();
}
function renderHistory() {
const rows = state.history;
$('#history-count').textContent = rows.length ? `${rows.length} graded` : '';
$('#history-empty').hidden = rows.length > 0;
$('#history-body').innerHTML = rows.map((g) => `
<tr data-history-row="${g.id}">
<td>
<div class="cardcell">
${g.thumbnail ? `<img src="${g.thumbnail}" alt="">` : '<span class="thumb-blank"></span>'}
<div>
<div class="cardcell-name">${esc(g.label || g.card_note || 'Untitled card')}</div>
<div class="cardcell-meta">${g.card_type ? `${esc(TYPE_LABEL[g.card_type] || 'Card')} · ` : ''}${g.image_count || 1} photo(s)</div>
</div>
</div>
</td>
<td><span class="pill ${gradePillClass(g.estimated_grade)}">${
g.estimated_grade === null ? 'n/a' : `PSA ${g.estimated_grade}`}</span></td>
<td class="cardcell-meta">${esc(g.confidence || '')}</td>
<td class="cardcell-meta">${esc(fmtWhen(g.created_at))}</td>
<td class="num"><button class="btn btn-quiet btn-sm" data-history-delete="${g.id}">Delete</button></td>
</tr>`).join('');
}
function renderEntryModal(g) {
$('#entry-modal').innerHTML = `
<div class="panel-head">
<h2>${esc(g.label || g.card_note || 'Untitled card')}</h2>
<button class="close" id="entry-close">&times;</button>
</div>
<div class="cardcell-meta" style="margin-bottom:10px">${esc(fmtWhen(g.created_at))} ·
${g.usage && g.usage.model ? esc(g.usage.model) : ''}</div>
${renderGradeBlock(g)}
<div class="drawer-actions">
<input class="input" id="entry-label" placeholder="Rename this card…" value="${esc(g.label || '')}" style="flex:1 1 200px">
<button class="btn btn-quiet btn-sm" id="entry-save-label">Save name</button>
<span class="spacer"></span>
<button class="btn btn-danger btn-sm" data-history-delete="${g.id}">Delete</button>
</div>`;
$('#entry-modal').hidden = false;
$('#entry-scrim').hidden = false;
}
function closeEntryModal() {
$('#entry-modal').hidden = true;
$('#entry-scrim').hidden = true;
}
$('#history-body').addEventListener('click', async (e) => {
const del = e.target.closest('[data-history-delete]');
if (del) {
e.stopPropagation();
await api(`/api/history/${del.dataset.historyDelete}`, { method: 'DELETE' });
await loadHistory();
return;
}
const row = e.target.closest('[data-history-row]');
if (row) {
try {
const data = await api(`/api/history/${row.dataset.historyRow}`);
renderEntryModal(data.grade);
} catch (err) {
banner(err.message, true);
}
}
});
$('#entry-modal').addEventListener('click', async (e) => {
if (e.target.closest('#entry-close')) return closeEntryModal();
const del = e.target.closest('[data-history-delete]');
if (del) {
await api(`/api/history/${del.dataset.historyDelete}`, { method: 'DELETE' });
closeEntryModal();
await loadHistory();
return;
}
if (e.target.closest('#entry-save-label')) {
const id = $('#entry-modal').querySelector('[data-history-delete]').dataset.historyDelete;
const label = $('#entry-label').value.trim();
await api(`/api/history/${id}`, { method: 'PATCH', body: { label } });
closeEntryModal();
await loadHistory();
banner('Renamed.');
}
});
$('#entry-scrim').addEventListener('click', closeEntryModal);
/* -------------------------------------------------------------- settings */
async function openSettings() {
const s = state.settings;
try { state.modelGuide = await api('/api/vision-models'); } catch (_) { state.modelGuide = {}; }
const modelOptions = Object.entries(state.modelGuide).map(([id, info]) =>
`<option value="${id}" ${s.vision_model === id ? 'selected' : ''}>
${esc(info.label)} ~$${info.per_grade.toFixed(3)}/grade</option>`).join('');
const locked = s.settings_locked;
$('#settings-modal').innerHTML = `
<div class="panel-head">
<h2>Settings</h2>
<button class="close" id="settings-close">&times;</button>
</div>
<div class="section">
<h3>Your API key</h3>
<div class="fields">
<div class="field wide">
<label>Anthropic API key (this browser only)</label>
<input class="input" id="set-my-key" type="password" value="${esc(myApiKey() || '')}"
placeholder="sk-ant-...">
<span class="suffix">Stored only in this browser and sent with your own grading
requests never saved on the server. Get one at console.anthropic.com.
${s.server_key_configured
? 'This server already has a key set up, so you can leave this blank and use that instead — but then the owner pays for your grades.'
: 'This server has no key of its own, so you need one here to grade anything.'}</span>
</div>
</div>
</div>
<div class="section">
<h3>Server settings${locked ? ' <span class="pill pill-raw">locked</span>' : ''}</h3>
${locked ? `<div class="note">This server is shared, so its settings are read-only.
Use your own key above.</div>` : `
<div class="fields">
<div class="field wide">
<label>Server API key (used when a visitor has none)</label>
<input class="input" id="set-api-key" type="password"
placeholder="${s.server_key_configured ? '•••••••• already set — type to replace' : 'sk-ant-...'}">
<span class="suffix">Never sent back to the browser once saved. Leave blank to keep
the current one.</span>
</div>
<div class="field wide">
<label>Vision model</label>
<select id="set-model">${modelOptions}</select>
<span class="suffix">Sonnet 5 is the default for a tested reason run head-to-head
against Haiku on the same cards, Haiku misread a PSA centering tolerance and landed
three grades off. Drop to Haiku only if cost matters more than accuracy to you.</span>
</div>
</div>`}
</div>
<div class="drawer-actions">
<button class="btn" id="settings-save">Save</button>
</div>`;
$('#settings-modal').hidden = false;
$('#modal-scrim').hidden = false;
}
/* A personal key lives in localStorage, never on the server that's what
lets someone use a shared instance without spending the owner's credits. */
function myApiKey() {
try { return localStorage.getItem('cardgrader_api_key') || ''; } catch (_) { return ''; }
}
function setMyApiKey(value) {
try {
if (value) localStorage.setItem('cardgrader_api_key', value);
else localStorage.removeItem('cardgrader_api_key');
} catch (_) { /* private browsing — the field just won't persist */ }
}
function closeSettings() {
$('#settings-modal').hidden = true;
$('#modal-scrim').hidden = true;
}
async function saveSettings() {
setMyApiKey($('#set-my-key').value.trim());
// Only push server settings when this instance allows it, and only send a
// key when one was actually typed — an empty box means "leave it alone",
// not "erase it".
const serverKeyField = $('#set-api-key');
if (serverKeyField) {
const payload = { vision_model: $('#set-model').value };
const typed = serverKeyField.value.trim();
if (typed) payload.anthropic_api_key = typed;
state.settings = await api('/api/settings', { method: 'POST', body: payload });
}
closeSettings();
updateModelCostHint();
banner('Settings saved.');
}
function updateModelCostHint() {
const info = state.modelGuide[state.settings.vision_model];
$('#model-cost-hint').textContent = info ? `~$${info.per_grade.toFixed(3)} per grade (${info.label})` : '';
}
$('#btn-settings').addEventListener('click', () => openSettings().catch((err) => banner(err.message, true)));
$('#settings-modal').addEventListener('click', (e) => {
if (e.target.closest('#settings-close')) return closeSettings();
if (e.target.closest('#settings-save')) saveSettings().catch((err) => banner(err.message, true));
});
$('#modal-scrim').addEventListener('click', closeSettings);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { closeSettings(); closeEntryModal(); }
});
/* ---------------------------------------------------------------- load */
async function load() {
state.settings = await api('/api/settings');
try { state.modelGuide = await api('/api/vision-models'); } catch (_) { state.modelGuide = {}; }
updateModelCostHint();
await loadHistory();
if (!state.settings.server_key_configured && !myApiKey()) {
banner('Add your Anthropic API key in Settings before grading a card.');
}
}
load().catch((err) => banner(`Could not load: ${err.message}`, true));
/* ------------------------------------------------------------------ pwa */
//
// Service workers only run in a secure context: https, or localhost. Over a
// plain LAN address (http://192.168.x.x) registration silently fails and the
// app stays a normal web page — which is exactly why the README pushes you
// through a tunnel rather than just handing friends your LAN IP.
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register(APP_BASE + '/sw.js').catch(() => {
/* Not fatal — the app works fine uninstalled. */
});
});
}
// Android/Chrome fires this instead of showing its own prompt, so the offer
// has to be surfaced deliberately. iOS never fires it: Safari has no
// programmatic install, only the manual Share > Add to Home Screen, so the
// hint below covers that case instead of pretending a button exists.
let deferredInstall = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredInstall = e;
const btn = $('#btn-install');
if (btn) btn.hidden = false;
});
document.addEventListener('click', async (e) => {
if (!e.target.closest('#btn-install')) return;
if (!deferredInstall) return;
deferredInstall.prompt();
await deferredInstall.userChoice;
deferredInstall = null;
$('#btn-install').hidden = true;
});

BIN
static/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 981 B

BIN
static/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
static/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

6
static/icon.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<rect width="128" height="128" rx="24" fill="#16224a"/>
<rect x="34" y="24" width="60" height="80" rx="8" fill="#2f6fd0" stroke="#35c6a8" stroke-width="3"/>
<path d="M48 66 L60 78 L84 50" fill="none" stroke="#ffffff" stroke-width="7"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 367 B

90
static/index.html Normal file
View file

@ -0,0 +1,90 @@
<!doctype html>
<html lang="en" data-base="__BASE__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Card Grader</title>
<link rel="manifest" href="__BASE__/static/manifest.json">
<link rel="icon" href="__BASE__/static/icon.svg">
<meta name="theme-color" content="#16224a">
<!-- iOS ignores the web app manifest for install behaviour and needs its own
meta tags. Without these, "Add to Home Screen" on an iPhone produces a
bookmark that opens in Safari with the address bar showing, rather than
something that looks and behaves like an installed app. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Card Grader">
<link rel="apple-touch-icon" href="__BASE__/static/icon-180.png">
<link rel="stylesheet" href="__BASE__/static/style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark"></span>
<h1>Card Grader</h1>
</div>
<div class="topbar-actions">
<button id="btn-install" class="btn" hidden>Install app</button>
<button id="btn-settings" class="btn btn-quiet">Settings</button>
</div>
</header>
<div id="banner" class="banner" hidden></div>
<main>
<!-- ------------------------------------------------------------- grade -->
<section class="panel">
<div class="panel-head">
<h2>Grade a card</h2>
<span class="hint" id="model-cost-hint"></span>
</div>
<div class="note">Front straight-on is the minimum. Adding the back lets it judge
back centering, and it can only tell a print line from a crease — about five PSA
grades apart — if it can check both sides.</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px">
<button id="btn-grade" class="btn">Choose photo(s)…</button>
<input id="grade-label" class="input" placeholder="Card name (optional, for your history)" style="flex:1 1 220px">
</div>
<input id="grade-file" type="file" accept="image/*" multiple hidden>
<div id="grade-status" class="search-status" hidden></div>
<div id="grade-review"></div>
</section>
<!-- ----------------------------------------------------------- history -->
<section class="panel">
<div class="panel-head">
<h2>History</h2>
<span class="hint" id="history-count"></span>
</div>
<div class="table-scroll">
<table class="grid" id="history-table">
<thead>
<tr>
<th class="col-card">Card</th>
<th>Grade</th>
<th>Confidence</th>
<th>When</th>
<th></th>
</tr>
</thead>
<tbody id="history-body"></tbody>
</table>
</div>
<p id="history-empty" class="empty" hidden>Nothing graded yet.</p>
</section>
</main>
<!-- ----------------------------------------------------------- settings -->
<div id="modal-scrim" class="scrim" hidden></div>
<div id="settings-modal" class="modal" hidden role="dialog" aria-label="Settings"></div>
<!-- ------------------------------------------------------- history entry -->
<div id="entry-scrim" class="scrim" hidden></div>
<div id="entry-modal" class="modal" hidden role="dialog" aria-label="Grade detail"></div>
<script src="__BASE__/static/app.js"></script>
</body>
</html>

17
static/manifest.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "Card Grader",
"short_name": "Card Grader",
"description": "Estimate a trading card's PSA grade from photos.",
"start_url": "__BASE__/",
"scope": "__BASE__/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0a0f1c",
"theme_color": "#16224a",
"icons": [
{ "src": "__BASE__/static/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "__BASE__/static/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "__BASE__/static/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "__BASE__/static/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }
]
}

367
static/style.css Normal file
View file

@ -0,0 +1,367 @@
/* Card Grader neutral theme, card-type agnostic (Pokemon, sports, TCG).
Colour discipline:
- Grade severities ride a lightness ladder (none -> minor -> moderate ->
major), so the table still reads as escalating severity in greyscale.
- Red is reserved for "you should distrust this number" (unmeasurable
edges, a low grade) never decorative.
- Light and dark are separately selected steps, not an inverted flip. */
:root {
color-scheme: light;
--surface-1: #ffffff;
--plane: #eef2fa;
--ink: #101828;
--ink-2: #475467;
--muted: #7a8699;
--hairline: #dfe6f2;
--rule: #c3cede;
--accent: #2f6fd0;
--accent-2: #35c6a8; /* mark accent — fills only, never text on light */
--accent-soft: #dbe8fc;
--good: #10a44a;
--good-text: #077a35;
--warning: #e8912f;
--critical: #d63a3a;
--header-1: #16224a;
--header-2: #24407e;
--header-3: #2f6fd0;
--ring: rgba(16, 24, 40, .10);
--shadow: 0 1px 2px rgba(16,24,40,.06), 0 8px 24px rgba(16,24,40,.10);
--radius: 13px;
}
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme="light"])) {
color-scheme: dark;
--surface-1: #141c2f;
--plane: #0a0f1c;
--ink: #ffffff;
--ink-2: #b6c2d6;
--muted: #8592a8;
--hairline: #253150;
--rule: #35446a;
--accent: #4b8ee8;
--accent-2: #45dcbb;
--accent-soft: #1d3a66;
--good: #22c55e;
--good-text: #34d36a;
--warning: #ffa64d;
--critical: #ef5350;
--header-1: #0d1530;
--header-2: #17285a;
--header-3: #1f4488;
--ring: rgba(255,255,255,.12);
--shadow: 0 1px 2px rgba(0,0,0,.45), 0 8px 28px rgba(0,0,0,.55);
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background-color: var(--plane);
background-image: radial-gradient(circle at 1px 1px, var(--hairline) 1px, transparent 0);
background-size: 22px 22px;
color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { margin: 0; font-weight: 600; }
h1 { font-size: 16px; letter-spacing: -.01em; }
h2 { font-size: 15px; }
h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 600; }
main {
max-width: 900px;
margin: 0 auto;
padding: 20px 24px 80px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ------------------------------------------------------------- topbar */
.topbar {
position: sticky; top: 0; z-index: 20;
display: flex; align-items: center; justify-content: space-between;
gap: 16px; flex-wrap: wrap;
padding: 13px 24px;
background: linear-gradient(100deg, var(--header-1), var(--header-2) 55%, var(--header-3));
color: #fff;
/* Holo-foil edge: the rainbow sweep you get tilting a refractor. Purely
decorative and it encodes nothing, so it's free to be vivid here where
no number lives. */
border-bottom: 3px solid transparent;
border-image: linear-gradient(90deg,
#ff5f8f, #ffb347, #ffe66d, #35c6a8, #4b8ee8, #a97bff, #ff5f8f) 1;
}
.topbar h1 { color: #fff; letter-spacing: .01em; }
.topbar .btn {
background: var(--accent-2); color: #06231d;
border-color: transparent; font-weight: 600;
}
.topbar .btn:hover { filter: brightness(1.06); }
.topbar .btn-quiet {
background: rgba(255,255,255,.14); color: #fff;
border-color: rgba(255,255,255,.38); font-weight: 500;
}
.topbar .btn-quiet:hover { background: rgba(255,255,255,.26); filter: none; }
.brand { display: flex; align-items: center; gap: 11px; }
/* Brand mark: a graded slab, drawn in plain CSS. A rounded card outline with
a corner clipped and a check "this one's been looked at" rather than
any single game's iconography, since this grades anything PSA does. */
.brand-mark {
position: relative; flex: none;
width: 26px; height: 26px; border-radius: 6px;
background: linear-gradient(160deg, var(--accent-2), var(--accent));
box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.4), 0 1px 4px rgba(0,0,0,.35);
}
.brand-mark::after {
content: ''; position: absolute; left: 6px; top: 6px;
width: 8px; height: 5px;
border-left: 2px solid #06231d; border-bottom: 2px solid #06231d;
transform: rotate(-45deg);
}
.topbar-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ------------------------------------------------------------ controls */
.btn {
font: inherit; font-weight: 500; font-size: 14px;
padding: 7px 14px; border-radius: 8px; cursor: pointer;
background: var(--accent); color: #fff;
border: 1px solid transparent;
text-decoration: none; display: inline-block; line-height: 1.4;
white-space: nowrap;
}
.btn:hover { filter: brightness(1.07); }
.btn:active { transform: translateY(.5px); }
.btn[disabled] { opacity: .5; cursor: default; filter: none; }
.btn-quiet {
background: var(--surface-1); color: var(--ink);
border-color: var(--rule);
}
.btn-quiet:hover { background: var(--plane); filter: none; }
.btn-danger { background: transparent; color: var(--critical); border-color: var(--critical); }
.btn-danger:hover { background: var(--critical); color: #fff; filter: none; }
.btn-sm { padding: 4px 10px; font-size: 13px; }
.input, select, textarea {
font: inherit; font-size: 14px;
padding: 7px 10px;
border: 1px solid var(--rule);
border-radius: 8px;
background: var(--surface-1);
color: var(--ink);
width: 100%;
}
.input:focus, select:focus, textarea:focus {
outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent;
}
textarea { resize: vertical; min-height: 60px; }
/* --------------------------------------------------------------- tiles */
.tile-value { font-size: 25px; font-weight: 700; letter-spacing: -.02em; }
.tile-sub { font-size: 12px; color: var(--ink-2); margin-top: 4px; }
.pos { color: var(--good-text); }
.neg { color: var(--critical); }
/* -------------------------------------------------------------- panels */
.panel {
background: var(--surface-1);
border: 1px solid var(--hairline);
border-radius: var(--radius);
padding: 16px 18px 18px;
box-shadow: 0 1px 2px rgba(16,24,40,.04);
}
.panel-head {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap; margin-bottom: 12px;
}
.hint { font-size: 13px; color: var(--muted); }
.search-status { margin-top: 10px; font-size: 13px; color: var(--ink-2); }
/* --------------------------------------------------------------- table */
.table-scroll { overflow-x: auto; }
.grid { width: 100%; border-collapse: collapse; font-size: 14px; }
.grid th {
text-align: left; font-size: 11.5px; font-weight: 600;
text-transform: uppercase; letter-spacing: .05em; color: var(--muted);
padding: 8px 10px; border-bottom: 1px solid var(--rule); white-space: nowrap;
}
.grid td {
padding: 9px 10px; border-bottom: 1px solid var(--hairline);
vertical-align: middle;
}
.grid tbody tr:hover { background: var(--accent-soft); }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.cardcell { display: flex; align-items: center; gap: 10px; }
.cardcell img, .cardcell .thumb-blank {
width: 40px; height: 56px; object-fit: cover;
border-radius: 4px; background: var(--plane); flex: none;
}
.thumb-blank {
display: inline-block;
border: 1px dashed var(--rule);
background:
repeating-linear-gradient(135deg, transparent 0 5px, var(--hairline) 5px 6px),
var(--plane);
}
.cardcell-name { font-weight: 600; line-height: 1.25; }
.cardcell-meta { font-size: 12px; color: var(--muted); }
.dash { color: var(--muted); }
/* --------------------------------------------------------------- pills */
.pill {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 600;
padding: 3.5px 10px; border-radius: 99px;
border: 1px solid var(--rule); color: var(--ink-2);
white-space: nowrap;
}
.pill-grade { border-color: var(--good); color: var(--good-text);
background: color-mix(in srgb, var(--good) 12%, transparent); }
.pill-marginal { border-color: var(--warning);
background: color-mix(in srgb, var(--warning) 14%, transparent); }
.pill-raw { border-color: var(--rule); color: var(--muted); }
.pill-critical { border-color: var(--critical); color: var(--critical);
background: color-mix(in srgb, var(--critical) 12%, transparent); }
.empty { text-align: center; color: var(--muted); padding: 28px 0 8px; font-size: 14px; }
/* -------------------------------------------------------------- modal */
.scrim {
position: fixed; inset: 0; z-index: 30;
background: rgba(11,11,11,.32);
}
.modal {
position: fixed; z-index: 40;
top: 50%; left: 50%; transform: translate(-50%, -50%);
width: min(560px, calc(100vw - 32px));
max-height: min(86vh, 900px); overflow-y: auto;
background: var(--surface-1);
border: 1px solid var(--hairline);
border-radius: 14px;
box-shadow: var(--shadow);
padding: 20px 22px;
}
.close {
background: none; border: none; cursor: pointer; font-size: 22px;
color: var(--muted); line-height: 1; padding: 0 4px;
}
.close:hover { color: var(--ink); }
.section { margin-bottom: 20px; }
.section > h3 { margin-bottom: 10px; }
.fields { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field.wide { grid-column: 1 / -1; }
.field label { font-size: 12px; color: var(--ink-2); }
.suffix { display: block; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
.drawer-actions {
display: flex; gap: 8px; flex-wrap: wrap;
padding-top: 14px; border-top: 1px solid var(--hairline);
}
.spacer { flex: 1; }
.note {
font-size: 12.5px; color: var(--ink-2);
background: var(--plane); border: 1px solid var(--hairline);
border-left: 3px solid var(--warning);
border-radius: 6px; padding: 9px 11px; margin-top: 10px;
}
.note-flag {
border-left-color: var(--critical);
color: var(--critical);
margin-top: 0; margin-bottom: 12px;
}
/* -------------------------------------------------------------- banner */
.banner {
position: sticky; top: 57px; z-index: 15;
padding: 10px 24px; font-size: 13.5px;
background: var(--accent-soft); color: var(--ink);
border-bottom: 1px solid var(--hairline);
}
.banner.err { background: var(--critical); color: #fff; }
.banner ul { margin: 4px 0 0; padding-left: 18px; }
/* ---------------------------------------------------------- grade slab */
/* Styled after the label on a graded slab, because that's the thing this
whole app is estimating it makes the number read as a verdict rather
than as one more statistic on the page. */
.slab {
display: flex; align-items: stretch; gap: 0;
border-radius: 10px; overflow: hidden;
border: 1px solid var(--rule);
background: var(--surface-1);
box-shadow: var(--shadow);
max-width: 420px;
}
.slab-main {
flex: 1; padding: 12px 14px;
display: flex; flex-direction: column; justify-content: center; gap: 2px;
}
.slab-title { font-size: 13px; font-weight: 700; line-height: 1.25; }
.slab-sub { font-size: 11.5px; color: var(--muted); }
.slab-grade {
flex: none; width: 104px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 10px 8px; color: #fff; text-align: center;
background: linear-gradient(150deg, var(--header-2), var(--header-3));
}
.slab-grade .n { font-size: 34px; font-weight: 800; line-height: 1; letter-spacing: -.02em; }
.slab-grade .word { font-size: 9.5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; opacity: .85; margin-top: 3px; }
/* Grade bands. Green for a card worth submitting, amber mid, red low
the same red reserved elsewhere for "distrust this", used here because a
low grade is genuinely the bad outcome. */
.slab-10 .slab-grade, .slab-9 .slab-grade { background: linear-gradient(150deg, #0d7a3a, #10a44a); }
.slab-8 .slab-grade, .slab-7 .slab-grade { background: linear-gradient(150deg, #1b6fbf, #2f6fd0); }
.slab-6 .slab-grade, .slab-5 .slab-grade { background: linear-gradient(150deg, #b4700f, #e8912f); }
.slab-low .slab-grade { background: linear-gradient(150deg, #9e2626, #d63a3a); }
/* A 10 gets the foil treatment. Deliberately reserved for the top grade so
it stays meaningful every card shimmering would say nothing. */
.slab-10 .slab-grade {
background: linear-gradient(140deg, #0d7a3a, #35c6a8 40%, #4b8ee8 70%, #a97bff);
}
.card-type-tag {
display: inline-block; font-size: 10.5px; font-weight: 700;
letter-spacing: .07em; text-transform: uppercase;
padding: 2px 7px; border-radius: 4px;
background: var(--accent-soft); color: var(--accent);
border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
}
/* --------------------------------------------------------------- phone */
@media (max-width: 700px) {
main { padding: 14px 12px 60px; }
.topbar { padding: 10px 12px; }
.fields { grid-template-columns: 1fr; }
.grid { font-size: 13.5px; }
}

62
static/sw.js Normal file
View file

@ -0,0 +1,62 @@
/* Service worker the piece that makes this installable as a real app.
*
* Deliberately minimal, and deliberately network-first for everything.
* Caching the shell aggressively is the usual PWA advice, but here it would
* mean shipping a stale UI against a changed API and calling it offline
* support this app cannot do anything useful without the network anyway,
* since grading is a live API call. So the cache exists only as a fallback
* for the app shell when the connection drops, and never for /api/.
*/
// Substituted server-side to the path this app is mounted under (e.g.
// "/cards"), or left empty when served from the domain root — see
// app.py's _static_templated. Every absolute reference below has to go
// through this, since a service worker has no page URL of its own to
// resolve relative paths against.
const BASE = '__BASE__';
const CACHE = 'card-grader-v1';
const SHELL = [
`${BASE}/`,
`${BASE}/static/app.js`,
`${BASE}/static/style.css`,
`${BASE}/static/icon.svg`,
`${BASE}/static/manifest.json`,
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
const url = new URL(request.url);
// Never cache the API. A stale grade or a stale settings blob would be
// worse than an honest failure.
if (url.pathname.startsWith(`${BASE}/api/`)) return;
event.respondWith(
fetch(request)
.then((response) => {
if (response && response.ok && url.origin === self.location.origin) {
const copy = response.clone();
caches.open(CACHE).then((cache) => cache.put(request, copy));
}
return response;
})
.catch(() => caches.match(request).then((hit) => hit || caches.match(`${BASE}/`)))
);
});

202
store.py Normal file
View file

@ -0,0 +1,202 @@
"""SQLite: settings and a log of past grade estimates.
One database, two tables. There is no inventory or pricing here this app
does exactly one thing (estimate a PSA grade from photos) and remembers what
it told you, so you can look back at a card without re-running the estimate.
"""
import json
import os
import sqlite3
import time
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# Overridable so a container can point this at a mounted volume (e.g.
# /data/grades.db) instead of the app's own directory, which is what makes
# the data survive a container recreate/image update.
DB_PATH = os.environ.get("CARD_GRADER_DB_PATH") or os.path.join(BASE_DIR, "grades.db")
SCHEMA = """
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS grades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
label TEXT, -- your own name for the card, optional
card_type TEXT, -- pokemon | sports | other_tcg | other
card_note TEXT, -- what the model read off the card
image_count INTEGER,
thumbnail TEXT, -- small JPEG, base64 first photo only
model TEXT,
estimated_grade INTEGER,
grade_low INTEGER,
grade_high INTEGER,
confidence TEXT,
categories_json TEXT,
edge_measurements_json TEXT,
centering_measurement_json TEXT,
limitations_json TEXT,
note TEXT,
estimated_cost REAL,
usage_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_grades_created ON grades(created_at DESC);
"""
DEFAULT_SETTINGS = {
"anthropic_api_key": "",
"vision_model": "claude-sonnet-5",
"vision_effort": "low",
}
def connect():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init():
conn = connect()
try:
conn.executescript(SCHEMA)
for key, value in DEFAULT_SETTINGS.items():
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
(key, json.dumps(value)),
)
conn.commit()
finally:
conn.close()
def now():
return time.strftime("%Y-%m-%dT%H:%M:%S")
# --------------------------------------------------------------- settings
def get_settings():
conn = connect()
try:
rows = conn.execute("SELECT key, value FROM settings").fetchall()
finally:
conn.close()
out = dict(DEFAULT_SETTINGS)
for row in rows:
try:
out[row["key"]] = json.loads(row["value"])
except (ValueError, TypeError):
out[row["key"]] = row["value"]
return out
def save_settings(updates):
conn = connect()
try:
for key, value in updates.items():
if key not in DEFAULT_SETTINGS:
continue
conn.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, json.dumps(value)),
)
conn.commit()
finally:
conn.close()
return get_settings()
# ------------------------------------------------------------------ grades
def save_grade(grade, thumbnail=None, label=None):
"""Persist one grading result. Returns the new row's id."""
conn = connect()
try:
cur = conn.execute(
"INSERT INTO grades "
"(created_at, label, card_type, card_note, image_count, thumbnail, "
" model, estimated_grade, "
" grade_low, grade_high, confidence, categories_json, "
" edge_measurements_json, centering_measurement_json, "
" limitations_json, note, estimated_cost, usage_json) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
now(), label, grade.get("card_type"), grade.get("card_note"),
grade.get("image_count"), thumbnail,
(grade.get("usage") or {}).get("model"),
grade.get("estimated_grade"), grade.get("grade_low"),
grade.get("grade_high"), grade.get("confidence"),
json.dumps(grade.get("categories")),
json.dumps(grade.get("edge_measurements")),
json.dumps(grade.get("centering_measurement")),
json.dumps(grade.get("limitations")),
grade.get("note"), grade.get("estimated_cost"),
json.dumps(grade.get("usage")),
),
)
conn.commit()
return cur.lastrowid
finally:
conn.close()
def _row_to_grade(row):
d = dict(row)
for key in ("categories_json", "edge_measurements_json",
"centering_measurement_json", "limitations_json", "usage_json"):
out_key = key[:-len("_json")]
raw = d.pop(key, None)
try:
d[out_key] = json.loads(raw) if raw else None
except (ValueError, TypeError):
d[out_key] = None
return d
def list_grades(limit=200):
conn = connect()
try:
rows = conn.execute(
"SELECT * FROM grades ORDER BY created_at DESC, id DESC LIMIT ?",
(limit,),
).fetchall()
finally:
conn.close()
return [_row_to_grade(r) for r in rows]
def get_grade(grade_id):
conn = connect()
try:
row = conn.execute("SELECT * FROM grades WHERE id = ?", (grade_id,)).fetchone()
finally:
conn.close()
return _row_to_grade(row) if row else None
def update_grade_label(grade_id, label):
conn = connect()
try:
conn.execute("UPDATE grades SET label = ? WHERE id = ?", (label, grade_id))
conn.commit()
finally:
conn.close()
return get_grade(grade_id)
def delete_grade(grade_id):
conn = connect()
try:
conn.execute("DELETE FROM grades WHERE id = ?", (grade_id,))
conn.commit()
finally:
conn.close()

719
vision.py Normal file
View file

@ -0,0 +1,719 @@
"""Estimate a trading card's PSA grade from photographs, using Claude's vision.
This is the one part of the app that costs money per use, and the one part
that can be confidently wrong a model eyeballing a photo can miss real wear
or invent damage that isn't there. The contract here is deliberately narrow:
* it returns an *estimate*, with a range and a confidence, never a verdict
* every category can honestly say "cannot_assess" rather than guess
* two of PSA's four categories (centering, edge whitening) are measured
directly from the pixels in cardimage.py and handed to the model as
numbers, rather than asked for by eye see that module for why
Works on any trading card: Pokemon, sports, Magic, whatever PSA grades.
"""
import base64
import json
import os
import cardimage
try:
import anthropic
except ImportError: # keeps the rest of the app importable without the SDK
anthropic = None
# Per-model capabilities. These differ in ways that are 400 errors, not
# preferences, so the request is built from this table rather than assuming a
# single shape:
# * `effort` is rejected outright by Haiku 4.5 — on that model "cheapest"
# means omitting `thinking`, which turns thinking off entirely.
# * `fallbacks` (server-side refusal recovery) only applies to the Opus/Fable
# tier; sending it elsewhere isn't supported.
# * image token cost differs because Haiku caps images at 1568px on the long
# edge while Sonnet 5 and Opus 5 accept 2576px. Oversized images are scaled
# down server-side, so there's nothing to do client-side either way.
MODELS = {
"claude-haiku-4-5": {
"label": "Haiku 4.5 — cheapest",
"effort": False, # sending output_config.effort is a 400
"adaptive_thinking": False,
"fallbacks": False,
"in_per_mtok": 1.00, "out_per_mtok": 5.00,
"approx_image_tokens": 1600,
},
"claude-sonnet-5": {
"label": "Sonnet 5 — balanced",
"effort": True,
"adaptive_thinking": True,
"fallbacks": False,
# Introductory pricing runs through 2026-08-31, then 3.00 / 15.00.
"in_per_mtok": 2.00, "out_per_mtok": 10.00,
"approx_image_tokens": 4800,
},
"claude-opus-5": {
"label": "Opus 5 — most accurate",
"effort": True,
"adaptive_thinking": True,
"fallbacks": True,
"in_per_mtok": 5.00, "out_per_mtok": 25.00,
"approx_image_tokens": 4800,
},
}
# Grading rewards the extra reasoning a thinking-capable model does — telling
# a print line from a crease, a reflection from real whitening — so this
# defaults to Sonnet regardless of what a cost-conscious default might
# otherwise pick. Tested directly against Haiku on the same cards: Haiku
# inverted a PSA centering-tolerance comparison and missed a grade by three
# levels on a card Sonnet read correctly. The per-grade cost difference is a
# few cents; a wrong grade estimate costs more than that.
DEFAULT_MODEL = "claude-sonnet-5"
DEFAULT_EFFORT = "low"
# On thinking-capable models this budget covers thinking *and* the JSON, since
# max_tokens caps their sum. On Haiku there's no thinking, so it's just the JSON.
MAX_TOKENS = 8000
SUPPORTED_MEDIA = {
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"gif": "image/gif", "webp": "image/webp",
}
class VisionError(Exception):
pass
def available():
"""Is the feature usable right now?"""
return anthropic is not None and bool(_api_key())
def _api_key():
return os.environ.get("ANTHROPIC_API_KEY", "").strip()
def media_type(filename, image_bytes=None):
"""The MIME type to declare for an image.
Prefers what the bytes actually are over what the filename claims
filenames arriving from a phone are frequently useless (no extension, a
content:// URI, a .HEIC that is really being converted upstream), and
refusing a readable image because of its name is the wrong failure.
"""
if image_bytes:
sniffed = cardimage.sniff_format(image_bytes)
if sniffed in cardimage.DIRECTLY_SUPPORTED:
return "image/{}".format(sniffed)
ext = (filename or "").rsplit(".", 1)[-1].lower()
return SUPPORTED_MEDIA.get(ext)
def _call_vision(images, system, schema, prompt, api_key=None, model=None,
effort=None, max_tokens=None, labels=None):
"""Shared plumbing for every vision call: auth, request shape per model
capability, and error/refusal handling. Returns (parsed_json, usage_dict).
Raises VisionError with a human-readable message on any failure callers
surface it in the UI rather than half-committing anything.
"""
if anthropic is None:
raise VisionError(
"The anthropic package isn't installed. Run: "
"python3 -m pip install --user anthropic"
)
if not images:
raise VisionError("No images to analyze.")
key = (api_key or _api_key()) or None
if not key:
raise VisionError(
"No Anthropic API key set. Add one in Settings, or export "
"ANTHROPIC_API_KEY before starting the app."
)
model = model if model in MODELS else DEFAULT_MODEL
caps = MODELS[model]
effort = effort or DEFAULT_EFFORT
client = anthropic.Anthropic(api_key=key)
content = []
for index, (image_bytes, filename) in enumerate(images):
mime = media_type(filename, image_bytes)
if not mime:
raise VisionError(
"Unsupported image type '{}'. Use PNG, JPEG, GIF or WebP.".format(filename)
)
# A caption immediately before its image is far more reliable than
# describing the running order once up front — with nine images in a
# grading request, positional bookkeeping is exactly what a model
# loses track of, and mislabelling which edge is worn is worse than
# not reporting it.
if labels and index < len(labels) and labels[index]:
content.append({"type": "text", "text": labels[index]})
encoded = base64.standard_b64encode(image_bytes).decode("utf-8")
content.append({"type": "image",
"source": {"type": "base64", "media_type": mime, "data": encoded}})
content.append({"type": "text", "text": prompt})
params = {
"model": model,
"max_tokens": max_tokens or MAX_TOKENS,
"system": system,
"output_config": {"format": {"type": "json_schema", "schema": schema}},
"messages": [{"role": "user", "content": content}],
}
if caps["effort"]:
# Effort is the right lever for these models. Deliberately not
# disabling thinking here: the cheap path is Haiku (which has no
# thinking at all), and a thinking-disabled Opus gives up the exact
# capability you'd be paying for.
params["output_config"]["effort"] = effort
try:
if caps["fallbacks"]:
# Safety classifiers can decline a request outright; a fallback
# re-runs it on another model server-side instead of failing.
response = client.beta.messages.create(
betas=["server-side-fallback-2026-07-01"],
fallbacks="default",
**params,
)
else:
response = client.messages.create(**params)
except anthropic.AuthenticationError:
raise VisionError("Anthropic rejected the API key. Check it in Settings.")
except anthropic.PermissionDeniedError:
raise VisionError("That API key doesn't have access to {}.".format(model))
except anthropic.RateLimitError:
raise VisionError("Anthropic is rate-limiting you. Wait a moment and retry.")
except anthropic.BadRequestError as exc:
raise VisionError("Anthropic rejected the request: {}".format(exc))
except anthropic.APIConnectionError:
raise VisionError("Couldn't reach Anthropic. Check your connection.")
except anthropic.APIStatusError as exc:
raise VisionError("Anthropic error {}: {}".format(exc.status_code, exc))
# A refusal returns HTTP 200 with empty/partial content — check before reading.
if response.stop_reason == "refusal":
raise VisionError(
"Claude declined to analyze this image. Try a different photo."
)
if response.stop_reason == "max_tokens":
raise VisionError(
"The response was cut off. Try fewer/simpler images at a time."
)
text = next((b.text for b in response.content if b.type == "text"), None)
if not text:
raise VisionError("Claude returned no readable result for this image.")
try:
parsed = json.loads(text)
except ValueError:
raise VisionError("Claude's response wasn't valid JSON.")
usage = response.usage
return parsed, {
"input_tokens": getattr(usage, "input_tokens", None),
"output_tokens": getattr(usage, "output_tokens", None),
"model": response.model,
}
GRADING_SYSTEM = """You estimate what PSA grade a trading card would likely \
receive, from photograph(s) of it. The card may be Pokemon, another trading \
card game, or a sports card PSA grades all of them on the same four \
criteria.
Be honest about the ceiling on this. Graders work with the physical card under \
raking light and magnification; you have a photo. Surface scratches, print \
lines, dimples, and light edge whitening are frequently invisible in a normal \
photo especially a seller's listing photo, which is often deliberately lit \
to hide them. Centering is the one thing a straight-on photo shows reliably. \
So: assess what you can actually see, say plainly what you cannot, and let \
the estimate range reflect that uncertainty rather than projecting false \
precision.
PSA weighs four things. Assess each one separately:
- CENTERING: the ratio of the border widths on opposing sides. PSA publishes \
hard tolerances for this, applied to the FRONT (the back is judged far more \
leniently, 75/25 for a 10 and 90/10 from 9 downwards):
55/45 to 60/40 .... allows a 10
60/40 ............. allows a 9
65/35 ............. allows an 8
70/30 ............. allows a 7
80/20 ............. allows a 6
85/15 ............. allows a 5 or 4
90/10 ............. allows a 3 or 2
Both axes are judged and the WORSE one governs, so a card at 52/48 \
left-to-right but 70/30 top-to-bottom is a 70/30 card and caps at 7.
When a MEASURED CENTERING block is supplied, use those figures they are \
computed from the pixels and are accurate to within a couple of percentage \
points, which is finer than this can be eyeballed. Treat a measured ratio \
as authoritative over your visual impression unless the photo is clearly \
taken at an angle, which stretches one border and invalidates the geometry.
Where no measurement is given, judge it from a straight-on shot only. An \
angled photo distorts borders in exactly the way that mimics or hides a \
centering problem, so say you cannot assess it rather than guessing.
Note that centering is a manufacturing trait, not damage: a badly centred \
card can still be pristine, and PSA may grade it strongly with an OC \
(off-centre) qualifier rather than a low number.
- CORNERS: look for whitening, fraying, softness, or blunting at each of the \
four corners. Sharp corners on all four is 9-10 territory; slight whitening \
visible under magnification but not to the eye is 8-9; obvious whitening or \
rounding drops it further. Assess all four separately and let the worst one \
drive the category graders do not average corners.
When magnified corner close-ups are provided, judge this category from \
them rather than from the full-card photo. They are digitally cropped and \
UPSCALED from that same photo, which means they add no information the \
original didn't contain: they only make existing detail easier to see. So \
treat softness, blur, or smeared edges that look like resampling artefacts \
as artefacts, NOT as card damage. Real corner wear looks like fibrous \
white paper showing through a coloured border, or a visibly blunted or \
bent tip not a uniformly soft edge. If a close-up is simply too blurry \
to tell the difference, say cannot_assess.
- EDGES: look for whitening, nicks, chipping, or roughness along the four \
edges. Assess top, right, bottom and left separately and let the worst one \
drive the category graders do not average edges.
Report whitening you can actually see. A visible white or light band along \
a cut edge is real wear and belongs in this category even if it is thin, \
even if it runs along only part of one side, and even if the rest of the \
card looks clean a single whitened edge is routinely the difference \
between a 9 and a 7. Do not talk yourself out of something visible on the \
grounds that it "might be lighting": if a light band follows the cut line \
consistently, call it. Say cannot_assess only when you genuinely cannot \
see the edge, not when you can see it and are unsure how bad it is.
Context that changes what counts as normal, not whether to report it: \
dark-bordered cards (1971 Topps, many modern chrome/prizm parallels) show \
the same amount of wear far more obviously than light-bordered ones, and \
white-bordered cards can hide it almost entirely so on a white border, \
look for a change in texture or a frayed cut line rather than a colour \
change.
When magnified edge strips are provided, judge this category from them.
- SURFACE: look for scratches, print lines, indentations, creases, staining, \
loss of gloss, or foil/holo scratching.
When SURFACE INSPECTION images are provided, judge this category from them, \
and do not answer cannot_assess without saying which specific thing you \
could not check. Each of those images is one region of the card shown \
TWICE: the untouched crop on the left, and on the right a processed version \
that cancels the artwork and leaves only fine surface texture. A scratch \
that is invisible on the left is often obvious on the right, which is the \
whole point of showing both.
Read them together. The right panel tells you WHERE something is; the left \
panel tells you WHAT it is. Only call something surface damage when it makes \
sense in both: a real scratch is a thin line that runs across artwork and \
text alike, ignoring the picture's own content, and it stays in the same \
place in both panels.
Things that light up in the right panel and are NOT damage do not report \
these: the outline of every letter and number (text edges always glow); the \
border between two areas of artwork; the regular dot or rosette pattern of \
the printing itself; the deliberate texture on holo, foil, etched and \
reverse-holo cards; and blocky square patterns from image compression. If \
the right panel is uniformly busy rather than showing distinct lines, that \
is print texture, not damage.
PRINT LINES are the important exception to that, and they are easy to \
dismiss as holo texture when they are not. A print line is a STRAIGHT band \
running parallel to one edge, usually spanning most or all of the card's \
width or height, of even thickness along its whole length, and it cuts \
straight across artwork, text and background alike without regard for any \
of them. Holo and refractor patterns radiate, swirl, or scatter; a print \
line does not it is mechanical, made by a roller, and looks it. On a \
foil, refractor or chrome card it often shows as a band where the shimmer \
is interrupted or duller than the rest. Look specifically for one across \
each third of the card, name where it runs, and report it PSA treats it \
as a print defect (the PD qualifier) and a pronounced one caps the grade \
regardless of how clean everything else is.
Genuinely unassessable cases still exist heavy glare hiding a whole \
region, a photo out of focus, or a holo pattern so strong it would mask a \
scratch. Say so specifically when that happens. But a normal, in-focus \
photo with these inspection images is enough to reach a real answer, so \
reaching for cannot_assess by default is not the honest choice here it is \
just the uninformative one.
On chrome and refractor stock, expect fine scratching; it is the norm rather \
than the exception, and its absence is what is notable.
HOW PSA COMBINES THE FOUR
The grade is capped by the WORST attribute, not averaged across them. Three \
pristine categories and one clear problem is a card graded on the problem. \
Work out the ceiling each category allows and take the lowest.
What each grade tolerates, in practice:
10 GEM-MT four sharp corners, full original gloss, no staining, sharp \
focus. One slight print imperfection is allowed.
9 MINT essentially a 10 with exactly ONE minor flaw a slight wax \
stain on the back, a minor print imperfection, or slightly \
off-white borders.
8 NM-MT looks 9 at a glance; on close inspection the slightest fraying \
at one or two corners, a minor print imperfection.
7 NM slight surface wear visible on close inspection, slight corner \
fraying, a minor print blemish.
6 EX-MT visible surface wear or a print defect. A very light scratch \
found only on close inspection. Graduated corner fraying. Minor \
edge chipping.
5 EX minor corner rounding becoming evident, more visible surface \
wear, minor chipping at the edges.
4 VG-EX slightly rounded corners with moderate fraying, light scuffing \
or scratching.
3 VG rounded corners, obvious surface wear and scratching.
2 GOOD badly frayed or rounded corners, advanced wear, creasing.
1 PR-FR heavy wear, major creasing, possible writing or tape.
Rules that override the category-by-category read:
- A CREASE is not ordinary surface wear. Any clear crease or fold caps a card \
in the low single digits (roughly 3 or below, 2 if pronounced) no matter how \
clean everything else looks. Because that verdict is so severe, do not reach \
it by elimination separate a crease from a PRINT LINE deliberately, since \
the two look alike in a photo and are about five grades apart:
A PRINT LINE is perfectly straight, of even thickness, runs parallel to an \
edge, and appears only on the printed side. The card is not deformed; the \
ink simply differs along that band. This is a print defect (PD), and on \
its own it does not stop a card grading in the 6-8 range.
A CREASE breaks the card itself. It usually shows a paired light-and-dark \
line where the surface bends and catches light differently, tends to \
wander rather than run perfectly straight, often runs at an angle or fades \
out mid-card, and shows on BOTH sides so if a back photo is supplied and \
the mark is absent there, it is almost certainly not a crease.
When the evidence genuinely does not separate the two, say so and give the \
benefit of the doubt to the print line, noting that a back photo would \
settle it. Do not cap a card at 3 on a maybe.
- A PRINT DEFECT is not handling damage. Print lines, dots, roller marks and \
slight colour registration errors happen at the factory, and PSA tolerates a \
minor one even at 10. Do not grade these like scratches and wear; note them \
separately. Severe ones do drag the grade and may earn a PD qualifier.
- The BACK is graded too, and you usually cannot see it. When only a front \
photo is given, say so in limitations: a back-only flaw such as a wax stain \
or poor back centering is invisible to you and can pull the real grade below \
your estimate. This is a reason to keep the range open at the bottom.
- Cards strong everywhere except one attribute may receive a QUALIFIER instead \
of a low grade OC (off-centre), PD (print defect), ST (stain), MK (marks), \
MC (miscut). Worth mentioning when the pattern fits, since a "PSA 8 OC" is a \
different market proposition from a plain PSA 5.
Vintage cards (roughly pre-1980) are graded on the same scale but almost never \
come back 9-10 original cutting and centering were far less consistent, so \
temper the estimate accordingly rather than assuming a clean-looking vintage \
card is a high grade. Conversely, do not penalise a vintage card twice for the \
era-typical soft cut that its grade already accounts for.
WHAT IS NORMAL FOR THIS KIND OF CARD
PSA applies the SAME four criteria and the same centering tolerances to every \
card, so there is no separate rubric to switch to. What changes between card \
types is the base rate how common a given flaw is, and therefore how much \
seeing it (or not seeing it) should move your estimate. Set card_type to what \
you actually see, then calibrate with the notes below. Do not report a flaw \
you cannot see just because it is common; this is about how to weigh what you \
DO see.
If card_type is "sports":
- Wax stains on the back exist only on wax-pack-era cards (roughly 1950s-80s). \
PSA explicitly tolerates a slight one even at 9. Never invent one; if a back \
photo shows a translucent greasy patch, that's what it is.
- Print dots, snow and light print speckling are endemic to late-80s/early-90s \
mass-produced sets. A minor one is a print defect, not handling damage.
- Centering on 1960s-70s Topps is notoriously poor 70/30 or worse is typical \
rather than exceptional, and the measured ratio should drive the grade \
without extra editorialising about it.
- Rough or "diamond" cuts are factory-normal on O-Pee-Chee and some older \
Topps. That's a cut characteristic, not edge wear.
- 1971 Topps and other black-bordered sets show every speck of corner and edge \
wear. Judge the actual amount visible, not the visual impression the border \
creates.
- Modern chrome stock (Prizm, Optic, Select, Topps Chrome) scratches readily; \
fine surface scratching is the norm and its absence is what's notable.
- Tobacco-era cards (T206 and similar, pre-1920) were hand-cut and are \
essentially never well-centred or sharp-cornered. A 5 is a strong grade there.
If card_type is "pokemon" or "other_tcg":
- WOTC-era Pokemon holos (1999-2003, Base through Skyrim) have a holo layer \
that scratches extremely easily. Fine scratching across the holo window is \
close to universal; a genuinely clean one is unusual and worth saying so.
- Dark and black-bordered sets (Team Rocket, Neo Destiny, older Magic) show \
edge whitening dramatically. Again, weigh the amount actually visible.
- Modern Pokemon ultra-rares (VMAX, ex, full art, Trainer Gallery) have \
DELIBERATE textured or etched surfaces. That texture is manufacturing, not \
damage, and must never be reported as scratching or roughness.
- Factory print lines are common on modern holo sheets treat as a print \
defect (PD), not handling wear. See the crease-vs-print-line rule above.
- Yu-Gi-Oh 1st Edition ultra/secret rares frequently bow or warp slightly from \
the foil layer. That is a manufacturing trait, NOT a crease or bend, and \
should not collapse the grade the way a real crease would.
- Japanese Pokemon cards are generally better centred and better cut than \
their English counterparts, so a poorly centred Japanese card is a more \
meaningful finding than the same ratio on an English one.
For each category give a severity: "none" (no issues visible), "minor", \
"moderate", "major", or "cannot_assess" when the photo genuinely doesn't \
support a judgement. Use "cannot_assess" freely it is far more useful than \
a confident guess, and a "none" that really meant "I couldn't see any because \
the photo is too small" is actively misleading.
Then give:
- estimated_grade: your single best estimate, a whole number 1-10.
- grade_low and grade_high: the realistic range this card could come back in, \
given what you could and couldn't assess. If you couldn't assess surface or \
centering, that range should be genuinely wide (e.g. 6-9), not cosmetic.
- confidence: "high" only for a sharp, straight-on, high-resolution photo \
where you could assess all four categories; "medium" when one or two \
categories are unassessable; "low" when the photo mainly supports identifying \
the card rather than grading it.
- limitations: list each specific thing the photo prevented you from checking \
("back not shown, so back centering and back corners are unknown", "resolution \
too low to see print lines or light surface scratches").
Never describe this as what the card *will* grade. It is an estimate of what \
it might grade, from a photo."""
GRADE_CATEGORY_SCHEMA = {
"type": "object",
"properties": {
"severity": {
"type": "string",
"enum": ["none", "minor", "moderate", "major", "cannot_assess"],
},
"observation": {
"type": "string",
"description": "What you specifically saw (or why you couldn't assess it).",
},
},
"required": ["severity", "observation"],
"additionalProperties": False,
}
GRADING_SCHEMA = {
"type": "object",
"properties": {
"card_type": {
"type": "string",
"enum": ["pokemon", "sports", "other_tcg", "other"],
"description": "Drives which base-rate notes apply; the rubric itself is identical.",
},
"card_note": {
"type": "string",
"description": "One line naming the card if legible (player/name, set, year) — for the history list.",
},
"estimated_grade": {
"type": ["integer", "null"],
"description": "Best single estimate, whole number 1-10, or null if ungradeable from these photos.",
},
"grade_low": {"type": ["integer", "null"]},
"grade_high": {"type": ["integer", "null"]},
"confidence": {"type": "string", "enum": ["high", "medium", "low"]},
"centering": GRADE_CATEGORY_SCHEMA,
"corners": GRADE_CATEGORY_SCHEMA,
"edges": GRADE_CATEGORY_SCHEMA,
"surface": GRADE_CATEGORY_SCHEMA,
"limitations": {
"type": "array", "items": {"type": "string"},
"description": "Specific things these photos prevented you from checking.",
},
"note": {"type": "string", "description": "Short overall summary."},
},
"required": ["card_type", "card_note", "estimated_grade", "grade_low",
"grade_high", "confidence", "centering", "corners", "edges",
"surface", "limitations", "note"],
"additionalProperties": False,
}
def _clean_grade(value):
if not isinstance(value, (int, float)) or isinstance(value, bool):
return None
return max(1, min(10, int(value)))
def _clean_category(raw):
raw = raw if isinstance(raw, dict) else {}
severity = raw.get("severity")
if severity not in ("none", "minor", "moderate", "major", "cannot_assess"):
severity = "cannot_assess"
return {"severity": severity, "observation": raw.get("observation") or ""}
def grade_card(images, api_key=None, model=None, effort=None, zoom_details=True):
"""Estimate the PSA grade a card would likely receive, from photo(s).
`images` is a list of (image_bytes, filename) pairs. More angles help a
lot here front, back, and corner close-ups each unlock a category the
others can't show. Returns the estimate, a range, per-category findings,
and what the photos couldn't support judging.
When `zoom_details` is on and Pillow is installed, magnified crops of the
first photo's four corners and four edge strips are generated and sent
alongside it. A corner or an edge band is a tiny part of a full-card
frame, so after the model's own downscaling there is often too little
left to judge which is why those categories came back "cannot_assess"
or missed visible whitening. Cropping first preserves that detail.
"""
supplied = len(images)
crops = []
if zoom_details and images and cardimage.available():
crops = cardimage.detail_crops(images[0][0], images[0][1])
labels = []
for i in range(supplied):
labels.append("FULL CARD photo{}:".format(
"" if supplied == 1 else " {} of {}".format(i + 1, supplied)))
for _, name in crops:
stem = name.rsplit(".", 1)[0]
if "-surface-" in stem:
quadrant = stem.split("-surface-", 1)[1].upper()
labels.append(
"SURFACE INSPECTION of the {} QUADRANT. Left half: the crop as "
"photographed. Right half: the same crop with the artwork "
"cancelled out so only fine surface texture remains. Use the "
"pair together to judge surface in this quadrant.".format(quadrant))
continue
region = "-".join(stem.rsplit("-", 2)[-2:])
if region.endswith("-edge"):
side = region[:-len("-edge")].upper()
labels.append(
"MAGNIFIED STRIP along the {} EDGE of the card. Real whitening "
"is an UNEVEN pale band of varying width along the cut. The "
"thin uniform line at the very boundary is the cut itself, "
"which every card has — do not report that as whitening. "
"Judge the {} edge from this image.".format(side, side.lower()))
else:
labels.append(
"MAGNIFIED CLOSE-UP of the {} CORNER.".format(region.upper()))
can_measure = zoom_details and images and cardimage.available()
measured = cardimage.edge_wear_profile(images[0][0]) if can_measure else None
centering = cardimage.centering_profile(images[0][0]) if can_measure else None
prompt_parts = []
if centering:
prompt_parts.append(
"MEASURED CENTERING — computed from the pixels by finding the "
"border on each side of the card:\n"
" left/right: {} (borders {}px and {}px)\n"
" top/bottom: {} (borders {}px and {}px)\n"
" worst axis: {:.0f}/{:.0f}\n"
"These are accurate to within about two percentage points, so use "
"them directly against the PSA centering tolerances rather than "
"estimating by eye. Disregard them only if the card is clearly "
"photographed at an angle, since that distorts the border widths "
"geometrically — say so if you think that is the case.".format(
centering["horizontal_label"],
centering["widths_px"]["left"], centering["widths_px"]["right"],
centering["vertical_label"],
centering["widths_px"]["top"], centering["widths_px"]["bottom"],
centering["worst"], 100 - centering["worst"]))
if measured and not measured.get("reliable"):
prompt_parts.append(
"EDGE WHITENING COULD NOT BE MEASURED on this card, because {}. "
"You are getting no numbers for it, so judge the edges from the "
"strip images alone — and judge them CONSERVATIVELY. On a border "
"like this a pale edge is usually the border itself or a "
"reflection rather than wear, so call whitening only where an "
"uneven band clearly differs from the rest of that same edge. When "
"unsure prefer cannot_assess over assuming wear: wrongly calling "
"whitening costs the card a grade it should have kept.".format(
measured.get("reason", "of its finish")))
elif measured:
rows = []
for side in ("top", "right", "bottom", "left"):
data = (measured.get("edges") or {}).get(side)
if data:
rows.append(" {} edge: {:.1f}% of its length".format(
side, data["percent"]))
if rows:
prompt_parts.append(
"MEASURED EDGE WHITENING — computed directly from the pixels, "
"not estimated by eye:\n" + "\n".join(rows) + "\n"
"Each figure is the share of that edge whose outermost border "
"is both lighter and less saturated than the SAME border a few "
"pixels further in, which is the signature of paper core "
"showing through. Because each column is compared against "
"itself, it is unaffected by the border's colour, by the "
"overall exposure, or by the cut line that every card has — "
"the three things that make whitening so easy to misjudge by "
"eye.\n"
"Read them comparatively: an edge far above the others on the "
"same card is the real finding. Roughly, under 15% is a clean "
"edge; 15-30% is ambiguous and is as often a lighting "
"highlight, a drop shadow, or a printed bevel along that side "
"as it is wear, so do NOT call it wear unless you can also see "
"an uneven pale band in that edge's strip image; 30-60% is "
"clear whitening; above 60% is heavy. A card whose worst edge "
"is under 15% has clean edges — say so plainly rather than "
"hunting for something to report.\n"
"Let these numbers lead the edges category, and use the edge "
"strip images to describe what the wear looks like and to "
"catch what the measurement does not look for at all, such as "
"a nick, a chip, or a crushed edge.")
if crops:
prompt_parts.append(
"The close-ups above are upscaled crops of the full card photo, each "
"captioned with the exact region it came from — trust those captions "
"when you say which corner or edge a problem is on. They add no "
"information the full photo lacked, only easier viewing, so do not "
"read resampling softness as card wear.")
prompt_parts.append("Estimate the PSA grade this trading card would likely receive.")
parsed, usage = _call_vision(
list(images) + crops, GRADING_SYSTEM, GRADING_SCHEMA, " ".join(prompt_parts),
api_key, model, effort, max_tokens=MAX_TOKENS, labels=labels,
)
low = _clean_grade(parsed.get("grade_low"))
high = _clean_grade(parsed.get("grade_high"))
if low is not None and high is not None and low > high:
low, high = high, low
card_type = parsed.get("card_type")
if card_type not in ("pokemon", "sports", "other_tcg", "other"):
card_type = "other"
return {
"closeups": len(crops),
"card_type": card_type,
"card_note": (parsed.get("card_note") or "").strip(),
"edge_measurements": measured,
"centering_measurement": centering,
"estimated_grade": _clean_grade(parsed.get("estimated_grade")),
"grade_low": low,
"grade_high": high,
"confidence": parsed.get("confidence") or "low",
"categories": {
"centering": _clean_category(parsed.get("centering")),
"corners": _clean_category(parsed.get("corners")),
"edges": _clean_category(parsed.get("edges")),
"surface": _clean_category(parsed.get("surface")),
},
"limitations": [l for l in (parsed.get("limitations") or []) if l],
"note": parsed.get("note") or "",
"usage": usage,
}
def estimate_cost(usage):
"""Actual USD cost of one grading call, from the reported token counts."""
if not usage:
return None
caps = MODELS.get(usage.get("model") or "", MODELS[DEFAULT_MODEL])
return ((usage.get("input_tokens") or 0) * caps["in_per_mtok"] / 1_000_000
+ (usage.get("output_tokens") or 0) * caps["out_per_mtok"] / 1_000_000)
def price_guide():
"""Per-model cost of a typical grading call (1 photo + 12 close-ups)."""
guide = {}
for model_id, caps in MODELS.items():
# 1 supplied photo + ~12 generated close-ups, JSON verdict out.
est_in = caps["approx_image_tokens"] * 5 + 600
est_out = 700
guide[model_id] = {
"label": caps["label"],
"supports_effort": caps["effort"],
"per_grade": round(est_in * caps["in_per_mtok"] / 1_000_000
+ est_out * caps["out_per_mtok"] / 1_000_000, 4),
}
return guide