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:
commit
c7bd71a3e1
19 changed files with 3618 additions and 0 deletions
289
README.md
Normal file
289
README.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue