# 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.