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