|
|
||
|---|---|---|
| .forgejo/workflows | ||
| nginx | ||
| static | ||
| .dockerignore | ||
| .gitignore | ||
| app.py | ||
| cardimage.py | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| README.md | ||
| store.py | ||
| vision.py | ||
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.
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.
What's kept, and for how long
Grades are kept forever: the estimate, every category finding, the measurements, and the thumbnail. Nothing expires there.
The original photos are a different matter. Each grade stores the photo(s) that produced it so Regrade can re-run without asking for them again — which is worth most right after a grading-logic change, and worth progressively less as time passes. They're also what makes the database grow: up to ~16MB per grade at the upload cap, so a few hundred grades would otherwise run into gigabytes.
So stored photos expire after 7 days, and the database is compacted to actually give the space back. Past that window a card's Regrade button asks you to pick the photo again instead of re-running instantly — and once you do, that card starts the clock over.
Change the window with CARD_GRADER_IMAGE_RETENTION_DAYS (set it to 0 to
keep photos forever, if disk isn't a concern):
CARD_GRADER_IMAGE_RETENTION_DAYS=30 python3 app.py
The countdown runs from when a card was last graded, not when it was first graded — so regrading a card keeps its photos for another full window.
Optional packages
Three packages unlock real functionality. Each is checked for at runtime, so the app runs without them — you just lose that capability.
python3 -m pip install --user anthropic # Claude models — Sonnet 5, Haiku 4.5, Opus 5
python3 -m pip install --user openai # OpenAI models — GPT-5.6 Sol
python3 -m pip install --user pillow # the corner/edge/surface measurement pipeline
You only need whichever provider package matches the model you actually use
— install both if you want the option to switch. 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 keys
Get an Anthropic key at console.anthropic.com and/or an OpenAI key at platform.openai.com, then paste whichever you use into Settings in the app — each provider has its own field, since the keys aren't interchangeable and you may want to hold both. Grading costs a few cents per card either way (Sonnet 5 and GPT-5.6 Sol are both currently ~$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), Opus 5 (more expensive, marginally more careful), or GPT-5.6 Sol (OpenAI, similar price to Sonnet). 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.
GPT-5.6 Sol hasn't been run through the same head-to-head testing — it's wired up and usable, but nobody has checked it against real graded cards the way Haiku was checked here. Treat its results with the scrutiny that implies until someone does that comparison.
The workflow
- 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.
- Estimate the grade. A few seconds, a few cents.
- Read the category table: centering, corners, edges, surface, each with a severity and a specific observation — not just a number.
- 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
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.
-
Copy the
CardGraderfolder onto the Unraid box — e.g. into/mnt/user/appdata/card-grader. -
Point your domain at it. Add an
Arecord 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. -
Bring the app up (over SSH on the box, or the Unraid Compose Manager plugin pointed at the same folder):
docker compose up -d --buildThis starts
card-graderbound only to127.0.0.1:8778on the host — reachable from nginx, not directly from the LAN or internet. Data persists to./data/grades.dbso it survives image rebuilds. -
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: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:alpineEdit
nginx/card-grader.conffirst and replacecard-grader.example.comwith your real subdomain. Then get a certificate (one-time, and again every ~60 days — cron it or usecertbot's built-in renewal timer):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 -
Set your API key(s) by opening
https://card-grader.yourdomain.comyourself first, going to Settings, and pasting them in. If you're usingCARD_GRADER_ADMIN_USER, this just works — settings stay writable for that one username indefinitely. If you're using the blanketCARD_GRADER_LOCK=1instead, that only works before the lock takes effect, so either set the key first and then bring the lock up, or temporarily comment out that line indocker-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:
-
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.
-
Lock your settings when hosting. Start the server with:
CARD_GRADER_LOCK=1 python3 app.pywhich refuses all settings writes from everyone. 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.
If your reverse proxy does per-user basic auth (as opposed to one shared password for everyone), you can name a single admin instead of locking settings out entirely:
CARD_GRADER_ADMIN_USER=yourname python3 app.pyOnly that username may write settings; everyone else gets the same read-only view
CARD_GRADER_LOCKgives everyone. Either way, everyone can still set their own personal key (point 1 above) — that never touches server settings at all.
Your stored API keys are 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:
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.