No description
Find a file
2026-08-22 09:48:26 -07:00
nginx Initial commit: Card Grader deployed to hippofam.com/cards 2026-08-22 09:22:41 -07:00
static Redesign: collector-grade dark theme, slab promoted to hero element 2026-08-22 09:48:26 -07:00
.gitignore Initial commit: Card Grader deployed to hippofam.com/cards 2026-08-22 09:22:41 -07:00
app.py Initial commit: Card Grader deployed to hippofam.com/cards 2026-08-22 09:22:41 -07:00
cardimage.py Initial commit: Card Grader deployed to hippofam.com/cards 2026-08-22 09:22:41 -07:00
docker-compose.yml Document actual Unraid/NPM deployment topology in docker-compose.yml 2026-08-22 09:23:15 -07:00
Dockerfile Initial commit: Card Grader deployed to hippofam.com/cards 2026-08-22 09:22:41 -07:00
README.md Initial commit: Card Grader deployed to hippofam.com/cards 2026-08-22 09:22:41 -07:00
store.py Initial commit: Card Grader deployed to hippofam.com/cards 2026-08-22 09:22:41 -07:00
vision.py Initial commit: Card Grader deployed to hippofam.com/cards 2026-08-22 09:22:41 -07:00

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.

Optional packages

Two 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   # 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, 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

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):

    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:

    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):

    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:

    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:

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.