vision.py now dispatches per-model to _call_anthropic or _call_openai -- same prompt, same schema, same cardimage.py measurements either way, only the request/response shape differs. Confirmed the existing GRADING_SCHEMA already satisfies OpenAI's strict-mode requirement (every property listed in required, additionalProperties:false at every level) with no changes. Settings gained a second axis: which server key applies now depends on the selected model's provider, and friends' personal keys are stored per provider (with a one-time migration from the old single-key localStorage slot) since a Claude key and an OpenAI key aren't interchangeable. CARD_GRADER_ADMIN_USER names one username (read from the proxy's forwarded basic-auth header) who alone may write server settings; everyone else keeps the same read-only view CARD_GRADER_LOCK used to give everyone, while still being able to set their own personal key. Deployed here as ninja_hippo. CARD_GRADER_LOCK remains the fallback when no admin is named.
342 lines
16 KiB
Markdown
342 lines
16 KiB
Markdown
# 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.
|
|
|
|
### 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):
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
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](https://console.anthropic.com)
|
|
and/or an OpenAI key at [platform.openai.com](https://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
|
|
|
|
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 API key(s)** by opening `https://card-grader.yourdomain.com`
|
|
yourself first, going to Settings, and pasting them in. If you're using
|
|
`CARD_GRADER_ADMIN_USER`, this just works — settings stay writable for
|
|
that one username indefinitely. If you're using the blanket
|
|
`CARD_GRADER_LOCK=1` instead, 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 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 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:
|
|
|
|
```bash
|
|
CARD_GRADER_ADMIN_USER=yourname python3 app.py
|
|
```
|
|
|
|
Only that username may write settings; everyone else gets the same
|
|
read-only view `CARD_GRADER_LOCK` gives 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:
|
|
|
|
```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.
|