card-grader/README.md
Barely Removable c7bd71a3e1 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.
2026-08-22 09:22:41 -07:00

289 lines
14 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.
### 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.